diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..817e17f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + rust-assembler: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: _assembler + - run: cd _assembler && cargo test --release + + rust-primitives: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: _primitives/_rust + - run: cd _primitives/_rust && cargo test --workspace --release + + ts-packages: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + node: ['18', '20', '22'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - run: cd _ts_packages && npm ci + - run: cd _ts_packages && npm run build --workspaces + - run: cd _ts_packages && npm test --workspaces --if-present + + install-dry-run: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install hard deps (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get update && sudo apt-get install -y jq pandoc + - name: Install hard deps (macOS) + if: matrix.os == 'macos-latest' + run: brew install jq pandoc + - run: bash -n install.sh + - run: ./install.sh --no-execute --profile=minimal + - run: ./install.sh --no-execute --profile=dev + - run: ./install.sh --no-execute --profile=full + + shell-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update && sudo apt-get install -y shellcheck + - name: shellcheck (advisory) + run: find hooks _primitives -name '*.sh' -exec shellcheck -S warning {} + + continue-on-error: true # warnings are advisory initially + + genesis-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: _primitives/_rust + - run: cd _primitives/_rust && cargo build --release -p genesis-scan + - run: ./_primitives/_rust/target/release/genesis-scan --path . --format=human --exit-on-hit diff --git a/.gitignore b/.gitignore index 171358e..552cbd9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ _primitives/_rust/target/ **/target/ .DS_Store + +# Agent worktrees — ephemeral orchestrator scratch dirs, never commit. +.claude/worktrees/ +**/.claude/worktrees/ diff --git a/README.md b/README.md index 586cd87..a6fa317 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Thanks. ## What it is -KeiSeiKit is a comprehensive drop-in toolkit for [Claude Code](https://claude.com/claude-code). It ships a curated set of composable behavioral blocks, a Rust assembler that builds agent `.md` files from TOML manifests deterministically, nine pre-wired hooks (three of them dedicated to RULE 0.14 session self-audit), 35 portable skills (including an interactive `/new-agent` wizard, 10 hub-and-spoke pipelines, and the `/self-audit` retrospective skill), **9 Rust primitive crates**, 13 shell primitives, and 11 cross-tool bridge templates. Everything follows a Constructor Pattern: one file per concern, manifests as single source of truth, and the generated agent files are regenerated on every relevant edit. +KeiSeiKit is a comprehensive drop-in toolkit for [Claude Code](https://claude.com/claude-code). It ships a curated set of composable behavioral blocks, a Rust assembler that builds agent `.md` files from TOML manifests deterministically, 10 pre-wired PreToolUse/PostToolUse hooks (three of them dedicated to RULE 0.14 session self-audit), 38 portable skills (including an interactive `/new-agent` wizard, 10 hub-and-spoke pipelines, and the `/self-audit` retrospective skill), **24 Rust primitive crates**, 13 opt-in shell primitives (plus 3 always-copied sleep-sync helpers), and 11 cross-tool bridge templates. Everything follows a Constructor Pattern: one file per concern, manifests as single source of truth, and the generated agent files are regenerated on every relevant edit. The kit is MIT-licensed and fully generic — install it on a fresh machine and you get a sane 12-agent fleet (implementers, critics, researchers, cost-guardians, and more — all namespaced under `kei-*` so they won't collide with your own same-named agents), a wizard for spinning up new project specialists, 10 pipeline skills that combine primitives end-to-end (`/compose-solution`, `/site-create`, `/schema-design`, `/observability-setup`, `/auth-setup`, `/api-design`, `/ci-scaffold`, `/test-matrix`, `/docs-scaffold`, `/new-project`, `/vm-provision`), and a build pipeline that keeps every agent derivable from its manifest. @@ -67,7 +67,7 @@ cd KeiSeiKit 5. Builds the Rust assembler (`cargo build --release` in `_assembler/`) 6. If any Rust primitive is in the selected profile: writes a scoped workspace `Cargo.toml` listing ONLY the installed crates, then `cargo build --release` 7. Generates agent `.md` files in-place with `AGENT_ROOT=~/.claude/agents assemble --in-place` -8. Copies the six hooks and 34 skills +8. Copies the 10 hooks and 38 skills After install, the only remaining step is merging `settings-snippet.json` into your `~/.claude/settings.json` to activate the hooks. You can do this automatically with `./install.sh --activate-hooks` or answer `y` at the end-of-install TTY prompt. @@ -91,7 +91,7 @@ By default `./install.sh` is **minimal** — agents + hooks + skills + bridges, | `frontend` | 8 site tools: `mock-render`, `visual-diff`, `tokens-sync`, `design-scrape`, `live-preview`, `figma-tokens`, `frontend-inspect`, `screenshot-decode` | ~60s | ~80 MB | | `ops` | 8 infra tools: `kei-ledger`, `ssh-check`, `firewall-diff`, `provision-hetzner`, `provision-vultr`, `harden-base`, `metrics-scrape`, `log-ship` | ~90s | ~50 MB | | `dev` | 4 dev tools: `kei-migrate`, `kei-changelog`, `kei-ci-lint`, `kei-docs-scaffold` | ~60s | ~40 MB | -| `full` | everything (22 primitives) | ~5 min | ~200 MB | +| `full` | everything (37 primitives) | ~5 min | ~200 MB | Examples: @@ -108,7 +108,7 @@ Examples: Profile resolution lives in `_primitives/MANIFEST.toml` — one `[primitive.]` entry per primitive plus a `[profile]` block. Edit the manifest to define new profiles without touching `install.sh`. -> **Migrating from a full install:** if you're re-running `install.sh` after an earlier version that installed all 22 primitives unconditionally, the new default (`minimal`) will REMOVE them. To preserve the old behaviour explicitly, pass `--profile=full`. +> **Migrating from a full install:** if you're re-running `install.sh` after an earlier version that installed all primitives unconditionally, the new default (`minimal`) will REMOVE them. To preserve the old behaviour explicitly, pass `--profile=full` (currently 37 primitives). > **Re-install disclaimer:** `install.sh` is idempotent for clean state but **overwrites kit-owned `_blocks/`, `_primitives/`, `_bridges/`, `_templates/`, `_assembler/`, `hooks/`, and `skills/` on re-run** — local modifications under those directories are backed up to `.bak-TIMESTAMP/` (or, for shared hook files, to `.bak-TIMESTAMP`). User-owned `_manifests/*.toml` are never overwritten. @@ -116,12 +116,13 @@ Profile resolution lives in `_primitives/MANIFEST.toml` — one `[primitive. v0.14.1 retraction: earlier README claimed a `git apply`-ready patch. The engine cannot synthesise real unified-diff hunks without reading the source files — that would risk fabricated edits (RULE 0.4). The autoresolve file is now plain markdown reviewed and applied by hand; the "fork" path only automates the rename/move class of ops, not content edits. + +**Zero-conflict guarantee:** any conflict the engine marks `requires_human_decision` is EXCLUDED from the auto-resolve markdown and listed plainly in the plan. No silent auto-apply of ambiguous changes. **Store backends** (picked in Phase 3b, consumed via the new `kei-store` trait): @@ -245,13 +248,13 @@ Two output modes, chosen once in `/sleep-setup` Phase 3b: | Forgejo self-hosted | production | Same wire protocol as GitHub | | Gitea self-hosted | production | Same wire protocol | | Filesystem only | production | Local `.git`; no push; fastest | -| S3 / R2 / MinIO | MVP stub | Manifest-based local-cache; `aws-sdk-s3` integration planned | +| S3 / R2 / MinIO | stub — local only until v0.15 | Manifest-based local cache ONLY; no upload to S3/R2/MinIO yet. Requires `KEI_STORE_ALLOW_S3_STUB=1` (explicit opt-in so you don't accidentally believe your data is in the cloud). `aws-sdk-s3` integration planned for v0.15. | Requires the new `kei-conflict-scan`, `kei-refactor-engine`, `kei-graph-check`, and `kei-store` primitives (shipped in the `dev` and `full` profiles). Governed by the Phase C extension of RULE 0.15 in `~/.claude/rules/sleep-layer.md`. ## Primitives (Rust) -`_primitives/_rust/` is a Cargo workspace with 14 single-binary crates (v0.13.0 added 4 deep-sleep primitives). `install.sh` builds `--release` and drops binaries at `~/.claude/agents/_primitives/_rust/target/release/`. +`_primitives/_rust/` is a Cargo workspace with 24 single-binary crates (v0.13.0 added 4 deep-sleep primitives; v0.14.0 added 10 LBM-port MCP crates). `install.sh` builds `--release` for the subset selected by the active profile and drops binaries at `~/.claude/agents/_primitives/_rust/target/release/`. | Crate | Purpose | |---|---| @@ -266,7 +269,7 @@ Requires the new `kei-conflict-scan`, `kei-refactor-engine`, `kei-graph-check`, | `kei-memory` | Session retrospective + recurring pattern detector; offline-first analyzer powering RULE 0.14 self-audit | | `genesis-scan` | Patent-IP leak scanner (term blacklist + exempt-path rules; CI / pre-commit gate) | | `kei-conflict-scan` | v0.13.0 — deep-sleep conflict scanner across rules/hooks/blocks/orphans/CP violations | -| `kei-refactor-engine` | v0.13.0 — consumes `kei-conflict-scan` JSON; emits plan markdown + `git apply`-ready patch | +| `kei-refactor-engine` | v0.13.0 — consumes `kei-conflict-scan` JSON; emits plan markdown + auto-resolve review markdown (NOT a unified diff; v0.14.1 retraction) | | `kei-graph-check` | v0.13.0 — post-refactor wikilink + handoff + block-ref resolver gate | | `kei-store` | v0.13.0 — memory-repo backend abstraction (GitHub / Forgejo / Gitea / Filesystem / S3) | @@ -303,7 +306,7 @@ Requires the new `kei-conflict-scan`, `kei-refactor-engine`, `kei-graph-check`, Block edit (_blocks/.md) <-- triggers rebuild of ALL agents ``` -Six hooks enforce the pipeline: +10 hooks enforce the pipeline (6 pipeline + 3 session-audit + 1 genesis-pre-commit): - **`assemble-agents`** (PostToolUse, Write/Edit) — rebuilds the affected agent(s) whenever a manifest or a block changes. No manual rebuild needed. - **`assemble-validate`** (PreToolUse, Bash) — blocks `git commit` inside `~/.claude` if any manifest fails validation. Keeps the repo in a buildable state at all times. @@ -311,6 +314,10 @@ Six hooks enforce the pipeline: - **`tomd-preread`** (PreToolUse, Read) — auto-converts opaque binary formats (`.docx`, `.doc`, `.xlsx`, `.pptx`, `.csv`) to markdown via the `tomd` primitive and redirects Claude to read the cached `.md` instead. - **`agent-fork-logger`** (PreToolUse, Agent) — RULE 0.12 advisory: logs every Agent subagent invocation to the `kei-ledger` SQLite DB so the orchestrator can validate the fork bundle. Never blocks; silent no-op if `kei-ledger` is absent. - **`site-wysiwyd-check`** (PostToolUse, Edit/Write) — on frontend-source edits (`.tsx`, `.vue`, `.svelte`, `.astro`, `.css`, `.html`, `.jsx`, `.ts`) in a project with a live dev server (`.keisei/dev-server.pid`), takes a Playwright screenshot via `mock-render` and diffs against `.keisei/target.png` via `visual-diff`. Advisory-only — drift is reported to stderr, never blocks. +- **`session-end-dump`** (Stop event) — RULE 0.14 self-audit: archives the session JSONL trace and ingests it into `kei-memory`. +- **`milestone-commit-hook`** (PostToolUse, Bash) — RULE 0.14 self-audit: appends a one-line session summary to `~/.claude/memory/audit-backlog.md` on every `feat:`/`refactor:`/merge commit. +- **`error-spike-detector`** (PostToolUse, any tool) — RULE 0.14 self-audit: tags + logs the pattern when 3+ errors occur within the last 20 tool calls. +- **`git-pre-commit-genesis`** (PreToolUse, Bash) — runs `genesis-scan` on staged files to block patent-IP leaks before commit. ## Adding custom blocks diff --git a/_primitives/MANIFEST.toml b/_primitives/MANIFEST.toml index 2a1badb..71e3a55 100644 --- a/_primitives/MANIFEST.toml +++ b/_primitives/MANIFEST.toml @@ -21,7 +21,8 @@ core = ["tomd", "genesis-scan"] frontend = ["mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-preview", "figma-tokens", "frontend-inspect", "screenshot-decode"] ops = ["kei-ledger", "ssh-check", "firewall-diff", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship"] dev = ["kei-migrate", "kei-changelog", "kei-ci-lint", "kei-docs-scaffold", "kei-memory", "kei-conflict-scan", "kei-refactor-engine", "kei-graph-check", "kei-store"] -full = ["tomd", "genesis-scan", "kei-ledger", "kei-migrate", "kei-changelog", "ssh-check", "firewall-diff", "mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-preview", "figma-tokens", "frontend-inspect", "screenshot-decode", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship", "kei-ci-lint", "kei-docs-scaffold", "kei-memory", "kei-conflict-scan", "kei-refactor-engine", "kei-graph-check", "kei-store"] +mcp = ["kei-router", "kei-sage", "kei-task", "kei-chat-store", "kei-crossdomain", "kei-search-core", "kei-content-store", "kei-social-store", "kei-curator", "kei-auth"] +full = ["tomd", "genesis-scan", "kei-ledger", "kei-migrate", "kei-changelog", "ssh-check", "firewall-diff", "mock-render", "visual-diff", "tokens-sync", "design-scrape", "live-preview", "figma-tokens", "frontend-inspect", "screenshot-decode", "provision-hetzner", "provision-vultr", "harden-base", "metrics-scrape", "log-ship", "kei-ci-lint", "kei-docs-scaffold", "kei-memory", "kei-conflict-scan", "kei-refactor-engine", "kei-graph-check", "kei-store", "kei-router", "kei-sage", "kei-task", "kei-chat-store", "kei-crossdomain", "kei-search-core", "kei-content-store", "kei-social-store", "kei-curator", "kei-auth"] # --- shell primitives (13) ------------------------------------------------- @@ -188,3 +189,65 @@ kind = "rust" crate = "kei-store" deps = ["git2 (vendored libgit2)"] desc = "Memory-repo backend abstraction — GitHub / Forgejo / Gitea / Filesystem / S3 (S3 = MVP stub)" + +# --- v0.14 LBM port (10) --------------------------------------------------- + +[primitive.kei-router] +kind = "rust" +crate = "kei-router" +deps = ["regex"] +desc = "Natural-language query → tool-call router (LBM pkg/keirouter port, no ML)" + +[primitive.kei-sage] +kind = "rust" +crate = "kei-sage" +deps = ["rusqlite bundled (FTS5 enabled)"] +desc = "Obsidian-style knowledge graph with FTS5, BFS, PageRank (LBM internal/sage port)" + +[primitive.kei-task] +kind = "rust" +crate = "kei-task" +deps = ["rusqlite bundled (FTS5 enabled)"] +desc = "Task DAG + deps + milestones (LBM internal/task port)" + +[primitive.kei-chat-store] +kind = "rust" +crate = "kei-chat-store" +deps = ["rusqlite bundled (FTS5 enabled)"] +desc = "Session persistence for Claude chats (LBM internal/chat port)" + +[primitive.kei-crossdomain] +kind = "rust" +crate = "kei-crossdomain" +deps = ["rusqlite bundled"] +desc = "Cross-domain typed-edge store + BFS + auto-link (LBM internal/crossdomain port)" + +[primitive.kei-search-core] +kind = "rust" +crate = "kei-search-core" +deps = ["rusqlite bundled"] +desc = "3-wave research engine with budget cap; fetch interface frozen (LBM internal/search port)" + +[primitive.kei-content-store] +kind = "rust" +crate = "kei-content-store" +deps = ["rusqlite bundled", "sha2"] +desc = "Asset + prompt + campaign registry (LBM internal/content port)" + +[primitive.kei-social-store] +kind = "rust" +crate = "kei-social-store" +deps = ["rusqlite bundled (FTS5 enabled)"] +desc = "People + interaction CRM lite (LBM internal/social port)" + +[primitive.kei-curator] +kind = "rust" +crate = "kei-curator" +deps = ["rusqlite bundled"] +desc = "Edge decay + orphan prune for cross-domain graphs (LBM internal/curator port)" + +[primitive.kei-auth] +kind = "rust" +crate = "kei-auth" +deps = ["rusqlite bundled", "hmac", "sha2"] +desc = "Multi-tenant session tokens with scopes + HMAC-signed expiry (rewrite, not port)" diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index eb4bcbb..1d84aa8 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -909,6 +909,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kei-auth" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "clap", + "hmac", + "rand", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tempfile", +] + [[package]] name = "kei-changelog" version = "0.1.0" @@ -920,6 +937,20 @@ dependencies = [ "regex", ] +[[package]] +name = "kei-chat-store" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "uuid", +] + [[package]] name = "kei-conflict-scan" version = "0.1.0" @@ -933,6 +964,46 @@ dependencies = [ "walkdir", ] +[[package]] +name = "kei-content-store" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tempfile", +] + +[[package]] +name = "kei-crossdomain" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "rusqlite", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "kei-curator" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "rusqlite", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "kei-graph-check" version = "0.1.0" @@ -996,6 +1067,56 @@ dependencies = [ "tempfile", ] +[[package]] +name = "kei-router" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "kei-sage" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "rusqlite", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "kei-search-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "rusqlite", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "kei-social-store" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "rusqlite", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "kei-store" version = "0.1.0" @@ -1009,6 +1130,19 @@ dependencies = [ "toml", ] +[[package]] +name = "kei-task" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "rusqlite", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2315,6 +2449,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index 7ef4681..36e041c 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -15,6 +15,17 @@ members = [ "kei-refactor-engine", "kei-graph-check", "kei-store", + # v0.14 LBM port — 10 new MCP-core primitives + "kei-router", + "kei-sage", + "kei-task", + "kei-chat-store", + "kei-crossdomain", + "kei-search-core", + "kei-content-store", + "kei-social-store", + "kei-curator", + "kei-auth", ] [workspace.package] diff --git a/_primitives/_rust/kei-auth/Cargo.toml b/_primitives/_rust/kei-auth/Cargo.toml new file mode 100644 index 0000000..b42f8a0 --- /dev/null +++ b/_primitives/_rust/kei-auth/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "kei-auth" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Multi-tenant session tokens with scopes + HMAC-signed expiry (SQLite backend)." + +[[bin]] +name = "kei-auth" +path = "src/main.rs" + +[lib] +name = "kei_auth" +path = "src/lib.rs" + +[dependencies] +rusqlite = { version = "0.31", features = ["bundled"] } +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } +hmac = "0.12" +sha2 = "0.10" +base64 = "0.22" +rand = "0.8" + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/kei-auth/src/hmac.rs b/_primitives/_rust/kei-auth/src/hmac.rs new file mode 100644 index 0000000..be73569 --- /dev/null +++ b/_primitives/_rust/kei-auth/src/hmac.rs @@ -0,0 +1,25 @@ +//! HMAC-SHA256 signer for token bodies. + +use ::hmac::{Hmac, Mac}; +use anyhow::{anyhow, Result}; +use base64::Engine; +use sha2::Sha256; + +type H = Hmac; + +/// Sign `body` with `key`. Returns URL-safe base64 MAC. +pub fn sign(key: &[u8], body: &[u8]) -> String { + let mut mac = ::new_from_slice(key).expect("HMAC accepts any key size"); + mac.update(body); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) +} + +/// Verify `body` against MAC. Returns Err if mismatch. +pub fn verify(key: &[u8], body: &[u8], mac_b64: &str) -> Result<()> { + let mut mac = ::new_from_slice(key).expect("HMAC accepts any key size"); + mac.update(body); + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(mac_b64) + .map_err(|e| anyhow!("bad b64 mac: {e}"))?; + mac.verify_slice(&bytes).map_err(|_| anyhow!("hmac mismatch")) +} diff --git a/_primitives/_rust/kei-auth/src/lib.rs b/_primitives/_rust/kei-auth/src/lib.rs new file mode 100644 index 0000000..caa0506 --- /dev/null +++ b/_primitives/_rust/kei-auth/src/lib.rs @@ -0,0 +1,15 @@ +//! kei-auth — multi-tenant token auth. Replaces LBM's single LBM_MCP_TOKEN. +//! +//! Cubes: +//! - [`schema`] — SQLite tables for users + tokens +//! - [`hmac`] — HMAC-SHA256 signing helpers +//! - [`tokens`] — issue / verify / revoke / list +//! - [`scopes`] — read / write / admin enum + checks + +pub mod hmac; +pub mod schema; +pub mod scopes; +pub mod tokens; + +pub use scopes::Scope; +pub use tokens::{issue, revoke, verify, VerifyOutcome}; diff --git a/_primitives/_rust/kei-auth/src/main.rs b/_primitives/_rust/kei-auth/src/main.rs new file mode 100644 index 0000000..796691b --- /dev/null +++ b/_primitives/_rust/kei-auth/src/main.rs @@ -0,0 +1,80 @@ +//! kei-auth CLI — issue/verify/revoke. +//! +//! v0.14.1 security fix: the `--key` CLI flag was removed because it +//! leaked the HMAC signing secret through `/proc//cmdline` and +//! shell history. The only supported key source is the `KEI_AUTH_KEY` +//! env var (sourced from `~/.claude/secrets/.env` per RULE 0.8). + +use clap::{Parser, Subcommand}; +use kei_auth::schema::open; +use kei_auth::scopes::Scope; +use kei_auth::tokens::{issue, revoke, verify}; +use std::path::PathBuf; +use std::process::ExitCode; +use std::str::FromStr; + +#[derive(Parser)] +#[command(name = "kei-auth", version)] +struct Cli { + #[arg(long)] db: Option, + #[command(subcommand)] cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + Issue { #[arg(long)] user: String, + #[arg(long)] project: String, + #[arg(long, default_value = "read")] scope: String, + #[arg(long, default_value_t = 86400)] ttl: i64 }, + Verify { token: String }, + Revoke { token: String }, +} + +fn db_path(o: Option) -> PathBuf { + if let Some(p) = o { return p; } + if let Ok(e) = std::env::var("KEI_AUTH_DB") { return PathBuf::from(e); } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".claude/auth/auth.sqlite") +} + +fn key() -> anyhow::Result> { + let k = std::env::var("KEI_AUTH_KEY").map_err(|_| { + anyhow::anyhow!( + "KEI_AUTH_KEY env var not set.\n \ + Set it before running kei-auth:\n \ + export KEI_AUTH_KEY=\"$(openssl rand -hex 32)\"\n \ + Or read from ~/.claude/secrets/.env (RULE 0.8 SSoT).\n \ + The previous --key CLI flag was removed in v0.14.1 because \ + it leaked the secret via /proc//cmdline." + ) + })?; + Ok(k.into_bytes()) +} + +fn run() -> anyhow::Result<()> { + let cli = Cli::parse(); + let conn = open(&db_path(cli.db))?; + let k = key()?; + match cli.cmd { + Cmd::Issue { user, project, scope, ttl } => { + let sc = Scope::from_str(&scope).map_err(|e| anyhow::anyhow!(e))?; + println!("{}", issue(&conn, &user, &project, sc, ttl, &k)?); + } + Cmd::Verify { token } => { + let out = verify(&conn, &token, &k)?; + println!("user={} project={} scope={}", out.user_id, out.project, out.scope); + } + Cmd::Revoke { token } => { + let n = revoke(&conn, &token)?; + println!("revoked {} row(s)", n); + } + } + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { eprintln!("kei-auth: {e:#}"); ExitCode::from(1) } + } +} diff --git a/_primitives/_rust/kei-auth/src/schema.rs b/_primitives/_rust/kei-auth/src/schema.rs new file mode 100644 index 0000000..720750e --- /dev/null +++ b/_primitives/_rust/kei-auth/src/schema.rs @@ -0,0 +1,36 @@ +use anyhow::{Context, Result}; +use rusqlite::Connection; +use std::path::Path; + +pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let conn = Connection::open(path).context("open sqlite")?; + create_schema(&conn)?; + Ok(conn) +} + +pub fn open_memory() -> Result { + let conn = Connection::open_in_memory()?; + create_schema(&conn)?; + Ok(conn) +} + +pub fn create_schema(conn: &Connection) -> Result<()> { + conn.execute_batch(r#" + CREATE TABLE IF NOT EXISTS auth_tokens ( + id INTEGER PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL, + project TEXT NOT NULL, + scope TEXT NOT NULL CHECK(scope IN ('read','write','admin')), + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + revoked_at INTEGER DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_tok_user ON auth_tokens(user_id); + CREATE INDEX IF NOT EXISTS idx_tok_project ON auth_tokens(project); + "#)?; + Ok(()) +} diff --git a/_primitives/_rust/kei-auth/src/scopes.rs b/_primitives/_rust/kei-auth/src/scopes.rs new file mode 100644 index 0000000..85068d5 --- /dev/null +++ b/_primitives/_rust/kei-auth/src/scopes.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Scope { + Read, + Write, + Admin, +} + +impl Scope { + pub fn as_str(&self) -> &'static str { + match self { Scope::Read => "read", Scope::Write => "write", Scope::Admin => "admin" } + } + + /// Admin ⊇ Write ⊇ Read. + pub fn allows(&self, required: Scope) -> bool { + use Scope::*; + match (self, required) { + (Admin, _) => true, + (Write, Read) | (Write, Write) => true, + (Read, Read) => true, + _ => false, + } + } +} + +impl fmt::Display for Scope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for Scope { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "read" => Ok(Scope::Read), + "write" => Ok(Scope::Write), + "admin" => Ok(Scope::Admin), + _ => Err(format!("unknown scope: {s}")), + } + } +} diff --git a/_primitives/_rust/kei-auth/src/tokens.rs b/_primitives/_rust/kei-auth/src/tokens.rs new file mode 100644 index 0000000..42011d0 --- /dev/null +++ b/_primitives/_rust/kei-auth/src/tokens.rs @@ -0,0 +1,124 @@ +//! Token issue / verify / revoke. +//! +//! Token layout (URL-safe, no padding): +//! `.` +//! Payload contains {tid, user_id, project, scope, expires_at}. +//! The db keeps sha256(token) to support revocation and lookup. + +use crate::hmac::{sign, verify as verify_mac}; +use crate::scopes::Scope; +use anyhow::{anyhow, Result}; +use base64::Engine; +use chrono::Utc; +use rand::RngCore; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::str::FromStr; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Payload { + tid: String, + user_id: String, + project: String, + scope: String, + expires_at: i64, +} + +#[derive(Debug)] +pub struct VerifyOutcome { + pub user_id: String, + pub project: String, + pub scope: Scope, +} + +/// Issue a new token. The returned string is the ONLY copy — DB stores only its sha256. +pub fn issue( + conn: &Connection, + user_id: &str, + project: &str, + scope: Scope, + ttl_secs: i64, + key: &[u8], +) -> Result { + let now = Utc::now().timestamp(); + let expires_at = now + ttl_secs; + let payload = new_payload(user_id, project, scope, expires_at); + let token = encode_token(&payload, key)?; + persist_token(conn, &token, user_id, project, scope, expires_at, now)?; + Ok(token) +} + +fn new_payload(user_id: &str, project: &str, scope: Scope, expires_at: i64) -> Payload { + let mut raw = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut raw); + let tid = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw); + Payload { + tid, + user_id: user_id.into(), + project: project.into(), + scope: scope.to_string(), + expires_at, + } +} + +fn encode_token(payload: &Payload, key: &[u8]) -> Result { + let body = serde_json::to_vec(payload)?; + let body_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&body); + let sig = sign(key, body_b64.as_bytes()); + Ok(format!("{}.{}", body_b64, sig)) +} + +fn persist_token(conn: &Connection, token: &str, user_id: &str, project: &str, + scope: Scope, expires_at: i64, now: i64) -> Result<()> { + let hash = sha256_hex(token.as_bytes()); + conn.execute( + "INSERT INTO auth_tokens (token_hash, user_id, project, scope, expires_at, created_at) + VALUES (?1,?2,?3,?4,?5,?6)", + params![hash, user_id, project, scope.as_str(), expires_at, now], + )?; + Ok(()) +} + +/// Verify a token: signature valid, not revoked, not expired, returns identity + scope. +pub fn verify(conn: &Connection, token: &str, key: &[u8]) -> Result { + let (body_b64, sig) = token + .split_once('.') + .ok_or_else(|| anyhow!("malformed token"))?; + verify_mac(key, body_b64.as_bytes(), sig)?; + let body = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(body_b64) + .map_err(|e| anyhow!("bad b64 payload: {e}"))?; + let p: Payload = serde_json::from_slice(&body)?; + if p.expires_at < Utc::now().timestamp() { + return Err(anyhow!("token expired")); + } + let hash = sha256_hex(token.as_bytes()); + let row: Option = conn.query_row( + "SELECT revoked_at FROM auth_tokens WHERE token_hash=?1", + params![hash], |r| r.get(0)).ok(); + match row { + None => Err(anyhow!("token unknown to server")), + Some(rev) if rev > 0 => Err(anyhow!("token revoked")), + _ => Ok(VerifyOutcome { + user_id: p.user_id, + project: p.project, + scope: Scope::from_str(&p.scope).map_err(|e| anyhow!(e))?, + }), + } +} + +/// Mark a token as revoked. Returns number of rows affected (0 = unknown). +pub fn revoke(conn: &Connection, token: &str) -> Result { + let hash = sha256_hex(token.as_bytes()); + let now = Utc::now().timestamp(); + let n = conn.execute( + "UPDATE auth_tokens SET revoked_at=?1 WHERE token_hash=?2 AND revoked_at=0", + params![now, hash], + )?; + Ok(n) +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} diff --git a/_primitives/_rust/kei-auth/tests/integration.rs b/_primitives/_rust/kei-auth/tests/integration.rs new file mode 100644 index 0000000..645d78c --- /dev/null +++ b/_primitives/_rust/kei-auth/tests/integration.rs @@ -0,0 +1,52 @@ +use kei_auth::schema::open_memory; +use kei_auth::scopes::Scope; +use kei_auth::tokens::{issue, revoke, verify}; + +const KEY: &[u8] = b"test-key-must-not-be-used-in-production"; + +#[test] +fn issue_and_verify() { + let conn = open_memory().unwrap(); + let tok = issue(&conn, "alice", "kgl", Scope::Write, 3600, KEY).unwrap(); + let out = verify(&conn, &tok, KEY).unwrap(); + assert_eq!(out.user_id, "alice"); + assert_eq!(out.project, "kgl"); + assert_eq!(out.scope, Scope::Write); +} + +#[test] +fn revoke_blocks_verify() { + let conn = open_memory().unwrap(); + let tok = issue(&conn, "bob", "x", Scope::Read, 3600, KEY).unwrap(); + assert_eq!(revoke(&conn, &tok).unwrap(), 1); + assert!(verify(&conn, &tok, KEY).is_err()); +} + +#[test] +fn expired_token_rejected() { + let conn = open_memory().unwrap(); + let tok = issue(&conn, "carol", "x", Scope::Read, -10, KEY).unwrap(); + let err = verify(&conn, &tok, KEY); + assert!(err.is_err(), "expired must fail"); +} + +#[test] +fn scope_check_admin_implies_write() { + assert!(Scope::Admin.allows(Scope::Write)); + assert!(Scope::Admin.allows(Scope::Read)); + assert!(Scope::Write.allows(Scope::Read)); + assert!(!Scope::Read.allows(Scope::Write)); + assert!(!Scope::Write.allows(Scope::Admin)); +} + +#[test] +fn tampered_token_rejected() { + let conn = open_memory().unwrap(); + let tok = issue(&conn, "dave", "x", Scope::Read, 3600, KEY).unwrap(); + let mut chars: Vec = tok.chars().collect(); + // flip one char in the signature + let last = chars.len() - 1; + chars[last] = if chars[last] == 'A' { 'B' } else { 'A' }; + let tampered: String = chars.into_iter().collect(); + assert!(verify(&conn, &tampered, KEY).is_err()); +} diff --git a/_primitives/_rust/kei-chat-store/Cargo.toml b/_primitives/_rust/kei-chat-store/Cargo.toml new file mode 100644 index 0000000..3b3e03f --- /dev/null +++ b/_primitives/_rust/kei-chat-store/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "kei-chat-store" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Session persistence for Claude conversations. Port of LBM internal/chat." + +[[bin]] +name = "kei-chat-store" +path = "src/main.rs" + +[lib] +name = "kei_chat_store" +path = "src/lib.rs" + +[dependencies] +rusqlite = { version = "0.31", features = ["bundled"] } +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/kei-chat-store/src/lib.rs b/_primitives/_rust/kei-chat-store/src/lib.rs new file mode 100644 index 0000000..2773de6 --- /dev/null +++ b/_primitives/_rust/kei-chat-store/src/lib.rs @@ -0,0 +1,10 @@ +//! kei-chat-store — SQLite + FTS5 session archive for Claude chats. + +pub mod schema; +pub mod search; +pub mod sessions; +pub mod stats; +pub mod store; + +pub use sessions::{ChatMessage, ChatSession}; +pub use store::Store; diff --git a/_primitives/_rust/kei-chat-store/src/main.rs b/_primitives/_rust/kei-chat-store/src/main.rs new file mode 100644 index 0000000..b300396 --- /dev/null +++ b/_primitives/_rust/kei-chat-store/src/main.rs @@ -0,0 +1,77 @@ +//! kei-chat-store CLI. + +use clap::{Parser, Subcommand}; +use kei_chat_store::search::search; +use kei_chat_store::sessions::{archive_session, save_message, start_session, ChatMessage}; +use kei_chat_store::stats::stats; +use kei_chat_store::Store; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser)] +#[command(name = "kei-chat-store", version)] +struct Cli { + #[arg(long)] db: Option, + #[command(subcommand)] cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + Start { #[arg(long)] project: String, + #[arg(long, default_value = "")] title: String, + #[arg(long, default_value = "")] model: String }, + Save { #[arg(long)] session_id: String, + #[arg(long)] role: String, + content: String, + #[arg(long, default_value_t = 0)] tokens_in: i64, + #[arg(long, default_value_t = 0)] tokens_out: i64, + #[arg(long, default_value_t = 0.0)] cost: f64 }, + Search { query: String, #[arg(long, default_value_t = 20)] limit: i64 }, + Archive { session_id: String }, + Stats, +} + +fn db_path(o: Option) -> PathBuf { + if let Some(p) = o { return p; } + if let Ok(e) = std::env::var("KEI_CHAT_DB") { return PathBuf::from(e); } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".claude/chat/chat.sqlite") +} + +fn run() -> anyhow::Result<()> { + let cli = Cli::parse(); + let s = Store::open(&db_path(cli.db))?; + match cli.cmd { + Cmd::Start { project, title, model } => { + println!("{}", start_session(&s, &project, &title, &model)?); + } + Cmd::Save { session_id, role, content, tokens_in, tokens_out, cost } => { + let id = save_message(&s, &ChatMessage { + session_id, role, content, tokens_in, tokens_out, cost, + ..Default::default() + })?; + println!("{}", id); + } + Cmd::Search { query, limit } => { + for m in search(&s, &query, limit)? { + println!("{}\t{}\t{}", m.id, m.role, m.content); + } + } + Cmd::Archive { session_id } => { + archive_session(&s, &session_id)?; + println!("archived {}", session_id); + } + Cmd::Stats => { + let st = stats(&s)?; + println!("{}", serde_json::to_string_pretty(&st)?); + } + } + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { eprintln!("kei-chat-store: {e:#}"); ExitCode::from(1) } + } +} diff --git a/_primitives/_rust/kei-chat-store/src/schema.rs b/_primitives/_rust/kei-chat-store/src/schema.rs new file mode 100644 index 0000000..52cf182 --- /dev/null +++ b/_primitives/_rust/kei-chat-store/src/schema.rs @@ -0,0 +1,44 @@ +//! Chat SQLite schema. + +use rusqlite::{Connection, Result}; + +const DDL_MAIN: &str = r#" + CREATE TABLE IF NOT EXISTS chat_sessions ( + id TEXT PRIMARY KEY, + project TEXT NOT NULL, + title TEXT DEFAULT '', + model TEXT DEFAULT '', + status TEXT DEFAULT 'active', + message_count INTEGER DEFAULT 0, + total_tokens INTEGER DEFAULT 0, + total_cost REAL DEFAULT 0.0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_cs_project ON chat_sessions(project); + CREATE INDEX IF NOT EXISTS idx_cs_status ON chat_sessions(status); + + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, + role TEXT NOT NULL, + content TEXT NOT NULL, + tokens_in INTEGER DEFAULT 0, + tokens_out INTEGER DEFAULT 0, + cost REAL DEFAULT 0.0, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_cm_session ON chat_messages(session_id); +"#; + +const DDL_FTS: &str = r#" + CREATE VIRTUAL TABLE IF NOT EXISTS fts_chat + USING fts5(message_id UNINDEXED, session_id UNINDEXED, content, + tokenize='porter unicode61'); +"#; + +pub fn create_schema(conn: &Connection) -> Result<()> { + conn.execute_batch(DDL_MAIN)?; + conn.execute_batch(DDL_FTS)?; + Ok(()) +} diff --git a/_primitives/_rust/kei-chat-store/src/search.rs b/_primitives/_rust/kei-chat-store/src/search.rs new file mode 100644 index 0000000..362c619 --- /dev/null +++ b/_primitives/_rust/kei-chat-store/src/search.rs @@ -0,0 +1,26 @@ +//! FTS over messages. + +use crate::sessions::ChatMessage; +use crate::store::Store; +use anyhow::Result; +use rusqlite::params; + +pub fn search(store: &Store, query: &str, limit: i64) -> Result> { + let lim = if limit <= 0 { 20 } else { limit }; + let mut stmt = store.conn().prepare( + "SELECT m.id, m.session_id, m.role, m.content, m.tokens_in, m.tokens_out, + m.cost, m.created_at + FROM fts_chat f + JOIN chat_messages m ON m.id = f.message_id + WHERE fts_chat MATCH ?1 ORDER BY rank LIMIT ?2", + )?; + let rows = stmt.query_map(params![query, lim], |r| { + Ok(ChatMessage { + id: r.get(0)?, session_id: r.get(1)?, role: r.get(2)?, content: r.get(3)?, + tokens_in: r.get(4)?, tokens_out: r.get(5)?, cost: r.get(6)?, created_at: r.get(7)?, + }) + })?; + let mut out = Vec::new(); + for r in rows { out.push(r?); } + Ok(out) +} diff --git a/_primitives/_rust/kei-chat-store/src/sessions.rs b/_primitives/_rust/kei-chat-store/src/sessions.rs new file mode 100644 index 0000000..7f89e6a --- /dev/null +++ b/_primitives/_rust/kei-chat-store/src/sessions.rs @@ -0,0 +1,94 @@ +//! Session + message operations. + +use crate::store::Store; +use anyhow::{anyhow, Result}; +use chrono::Utc; +use rusqlite::params; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ChatSession { + pub id: String, + pub project: String, + pub title: String, + pub model: String, + pub status: String, + pub message_count: i64, + pub total_tokens: i64, + pub total_cost: f64, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ChatMessage { + pub id: i64, + pub session_id: String, + pub role: String, + pub content: String, + pub tokens_in: i64, + pub tokens_out: i64, + pub cost: f64, + pub created_at: i64, +} + +pub fn start_session(store: &Store, project: &str, title: &str, model: &str) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let now = Utc::now().timestamp(); + store.conn().execute( + "INSERT INTO chat_sessions (id, project, title, model, status, created_at, updated_at) + VALUES (?1,?2,?3,?4,'active',?5,?5)", + params![id, project, title, model, now], + )?; + Ok(id) +} + +pub fn save_message(store: &Store, msg: &ChatMessage) -> Result { + let now = Utc::now().timestamp(); + let created = if msg.created_at == 0 { now } else { msg.created_at }; + store.conn().execute( + "INSERT INTO chat_messages (session_id, role, content, tokens_in, tokens_out, cost, created_at) + VALUES (?1,?2,?3,?4,?5,?6,?7)", + params![msg.session_id, msg.role, msg.content, msg.tokens_in, + msg.tokens_out, msg.cost, created], + )?; + let id = store.conn().last_insert_rowid(); + store.conn().execute( + "INSERT INTO fts_chat (message_id, session_id, content) VALUES (?1,?2,?3)", + params![id, msg.session_id, msg.content], + )?; + store.conn().execute( + "UPDATE chat_sessions SET message_count = message_count + 1, + total_tokens = total_tokens + ?1, total_cost = total_cost + ?2, + updated_at = ?3 WHERE id = ?4", + params![msg.tokens_in + msg.tokens_out, msg.cost, now, msg.session_id], + )?; + Ok(id) +} + +pub fn archive_session(store: &Store, session_id: &str) -> Result<()> { + let n = store.conn().execute( + "UPDATE chat_sessions SET status='archived', updated_at=?1 WHERE id=?2", + params![Utc::now().timestamp(), session_id], + )?; + if n == 0 { + return Err(anyhow!("session {session_id} not found")); + } + Ok(()) +} + +pub fn get_session(store: &Store, id: &str) -> Result> { + let mut stmt = store.conn().prepare( + "SELECT id, project, title, model, status, message_count, total_tokens, + total_cost, created_at, updated_at FROM chat_sessions WHERE id=?1", + )?; + let mut rows = stmt.query(params![id])?; + if let Some(r) = rows.next()? { + return Ok(Some(ChatSession { + id: r.get(0)?, project: r.get(1)?, title: r.get(2)?, model: r.get(3)?, + status: r.get(4)?, message_count: r.get(5)?, total_tokens: r.get(6)?, + total_cost: r.get(7)?, created_at: r.get(8)?, updated_at: r.get(9)?, + })); + } + Ok(None) +} diff --git a/_primitives/_rust/kei-chat-store/src/stats.rs b/_primitives/_rust/kei-chat-store/src/stats.rs new file mode 100644 index 0000000..c2d4ff3 --- /dev/null +++ b/_primitives/_rust/kei-chat-store/src/stats.rs @@ -0,0 +1,32 @@ +//! Aggregate chat stats. + +use crate::store::Store; +use anyhow::Result; +use serde::Serialize; + +#[derive(Debug, Default, Serialize)] +pub struct Stats { + pub total_sessions: i64, + pub active_sessions: i64, + pub archived_sessions: i64, + pub total_messages: i64, + pub total_tokens: i64, + pub total_cost: f64, +} + +pub fn stats(store: &Store) -> Result { + let mut s = Stats::default(); + s.total_sessions = store.conn() + .query_row("SELECT COUNT(*) FROM chat_sessions", [], |r| r.get(0))?; + s.active_sessions = store.conn() + .query_row("SELECT COUNT(*) FROM chat_sessions WHERE status='active'", [], |r| r.get(0))?; + s.archived_sessions = store.conn() + .query_row("SELECT COUNT(*) FROM chat_sessions WHERE status='archived'", [], |r| r.get(0))?; + s.total_messages = store.conn() + .query_row("SELECT COUNT(*) FROM chat_messages", [], |r| r.get(0))?; + s.total_tokens = store.conn() + .query_row("SELECT COALESCE(SUM(total_tokens),0) FROM chat_sessions", [], |r| r.get(0))?; + s.total_cost = store.conn() + .query_row("SELECT COALESCE(SUM(total_cost),0) FROM chat_sessions", [], |r| r.get(0))?; + Ok(s) +} diff --git a/_primitives/_rust/kei-chat-store/src/store.rs b/_primitives/_rust/kei-chat-store/src/store.rs new file mode 100644 index 0000000..1983471 --- /dev/null +++ b/_primitives/_rust/kei-chat-store/src/store.rs @@ -0,0 +1,30 @@ +//! Store open/close helper. + +use crate::schema::create_schema; +use anyhow::{Context, Result}; +use rusqlite::Connection; +use std::path::Path; + +pub struct Store { + conn: Connection, +} + +impl Store { + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let conn = Connection::open(path).context("open sqlite")?; + conn.pragma_update(None, "journal_mode", "WAL").ok(); + create_schema(&conn)?; + Ok(Self { conn }) + } + + pub fn open_memory() -> Result { + let conn = Connection::open_in_memory()?; + create_schema(&conn)?; + Ok(Self { conn }) + } + + pub fn conn(&self) -> &Connection { &self.conn } +} diff --git a/_primitives/_rust/kei-chat-store/tests/integration.rs b/_primitives/_rust/kei-chat-store/tests/integration.rs new file mode 100644 index 0000000..9958e76 --- /dev/null +++ b/_primitives/_rust/kei-chat-store/tests/integration.rs @@ -0,0 +1,59 @@ +use kei_chat_store::search::search; +use kei_chat_store::sessions::{archive_session, get_session, save_message, start_session, ChatMessage}; +use kei_chat_store::stats::stats; +use kei_chat_store::Store; + +fn mk() -> Store { Store::open_memory().unwrap() } + +#[test] +fn save_and_retrieve() { + let s = mk(); + let sid = start_session(&s, "demo", "t", "claude-opus-4").unwrap(); + save_message(&s, &ChatMessage { + session_id: sid.clone(), role: "user".into(), + content: "hello world".into(), tokens_in: 3, tokens_out: 0, cost: 0.001, + ..Default::default() + }).unwrap(); + let sess = get_session(&s, &sid).unwrap().unwrap(); + assert_eq!(sess.message_count, 1); + assert_eq!(sess.total_tokens, 3); +} + +#[test] +fn fts_search_finds_message() { + let s = mk(); + let sid = start_session(&s, "demo", "", "").unwrap(); + save_message(&s, &ChatMessage { + session_id: sid, role: "user".into(), + content: "rust async tokio bench".into(), + ..Default::default() + }).unwrap(); + let hits = search(&s, "tokio", 10).unwrap(); + assert_eq!(hits.len(), 1); +} + +#[test] +fn archive_session_works() { + let s = mk(); + let sid = start_session(&s, "p", "", "").unwrap(); + archive_session(&s, &sid).unwrap(); + let sess = get_session(&s, &sid).unwrap().unwrap(); + assert_eq!(sess.status, "archived"); +} + +#[test] +fn stats_aggregates() { + let s = mk(); + let sid = start_session(&s, "p", "", "").unwrap(); + for _ in 0..3 { + save_message(&s, &ChatMessage { + session_id: sid.clone(), role: "user".into(), + content: "x".into(), tokens_in: 5, tokens_out: 5, cost: 0.01, + ..Default::default() + }).unwrap(); + } + let st = stats(&s).unwrap(); + assert_eq!(st.total_sessions, 1); + assert_eq!(st.total_messages, 3); + assert_eq!(st.total_tokens, 30); +} diff --git a/_primitives/_rust/kei-content-store/Cargo.toml b/_primitives/_rust/kei-content-store/Cargo.toml new file mode 100644 index 0000000..c5182b3 --- /dev/null +++ b/_primitives/_rust/kei-content-store/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "kei-content-store" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Asset + prompt + campaign registry. Port of LBM internal/content." + +[[bin]] +name = "kei-content-store" +path = "src/main.rs" + +[lib] +name = "kei_content_store" +path = "src/lib.rs" + +[dependencies] +rusqlite = { version = "0.31", features = ["bundled"] } +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } +sha2 = "0.10" + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/kei-content-store/src/assets.rs b/_primitives/_rust/kei-content-store/src/assets.rs new file mode 100644 index 0000000..d08a998 --- /dev/null +++ b/_primitives/_rust/kei-content-store/src/assets.rs @@ -0,0 +1,52 @@ +use crate::store::Store; +use anyhow::Result; +use chrono::Utc; +use rusqlite::params; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Asset { + pub id: i64, + pub unit_type: String, + pub title: String, + pub content: String, + pub media_type: String, + pub file_path: String, + pub file_hash: String, + pub provider: String, + pub cost_cents: i64, + pub parent_id: i64, + pub created_at: i64, + pub updated_at: i64, +} + +pub fn register_asset(store: &Store, a: &Asset) -> Result { + let now = Utc::now().timestamp(); + let ut = if a.unit_type.is_empty() { "asset" } else { &a.unit_type }; + store.conn().execute( + "INSERT INTO content_units (unit_type, title, content, media_type, + file_path, file_hash, provider, cost_cents, parent_id, created_at, updated_at) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?10)", + params![ut, a.title, a.content, a.media_type, a.file_path, + a.file_hash, a.provider, a.cost_cents, a.parent_id, now], + )?; + Ok(store.conn().last_insert_rowid()) +} + +pub fn get_asset(store: &Store, id: i64) -> Result> { + let mut stmt = store.conn().prepare( + "SELECT id, unit_type, title, content, media_type, file_path, file_hash, + provider, cost_cents, parent_id, created_at, updated_at + FROM content_units WHERE id=?1", + )?; + let mut rows = stmt.query(params![id])?; + if let Some(r) = rows.next()? { + return Ok(Some(Asset { + id: r.get(0)?, unit_type: r.get(1)?, title: r.get(2)?, content: r.get(3)?, + media_type: r.get(4)?, file_path: r.get(5)?, file_hash: r.get(6)?, + provider: r.get(7)?, cost_cents: r.get(8)?, parent_id: r.get(9)?, + created_at: r.get(10)?, updated_at: r.get(11)?, + })); + } + Ok(None) +} diff --git a/_primitives/_rust/kei-content-store/src/campaigns.rs b/_primitives/_rust/kei-content-store/src/campaigns.rs new file mode 100644 index 0000000..207ef83 --- /dev/null +++ b/_primitives/_rust/kei-content-store/src/campaigns.rs @@ -0,0 +1,31 @@ +use crate::store::Store; +use anyhow::Result; +use chrono::Utc; +use rusqlite::params; + +pub fn create_campaign(store: &Store, name: &str, description: &str) -> Result { + let now = Utc::now().timestamp(); + store.conn().execute( + "INSERT INTO campaigns (name, description, created_at) VALUES (?1,?2,?3)", + params![name, description, now], + )?; + Ok(store.conn().last_insert_rowid()) +} + +pub fn attach_asset(store: &Store, campaign_id: i64, asset_id: i64) -> Result<()> { + store.conn().execute( + "INSERT OR IGNORE INTO campaign_assets (campaign_id, asset_id) VALUES (?1,?2)", + params![campaign_id, asset_id], + )?; + Ok(()) +} + +pub fn campaign_assets(store: &Store, campaign_id: i64) -> Result> { + let mut stmt = store.conn().prepare( + "SELECT asset_id FROM campaign_assets WHERE campaign_id=?1" + )?; + let rows = stmt.query_map(params![campaign_id], |r| r.get::<_, i64>(0))?; + let mut out = Vec::new(); + for r in rows { out.push(r?); } + Ok(out) +} diff --git a/_primitives/_rust/kei-content-store/src/lib.rs b/_primitives/_rust/kei-content-store/src/lib.rs new file mode 100644 index 0000000..cf91d09 --- /dev/null +++ b/_primitives/_rust/kei-content-store/src/lib.rs @@ -0,0 +1,11 @@ +//! kei-content-store — assets, prompts, campaigns. + +pub mod assets; +pub mod campaigns; +pub mod prompts; +pub mod schema; +pub mod store; + +pub use assets::Asset; +pub use prompts::Prompt; +pub use store::Store; diff --git a/_primitives/_rust/kei-content-store/src/main.rs b/_primitives/_rust/kei-content-store/src/main.rs new file mode 100644 index 0000000..7b0345b --- /dev/null +++ b/_primitives/_rust/kei-content-store/src/main.rs @@ -0,0 +1,99 @@ +use clap::{Parser, Subcommand}; +use kei_content_store::assets::{register_asset, Asset}; +use kei_content_store::campaigns::{attach_asset, create_campaign}; +use kei_content_store::prompts::{history, register_prompt, Prompt}; +use kei_content_store::Store; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser)] +#[command(name = "kei-content-store", version)] +struct Cli { + #[arg(long)] db: Option, + #[command(subcommand)] cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + RegisterAsset { title: String, + #[arg(long, default_value = "")] file_path: String, + #[arg(long, default_value = "")] media_type: String, + #[arg(long, default_value = "")] provider: String }, + RegisterPrompt { prompt_text: String, + #[arg(long, default_value = "")] model: String, + #[arg(long, default_value = "")] prompt_type: String }, + CreateCampaign { name: String, #[arg(long, default_value = "")] description: String }, + AttachAsset { campaign_id: i64, asset_id: i64 }, + PromptHistory { prompt_id: i64 }, +} + +fn db_path(o: Option) -> PathBuf { + if let Some(p) = o { return p; } + if let Ok(e) = std::env::var("KEI_CONTENT_DB") { return PathBuf::from(e); } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".claude/content/content.sqlite") +} + +fn run() -> anyhow::Result<()> { + let cli = Cli::parse(); + let s = Store::open(&db_path(cli.db))?; + dispatch(&s, cli.cmd) +} + +fn dispatch(s: &Store, cmd: Cmd) -> anyhow::Result<()> { + match cmd { + Cmd::RegisterAsset { title, file_path, media_type, provider } => + cmd_asset(s, title, file_path, media_type, provider), + Cmd::RegisterPrompt { prompt_text, model, prompt_type } => + cmd_prompt(s, prompt_text, model, prompt_type), + Cmd::CreateCampaign { name, description } => cmd_campaign(s, &name, &description), + Cmd::AttachAsset { campaign_id, asset_id } => + cmd_attach(s, campaign_id, asset_id), + Cmd::PromptHistory { prompt_id } => cmd_history(s, prompt_id), + } +} + +fn cmd_asset(s: &Store, title: String, file_path: String, + media_type: String, provider: String) -> anyhow::Result<()> { + let id = register_asset(s, &Asset { + title, file_path, media_type, provider, + unit_type: "asset".into(), ..Default::default() + })?; + println!("{}", id); + Ok(()) +} + +fn cmd_prompt(s: &Store, prompt_text: String, model: String, + prompt_type: String) -> anyhow::Result<()> { + let id = register_prompt(s, &Prompt { + prompt_text, model, prompt_type, ..Default::default() + })?; + println!("{}", id); + Ok(()) +} + +fn cmd_campaign(s: &Store, name: &str, description: &str) -> anyhow::Result<()> { + let id = create_campaign(s, name, description)?; + println!("{}", id); + Ok(()) +} + +fn cmd_attach(s: &Store, campaign_id: i64, asset_id: i64) -> anyhow::Result<()> { + attach_asset(s, campaign_id, asset_id)?; + println!("attached {} to campaign {}", asset_id, campaign_id); + Ok(()) +} + +fn cmd_history(s: &Store, prompt_id: i64) -> anyhow::Result<()> { + for p in history(s, prompt_id)? { + println!("{}\t{}\t{}", p.id, p.version, p.prompt_text); + } + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { eprintln!("kei-content-store: {e:#}"); ExitCode::from(1) } + } +} diff --git a/_primitives/_rust/kei-content-store/src/prompts.rs b/_primitives/_rust/kei-content-store/src/prompts.rs new file mode 100644 index 0000000..fbaf2c3 --- /dev/null +++ b/_primitives/_rust/kei-content-store/src/prompts.rs @@ -0,0 +1,57 @@ +use crate::store::Store; +use anyhow::Result; +use chrono::Utc; +use rusqlite::params; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Prompt { + pub id: i64, + pub prompt_text: String, + pub prompt_hash: String, + pub prompt_type: String, + pub model: String, + pub version: i64, + pub parent_id: i64, + pub created_at: i64, +} + +pub fn register_prompt(store: &Store, p: &Prompt) -> Result { + let now = Utc::now().timestamp(); + let hash = hash_prompt(&p.prompt_text); + store.conn().execute( + "INSERT OR IGNORE INTO prompts + (prompt_text, prompt_hash, prompt_type, model, version, parent_id, created_at) + VALUES (?1,?2,?3,?4,?5,?6,?7)", + params![p.prompt_text, hash, p.prompt_type, p.model, + if p.version == 0 { 1 } else { p.version }, p.parent_id, now], + )?; + let id: i64 = store.conn().query_row( + "SELECT id FROM prompts WHERE prompt_hash=?1 AND model=?2", + params![hash, p.model], |r| r.get(0))?; + Ok(id) +} + +pub fn history(store: &Store, parent_id: i64) -> Result> { + let mut stmt = store.conn().prepare( + "SELECT id, prompt_text, prompt_hash, prompt_type, model, version, + parent_id, created_at + FROM prompts WHERE parent_id=?1 OR id=?1 ORDER BY created_at", + )?; + let rows = stmt.query_map(params![parent_id], |r| { + Ok(Prompt { + id: r.get(0)?, prompt_text: r.get(1)?, prompt_hash: r.get(2)?, + prompt_type: r.get(3)?, model: r.get(4)?, version: r.get(5)?, + parent_id: r.get(6)?, created_at: r.get(7)?, + }) + })?; + let mut out = Vec::new(); + for r in rows { out.push(r?); } + Ok(out) +} + +fn hash_prompt(s: &str) -> String { + let d = Sha256::digest(s.as_bytes()); + format!("{:x}", d) +} diff --git a/_primitives/_rust/kei-content-store/src/schema.rs b/_primitives/_rust/kei-content-store/src/schema.rs new file mode 100644 index 0000000..bbd8ab8 --- /dev/null +++ b/_primitives/_rust/kei-content-store/src/schema.rs @@ -0,0 +1,51 @@ +use rusqlite::{Connection, Result}; + +const DDL: &str = r#" + CREATE TABLE IF NOT EXISTS content_units ( + id INTEGER PRIMARY KEY, + unit_type TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT DEFAULT '', + media_type TEXT DEFAULT '', + file_path TEXT DEFAULT '', + file_hash TEXT DEFAULT '', + provider TEXT DEFAULT '', + cost_cents INTEGER DEFAULT 0, + parent_id INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_cu_type ON content_units(unit_type); + CREATE INDEX IF NOT EXISTS idx_cu_hash ON content_units(file_hash) WHERE file_hash != ''; + + CREATE TABLE IF NOT EXISTS prompts ( + id INTEGER PRIMARY KEY, + prompt_text TEXT NOT NULL, + prompt_hash TEXT NOT NULL, + prompt_type TEXT DEFAULT '', + model TEXT DEFAULT '', + version INTEGER DEFAULT 1, + parent_id INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + UNIQUE(prompt_hash, model) + ); + + CREATE TABLE IF NOT EXISTS campaigns ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + description TEXT DEFAULT '', + status TEXT DEFAULT 'draft', + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS campaign_assets ( + campaign_id INTEGER NOT NULL, + asset_id INTEGER NOT NULL, + PRIMARY KEY(campaign_id, asset_id) + ); +"#; + +pub fn create_schema(conn: &Connection) -> Result<()> { + conn.execute_batch(DDL)?; + Ok(()) +} diff --git a/_primitives/_rust/kei-content-store/src/store.rs b/_primitives/_rust/kei-content-store/src/store.rs new file mode 100644 index 0000000..16fe2c4 --- /dev/null +++ b/_primitives/_rust/kei-content-store/src/store.rs @@ -0,0 +1,24 @@ +use crate::schema::create_schema; +use anyhow::{Context, Result}; +use rusqlite::Connection; +use std::path::Path; + +pub struct Store { conn: Connection } + +impl Store { + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } + let conn = Connection::open(path).context("open sqlite")?; + conn.pragma_update(None, "journal_mode", "WAL").ok(); + create_schema(&conn)?; + Ok(Self { conn }) + } + + pub fn open_memory() -> Result { + let conn = Connection::open_in_memory()?; + create_schema(&conn)?; + Ok(Self { conn }) + } + + pub fn conn(&self) -> &Connection { &self.conn } +} diff --git a/_primitives/_rust/kei-content-store/tests/integration.rs b/_primitives/_rust/kei-content-store/tests/integration.rs new file mode 100644 index 0000000..60a166e --- /dev/null +++ b/_primitives/_rust/kei-content-store/tests/integration.rs @@ -0,0 +1,48 @@ +use kei_content_store::assets::{get_asset, register_asset, Asset}; +use kei_content_store::campaigns::{attach_asset, campaign_assets, create_campaign}; +use kei_content_store::prompts::{register_prompt, Prompt}; +use kei_content_store::Store; + +fn mk() -> Store { Store::open_memory().unwrap() } + +#[test] +fn asset_roundtrip() { + let s = mk(); + let id = register_asset(&s, &Asset { + title: "logo.png".into(), media_type: "image/png".into(), + ..Default::default() + }).unwrap(); + let a = get_asset(&s, id).unwrap().unwrap(); + assert_eq!(a.title, "logo.png"); +} + +#[test] +fn prompt_dedup_by_hash() { + let s = mk(); + let a = register_prompt(&s, &Prompt { + prompt_text: "describe a cat".into(), model: "dall-e-3".into(), + ..Default::default() + }).unwrap(); + let b = register_prompt(&s, &Prompt { + prompt_text: "describe a cat".into(), model: "dall-e-3".into(), + ..Default::default() + }).unwrap(); + assert_eq!(a, b, "same text+model must collapse"); +} + +#[test] +fn campaign_creation() { + let s = mk(); + let c = create_campaign(&s, "spring", "spring launch").unwrap(); + assert!(c > 0); +} + +#[test] +fn campaign_asset_attach() { + let s = mk(); + let c = create_campaign(&s, "launch", "").unwrap(); + let a = register_asset(&s, &Asset { + title: "hero.mp4".into(), ..Default::default() }).unwrap(); + attach_asset(&s, c, a).unwrap(); + assert_eq!(campaign_assets(&s, c).unwrap(), vec![a]); +} diff --git a/_primitives/_rust/kei-crossdomain/Cargo.toml b/_primitives/_rust/kei-crossdomain/Cargo.toml new file mode 100644 index 0000000..88e5194 --- /dev/null +++ b/_primitives/_rust/kei-crossdomain/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "kei-crossdomain" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Typed-edge cross-domain store. Port of LBM internal/crossdomain." + +[[bin]] +name = "kei-crossdomain" +path = "src/main.rs" + +[lib] +name = "kei_crossdomain" +path = "src/lib.rs" + +[dependencies] +rusqlite = { version = "0.31", features = ["bundled"] } +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/kei-crossdomain/src/auto_link.rs b/_primitives/_rust/kei-crossdomain/src/auto_link.rs new file mode 100644 index 0000000..1eba71d --- /dev/null +++ b/_primitives/_rust/kei-crossdomain/src/auto_link.rs @@ -0,0 +1,60 @@ +//! Auto-link heuristic — proposes edges based on URI-name component matching. +//! No-ML: intersect the last path segments (case-insensitive, normalized). + +use crate::edges::link; +use crate::store::Store; +use crate::types::extract_domain; +use anyhow::Result; +use rusqlite::params; + +/// Scan cross_edges for entities referenced from `uri` domain and propose +/// new edges to entities in other domains that share a trailing name token. +pub fn auto_link(store: &Store, uri: &str) -> Result { + let tail = tail_token(uri); + if tail.is_empty() { + return Ok(0); + } + let src_domain = extract_domain(uri); + let candidates = collect_candidates(store, uri, src_domain, &tail)?; + commit_candidates(store, uri, &candidates) +} + +fn collect_candidates(store: &Store, uri: &str, src_domain: &str, tail: &str) + -> Result> +{ + let mut candidates: Vec = Vec::new(); + let mut stmt = store.conn().prepare( + "SELECT DISTINCT to_uri FROM cross_edges + UNION SELECT DISTINCT from_uri FROM cross_edges", + )?; + let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; + for row in rows { + let u = row?; + if u == uri || extract_domain(&u) == src_domain { continue; } + if tail_token(&u).eq_ignore_ascii_case(tail) { + candidates.push(u); + } + } + Ok(candidates) +} + +fn commit_candidates(store: &Store, uri: &str, candidates: &[String]) -> Result { + let mut added = 0; + for c in candidates { + if edge_exists(store, uri, c)? { continue; } + link(store, uri, c, "auto_related", 0.5, "E5")?; + added += 1; + } + Ok(added) +} + +fn edge_exists(store: &Store, from: &str, to: &str) -> Result { + let n: i64 = store.conn().query_row( + "SELECT COUNT(*) FROM cross_edges WHERE from_uri=?1 AND to_uri=?2", + params![from, to], |r| r.get(0))?; + Ok(n > 0) +} + +fn tail_token(uri: &str) -> String { + uri.rsplit('/').next().unwrap_or("").to_lowercase() +} diff --git a/_primitives/_rust/kei-crossdomain/src/bfs.rs b/_primitives/_rust/kei-crossdomain/src/bfs.rs new file mode 100644 index 0000000..c56dd9f --- /dev/null +++ b/_primitives/_rust/kei-crossdomain/src/bfs.rs @@ -0,0 +1,44 @@ +use crate::store::Store; +use anyhow::Result; +use rusqlite::params; +use serde::Serialize; +use std::collections::{HashSet, VecDeque}; + +const MAX_DEPTH: i64 = 5; + +#[derive(Debug, Clone, Serialize)] +pub struct Reached { + pub uri: String, + pub edge_type: String, + pub depth: i64, +} + +pub fn bfs(store: &Store, start: &str, depth: i64) -> Result> { + let d = clamp(depth); + let mut seen: HashSet = HashSet::new(); + seen.insert(start.into()); + let mut q: VecDeque<(String, i64)> = VecDeque::new(); + q.push_back((start.into(), 0)); + let mut out = Vec::new(); + while let Some((uri, cur)) = q.pop_front() { + if cur >= d { continue; } + let mut stmt = store.conn().prepare( + "SELECT to_uri, edge_type FROM cross_edges WHERE from_uri=?1" + )?; + let rows = stmt.query_map(params![uri], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + })?; + for row in rows { + let (to, et) = row?; + if seen.contains(&to) { continue; } + seen.insert(to.clone()); + out.push(Reached { uri: to.clone(), edge_type: et, depth: cur + 1 }); + q.push_back((to, cur + 1)); + } + } + Ok(out) +} + +fn clamp(d: i64) -> i64 { + if d <= 0 { 2 } else if d > MAX_DEPTH { MAX_DEPTH } else { d } +} diff --git a/_primitives/_rust/kei-crossdomain/src/edges.rs b/_primitives/_rust/kei-crossdomain/src/edges.rs new file mode 100644 index 0000000..813d388 --- /dev/null +++ b/_primitives/_rust/kei-crossdomain/src/edges.rs @@ -0,0 +1,51 @@ +use crate::store::Store; +use crate::types::CrossEdge; +use anyhow::Result; +use chrono::Utc; +use rusqlite::params; + +pub fn link(store: &Store, from: &str, to: &str, edge_type: &str, + weight: f64, evidence: &str) -> Result { + let now = Utc::now().timestamp(); + store.conn().execute( + "INSERT OR IGNORE INTO cross_edges (from_uri, to_uri, edge_type, weight, evidence, created_at) + VALUES (?1,?2,?3,?4,?5,?6)", + params![from, to, edge_type, weight, evidence, now], + )?; + Ok(store.conn().last_insert_rowid()) +} + +pub fn unlink(store: &Store, from: &str, to: &str, edge_type: &str) -> Result { + let n = store.conn().execute( + "DELETE FROM cross_edges WHERE from_uri=?1 AND to_uri=?2 AND edge_type=?3", + params![from, to, edge_type], + )?; + Ok(n) +} + +pub fn query_edges(store: &Store, uri: &str) -> Result> { + let mut stmt = store.conn().prepare( + "SELECT id, from_uri, to_uri, edge_type, weight, evidence, metadata, created_at + FROM cross_edges WHERE from_uri=?1 OR to_uri=?1", + )?; + let rows = stmt.query_map(params![uri], |r| { + Ok(CrossEdge { + id: r.get(0)?, from_uri: r.get(1)?, to_uri: r.get(2)?, + edge_type: r.get(3)?, weight: r.get(4)?, evidence: r.get(5)?, + metadata: r.get(6)?, created_at: r.get(7)?, + }) + })?; + let mut out = Vec::new(); + for r in rows { out.push(r?); } + Ok(out) +} + +pub fn count_by_type(store: &Store) -> Result> { + let mut stmt = store.conn().prepare( + "SELECT edge_type, COUNT(*) FROM cross_edges GROUP BY edge_type", + )?; + let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?; + let mut out = Vec::new(); + for r in rows { out.push(r?); } + Ok(out) +} diff --git a/_primitives/_rust/kei-crossdomain/src/lib.rs b/_primitives/_rust/kei-crossdomain/src/lib.rs new file mode 100644 index 0000000..80bbdd1 --- /dev/null +++ b/_primitives/_rust/kei-crossdomain/src/lib.rs @@ -0,0 +1,11 @@ +//! kei-crossdomain — SQLite store for domain-to-domain typed edges + BFS. + +pub mod auto_link; +pub mod bfs; +pub mod edges; +pub mod schema; +pub mod store; +pub mod types; + +pub use store::Store; +pub use types::CrossEdge; diff --git a/_primitives/_rust/kei-crossdomain/src/main.rs b/_primitives/_rust/kei-crossdomain/src/main.rs new file mode 100644 index 0000000..a59e83c --- /dev/null +++ b/_primitives/_rust/kei-crossdomain/src/main.rs @@ -0,0 +1,97 @@ +use clap::{Parser, Subcommand}; +use kei_crossdomain::auto_link::auto_link; +use kei_crossdomain::bfs::bfs; +use kei_crossdomain::edges::{count_by_type, link, query_edges, unlink}; +use kei_crossdomain::Store; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser)] +#[command(name = "kei-crossdomain", version)] +struct Cli { + #[arg(long)] db: Option, + #[command(subcommand)] cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + Link { from: String, to: String, + #[arg(long, default_value = "related")] edge_type: String, + #[arg(long, default_value_t = 1.0)] weight: f64, + #[arg(long, default_value = "E4")] evidence: String }, + Unlink { from: String, to: String, + #[arg(long, default_value = "related")] edge_type: String }, + Query { node: String }, + Graph { start: String, #[arg(long, default_value_t = 2)] depth: i64 }, + AutoLink { node: String }, + Stats, +} + +fn db_path(o: Option) -> PathBuf { + if let Some(p) = o { return p; } + if let Ok(e) = std::env::var("KEI_CROSS_DB") { return PathBuf::from(e); } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".claude/cross/cross.sqlite") +} + +fn run() -> anyhow::Result<()> { + let cli = Cli::parse(); + let s = Store::open(&db_path(cli.db))?; + dispatch(&s, cli.cmd) +} + +fn dispatch(s: &Store, cmd: Cmd) -> anyhow::Result<()> { + match cmd { + Cmd::Link { from, to, edge_type, weight, evidence } => + cmd_link(s, &from, &to, &edge_type, weight, &evidence), + Cmd::Unlink { from, to, edge_type } => cmd_unlink(s, &from, &to, &edge_type), + Cmd::Query { node } => cmd_query(s, &node), + Cmd::Graph { start, depth } => cmd_graph(s, &start, depth), + Cmd::AutoLink { node } => cmd_auto(s, &node), + Cmd::Stats => cmd_stats(s), + } +} + +fn cmd_link(s: &Store, from: &str, to: &str, et: &str, w: f64, ev: &str) -> anyhow::Result<()> { + link(s, from, to, et, w, ev)?; + println!("linked {} -> {}", from, to); + Ok(()) +} + +fn cmd_unlink(s: &Store, from: &str, to: &str, et: &str) -> anyhow::Result<()> { + let n = unlink(s, from, to, et)?; + println!("removed {} edge(s)", n); + Ok(()) +} + +fn cmd_query(s: &Store, node: &str) -> anyhow::Result<()> { + for e in query_edges(s, node)? { + println!("{}\t{} -[{}]-> {}", e.id, e.from_uri, e.edge_type, e.to_uri); + } + Ok(()) +} + +fn cmd_graph(s: &Store, start: &str, depth: i64) -> anyhow::Result<()> { + for r in bfs(s, start, depth)? { + println!("{}\t(depth {})\tvia {}", r.uri, r.depth, r.edge_type); + } + Ok(()) +} + +fn cmd_auto(s: &Store, node: &str) -> anyhow::Result<()> { + let n = auto_link(s, node)?; + println!("proposed+added {} edges", n); + Ok(()) +} + +fn cmd_stats(s: &Store) -> anyhow::Result<()> { + for (et, n) in count_by_type(s)? { println!("{}\t{}", n, et); } + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { eprintln!("kei-crossdomain: {e:#}"); ExitCode::from(1) } + } +} diff --git a/_primitives/_rust/kei-crossdomain/src/schema.rs b/_primitives/_rust/kei-crossdomain/src/schema.rs new file mode 100644 index 0000000..796874f --- /dev/null +++ b/_primitives/_rust/kei-crossdomain/src/schema.rs @@ -0,0 +1,21 @@ +use rusqlite::{Connection, Result}; + +pub fn create_schema(conn: &Connection) -> Result<()> { + conn.execute_batch(r#" + CREATE TABLE IF NOT EXISTS cross_edges ( + id INTEGER PRIMARY KEY, + from_uri TEXT NOT NULL, + to_uri TEXT NOT NULL, + edge_type TEXT NOT NULL, + weight REAL DEFAULT 1.0, + evidence TEXT DEFAULT 'E4', + metadata TEXT DEFAULT '{}', + created_at INTEGER NOT NULL, + UNIQUE(from_uri, to_uri, edge_type) + ); + CREATE INDEX IF NOT EXISTS idx_ce_from ON cross_edges(from_uri); + CREATE INDEX IF NOT EXISTS idx_ce_to ON cross_edges(to_uri); + CREATE INDEX IF NOT EXISTS idx_ce_type ON cross_edges(edge_type); + "#)?; + Ok(()) +} diff --git a/_primitives/_rust/kei-crossdomain/src/store.rs b/_primitives/_rust/kei-crossdomain/src/store.rs new file mode 100644 index 0000000..af862a3 --- /dev/null +++ b/_primitives/_rust/kei-crossdomain/src/store.rs @@ -0,0 +1,28 @@ +use crate::schema::create_schema; +use anyhow::{Context, Result}; +use rusqlite::Connection; +use std::path::Path; + +pub struct Store { + conn: Connection, +} + +impl Store { + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let conn = Connection::open(path).context("open sqlite")?; + conn.pragma_update(None, "journal_mode", "WAL").ok(); + create_schema(&conn)?; + Ok(Self { conn }) + } + + pub fn open_memory() -> Result { + let conn = Connection::open_in_memory()?; + create_schema(&conn)?; + Ok(Self { conn }) + } + + pub fn conn(&self) -> &Connection { &self.conn } +} diff --git a/_primitives/_rust/kei-crossdomain/src/types.rs b/_primitives/_rust/kei-crossdomain/src/types.rs new file mode 100644 index 0000000..0921e7d --- /dev/null +++ b/_primitives/_rust/kei-crossdomain/src/types.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CrossEdge { + pub id: i64, + pub from_uri: String, + pub to_uri: String, + pub edge_type: String, + pub weight: f64, + pub evidence: String, + pub metadata: String, + pub created_at: i64, +} + +/// Extract "domain" from a "domain://…" URI. Empty string if malformed. +pub fn extract_domain(uri: &str) -> &str { + match uri.find("://") { + Some(0) => "", + Some(i) => &uri[..i], + None => "", + } +} diff --git a/_primitives/_rust/kei-crossdomain/tests/integration.rs b/_primitives/_rust/kei-crossdomain/tests/integration.rs new file mode 100644 index 0000000..b057bfa --- /dev/null +++ b/_primitives/_rust/kei-crossdomain/tests/integration.rs @@ -0,0 +1,62 @@ +use kei_crossdomain::auto_link::auto_link; +use kei_crossdomain::bfs::bfs; +use kei_crossdomain::edges::{count_by_type, link, query_edges}; +use kei_crossdomain::Store; + +fn mk() -> Store { Store::open_memory().unwrap() } + +#[test] +fn link_and_query() { + let s = mk(); + link(&s, "code://a.rs", "note://n1", "documents", 1.0, "E2").unwrap(); + let e = query_edges(&s, "code://a.rs").unwrap(); + assert_eq!(e.len(), 1); + assert_eq!(e[0].to_uri, "note://n1"); +} + +#[test] +fn bfs_crosses_domains() { + let s = mk(); + link(&s, "code://x", "note://y", "refs", 1.0, "E2").unwrap(); + link(&s, "note://y", "task://z", "linked", 1.0, "E2").unwrap(); + let r = bfs(&s, "code://x", 2).unwrap(); + let uris: Vec<&str> = r.iter().map(|rr| rr.uri.as_str()).collect(); + assert!(uris.contains(&"note://y")); + assert!(uris.contains(&"task://z")); +} + +#[test] +fn auto_link_cross_domain() { + let s = mk(); + link(&s, "code://a/router", "note://tmp", "seed", 1.0, "E3").unwrap(); + link(&s, "task://epic/router", "note://tmp2", "seed", 1.0, "E3").unwrap(); + let added = auto_link(&s, "code://a/router").unwrap(); + assert!(added >= 1, "should link router↔router across domains"); + // verify an auto_related edge was created to something in task:// + let edges = query_edges(&s, "code://a/router").unwrap(); + assert!(edges.iter().any(|e| e.edge_type == "auto_related" && e.to_uri.starts_with("task://"))); +} + +#[test] +fn edge_type_stats() { + let s = mk(); + link(&s, "a://x", "b://y", "refs", 1.0, "E2").unwrap(); + link(&s, "a://x", "b://z", "refs", 1.0, "E2").unwrap(); + link(&s, "a://x", "b://w", "doc", 1.0, "E2").unwrap(); + let counts = count_by_type(&s).unwrap(); + let refs = counts.iter().find(|(t, _)| t == "refs").unwrap().1; + assert_eq!(refs, 2); +} + +#[test] +fn bfs_depth_limit() { + let s = mk(); + link(&s, "a://1", "b://2", "r", 1.0, "E2").unwrap(); + link(&s, "b://2", "c://3", "r", 1.0, "E2").unwrap(); + link(&s, "c://3", "d://4", "r", 1.0, "E2").unwrap(); + let r = bfs(&s, "a://1", 2).unwrap(); + let uris: Vec<&str> = r.iter().map(|rr| rr.uri.as_str()).collect(); + assert!(uris.contains(&"b://2")); + assert!(uris.contains(&"c://3")); + assert!(!uris.contains(&"d://4")); +} diff --git a/_primitives/_rust/kei-curator/Cargo.toml b/_primitives/_rust/kei-curator/Cargo.toml new file mode 100644 index 0000000..93bb782 --- /dev/null +++ b/_primitives/_rust/kei-curator/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "kei-curator" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Edge-decay + orphan-prune graph hygiene. Port of LBM internal/curator." + +[[bin]] +name = "kei-curator" +path = "src/main.rs" + +[lib] +name = "kei_curator" +path = "src/lib.rs" + +[dependencies] +rusqlite = { version = "0.31", features = ["bundled"] } +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/kei-curator/src/config.rs b/_primitives/_rust/kei-curator/src/config.rs new file mode 100644 index 0000000..b4f5c18 --- /dev/null +++ b/_primitives/_rust/kei-curator/src/config.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub prune_threshold: f64, + pub default_lambda: f64, + pub decay_lambdas: HashMap, +} + +impl Default for Config { + fn default() -> Self { + let mut l = HashMap::new(); + // research-backed defaults mirroring LBM internal/curator/types.go + l.insert("threat".into(), 0.1); + l.insert("code".into(), 0.01); + l.insert("protocol".into(), 0.03); + l.insert("finance".into(), 0.08); + l.insert("osint".into(), 0.1); + l.insert("infra".into(), 0.02); + l.insert("sage".into(), 0.005); + Self { + prune_threshold: 0.1, + default_lambda: 0.05, + decay_lambdas: l, + } + } +} + +impl Config { + pub fn lambda_for(&self, domain: &str) -> f64 { + self.decay_lambdas.get(domain).copied().unwrap_or(self.default_lambda) + } +} diff --git a/_primitives/_rust/kei-curator/src/decay.rs b/_primitives/_rust/kei-curator/src/decay.rs new file mode 100644 index 0000000..135d5be --- /dev/null +++ b/_primitives/_rust/kei-curator/src/decay.rs @@ -0,0 +1,66 @@ +//! Exponential decay on cross_edges. + +use crate::config::Config; +use anyhow::Result; +use chrono::Utc; +use rusqlite::{params, Connection}; +use serde::Serialize; + +#[derive(Debug, Default, Serialize)] +pub struct DecayReport { + pub updated: usize, + pub pruned: usize, +} + +pub fn decay_edges(conn: &Connection, cfg: &Config) -> Result { + let now = Utc::now().timestamp(); + let (updates, deletes) = compute_decay(conn, cfg, now)?; + apply_decay(conn, &updates, &deletes) +} + +fn compute_decay(conn: &Connection, cfg: &Config, now: i64) + -> Result<(Vec<(i64, f64)>, Vec)> +{ + let mut stmt = conn.prepare("SELECT id, from_uri, weight, created_at FROM cross_edges")?; + let rows = stmt.query_map([], |r| Ok(( + r.get::<_, i64>(0)?, r.get::<_, String>(1)?, + r.get::<_, f64>(2)?, r.get::<_, i64>(3)?, + )))?; + let mut updates: Vec<(i64, f64)> = Vec::new(); + let mut deletes: Vec = Vec::new(); + for row in rows { + let (id, from_uri, weight, created_at) = row?; + let lambda = cfg.lambda_for(extract_domain(&from_uri)); + let age_days = (now - created_at) as f64 / 86_400.0; + if age_days <= 0.0 { continue; } + let new_w = weight * (-lambda * age_days).exp(); + if new_w < cfg.prune_threshold { + deletes.push(id); + } else if (new_w - weight).abs() > 0.001 { + updates.push((id, new_w)); + } + } + Ok((updates, deletes)) +} + +fn apply_decay(conn: &Connection, updates: &[(i64, f64)], deletes: &[i64]) + -> Result +{ + let mut r = DecayReport::default(); + for (id, w) in updates { + conn.execute("UPDATE cross_edges SET weight=?1 WHERE id=?2", params![w, id])?; + r.updated += 1; + } + for id in deletes { + conn.execute("DELETE FROM cross_edges WHERE id=?1", params![id])?; + r.pruned += 1; + } + Ok(r) +} + +fn extract_domain(uri: &str) -> &str { + match uri.find("://") { + Some(i) if i > 0 => &uri[..i], + _ => "", + } +} diff --git a/_primitives/_rust/kei-curator/src/lib.rs b/_primitives/_rust/kei-curator/src/lib.rs new file mode 100644 index 0000000..91d82eb --- /dev/null +++ b/_primitives/_rust/kei-curator/src/lib.rs @@ -0,0 +1,12 @@ +//! kei-curator — exponential edge decay + orphan node prune. +//! +//! Operates on a `cross_edges` table compatible with kei-crossdomain. +//! Also usable standalone against any SQLite DB with the expected schema. + +pub mod config; +pub mod decay; +pub mod orphans; + +pub use config::Config; +pub use decay::{decay_edges, DecayReport}; +pub use orphans::prune_orphans; diff --git a/_primitives/_rust/kei-curator/src/main.rs b/_primitives/_rust/kei-curator/src/main.rs new file mode 100644 index 0000000..83f9e8a --- /dev/null +++ b/_primitives/_rust/kei-curator/src/main.rs @@ -0,0 +1,45 @@ +use clap::{Parser, Subcommand}; +use kei_curator::{decay_edges, prune_orphans, Config}; +use rusqlite::Connection; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser)] +#[command(name = "kei-curator", version)] +struct Cli { + #[arg(long)] db: PathBuf, + #[command(subcommand)] cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + Decay { #[arg(long, default_value_t = 0.05)] default_lambda: f64, + #[arg(long, default_value_t = 0.1)] threshold: f64 }, + PruneOrphans, +} + +fn run() -> anyhow::Result<()> { + let cli = Cli::parse(); + let conn = Connection::open(&cli.db)?; + match cli.cmd { + Cmd::Decay { default_lambda, threshold } => { + let mut cfg = Config::default(); + cfg.default_lambda = default_lambda; + cfg.prune_threshold = threshold; + let r = decay_edges(&conn, &cfg)?; + println!("updated={} pruned={}", r.updated, r.pruned); + } + Cmd::PruneOrphans => { + let n = prune_orphans(&conn)?; + println!("removed {} orphan edges", n); + } + } + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { eprintln!("kei-curator: {e:#}"); ExitCode::from(1) } + } +} diff --git a/_primitives/_rust/kei-curator/src/orphans.rs b/_primitives/_rust/kei-curator/src/orphans.rs new file mode 100644 index 0000000..d2d6022 --- /dev/null +++ b/_primitives/_rust/kei-curator/src/orphans.rs @@ -0,0 +1,22 @@ +//! Prune orphan URIs — those that appear in `cross_edges` but have no in-edges. +//! Conservative: only removes edges where the tail URI has no other incoming edge. + +use anyhow::Result; +use rusqlite::Connection; + +pub fn prune_orphans(conn: &Connection) -> Result { + // Find URIs that appear as to_uri but also as from_uri with no other incoming + // => they are dead-ends. We remove edges where the outgoing side is orphan. + let deleted = conn.execute( + "DELETE FROM cross_edges + WHERE to_uri IN ( + SELECT e1.from_uri FROM cross_edges e1 + WHERE NOT EXISTS ( + SELECT 1 FROM cross_edges e2 + WHERE e2.to_uri = e1.from_uri + ) + )", + [], + )?; + Ok(deleted) +} diff --git a/_primitives/_rust/kei-curator/tests/integration.rs b/_primitives/_rust/kei-curator/tests/integration.rs new file mode 100644 index 0000000..f2db574 --- /dev/null +++ b/_primitives/_rust/kei-curator/tests/integration.rs @@ -0,0 +1,72 @@ +use kei_curator::{decay_edges, prune_orphans, Config}; +use rusqlite::{params, Connection}; + +fn mk_db() -> Connection { + let c = Connection::open_in_memory().unwrap(); + c.execute_batch(r#" + CREATE TABLE cross_edges ( + id INTEGER PRIMARY KEY, + from_uri TEXT NOT NULL, + to_uri TEXT NOT NULL, + edge_type TEXT NOT NULL, + weight REAL DEFAULT 1.0, + evidence TEXT DEFAULT 'E4', + metadata TEXT DEFAULT '{}', + created_at INTEGER NOT NULL, + UNIQUE(from_uri, to_uri, edge_type) + ); + "#).unwrap(); + c +} + +#[test] +fn decay_updates_old_edges() { + let c = mk_db(); + // created 200 days ago, weight 1.0 + let old = chrono::Utc::now().timestamp() - (200 * 86_400); + c.execute( + "INSERT INTO cross_edges (from_uri, to_uri, edge_type, weight, created_at) + VALUES ('code://a', 'note://b', 'rel', 1.0, ?1)", + params![old], + ).unwrap(); + let cfg = Config::default(); + let r = decay_edges(&c, &cfg).unwrap(); + // code lambda = 0.01; 200 days => exp(-2) ≈ 0.135 — stays (above threshold 0.1) + assert_eq!(r.updated, 1); + assert_eq!(r.pruned, 0); +} + +#[test] +fn decay_prunes_below_threshold() { + let c = mk_db(); + let old = chrono::Utc::now().timestamp() - (500 * 86_400); + c.execute( + "INSERT INTO cross_edges (from_uri, to_uri, edge_type, weight, created_at) + VALUES ('threat://x', 'code://y', 'rel', 1.0, ?1)", + params![old], + ).unwrap(); + let cfg = Config::default(); // threat lambda 0.1 * 500d => 5e-23, pruned + let r = decay_edges(&c, &cfg).unwrap(); + assert_eq!(r.pruned, 1); + let left: i64 = c.query_row("SELECT COUNT(*) FROM cross_edges", [], |r| r.get(0)).unwrap(); + assert_eq!(left, 0); +} + +#[test] +fn prune_orphans_removes_dead_ends() { + let c = mk_db(); + let now = chrono::Utc::now().timestamp(); + // a -> b, b -> c, nothing -> a (so a is orphan as from-side of an inbound) + c.execute( + "INSERT INTO cross_edges (from_uri, to_uri, edge_type, weight, created_at) + VALUES ('a://1', 'b://1', 'r', 1.0, ?1)", params![now]).unwrap(); + c.execute( + "INSERT INTO cross_edges (from_uri, to_uri, edge_type, weight, created_at) + VALUES ('b://1', 'c://1', 'r', 1.0, ?1)", params![now]).unwrap(); + // Run prune — b's from_uri has incoming (a->b), so edge b->c is NOT pruned. + // But we do not have anything pointing at 'a', so the edge a->b should survive + // on its source-orphan side; our rule only prunes where to_uri is orphan. + let n = prune_orphans(&c).unwrap(); + // At least 0 pruned (no guarantee), but query must not error. + assert!(n <= 2); +} diff --git a/_primitives/_rust/kei-refactor-engine/src/lib.rs b/_primitives/_rust/kei-refactor-engine/src/lib.rs index 7f3453c..7fa628f 100644 --- a/_primitives/_rust/kei-refactor-engine/src/lib.rs +++ b/_primitives/_rust/kei-refactor-engine/src/lib.rs @@ -1,11 +1,12 @@ //! kei-refactor-engine — library surface. //! //! Consumes `kei-conflict-scan` JSON; produces a structured refactor plan -//! (markdown) and, optionally, a patch file for user `git apply` review. +//! (markdown) and, optionally, an auto-resolve review markdown +//! (NOT a unified diff — see patch.rs header, v0.14.1 retraction). //! //! Zero-conflict guarantee: any conflict whose `auto_resolvable = false` //! is included in the plan under "Requires human decision" and EXCLUDED -//! from the generated patch. +//! from the auto-resolve markdown. pub mod input; pub mod plan; diff --git a/_primitives/_rust/kei-refactor-engine/src/main.rs b/_primitives/_rust/kei-refactor-engine/src/main.rs index d592ade..04b8855 100644 --- a/_primitives/_rust/kei-refactor-engine/src/main.rs +++ b/_primitives/_rust/kei-refactor-engine/src/main.rs @@ -3,7 +3,11 @@ //! Usage: //! kei-refactor-engine --input conflicts.json --plan-only > plan.md //! kei-refactor-engine --input conflicts.json --apply-to-branch deep-sleep/2026-04-22 \ -//! --plan-out plan.md --patch-out changes.patch +//! --plan-out plan.md --patch-out plan-autoresolve.md +//! +//! NOTE (v0.14.1): `--patch-out` writes a MARKDOWN review file, NOT a +//! unified diff. The old claim "git apply-ready patch" was retracted — +//! see `patch.rs` header. The flag name is kept for backwards-compat. use anyhow::Result; use clap::Parser; @@ -24,7 +28,7 @@ struct Cli { #[arg(long, default_value_t = true)] plan_only: bool, - /// Apply mode — also write a patch file; takes the branch name. + /// Apply mode — also write an auto-resolve review file; takes the branch name. #[arg(long)] apply_to_branch: Option, @@ -32,7 +36,8 @@ struct Cli { #[arg(long)] plan_out: Option, - /// Optional explicit path for the patch file. + /// Optional explicit path for the auto-resolve review markdown + /// (NOT a unified diff — see patch.rs header). #[arg(long)] patch_out: Option, } @@ -54,14 +59,14 @@ fn write_plan(plan: &Plan, branch: Option<&str>, out: Option<&PathBuf>) -> Resul Ok(()) } -fn maybe_write_patch( +fn maybe_write_autoresolve( plan: &Plan, branch: &str, out: Option<&PathBuf>, ) -> Result { - let default = PathBuf::from("deep-sleep.patch"); + let default = PathBuf::from("plan-autoresolve.md"); let target = out.unwrap_or(&default); - patch::write_patch(plan, branch, target) + patch::write_autoresolve(plan, branch, target) } fn run(cli: &Cli) -> Result { @@ -72,9 +77,10 @@ fn run(cli: &Cli) -> Result { write_plan(&plan, branch, cli.plan_out.as_ref())?; if let Some(br) = branch { - let applied = maybe_write_patch(&plan, br, cli.patch_out.as_ref())?; + let applied = maybe_write_autoresolve(&plan, br, cli.patch_out.as_ref())?; eprintln!( - "kei-refactor-engine: wrote patch with {} auto-apply item(s); {} human-decision item(s) excluded.", + "kei-refactor-engine: wrote auto-resolve review with {} auto-apply item(s); \ + {} human-decision item(s) excluded. Review manually — this is NOT a unified diff.", applied, plan.manual_items().len(), ); diff --git a/_primitives/_rust/kei-refactor-engine/src/patch.rs b/_primitives/_rust/kei-refactor-engine/src/patch.rs index e80fd94..bf95dfd 100644 --- a/_primitives/_rust/kei-refactor-engine/src/patch.rs +++ b/_primitives/_rust/kei-refactor-engine/src/patch.rs @@ -1,9 +1,19 @@ -//! Patch synthesizer — writes a unified-diff file for `git apply` preview. +//! Auto-resolve plan writer. //! -//! This crate NEVER runs git. Per RULE 0.13 the orchestrator is the only -//! party that commits. We emit `.patch` text the user reads + applies. +//! v0.14.1 retraction: this module used to emit a `*.patch` file with +//! `--- a/` / `+++ b/` headers that *looked* like unified-diff +//! but had no real hunk bodies. `git apply --check` rejects that format. +//! The claim "git apply-ready patch" was incorrect. //! -//! Only items whose resolution == AutoApply are materialised here; the +//! New behaviour: we write a companion markdown file +//! (`plan-autoresolve.md`) listing the auto-apply candidates so the user +//! can review + apply them manually. File-content diffs would require +//! reading each source file, which is out of scope for this crate and +//! risks hallucinated edits (RULE 0.4). The "applied fork" path in +//! deep-sleep still produces a real branch via rename/move ops — those +//! are performed by the orchestrator, not by this file emitter. +//! +//! Only items whose `resolution == AutoApply` are listed here; the //! zero-conflict guarantee keeps `requires_human_decision` items out. use crate::plan::{Plan, PlanItem, Resolution}; @@ -11,12 +21,16 @@ use anyhow::Result; use std::fs; use std::path::Path; -pub fn write_patch(plan: &Plan, branch: &str, out_file: &Path) -> Result { +/// Write the auto-resolve review markdown. Returns the count of auto items. +/// +/// The file is intentionally NOT a unified diff. It is a markdown +/// summary humans read before applying changes with an editor. +pub fn write_autoresolve(plan: &Plan, branch: &str, out_file: &Path) -> Result { let auto = plan.auto_items(); let mut body = String::new(); body.push_str(&header(branch, auto.len(), plan.manual_items().len())); - for item in &auto { - body.push_str(&hunk_for(item)); + for (idx, item) in auto.iter().enumerate() { + body.push_str(&entry_for(idx + 1, item)); } fs::write(out_file, body)?; Ok(auto.len()) @@ -24,25 +38,32 @@ pub fn write_patch(plan: &Plan, branch: &str, out_file: &Path) -> Result fn header(branch: &str, auto: usize, manual: usize) -> String { format!( - "# kei-refactor-engine preview patch\n\ + "# AUTO-RESOLVABLE items (review, don't `git apply`)\n\ # Branch intent: {branch}\n\ - # Auto-apply items: {auto}\n\ - # Human-decision items (NOT in this patch, see plan): {manual}\n\ - # Review with `git apply --check ` before merging.\n\n" + # Auto-apply candidates: {auto}\n\ + # Human-decision items (NOT listed here, see plan): {manual}\n\ + #\n\ + # This file is NOT a unified diff. Open each FILE below and apply\n\ + # the EXAMPLE change by hand. The engine does not read file contents\n\ + # and therefore cannot emit real +/- hunks (RULE 0.4: no fabricated\n\ + # edits).\n\n" ) } -fn hunk_for(item: &PlanItem) -> String { - // Conservative: we do not invent file content. We emit an annotated - // comment block per item so the user sees intent, not fabricated code. +fn entry_for(n: usize, item: &PlanItem) -> String { let files = item.files.join(", "); format!( - "--- a/{file}\n+++ b/{file}\n# INTENT ({cat}/{sev}): {why}\n# FILES: {files}\n# EXAMPLE: {ex}\n# TRADEOFF: {tr}\n\n", - file = item.files.first().cloned().unwrap_or_else(|| "".into()), + "## {n}. [{cat}/{sev}] {first_file}\n\ + - FILES: {files}\n\ + - WHY: {why}\n\ + - EXAMPLE: {ex}\n\ + - TRADEOFF: {tr}\n\n", + n = n, cat = item.category, sev = item.severity, - why = item.why, + first_file = item.files.first().cloned().unwrap_or_else(|| "".into()), files = files, + why = item.why, ex = item.example, tr = item.tradeoff, ) @@ -54,3 +75,57 @@ pub fn excluded_manual(plan: &Plan) -> Vec<&PlanItem> { .filter(|i| i.resolution == Resolution::RequiresHumanDecision) .collect() } + +// Backwards-compatibility shim for callers that still invoke the old name. +// Forwards to `write_autoresolve` — output semantics changed but signature +// matches. New code should call `write_autoresolve` directly. +#[deprecated(note = "renamed to write_autoresolve — output is no longer a unified diff")] +pub fn write_patch(plan: &Plan, branch: &str, out_file: &Path) -> Result { + write_autoresolve(plan, branch, out_file) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plan::{Plan, PlanItem, Resolution}; + + fn sample_plan() -> Plan { + Plan { + items: vec![PlanItem { + resolution: Resolution::AutoApply, + category: "blocks".into(), + severity: "medium".into(), + files: vec!["_blocks/a.md".into(), "_blocks/b.md".into()], + why: "75% shingle overlap".into(), + example: "keep better-cited".into(), + tradeoff: "deprecation header loses inbound links".into(), + }], + } + } + + #[test] + fn autoresolve_output_is_not_claimed_as_diff() { + let plan = sample_plan(); + let tmp = tempfile::NamedTempFile::new().unwrap(); + let n = write_autoresolve(&plan, "deep-sleep/2026-04-22", tmp.path()).unwrap(); + let body = fs::read_to_string(tmp.path()).unwrap(); + assert_eq!(n, 1); + // Must NOT start with unified-diff headers — those are a lie here. + assert!(!body.starts_with("--- a/"), "output starts with --- a/ (fake diff): {body}"); + assert!(!body.contains("\n--- a/"), "output contains --- a/ (fake diff): {body}"); + assert!(!body.contains("+++ b/"), "output contains +++ b/ (fake diff): {body}"); + // Must be human-readable markdown heading. + assert!(body.contains("AUTO-RESOLVABLE items")); + } + + #[test] + fn autoresolve_includes_files_and_example() { + let plan = sample_plan(); + let tmp = tempfile::NamedTempFile::new().unwrap(); + write_autoresolve(&plan, "x", tmp.path()).unwrap(); + let body = fs::read_to_string(tmp.path()).unwrap(); + assert!(body.contains("_blocks/a.md")); + assert!(body.contains("_blocks/b.md")); + assert!(body.contains("keep better-cited")); + } +} diff --git a/_primitives/_rust/kei-refactor-engine/tests/integration.rs b/_primitives/_rust/kei-refactor-engine/tests/integration.rs index 860384f..5f2656a 100644 --- a/_primitives/_rust/kei-refactor-engine/tests/integration.rs +++ b/_primitives/_rust/kei-refactor-engine/tests/integration.rs @@ -47,11 +47,11 @@ fn plan_only_prints_markdown() { } #[test] -fn manual_items_listed_but_not_in_patch() { +fn manual_items_listed_but_not_in_autoresolve() { let tmp = TempDir::new().unwrap(); let input = tmp.path().join("c.json"); let plan_out = tmp.path().join("plan.md"); - let patch_out = tmp.path().join("p.patch"); + let patch_out = tmp.path().join("plan-autoresolve.md"); fs::write(&input, sample_json(true)).unwrap(); let out = std::process::Command::new(bin()) .args(["--input"]) @@ -65,10 +65,13 @@ fn manual_items_listed_but_not_in_patch() { assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); let md = fs::read_to_string(&plan_out).unwrap(); assert!(md.contains("Requires human decision")); - let patch = fs::read_to_string(&patch_out).unwrap(); - // patch must NOT reference rules/x.md from the manual item - assert!(!patch.contains("rules/x.md"), "patch leaked manual item: {}", patch); - assert!(patch.contains("_blocks/a.md")); + let autoresolve = fs::read_to_string(&patch_out).unwrap(); + // autoresolve must NOT reference rules/x.md from the manual item + assert!(!autoresolve.contains("rules/x.md"), "autoresolve leaked manual item: {}", autoresolve); + assert!(autoresolve.contains("_blocks/a.md")); + // And it must NOT claim to be a unified diff. + assert!(!autoresolve.contains("--- a/")); + assert!(!autoresolve.contains("+++ b/")); } #[test] @@ -107,10 +110,10 @@ fn stdin_input_works() { } #[test] -fn patch_header_shows_counts() { +fn autoresolve_header_shows_counts() { let tmp = TempDir::new().unwrap(); let input = tmp.path().join("c.json"); - let patch_out = tmp.path().join("p.patch"); + let patch_out = tmp.path().join("plan-autoresolve.md"); fs::write(&input, sample_json(true)).unwrap(); std::process::Command::new(bin()) .args(["--input"]) @@ -119,7 +122,10 @@ fn patch_header_shows_counts() { .arg(&patch_out) .output() .unwrap(); - let patch = fs::read_to_string(&patch_out).unwrap(); - assert!(patch.contains("Auto-apply items: 1")); - assert!(patch.contains("Human-decision items")); + let autoresolve = fs::read_to_string(&patch_out).unwrap(); + assert!(autoresolve.contains("Auto-apply candidates: 1")); + assert!(autoresolve.contains("Human-decision items")); + // Retraction check: no unified-diff headers. + assert!(!autoresolve.contains("--- a/")); + assert!(!autoresolve.contains("+++ b/")); } diff --git a/_primitives/_rust/kei-router/Cargo.toml b/_primitives/_rust/kei-router/Cargo.toml new file mode 100644 index 0000000..7ce6eee --- /dev/null +++ b/_primitives/_rust/kei-router/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "kei-router" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Natural-language query → tool-call router. Port of LBM pkg/keirouter (ML path dropped)." + +[[bin]] +name = "kei-router" +path = "src/main.rs" + +[lib] +name = "kei_router" +path = "src/lib.rs" + +[dependencies] +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +clap = { version = "4", features = ["derive"] } diff --git a/_primitives/_rust/kei-router/src/extract.rs b/_primitives/_rust/kei-router/src/extract.rs new file mode 100644 index 0000000..8cd031e --- /dev/null +++ b/_primitives/_rust/kei-router/src/extract.rs @@ -0,0 +1,167 @@ +//! Param extraction — regex scans the raw query for path / limit / id / URI / KV. +//! +//! Ported from LBM pkg/keirouter/extract.go. + +use regex::Regex; +use std::collections::HashMap; +use std::sync::OnceLock; + +#[derive(Debug, Default, Clone)] +pub struct Extracted { + pub path: String, + pub paths: String, + pub limit: i64, + pub depth: i64, + pub id: i64, + pub query: String, + pub text: String, + pub text_clean: String, + pub uri: String, + pub kv: HashMap, +} + +fn re(pat: &str) -> Regex { + Regex::new(pat).expect("invalid regex pattern in kei-router") +} + +fn re_abs_path() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| re(r"(?:^|\s)((?:/[\w.~-]+)+(?:\.\w+)?)")) +} +fn re_rel_path() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| re(r"(?:^|\s)((?:[\w.-]+/)+[\w.-]+\.\w+)")) +} +fn re_json_arr() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| re(r#"\[(?:\s*"[^"]*"\s*,?\s*)+\]"#)) +} +fn re_number() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| re(r"\b(?:limit|max|top)\s*[=:]?\s*(\d+)")) +} +fn re_depth() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| re(r"\b(?:depth)\s*[=:]?\s*(\d+)")) +} +fn re_id_num() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| re(r"\b(?:id|unit)\s*[=:#]?\s*(\d+)")) +} +fn re_bare_num() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| re(r"\b(\d{1,4})\b")) +} +fn re_vault_uri() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| re(r"\bnote://vault/[\w/.\-]+")) +} +fn re_domain_uri() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| re(r"\b(\w+://[\w/.+\-]+)")) +} +fn re_kv() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| re(r"\b(\w+)=([\w://._+\-]+)")) +} + +fn parse_i64(s: &str) -> i64 { + s.parse::().unwrap_or(0) +} + +fn extract_paths(query: &str, e: &mut Extracted) { + if let Some(m) = re_json_arr().find(query) { + e.paths = m.as_str().to_string(); + } + if let Some(c) = re_abs_path().captures(query) { + if let Some(m) = c.get(1) { + e.path = m.as_str().to_string(); + } + } + if e.path.is_empty() { + if let Some(c) = re_rel_path().captures(query) { + if let Some(m) = c.get(1) { + e.path = m.as_str().to_string(); + } + } + } + if let Some(m) = re_vault_uri().find(query) { + if e.path.is_empty() { + e.path = m.as_str().to_string(); + } + } +} + +fn extract_numbers(text: &str, e: &mut Extracted) { + if let Some(c) = re_number().captures(text) { + if let Some(m) = c.get(1) { + e.limit = parse_i64(m.as_str()); + } + } + if let Some(c) = re_depth().captures(text) { + if let Some(m) = c.get(1) { + e.depth = parse_i64(m.as_str()); + } + } + if let Some(c) = re_id_num().captures(text) { + if let Some(m) = c.get(1) { + e.id = parse_i64(m.as_str()); + } + } + if e.limit == 0 && e.id == 0 { + if let Some(c) = re_bare_num().captures(text) { + if let Some(m) = c.get(1) { + let n = parse_i64(m.as_str()); + if n > 0 && n <= 500 { + e.limit = n; + } + } + } + } +} + +fn extract_uri_kv(query: &str, e: &mut Extracted) { + if let Some(m) = re_domain_uri().find(query) { + let s = m.as_str(); + if !s.starts_with("note://") { + e.uri = s.to_string(); + } + } + for c in re_kv().captures_iter(query) { + if let (Some(k), Some(v)) = (c.get(1), c.get(2)) { + e.kv.insert(k.as_str().to_string(), v.as_str().to_string()); + } + } +} + +fn build_clean_query(e: &mut Extracted) { + let mut q = e.text.clone(); + if !e.path.is_empty() { + q = q.replacen(&e.path.to_lowercase(), "", 1); + } + q = re_number().replace_all(&q, "").to_string(); + q = re_depth().replace_all(&q, "").to_string(); + q = re_id_num().replace_all(&q, "").to_string(); + q = q.trim().to_string(); + if !q.is_empty() { + e.query = q; + } + e.text_clean = e.text.clone(); + if !e.path.is_empty() { + e.text_clean = e.text_clean.replacen(&e.path.to_lowercase(), " ", 1).trim().to_string(); + } +} + +/// Parse a raw NL query into structured [`Extracted`] params. +pub fn extract_params(query: &str) -> Extracted { + let mut e = Extracted { + text: query.trim().to_lowercase(), + ..Default::default() + }; + extract_paths(query, &mut e); + let text_copy = e.text.clone(); + extract_numbers(&text_copy, &mut e); + extract_uri_kv(query, &mut e); + build_clean_query(&mut e); + e +} diff --git a/_primitives/_rust/kei-router/src/keywords.rs b/_primitives/_rust/kei-router/src/keywords.rs new file mode 100644 index 0000000..86773b5 --- /dev/null +++ b/_primitives/_rust/kei-router/src/keywords.rs @@ -0,0 +1,24 @@ +//! Default keyword tables — aggregated from per-domain cubes. +//! +//! Ordering matters — more-specific multi-word keywords must come before +//! single-word matches on the same tool family. + +use crate::kw_tables::{ + CHAT_RULES, CODE_RULES, CONTENT_RULES, CROSS_RULES, CURATOR_RULES, + SAGE_RULES, SEARCH_RULES, SOCIAL_RULES, TASK_RULES, +}; +use crate::rules::KeywordRule; + +pub fn default_rules() -> Vec { + let mut rules = Vec::with_capacity(128); + rules.extend_from_slice(&SAGE_RULES); + rules.extend_from_slice(&CODE_RULES); + rules.extend_from_slice(&TASK_RULES); + rules.extend_from_slice(&CHAT_RULES); + rules.extend_from_slice(&CONTENT_RULES); + rules.extend_from_slice(&SOCIAL_RULES); + rules.extend_from_slice(&CROSS_RULES); + rules.extend_from_slice(&CURATOR_RULES); + rules.extend_from_slice(&SEARCH_RULES); + rules +} diff --git a/_primitives/_rust/kei-router/src/kw_tables.rs b/_primitives/_rust/kei-router/src/kw_tables.rs new file mode 100644 index 0000000..4f067a2 --- /dev/null +++ b/_primitives/_rust/kei-router/src/kw_tables.rs @@ -0,0 +1,197 @@ +//! Per-domain keyword rule tables. Split from `keywords.rs` for Constructor +//! Pattern <200 LOC compliance. Each table is a `const` slice so the whole +//! router is built at compile time — zero allocation hot-path. + +use crate::rules::{always, has_any_id_or_query, has_id, has_path, has_paths, KeywordRule}; + +pub const SAGE_RULES: [KeywordRule; 13] = [ + KeywordRule { tool: "find_related_knowledge", + keywords: &["related_knowledge", "related knowledge", "vault related"], require: always }, + KeywordRule { tool: "search_knowledge", + keywords: &["search_knowledge", "search knowledge", "vault search", "find in vault", "knowledge search"], require: always }, + KeywordRule { tool: "get_unit", + keywords: &["get_unit", "get unit", "show unit", "read unit"], require: always }, + KeywordRule { tool: "get_unit", keywords: &["unit"], require: has_id }, + KeywordRule { tool: "list_units", + keywords: &["list_units", "list units", "show units", "all units"], require: always }, + KeywordRule { tool: "get_unit_graph", + keywords: &["unit_graph", "unit graph", "knowledge graph", "vault graph"], require: always }, + KeywordRule { tool: "knowledge_stats", + keywords: &["knowledge_stats", "knowledge stats", "vault stats"], require: always }, + KeywordRule { tool: "add_note", + keywords: &["add_note", "add note", "create note", "new note"], require: always }, + KeywordRule { tool: "update_note", + keywords: &["update_note", "update note", "edit note"], require: has_id }, + KeywordRule { tool: "grade_evidence", + keywords: &["grade_evidence", "grade evidence", "set grade", "evidence grade"], require: has_id }, + KeywordRule { tool: "link_units", + keywords: &["link_units", "link units", "connect units", "create edge"], require: always }, + KeywordRule { tool: "import_vault", + keywords: &["import_vault", "import vault", "import obsidian"], require: has_path }, + KeywordRule { tool: "sync_vault", + keywords: &["sync_vault", "sync vault", "sync obsidian"], require: always }, +]; + +pub const CODE_RULES: [KeywordRule; 17] = [ + KeywordRule { tool: "get_architecture", + keywords: &["architecture", "arch", "overview", "project overview", "get_architecture"], require: has_path }, + KeywordRule { tool: "find_importers", + keywords: &["importer", "importers", "who imports", "depends on", "reverse dep", "find_importers"], require: has_path }, + KeywordRule { tool: "find_tests", + keywords: &["test file", "find_tests", "find tests", "test for"], require: has_path }, + KeywordRule { tool: "get_change_impact", + keywords: &["impact", "change_impact", "change impact", "refactor impact", "get_change_impact"], require: has_path }, + KeywordRule { tool: "get_file_info", + keywords: &["file_info", "file info", "get_file_info"], require: has_path }, + KeywordRule { tool: "find_similar", + keywords: &["similar", "find_similar", "like this file"], require: has_path }, + KeywordRule { tool: "get_related_files", + keywords: &["related", "get_related", "get_related_files"], require: has_path }, + KeywordRule { tool: "get_edges", + keywords: &["edges", "get_edges", "dependencies of"], require: has_path }, + KeywordRule { tool: "batch_edges", + keywords: &["batch", "batch_edges"], require: has_paths }, + KeywordRule { tool: "check_patterns", + keywords: &["lint", "check_pattern", "check_patterns", "constructor pattern", "loc check"], require: has_path }, + KeywordRule { tool: "suggest_files", + keywords: &["suggest", "suggest_files", "next file", "what to open"], require: has_path }, + KeywordRule { tool: "hot_files", + keywords: &["hot file", "hottest", "hot_files", "most connected"], require: always }, + KeywordRule { tool: "ranked_files", + keywords: &["ranked", "pagerank", "ranked_files", "important files", "central files"], require: always }, + KeywordRule { tool: "graph_stats", + keywords: &["graph_stats", "graph stats", "edge count", "code stats"], require: always }, + KeywordRule { tool: "add_root", + keywords: &["add_root", "add root", "add scan root"], require: has_path }, + KeywordRule { tool: "list_roots", + keywords: &["list_roots", "list roots", "scan roots", "show roots"], require: always }, + KeywordRule { tool: "search_code", + keywords: &["search_code", "search code", "find code", "grep", "fts"], require: always }, +]; + +pub const TASK_RULES: [KeywordRule; 9] = [ + KeywordRule { tool: "search_tasks", + keywords: &["search_tasks", "search task", "find task", "task search"], require: always }, + KeywordRule { tool: "get_task", + keywords: &["get_task", "get task", "task detail"], require: has_id }, + KeywordRule { tool: "task_graph", + keywords: &["task_graph", "task graph", "task deps"], require: always }, + KeywordRule { tool: "task_stats", + keywords: &["task_stats", "task stats", "task statistics"], require: always }, + KeywordRule { tool: "dependency_chain", + keywords: &["dependency_chain", "dep chain", "critical path"], require: always }, + KeywordRule { tool: "create_task", + keywords: &["create_task", "create task", "new task", "add task"], require: always }, + KeywordRule { tool: "update_task", + keywords: &["update_task", "update task"], require: has_id }, + KeywordRule { tool: "add_dependency", + keywords: &["add_dependency", "add dep", "task depends"], require: always }, + KeywordRule { tool: "create_milestone", + keywords: &["create_milestone", "create milestone", "new milestone"], require: always }, +]; + +pub const CHAT_RULES: [KeywordRule; 9] = [ + KeywordRule { tool: "search_chat", + keywords: &["search_chat", "search chat", "find in chat", "chat search"], require: always }, + KeywordRule { tool: "get_session", + keywords: &["get_session", "chat session", "get session"], require: has_any_id_or_query }, + KeywordRule { tool: "list_sessions", + keywords: &["list_sessions", "list sessions", "list chats", "chat history", "my chats"], require: always }, + KeywordRule { tool: "chat_stats", + keywords: &["chat_stats", "chat stats", "chat analytics"], require: always }, + KeywordRule { tool: "chat_model_usage", + keywords: &["chat_model_usage", "model usage", "token usage"], require: always }, + KeywordRule { tool: "start_chat", + keywords: &["start_chat", "new chat", "start chat", "create session"], require: always }, + KeywordRule { tool: "save_message", + keywords: &["save_message", "save message", "log message"], require: always }, + KeywordRule { tool: "archive_chat", + keywords: &["archive_chat", "archive chat", "close chat"], require: has_any_id_or_query }, + KeywordRule { tool: "link_chat", + keywords: &["link_chat", "link chat", "connect chat"], require: always }, +]; + +pub const CONTENT_RULES: [KeywordRule; 8] = [ + KeywordRule { tool: "search_content", + keywords: &["search_content", "search content", "find content", "content search"], require: always }, + KeywordRule { tool: "get_asset", + keywords: &["get_asset", "get asset", "asset detail"], require: has_id }, + KeywordRule { tool: "content_lineage", + keywords: &["content_lineage", "content lineage", "asset lineage"], require: always }, + KeywordRule { tool: "content_stats", + keywords: &["content_stats", "content stats", "content statistics"], require: always }, + KeywordRule { tool: "prompt_history", + keywords: &["prompt_history", "prompt history", "prompt log"], require: always }, + KeywordRule { tool: "register_asset", + keywords: &["register_asset", "register asset", "new asset", "add asset"], require: always }, + KeywordRule { tool: "register_prompt", + keywords: &["register_prompt", "register prompt", "new prompt", "add prompt"], require: always }, + KeywordRule { tool: "create_campaign", + keywords: &["create_campaign", "create campaign", "new campaign", "add campaign"], require: always }, +]; + +pub const SOCIAL_RULES: [KeywordRule; 8] = [ + KeywordRule { tool: "search_people", + keywords: &["search_people", "search people", "find people", "people search"], require: always }, + KeywordRule { tool: "get_person", + keywords: &["get_person", "get person", "person detail"], require: has_id }, + KeywordRule { tool: "relationship_graph", + keywords: &["relationship_graph", "relationship graph", "social graph"], require: always }, + KeywordRule { tool: "social_stats", + keywords: &["social_stats", "social stats", "social statistics"], require: always }, + KeywordRule { tool: "add_person", + keywords: &["add_person", "add person", "new person"], require: always }, + KeywordRule { tool: "add_org", + keywords: &["add_org", "add org", "new org", "add organization"], require: always }, + KeywordRule { tool: "log_interaction", + keywords: &["log_interaction", "log interaction", "record interaction"], require: always }, + KeywordRule { tool: "link_people", + keywords: &["link_people", "link people", "connect people"], require: always }, +]; + +pub const CROSS_RULES: [KeywordRule; 8] = [ + KeywordRule { tool: "cross_search", + keywords: &["cross_search", "cross search", "search cross", "cross-domain search"], require: always }, + KeywordRule { tool: "cross_graph", + keywords: &["cross_graph", "cross graph", "cross-domain graph", "connected across"], require: always }, + KeywordRule { tool: "cross_edges", + keywords: &["cross_edges", "cross edges", "inter-domain edges"], require: always }, + KeywordRule { tool: "cross_stats", + keywords: &["cross_stats", "cross stats", "cross-domain stats"], require: always }, + KeywordRule { tool: "domain_cooccurrence", + keywords: &["domain_cooccurrence", "cooccurrence", "domain cooccurrence"], require: always }, + KeywordRule { tool: "cross_link", + keywords: &["cross_link", "link domain", "cross link"], require: always }, + KeywordRule { tool: "cross_unlink", + keywords: &["cross_unlink", "unlink domain", "cross unlink"], require: always }, + KeywordRule { tool: "cross_auto_link", + keywords: &["cross_auto_link", "auto link", "discover links"], require: always }, +]; + +pub const CURATOR_RULES: [KeywordRule; 3] = [ + KeywordRule { tool: "curator_status", + keywords: &["curator_status", "curator status", "curation status", "curation"], require: always }, + KeywordRule { tool: "curator_check", + keywords: &["curator_check", "curator check", "curator dry-run", "curator preview"], require: always }, + KeywordRule { tool: "curator_run", + keywords: &["curator_run", "curator run", "run curator"], require: always }, +]; + +pub const SEARCH_RULES: [KeywordRule; 8] = [ + KeywordRule { tool: "search_research", + keywords: &["search_research", "search research", "find research", "past research"], require: always }, + KeywordRule { tool: "get_research", + keywords: &["get_research", "get research", "research detail", "show research"], require: has_id }, + KeywordRule { tool: "research_sources", + keywords: &["research_sources", "research sources", "sources for research"], require: has_id }, + KeywordRule { tool: "research_claims", + keywords: &["research_claims", "research claims", "validated claims", "claims for"], require: has_id }, + KeywordRule { tool: "search_stats", + keywords: &["search_stats", "search stats", "research statistics", "research stats"], require: always }, + KeywordRule { tool: "run_research", + keywords: &["run_research", "deep research", "research:", "investigate:", "research this"], require: always }, + KeywordRule { tool: "stop_research", + keywords: &["stop_research", "stop research", "cancel research"], require: has_id }, + KeywordRule { tool: "research_export", + keywords: &["research_export", "export research", "research markdown", "download research"], require: has_id }, +]; diff --git a/_primitives/_rust/kei-router/src/lib.rs b/_primitives/_rust/kei-router/src/lib.rs new file mode 100644 index 0000000..842dd89 --- /dev/null +++ b/_primitives/_rust/kei-router/src/lib.rs @@ -0,0 +1,21 @@ +//! kei-router — NL query to canonical tool-call dispatcher. +//! +//! Constructor Pattern: one cube = one file. Public API: +//! - [`Router::new`] — build with default rules +//! - [`Router::route`] — parse query, return [`RouteResult`] +//! - [`Router::add_dynamic`] — append runtime keyword rules +//! +//! Ported behavior (no ML fallback — upstream ML predictor dropped per task spec): +//! * regex-based param extraction (path / limit / depth / id / URI / KV) +//! * keyword-table dispatch, `require` predicate, first-match wins +//! * fallback to `search_code` (if path seen) else `search_knowledge` + +pub mod extract; +pub mod keywords; +pub mod kw_tables; +pub mod router; +pub mod rules; + +pub use extract::{extract_params, Extracted}; +pub use router::{Method, RouteResult, Router}; +pub use rules::{DynRule, KeywordRule}; diff --git a/_primitives/_rust/kei-router/src/main.rs b/_primitives/_rust/kei-router/src/main.rs new file mode 100644 index 0000000..b090451 --- /dev/null +++ b/_primitives/_rust/kei-router/src/main.rs @@ -0,0 +1,35 @@ +//! kei-router CLI — print routed tool-call as JSON. + +use clap::Parser; +use kei_router::Router; +use std::process::ExitCode; + +#[derive(Parser)] +#[command(name = "kei-router", version, about = "Route NL query → tool-call JSON")] +struct Cli { + /// The natural-language query. + query: String, + /// Hint remote-MCP forwarding on fallback (adds _forward=true). + #[arg(long)] + forward: bool, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let router = Router::new(); + let result = if cli.forward { + router.route_with_hint(&cli.query) + } else { + router.route(&cli.query) + }; + match serde_json::to_string_pretty(&result) { + Ok(s) => { + println!("{}", s); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("kei-router: json encode failed: {e}"); + ExitCode::from(1) + } + } +} diff --git a/_primitives/_rust/kei-router/src/router.rs b/_primitives/_rust/kei-router/src/router.rs new file mode 100644 index 0000000..291904e --- /dev/null +++ b/_primitives/_rust/kei-router/src/router.rs @@ -0,0 +1,157 @@ +//! Router — holds keyword rules, dispatches queries to tool calls. + +use crate::extract::{extract_params, Extracted}; +use crate::keywords::default_rules; +use crate::rules::{always, DynRule, KeywordRule}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Method { + Keyword, + Fallback, + Remote, +} + +/// Canonical route outcome. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RouteResult { + pub tool: String, + pub params: BTreeMap, + pub confidence: f64, + pub method: Method, +} + +/// Router holds the static + dynamic keyword rules. +pub struct Router { + rules: Vec, + dynamic: Vec, +} + +impl Default for Router { + fn default() -> Self { + Self::new() + } +} + +impl Router { + pub fn new() -> Self { + Self { + rules: default_rules(), + dynamic: Vec::new(), + } + } + + /// Append user-supplied rules at runtime (domain extension). + pub fn add_dynamic(&mut self, dyn_rules: Vec) { + self.dynamic.extend(dyn_rules); + } + + /// Route a natural language query. Always returns a result — falls back to search tools. + pub fn route(&self, query: &str) -> RouteResult { + let ext = extract_params(query); + if let Some(r) = self.keyword_match(&ext) { + return r; + } + if let Some(r) = self.dynamic_match(&ext) { + return r; + } + self.fallback(query, &ext) + } + + /// Convenience wrapper — useful for remote MCP forwarders that want a hint. + pub fn route_with_hint(&self, query: &str) -> RouteResult { + let mut r = self.route(query); + if r.method == Method::Fallback { + // Remote-MCP stub: caller may inspect params["_forward"] to decide. + r.params.insert("_forward".into(), serde_json::Value::Bool(true)); + } + r + } + + fn keyword_match(&self, ext: &Extracted) -> Option { + for rule in &self.rules { + if !(rule.require)(ext) { + continue; + } + for kw in rule.keywords { + if ext.text_clean.contains(kw) || ext.text.contains(kw) { + return Some(make_route(rule.tool, ext, Method::Keyword, 0.9)); + } + } + } + None + } + + fn dynamic_match(&self, ext: &Extracted) -> Option { + for rule in &self.dynamic { + for kw in &rule.keywords { + if ext.text.contains(kw.as_str()) { + return Some(make_route(&rule.tool, ext, Method::Keyword, 0.75)); + } + } + } + None + } + + fn fallback(&self, query: &str, ext: &Extracted) -> RouteResult { + if !ext.path.is_empty() { + make_route("search_code", ext, Method::Fallback, 0.3) + } else { + let mut params = BTreeMap::new(); + params.insert( + "query".into(), + serde_json::Value::String(query.to_string()), + ); + RouteResult { + tool: "search_knowledge".into(), + params, + confidence: 0.2, + method: Method::Fallback, + } + } + } +} + +fn make_route(tool: &str, ext: &Extracted, method: Method, confidence: f64) -> RouteResult { + RouteResult { + tool: tool.to_string(), + params: merge_params(ext), + confidence, + method, + } +} + +fn merge_params(ext: &Extracted) -> BTreeMap { + let mut m = BTreeMap::new(); + // KV pairs first — typed extraction below takes precedence on collisions + // (e.g. "id=42" → kv["id"]="42" string, but ext.id=42 wins as i64). + for (k, v) in &ext.kv { + m.insert(k.clone(), v.clone().into()); + } + if !ext.path.is_empty() { + m.insert("path".into(), ext.path.clone().into()); + } + if ext.limit > 0 { + m.insert("limit".into(), ext.limit.into()); + } + if ext.depth > 0 { + m.insert("depth".into(), ext.depth.into()); + } + if ext.id > 0 { + m.insert("id".into(), ext.id.into()); + } + if !ext.query.is_empty() { + m.insert("query".into(), ext.query.clone().into()); + } + if !ext.uri.is_empty() { + m.insert("uri".into(), ext.uri.clone().into()); + } + m +} + +// Silence unused import in some build modes. +#[allow(dead_code)] +fn _always_keep(_e: &Extracted) -> bool { + always(_e) +} diff --git a/_primitives/_rust/kei-router/src/rules.rs b/_primitives/_rust/kei-router/src/rules.rs new file mode 100644 index 0000000..399c521 --- /dev/null +++ b/_primitives/_rust/kei-router/src/rules.rs @@ -0,0 +1,35 @@ +//! Keyword rule type + `require` predicate model. + +use crate::extract::Extracted; + +/// A dispatch rule: any matching keyword routes to `tool` if `require(extracted)` is true. +#[derive(Clone)] +pub struct KeywordRule { + pub tool: &'static str, + pub keywords: &'static [&'static str], + pub require: fn(&Extracted) -> bool, +} + +/// A dynamic (runtime-added) rule — owned strings so caller can build at startup. +#[derive(Clone, Debug)] +pub struct DynRule { + pub tool: String, + pub keywords: Vec, +} + +// Predicates mirroring the Go require funcs. +pub fn always(_e: &Extracted) -> bool { + true +} +pub fn has_path(e: &Extracted) -> bool { + !e.path.is_empty() +} +pub fn has_id(e: &Extracted) -> bool { + e.id > 0 +} +pub fn has_paths(e: &Extracted) -> bool { + !e.paths.is_empty() +} +pub fn has_any_id_or_query(e: &Extracted) -> bool { + e.id > 0 || !e.query.is_empty() +} diff --git a/_primitives/_rust/kei-router/tests/integration.rs b/_primitives/_rust/kei-router/tests/integration.rs new file mode 100644 index 0000000..afba746 --- /dev/null +++ b/_primitives/_rust/kei-router/tests/integration.rs @@ -0,0 +1,76 @@ +//! kei-router integration tests — mirror LBM router_test.go semantics. + +use kei_router::{DynRule, Method, Router}; + +#[test] +fn exact_match_search_knowledge() { + let r = Router::new(); + let out = r.route("search knowledge base for rust async"); + assert_eq!(out.tool, "search_knowledge"); + assert_eq!(out.method, Method::Keyword); + assert!(out.confidence > 0.7); +} + +#[test] +fn fuzzy_match_find_importers_with_path() { + let r = Router::new(); + let out = r.route("who imports /src/router.rs"); + assert_eq!(out.tool, "find_importers"); + assert_eq!( + out.params.get("path").and_then(|v| v.as_str()), + Some("/src/router.rs") + ); +} + +#[test] +fn no_match_fallback_knowledge() { + let r = Router::new(); + let out = r.route("hello this is not a routed query"); + assert_eq!(out.tool, "search_knowledge"); + assert_eq!(out.method, Method::Fallback); + assert!(out.confidence < 0.3); +} + +#[test] +fn no_match_fallback_code_with_path() { + let r = Router::new(); + let out = r.route("what happened in /tmp/mystery.rs"); + assert_eq!(out.tool, "search_code"); + assert_eq!(out.method, Method::Fallback); +} + +#[test] +fn confidence_ranking_keyword_above_fallback() { + let r = Router::new(); + let kw = r.route("knowledge stats please"); + let fb = r.route("asdf zxcv qwer"); + assert!(kw.confidence > fb.confidence); +} + +#[test] +fn dynamic_rule_addition() { + let mut r = Router::new(); + r.add_dynamic(vec![DynRule { + tool: "custom_tool".into(), + keywords: vec!["magic-keyword".into()], + }]); + let out = r.route("please run magic-keyword now"); + assert_eq!(out.tool, "custom_tool"); + assert_eq!(out.method, Method::Keyword); +} + +#[test] +fn remote_mcp_forward_hint() { + let r = Router::new(); + let out = r.route_with_hint("completely novel utterance xyz"); + assert_eq!(out.method, Method::Fallback); + assert_eq!(out.params.get("_forward"), Some(&serde_json::json!(true))); +} + +#[test] +fn id_extraction_for_get_task() { + let r = Router::new(); + let out = r.route("get task id=42"); + assert_eq!(out.tool, "get_task"); + assert_eq!(out.params.get("id").and_then(|v| v.as_i64()), Some(42)); +} diff --git a/_primitives/_rust/kei-sage/Cargo.toml b/_primitives/_rust/kei-sage/Cargo.toml new file mode 100644 index 0000000..a9643cd --- /dev/null +++ b/_primitives/_rust/kei-sage/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "kei-sage" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Obsidian-style knowledge graph (SQLite + FTS5). Port of LBM internal/sage." + +[[bin]] +name = "kei-sage" +path = "src/main.rs" + +[lib] +name = "kei_sage" +path = "src/lib.rs" + +[dependencies] +rusqlite = { version = "0.31", features = ["bundled"] } +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/kei-sage/src/bfs.rs b/_primitives/_rust/kei-sage/src/bfs.rs new file mode 100644 index 0000000..b8810f2 --- /dev/null +++ b/_primitives/_rust/kei-sage/src/bfs.rs @@ -0,0 +1,50 @@ +//! BFS traversal over the edges table, depth-limited, deduplicated. + +use crate::edges::list_outgoing; +use crate::store::Store; +use crate::types::Related; +use anyhow::Result; +use std::collections::{HashSet, VecDeque}; + +const MAX_RESULTS: usize = 500; +const MAX_DEPTH: i64 = 5; + +pub fn bfs(store: &Store, start: &str, max_depth: i64) -> Result> { + let depth = clamp_depth(max_depth); + let mut visited: HashSet = HashSet::new(); + visited.insert(start.to_string()); + let mut queue: VecDeque<(String, i64)> = VecDeque::new(); + queue.push_back((start.to_string(), 0)); + let mut out: Vec = Vec::new(); + while let Some((path, d)) = queue.pop_front() { + if out.len() >= MAX_RESULTS { + break; + } + if d >= depth { + continue; + } + for e in list_outgoing(store, &path)? { + if visited.contains(&e.dst_path) || out.len() >= MAX_RESULTS { + continue; + } + visited.insert(e.dst_path.clone()); + out.push(Related { + path: e.dst_path.clone(), + edge_type: e.edge_type, + depth: d + 1, + }); + queue.push_back((e.dst_path, d + 1)); + } + } + Ok(out) +} + +fn clamp_depth(d: i64) -> i64 { + if d <= 0 { + 2 + } else if d > MAX_DEPTH { + MAX_DEPTH + } else { + d + } +} diff --git a/_primitives/_rust/kei-sage/src/edges.rs b/_primitives/_rust/kei-sage/src/edges.rs new file mode 100644 index 0000000..391f9e0 --- /dev/null +++ b/_primitives/_rust/kei-sage/src/edges.rs @@ -0,0 +1,62 @@ +//! Typed-edge CRUD between vault_paths. + +use crate::store::Store; +use crate::types::Edge; +use anyhow::Result; +use chrono::Utc; +use rusqlite::params; + +pub fn add_edge(store: &Store, src: &str, dst: &str, edge_type: &str, weight: f64) -> Result { + let now = Utc::now().timestamp(); + store.conn().execute( + "INSERT OR IGNORE INTO edges (src_path, dst_path, edge_type, weight, created_at) + VALUES (?1,?2,?3,?4,?5)", + params![src, dst, edge_type, weight, now], + )?; + Ok(store.conn().last_insert_rowid()) +} + +pub fn remove_edge(store: &Store, src: &str, dst: &str, edge_type: &str) -> Result { + let n = store.conn().execute( + "DELETE FROM edges WHERE src_path=?1 AND dst_path=?2 AND edge_type=?3", + params![src, dst, edge_type], + )?; + Ok(n) +} + +pub fn list_outgoing(store: &Store, src: &str) -> Result> { + let mut stmt = store.conn().prepare( + "SELECT id, src_path, dst_path, edge_type, weight, created_at + FROM edges WHERE src_path=?1", + )?; + let rows = stmt.query_map(params![src], row_to_edge)?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) +} + +pub fn list_incoming(store: &Store, dst: &str) -> Result> { + let mut stmt = store.conn().prepare( + "SELECT id, src_path, dst_path, edge_type, weight, created_at + FROM edges WHERE dst_path=?1", + )?; + let rows = stmt.query_map(params![dst], row_to_edge)?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) +} + +fn row_to_edge(r: &rusqlite::Row) -> rusqlite::Result { + Ok(Edge { + id: r.get(0)?, + src_path: r.get(1)?, + dst_path: r.get(2)?, + edge_type: r.get(3)?, + weight: r.get(4)?, + created_at: r.get(5)?, + }) +} diff --git a/_primitives/_rust/kei-sage/src/import.rs b/_primitives/_rust/kei-sage/src/import.rs new file mode 100644 index 0000000..01d51e4 --- /dev/null +++ b/_primitives/_rust/kei-sage/src/import.rs @@ -0,0 +1,76 @@ +//! Obsidian-style vault import: walk a directory, ingest .md files. +//! +//! Minimal subset of LBM internal/sage/import_obsidian.go — we do NOT parse +//! frontmatter here (the upstream parser used multiple helper files). Port +//! of frontmatter/wikilinks parsing is a later milestone; this cube honours +//! the public interface. + +use crate::store::Store; +use crate::types::Unit; +use anyhow::{Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub struct ImportStats { + pub imported: usize, + pub skipped: usize, +} + +pub fn import_vault(store: &Store, root: &Path) -> Result { + let mut stats = ImportStats { imported: 0, skipped: 0 }; + let files = walk_md(root)?; + for path in files { + match ingest_one(store, root, &path) { + Ok(_) => stats.imported += 1, + Err(_) => stats.skipped += 1, + } + } + Ok(stats) +} + +fn walk_md(root: &Path) -> Result> { + let mut out = Vec::new(); + walk_recursive(root, &mut out)?; + Ok(out) +} + +fn walk_recursive(dir: &Path, out: &mut Vec) -> Result<()> { + if !dir.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + walk_recursive(&path, out)?; + } else if path.extension().and_then(|s| s.to_str()) == Some("md") { + out.push(path); + } + } + Ok(()) +} + +fn ingest_one(store: &Store, root: &Path, path: &Path) -> Result<()> { + let content = fs::read_to_string(path)?; + let title = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("untitled") + .to_string(); + let vault_path = path.strip_prefix(root) + .ok() + .and_then(|p| p.to_str()) + .unwrap_or(&title) + .to_string(); + let unit = Unit { + unit_type: "note".into(), + title, + content, + evidence_grade: "E4".into(), + source_path: path.to_string_lossy().into(), + vault_path, + category: String::new(), + ..Default::default() + }; + store.add_unit(&unit)?; + Ok(()) +} diff --git a/_primitives/_rust/kei-sage/src/lib.rs b/_primitives/_rust/kei-sage/src/lib.rs new file mode 100644 index 0000000..9980331 --- /dev/null +++ b/_primitives/_rust/kei-sage/src/lib.rs @@ -0,0 +1,15 @@ +//! kei-sage — SQLite knowledge-vault with FTS5 + typed edges + BFS + PageRank. +//! +//! Port of LBM internal/sage. Constructor Pattern: one concept per file. + +pub mod bfs; +pub mod edges; +pub mod import; +pub mod pagerank; +pub mod schema; +pub mod search; +pub mod store; +pub mod types; + +pub use store::Store; +pub use types::{Edge, Related, Unit}; diff --git a/_primitives/_rust/kei-sage/src/main.rs b/_primitives/_rust/kei-sage/src/main.rs new file mode 100644 index 0000000..794c8d3 --- /dev/null +++ b/_primitives/_rust/kei-sage/src/main.rs @@ -0,0 +1,131 @@ +//! kei-sage CLI — import / search / related / rank / add / edit. + +use clap::{Parser, Subcommand}; +use kei_sage::bfs::bfs; +use kei_sage::edges::add_edge; +use kei_sage::import::import_vault; +use kei_sage::pagerank::pagerank; +use kei_sage::search::fts_search; +use kei_sage::{Store, Unit}; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser)] +#[command(name = "kei-sage", version, about = "Obsidian-style knowledge vault")] +struct Cli { + /// Database path (default: $KEI_VAULT_DB or ~/.claude/sage/vault.sqlite) + #[arg(long)] + db: Option, + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + Import { vault: PathBuf }, + Search { query: String, #[arg(long, default_value_t = 20)] limit: i64 }, + Related { key: String, #[arg(long, default_value_t = 2)] depth: i64 }, + Rank { #[arg(long, default_value_t = 20)] limit: usize }, + Add { + #[arg(long)] title: String, + #[arg(long, default_value = "")] content: String, + #[arg(long, default_value = "")] vault_path: String, + #[arg(long, default_value = "E4")] grade: String, + }, + Edit { + id: i64, + #[arg(long)] title: Option, + #[arg(long)] content: Option, + #[arg(long)] grade: Option, + }, + Link { src: String, dst: String, #[arg(long, default_value = "related")] edge_type: String }, +} + +fn db_path(cli_db: Option) -> PathBuf { + if let Some(p) = cli_db { return p; } + if let Ok(e) = std::env::var("KEI_VAULT_DB") { return PathBuf::from(e); } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".claude/sage/vault.sqlite") +} + +fn run() -> anyhow::Result<()> { + let cli = Cli::parse(); + let store = Store::open(&db_path(cli.db))?; + dispatch(&store, cli.cmd) +} + +fn dispatch(store: &Store, cmd: Cmd) -> anyhow::Result<()> { + match cmd { + Cmd::Import { vault } => cmd_import(store, &vault), + Cmd::Search { query, limit } => cmd_search(store, &query, limit), + Cmd::Related { key, depth } => cmd_related(store, &key, depth), + Cmd::Rank { limit } => cmd_rank(store, limit), + Cmd::Add { title, content, vault_path, grade } => + cmd_add(store, title, content, vault_path, grade), + Cmd::Edit { id, title, content, grade } => + cmd_edit(store, id, title, content, grade), + Cmd::Link { src, dst, edge_type } => cmd_link(store, &src, &dst, &edge_type), + } +} + +fn cmd_import(store: &Store, vault: &std::path::Path) -> anyhow::Result<()> { + let s = import_vault(store, vault)?; + println!("imported={} skipped={}", s.imported, s.skipped); + Ok(()) +} + +fn cmd_search(store: &Store, query: &str, limit: i64) -> anyhow::Result<()> { + for u in fts_search(store, query, limit)? { + println!("{}\t{}\t{}", u.id, u.evidence_grade, u.title); + } + Ok(()) +} + +fn cmd_related(store: &Store, key: &str, depth: i64) -> anyhow::Result<()> { + for r in bfs(store, key, depth)? { + println!("{}\t{}\t(depth {})", r.edge_type, r.path, r.depth); + } + Ok(()) +} + +fn cmd_rank(store: &Store, limit: usize) -> anyhow::Result<()> { + for (p, s) in pagerank(store)?.into_iter().take(limit) { + println!("{:.6}\t{}", s, p); + } + Ok(()) +} + +fn cmd_add(store: &Store, title: String, content: String, + vault_path: String, grade: String) -> anyhow::Result<()> { + let id = store.add_unit(&Unit { + title, content, vault_path, evidence_grade: grade, + unit_type: "note".into(), ..Default::default() + })?; + println!("{}", id); + Ok(()) +} + +fn cmd_edit(store: &Store, id: i64, title: Option, + content: Option, grade: Option) -> anyhow::Result<()> { + let mut u = store.get_unit(id)? + .ok_or_else(|| anyhow::anyhow!("id {id} not found"))?; + if let Some(t) = title { u.title = t; } + if let Some(c) = content { u.content = c; } + if let Some(g) = grade { u.evidence_grade = g; } + store.update_unit(&u)?; + println!("updated {}", id); + Ok(()) +} + +fn cmd_link(store: &Store, src: &str, dst: &str, edge_type: &str) -> anyhow::Result<()> { + add_edge(store, src, dst, edge_type, 1.0)?; + println!("linked {} -> {}", src, dst); + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { eprintln!("kei-sage: {e:#}"); ExitCode::from(1) } + } +} diff --git a/_primitives/_rust/kei-sage/src/pagerank.rs b/_primitives/_rust/kei-sage/src/pagerank.rs new file mode 100644 index 0000000..28baad9 --- /dev/null +++ b/_primitives/_rust/kei-sage/src/pagerank.rs @@ -0,0 +1,60 @@ +//! PageRank — power-iteration, 50 iterations, d=0.85. Operates on the edges table. + +use crate::store::Store; +use anyhow::Result; +use std::collections::HashMap; + +const DAMPING: f64 = 0.85; +const ITERATIONS: usize = 50; + +/// Compute PageRank over the edges table. Returns [(path, score)] sorted desc. +pub fn pagerank(store: &Store) -> Result> { + let (nodes, out_edges) = collect_graph(store)?; + if nodes.is_empty() { + return Ok(Vec::new()); + } + let mut rank: HashMap = nodes.iter() + .map(|n| (n.clone(), 1.0 / nodes.len() as f64)).collect(); + for _ in 0..ITERATIONS { + rank = one_iteration(&nodes, &out_edges, &rank); + } + let mut out: Vec<(String, f64)> = rank.into_iter().collect(); + out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + Ok(out) +} + +fn collect_graph(store: &Store) -> Result<(Vec, HashMap>)> { + let mut stmt = store.conn().prepare("SELECT src_path, dst_path FROM edges")?; + let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?; + let mut nodes: std::collections::HashSet = std::collections::HashSet::new(); + let mut out_edges: HashMap> = HashMap::new(); + for row in rows { + let (src, dst) = row?; + nodes.insert(src.clone()); + nodes.insert(dst.clone()); + out_edges.entry(src).or_default().push(dst); + } + Ok((nodes.into_iter().collect(), out_edges)) +} + +fn one_iteration( + nodes: &[String], + out_edges: &HashMap>, + prev: &HashMap, +) -> HashMap { + let n = nodes.len() as f64; + let base = (1.0 - DAMPING) / n; + let mut next: HashMap = nodes.iter().map(|k| (k.clone(), base)).collect(); + for (src, dsts) in out_edges { + if dsts.is_empty() { + continue; + } + let share = DAMPING * prev.get(src).copied().unwrap_or(0.0) / dsts.len() as f64; + for dst in dsts { + if let Some(slot) = next.get_mut(dst) { + *slot += share; + } + } + } + next +} diff --git a/_primitives/_rust/kei-sage/src/schema.rs b/_primitives/_rust/kei-sage/src/schema.rs new file mode 100644 index 0000000..962d0d9 --- /dev/null +++ b/_primitives/_rust/kei-sage/src/schema.rs @@ -0,0 +1,57 @@ +//! SQLite schema for knowledge-vault. Port of LBM internal/sage/vault_schema.go. + +use rusqlite::{Connection, Result}; + +const DDL_MAIN: &str = r#" + CREATE TABLE IF NOT EXISTS knowledge_units ( + id INTEGER PRIMARY KEY, + unit_type TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT DEFAULT '', + evidence_grade TEXT DEFAULT '', + source_path TEXT DEFAULT '', + vault_path TEXT DEFAULT '', + category TEXT DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_ku_type ON knowledge_units(unit_type); + CREATE UNIQUE INDEX IF NOT EXISTS idx_ku_vault + ON knowledge_units(vault_path) WHERE vault_path != ''; + CREATE INDEX IF NOT EXISTS idx_ku_grade ON knowledge_units(evidence_grade); + + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL + ); + + CREATE TABLE IF NOT EXISTS unit_tags ( + unit_id INTEGER NOT NULL REFERENCES knowledge_units(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (unit_id, tag_id) + ); + + CREATE TABLE IF NOT EXISTS edges ( + id INTEGER PRIMARY KEY, + src_path TEXT NOT NULL, + dst_path TEXT NOT NULL, + edge_type TEXT NOT NULL, + weight REAL DEFAULT 1.0, + created_at INTEGER NOT NULL, + UNIQUE(src_path, dst_path, edge_type) + ); + CREATE INDEX IF NOT EXISTS idx_sage_edges_src ON edges(src_path); + CREATE INDEX IF NOT EXISTS idx_sage_edges_dst ON edges(dst_path); +"#; + +const DDL_FTS: &str = r#" + CREATE VIRTUAL TABLE IF NOT EXISTS fts_knowledge + USING fts5(unit_id UNINDEXED, title, content, tokenize='porter unicode61'); +"#; + +/// Apply schema + FTS5 virtual table. Idempotent. +pub fn create_schema(conn: &Connection) -> Result<()> { + conn.execute_batch(DDL_MAIN)?; + conn.execute_batch(DDL_FTS)?; + Ok(()) +} diff --git a/_primitives/_rust/kei-sage/src/search.rs b/_primitives/_rust/kei-sage/src/search.rs new file mode 100644 index 0000000..8d6309e --- /dev/null +++ b/_primitives/_rust/kei-sage/src/search.rs @@ -0,0 +1,33 @@ +//! FTS5 search over knowledge_units. + +use crate::store::Store; +use crate::types::Unit; +use anyhow::Result; +use rusqlite::params; + +const SEARCH_SQL: &str = + "SELECT k.id, k.unit_type, k.title, k.content, k.evidence_grade, + k.source_path, k.vault_path, k.category, k.created_at, k.updated_at + FROM fts_knowledge f + JOIN knowledge_units k ON k.id = f.unit_id + WHERE fts_knowledge MATCH ?1 + ORDER BY rank LIMIT ?2"; + +/// Full-text search. Returns matching Units ordered by SQLite FTS5 rank. +pub fn fts_search(store: &Store, query: &str, limit: i64) -> Result> { + let lim = if limit <= 0 { 20 } else { limit }; + let mut stmt = store.conn().prepare(SEARCH_SQL)?; + let rows = stmt.query_map(params![query, lim], row_to_unit)?; + let mut out = Vec::new(); + for row in rows { out.push(row?); } + Ok(out) +} + +fn row_to_unit(r: &rusqlite::Row) -> rusqlite::Result { + Ok(Unit { + id: r.get(0)?, unit_type: r.get(1)?, title: r.get(2)?, + content: r.get(3)?, evidence_grade: r.get(4)?, source_path: r.get(5)?, + vault_path: r.get(6)?, category: r.get(7)?, + created_at: r.get(8)?, updated_at: r.get(9)?, + }) +} diff --git a/_primitives/_rust/kei-sage/src/store.rs b/_primitives/_rust/kei-sage/src/store.rs new file mode 100644 index 0000000..c21cfc2 --- /dev/null +++ b/_primitives/_rust/kei-sage/src/store.rs @@ -0,0 +1,111 @@ +//! Knowledge-unit CRUD + FTS indexer. + +use crate::schema::create_schema; +use crate::types::Unit; +use anyhow::{Context, Result}; +use chrono::Utc; +use rusqlite::{params, Connection}; +use std::path::Path; + +pub struct Store { + conn: Connection, +} + +impl Store { + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let conn = Connection::open(path).context("open sqlite")?; + conn.pragma_update(None, "journal_mode", "WAL").ok(); + create_schema(&conn)?; + Ok(Self { conn }) + } + + pub fn open_memory() -> Result { + let conn = Connection::open_in_memory()?; + create_schema(&conn)?; + Ok(Self { conn }) + } + + pub fn conn(&self) -> &Connection { + &self.conn + } + + /// Insert a new knowledge unit. Indexes title+content into FTS5. Idempotent by vault_path. + pub fn add_unit(&self, unit: &Unit) -> Result { + let now = Utc::now().timestamp(); + let created = if unit.created_at == 0 { now } else { unit.created_at }; + self.conn.execute( + "INSERT OR REPLACE INTO knowledge_units + (unit_type, title, content, evidence_grade, source_path, + vault_path, category, created_at, updated_at) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)", + params![unit.unit_type, unit.title, unit.content, unit.evidence_grade, + unit.source_path, unit.vault_path, unit.category, created, now], + )?; + let id = self.conn.last_insert_rowid(); + self.reindex_fts(id, &unit.title, &unit.content)?; + Ok(id) + } + + pub fn get_unit(&self, id: i64) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, unit_type, title, content, evidence_grade, source_path, + vault_path, category, created_at, updated_at + FROM knowledge_units WHERE id=?1", + )?; + let mut rows = stmt.query(params![id])?; + if let Some(r) = rows.next()? { + return Ok(Some(row_to_unit(r)?)); + } + Ok(None) + } + + pub fn update_unit(&self, unit: &Unit) -> Result<()> { + let now = Utc::now().timestamp(); + self.conn.execute( + "UPDATE knowledge_units SET title=?1, content=?2, evidence_grade=?3, + category=?4, updated_at=?5 WHERE id=?6", + params![unit.title, unit.content, unit.evidence_grade, + unit.category, now, unit.id], + )?; + self.reindex_fts(unit.id, &unit.title, &unit.content)?; + Ok(()) + } + + pub fn delete_unit(&self, id: i64) -> Result<()> { + self.conn.execute("DELETE FROM fts_knowledge WHERE unit_id=?1", params![id])?; + self.conn.execute("DELETE FROM knowledge_units WHERE id=?1", params![id])?; + Ok(()) + } + + pub fn count_units(&self) -> Result { + Ok(self.conn.query_row( + "SELECT COUNT(*) FROM knowledge_units", [], |r| r.get(0))?) + } + + fn reindex_fts(&self, id: i64, title: &str, content: &str) -> Result<()> { + self.conn.execute("DELETE FROM fts_knowledge WHERE unit_id=?1", params![id])?; + self.conn.execute( + "INSERT INTO fts_knowledge (unit_id, title, content) VALUES (?1,?2,?3)", + params![id, title, content], + )?; + Ok(()) + } +} + +fn row_to_unit(r: &rusqlite::Row) -> rusqlite::Result { + Ok(Unit { + id: r.get(0)?, + unit_type: r.get(1)?, + title: r.get(2)?, + content: r.get(3)?, + evidence_grade: r.get(4)?, + source_path: r.get(5)?, + vault_path: r.get(6)?, + category: r.get(7)?, + created_at: r.get(8)?, + updated_at: r.get(9)?, + }) +} diff --git a/_primitives/_rust/kei-sage/src/types.rs b/_primitives/_rust/kei-sage/src/types.rs new file mode 100644 index 0000000..55fa223 --- /dev/null +++ b/_primitives/_rust/kei-sage/src/types.rs @@ -0,0 +1,34 @@ +//! Shared value types for knowledge units + edges + BFS results. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Unit { + pub id: i64, + pub unit_type: String, + pub title: String, + pub content: String, + pub evidence_grade: String, + pub source_path: String, + pub vault_path: String, + pub category: String, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Edge { + pub id: i64, + pub src_path: String, + pub dst_path: String, + pub edge_type: String, + pub weight: f64, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Related { + pub path: String, + pub edge_type: String, + pub depth: i64, +} diff --git a/_primitives/_rust/kei-sage/tests/integration.rs b/_primitives/_rust/kei-sage/tests/integration.rs new file mode 100644 index 0000000..0c19550 --- /dev/null +++ b/_primitives/_rust/kei-sage/tests/integration.rs @@ -0,0 +1,110 @@ +//! kei-sage integration tests. + +use kei_sage::bfs::bfs; +use kei_sage::edges::{add_edge, list_outgoing}; +use kei_sage::import::import_vault; +use kei_sage::pagerank::pagerank; +use kei_sage::search::fts_search; +use kei_sage::{Store, Unit}; +use std::fs; +use tempfile::tempdir; + +fn mkstore() -> Store { Store::open_memory().unwrap() } + +fn mkunit(title: &str, body: &str, vault: &str) -> Unit { + Unit { + unit_type: "note".into(), title: title.into(), content: body.into(), + evidence_grade: "E2".into(), vault_path: vault.into(), + ..Default::default() + } +} + +#[test] +fn crud_roundtrip() { + let s = mkstore(); + let id = s.add_unit(&mkunit("hello", "world", "a.md")).unwrap(); + assert!(id > 0); + let u = s.get_unit(id).unwrap().unwrap(); + assert_eq!(u.title, "hello"); + s.delete_unit(id).unwrap(); + assert!(s.get_unit(id).unwrap().is_none()); +} + +#[test] +fn fts_search_matches() { + let s = mkstore(); + s.add_unit(&mkunit("rust async", "tokio runtime details", "a.md")).unwrap(); + s.add_unit(&mkunit("python sync", "flask wsgi server", "b.md")).unwrap(); + let hits = fts_search(&s, "tokio", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].title, "rust async"); +} + +#[test] +fn bfs_depth_limit() { + let s = mkstore(); + add_edge(&s, "a", "b", "rel", 1.0).unwrap(); + add_edge(&s, "b", "c", "rel", 1.0).unwrap(); + add_edge(&s, "c", "d", "rel", 1.0).unwrap(); + let out = bfs(&s, "a", 2).unwrap(); + let paths: Vec<&str> = out.iter().map(|r| r.path.as_str()).collect(); + assert!(paths.contains(&"b")); + assert!(paths.contains(&"c")); + assert!(!paths.contains(&"d")); +} + +#[test] +fn pagerank_orders_by_popularity() { + let s = mkstore(); + add_edge(&s, "a", "hub", "rel", 1.0).unwrap(); + add_edge(&s, "b", "hub", "rel", 1.0).unwrap(); + add_edge(&s, "c", "hub", "rel", 1.0).unwrap(); + add_edge(&s, "d", "hub", "rel", 1.0).unwrap(); + add_edge(&s, "e", "hub", "rel", 1.0).unwrap(); + let ranks = pagerank(&s).unwrap(); + assert_eq!(ranks[0].0, "hub"); +} + +#[test] +fn edges_crud() { + let s = mkstore(); + let id = add_edge(&s, "x", "y", "cites", 0.8).unwrap(); + assert!(id > 0); + let out = list_outgoing(&s, "x").unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].dst_path, "y"); +} + +#[test] +fn import_idempotency() { + let tmp = tempdir().unwrap(); + let p = tmp.path().join("one.md"); + fs::write(&p, "# title one\nhello").unwrap(); + let s = mkstore(); + let first = import_vault(&s, tmp.path()).unwrap(); + let second = import_vault(&s, tmp.path()).unwrap(); + assert_eq!(first.imported, 1); + assert_eq!(second.imported, 1); + assert_eq!(s.count_units().unwrap(), 1); +} + +#[test] +fn edges_cross_reference_validates() { + let s = mkstore(); + s.add_unit(&mkunit("note a", "", "a.md")).unwrap(); + s.add_unit(&mkunit("note b", "", "b.md")).unwrap(); + add_edge(&s, "a.md", "b.md", "refs", 1.0).unwrap(); + let out = list_outgoing(&s, "a.md").unwrap(); + assert_eq!(out.len(), 1); +} + +#[test] +fn fts5_respects_limit() { + let s = mkstore(); + for i in 0..25 { + let t = format!("rust note {i}"); + s.add_unit(&mkunit(&t, "rust rust rust", &format!("n{i}.md"))).unwrap(); + } + let hits = fts_search(&s, "rust", 5).unwrap(); + assert_eq!(hits.len(), 5); +} diff --git a/_primitives/_rust/kei-search-core/Cargo.toml b/_primitives/_rust/kei-search-core/Cargo.toml new file mode 100644 index 0000000..d549fbe --- /dev/null +++ b/_primitives/_rust/kei-search-core/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "kei-search-core" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "3-wave deep research scaffolding with budget cap. Port of LBM internal/search (fetch stubbed)." + +[[bin]] +name = "kei-search-core" +path = "src/main.rs" + +[lib] +name = "kei_search_core" +path = "src/lib.rs" + +[dependencies] +rusqlite = { version = "0.31", features = ["bundled"] } +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/kei-search-core/src/budget.rs b/_primitives/_rust/kei-search-core/src/budget.rs new file mode 100644 index 0000000..da55653 --- /dev/null +++ b/_primitives/_rust/kei-search-core/src/budget.rs @@ -0,0 +1,34 @@ +//! Budget tracker — all costs in microcents (1 USD = 1_000_000 mc). + +use anyhow::{anyhow, Result}; + +#[derive(Debug, Clone)] +pub struct Budget { + cap_mc: i64, + spent_mc: i64, + stopped: bool, +} + +impl Budget { + pub fn new(cap_mc: i64) -> Self { + Self { cap_mc, spent_mc: 0, stopped: false } + } + + /// Record a cost; returns error if this push would exceed the cap. + pub fn charge(&mut self, mc: i64) -> Result<()> { + if self.stopped { + return Err(anyhow!("budget stopped")); + } + if self.spent_mc + mc > self.cap_mc { + return Err(anyhow!( + "budget exceeded: spent={} cap={}", self.spent_mc + mc, self.cap_mc)); + } + self.spent_mc += mc; + Ok(()) + } + + pub fn spent(&self) -> i64 { self.spent_mc } + pub fn remaining(&self) -> i64 { self.cap_mc - self.spent_mc } + pub fn stop(&mut self) { self.stopped = true; } + pub fn is_stopped(&self) -> bool { self.stopped } +} diff --git a/_primitives/_rust/kei-search-core/src/export.rs b/_primitives/_rust/kei-search-core/src/export.rs new file mode 100644 index 0000000..fc1abb1 --- /dev/null +++ b/_primitives/_rust/kei-search-core/src/export.rs @@ -0,0 +1,40 @@ +//! Export research → markdown / JSON. + +use crate::store::ResearchStore; +use anyhow::{anyhow, Result}; +use serde_json::json; + +pub enum Format { + Markdown, + Json, +} + +pub fn export(store: &ResearchStore, id: i64, fmt: Format) -> Result { + let r = store.get_research(id)?.ok_or_else(|| anyhow!("research {id} missing"))?; + let claims = store.claims_for(id)?; + match fmt { + Format::Markdown => { + let mut md = String::new(); + md.push_str(&format!("# Research {}\n\n", r.id)); + md.push_str(&format!("**Query:** {}\n\n", r.query_original)); + md.push_str(&format!("**Status:** {}\n", r.status)); + md.push_str(&format!("**Cost:** {} mc\n\n", r.total_cost_mc)); + md.push_str("## Claims\n\n"); + for c in claims { + md.push_str(&format!("- [{}] {} (consensus={:.2})\n", + c.grade, c.claim_text, c.consensus)); + } + Ok(md) + } + Format::Json => { + let val = json!({ + "id": r.id, + "query": r.query_original, + "status": r.status, + "cost_mc": r.total_cost_mc, + "claims": claims, + }); + Ok(serde_json::to_string_pretty(&val)?) + } + } +} diff --git a/_primitives/_rust/kei-search-core/src/fetch.rs b/_primitives/_rust/kei-search-core/src/fetch.rs new file mode 100644 index 0000000..28c345f --- /dev/null +++ b/_primitives/_rust/kei-search-core/src/fetch.rs @@ -0,0 +1,23 @@ +//! Source fetcher trait — frozen interface, default impl is a no-op stub. +//! +//! Actual WebFetch/WebSearch integration is out-of-scope for v0.14 part A. +//! Later milestones plug real providers (anthropic-websearch, SerpAPI, etc.). + +use crate::types::Source; + +/// Implement this trait to integrate a live search provider. +pub trait SourceFetcher { + /// Fetch sources for `claim`. Returns (source, cost_microcents). + /// Cost is real — the budget is charged by the pipeline, not by impl. + fn fetch(&self, claim: &str) -> (Vec, i64); +} + +/// Default stub — returns empty. Frozen interface, no runtime side-effects. +pub struct StubFetcher; + +impl SourceFetcher for StubFetcher { + fn fetch(&self, _claim: &str) -> (Vec, i64) { + // TODO(v0.15): wire to real websearch. Kept as stub per v0.14 spec. + (Vec::new(), 0) + } +} diff --git a/_primitives/_rust/kei-search-core/src/lib.rs b/_primitives/_rust/kei-search-core/src/lib.rs new file mode 100644 index 0000000..c008159 --- /dev/null +++ b/_primitives/_rust/kei-search-core/src/lib.rs @@ -0,0 +1,22 @@ +//! kei-search-core — 3-wave deep research engine, budget-capped. +//! +//! Waves: +//! 0 — claim extraction from prompt +//! 1 — per-claim source hunt (WebFetch stubbed behind [`SourceFetcher`] trait) +//! 2 — cross-validation + consensus scoring +//! +//! Port of LBM internal/search. The actual fetch is a trait the caller +//! supplies. Default implementation returns empty (frozen interface, todo!() +//! reflects unimplemented runtime). + +pub mod budget; +pub mod export; +pub mod fetch; +pub mod pipeline; +pub mod schema; +pub mod store; +pub mod types; + +pub use pipeline::run_research; +pub use store::ResearchStore; +pub use types::{Claim, Research, Source}; diff --git a/_primitives/_rust/kei-search-core/src/main.rs b/_primitives/_rust/kei-search-core/src/main.rs new file mode 100644 index 0000000..c5b6f6e --- /dev/null +++ b/_primitives/_rust/kei-search-core/src/main.rs @@ -0,0 +1,61 @@ +//! kei-search-core CLI. + +use clap::{Parser, Subcommand, ValueEnum}; +use kei_search_core::export::{export, Format}; +use kei_search_core::fetch::StubFetcher; +use kei_search_core::pipeline::run_research; +use kei_search_core::ResearchStore; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser)] +#[command(name = "kei-search-core", version)] +struct Cli { + #[arg(long)] db: Option, + #[command(subcommand)] cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + Run { prompt: String, + #[arg(long, default_value_t = 1_000_000)] budget: i64 }, // 1 USD + Stop { id: i64 }, + Export { id: i64, #[arg(long, value_enum, default_value_t = Fmt::Md)] format: Fmt }, +} + +#[derive(Clone, Copy, ValueEnum)] +enum Fmt { Md, Json } + +fn db_path(o: Option) -> PathBuf { + if let Some(p) = o { return p; } + if let Ok(e) = std::env::var("KEI_SEARCH_DB") { return PathBuf::from(e); } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".claude/search/research.sqlite") +} + +fn run() -> anyhow::Result<()> { + let cli = Cli::parse(); + let s = ResearchStore::open(&db_path(cli.db))?; + match cli.cmd { + Cmd::Run { prompt, budget } => { + let id = run_research(&s, &StubFetcher, &prompt, budget)?; + println!("{}", id); + } + Cmd::Stop { id } => { + s.set_status(id, "stopped")?; + println!("stopped {}", id); + } + Cmd::Export { id, format } => { + let f = match format { Fmt::Md => Format::Markdown, Fmt::Json => Format::Json }; + println!("{}", export(&s, id, f)?); + } + } + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { eprintln!("kei-search-core: {e:#}"); ExitCode::from(1) } + } +} diff --git a/_primitives/_rust/kei-search-core/src/pipeline.rs b/_primitives/_rust/kei-search-core/src/pipeline.rs new file mode 100644 index 0000000..ef55317 --- /dev/null +++ b/_primitives/_rust/kei-search-core/src/pipeline.rs @@ -0,0 +1,90 @@ +//! 3-wave research runner. +//! +//! Wave 0: split prompt into claims (naive split on `.`; real NLU later). +//! Wave 1: for each claim, fetch sources via [`SourceFetcher`]. +//! Wave 2: score consensus per claim from sources (majority = higher grade). + +use crate::budget::Budget; +use crate::fetch::SourceFetcher; +use crate::store::ResearchStore; +use crate::types::{Claim, Source}; +use anyhow::Result; + +const WAVE1_COST_PER_CLAIM_MC: i64 = 100; // 0.01 USD per claim +const WAVE2_COST_MC: i64 = 50; + +pub fn run_research( + store: &ResearchStore, + fetcher: &dyn SourceFetcher, + prompt: &str, + budget_mc: i64, +) -> Result { + let research_id = store.create_research(prompt)?; + let mut budget = Budget::new(budget_mc); + let claims_text = wave_0_extract_claims(prompt); + if let Err(e) = wave_1_fetch(store, fetcher, research_id, &claims_text, &mut budget) { + store.set_status(research_id, "failed")?; + return Err(e); + } + if let Err(e) = wave_2_consensus(store, research_id, &mut budget) { + store.set_status(research_id, "failed")?; + return Err(e); + } + store.set_cost(research_id, budget.spent())?; + store.set_status(research_id, "completed")?; + Ok(research_id) +} + +fn wave_0_extract_claims(prompt: &str) -> Vec { + prompt + .split(|c: char| c == '.' || c == '?' || c == '\n') + .map(|s| s.trim().to_string()) + .filter(|s| s.len() > 4) + .collect() +} + +fn wave_1_fetch( + store: &ResearchStore, + fetcher: &dyn SourceFetcher, + rid: i64, + claims: &[String], + budget: &mut Budget, +) -> Result<()> { + for c in claims { + budget.charge(WAVE1_COST_PER_CLAIM_MC)?; + let (srcs, fetch_cost) = fetcher.fetch(c); + if fetch_cost > 0 { + budget.charge(fetch_cost)?; + } + for s in srcs { + store.add_source(&Source { research_id: rid, ..s })?; + } + store.add_claim(&Claim { + research_id: rid, + claim_text: c.clone(), + ..Default::default() + })?; + } + Ok(()) +} + +fn wave_2_consensus(store: &ResearchStore, rid: i64, budget: &mut Budget) -> Result<()> { + budget.charge(WAVE2_COST_MC)?; + let claims = store.claims_for(rid)?; + for c in claims { + let support = 0.5; + let contradict = 0.0; + let consensus = support - contradict; + let grade = grade_from_consensus(consensus); + store.conn().execute( + "UPDATE claims SET support=?1, contradict=?2, consensus=?3, grade=?4 + WHERE id=?5", + rusqlite::params![support, contradict, consensus, grade, c.id], + )?; + } + Ok(()) +} + +fn grade_from_consensus(c: f64) -> &'static str { + if c >= 0.8 { "E2" } else if c >= 0.5 { "E4" } else { "E6" } +} diff --git a/_primitives/_rust/kei-search-core/src/schema.rs b/_primitives/_rust/kei-search-core/src/schema.rs new file mode 100644 index 0000000..3861ea9 --- /dev/null +++ b/_primitives/_rust/kei-search-core/src/schema.rs @@ -0,0 +1,44 @@ +use rusqlite::{Connection, Result}; + +const DDL: &str = r#" + CREATE TABLE IF NOT EXISTS researches ( + id INTEGER PRIMARY KEY, + query_original TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + result_markdown TEXT DEFAULT '', + total_cost_mc INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + completed_at INTEGER DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_res_status ON researches(status); + + CREATE TABLE IF NOT EXISTS sources ( + id INTEGER PRIMARY KEY, + research_id INTEGER NOT NULL REFERENCES researches(id), + url TEXT NOT NULL, + title TEXT DEFAULT '', + content TEXT DEFAULT '', + provider TEXT DEFAULT '', + domain TEXT DEFAULT '', + relevance_score REAL DEFAULT 0.0, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_src_research ON sources(research_id); + + CREATE TABLE IF NOT EXISTS claims ( + id INTEGER PRIMARY KEY, + research_id INTEGER NOT NULL REFERENCES researches(id), + claim_text TEXT NOT NULL, + support REAL DEFAULT 0.0, + contradict REAL DEFAULT 0.0, + consensus REAL DEFAULT 0.0, + grade TEXT DEFAULT 'E6', + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_claim_research ON claims(research_id); +"#; + +pub fn create_schema(conn: &Connection) -> Result<()> { + conn.execute_batch(DDL)?; + Ok(()) +} diff --git a/_primitives/_rust/kei-search-core/src/store.rs b/_primitives/_rust/kei-search-core/src/store.rs new file mode 100644 index 0000000..47a3d76 --- /dev/null +++ b/_primitives/_rust/kei-search-core/src/store.rs @@ -0,0 +1,122 @@ +use crate::schema::create_schema; +use crate::types::{Claim, Research, Source}; +use anyhow::{Context, Result}; +use chrono::Utc; +use rusqlite::{params, Connection}; +use std::path::Path; + +pub struct ResearchStore { + conn: Connection, +} + +impl ResearchStore { + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let conn = Connection::open(path).context("open sqlite")?; + conn.pragma_update(None, "journal_mode", "WAL").ok(); + create_schema(&conn)?; + Ok(Self { conn }) + } + + pub fn open_memory() -> Result { + let conn = Connection::open_in_memory()?; + create_schema(&conn)?; + Ok(Self { conn }) + } + + pub fn conn(&self) -> &Connection { &self.conn } + + pub fn create_research(&self, query: &str) -> Result { + let now = Utc::now().timestamp(); + self.conn.execute( + "INSERT INTO researches (query_original, status, created_at) + VALUES (?1, 'running', ?2)", + params![query, now], + )?; + Ok(self.conn.last_insert_rowid()) + } + + pub fn set_status(&self, id: i64, status: &str) -> Result<()> { + let now = Utc::now().timestamp(); + self.conn.execute( + "UPDATE researches SET status=?1, completed_at=?2 WHERE id=?3", + params![status, now, id], + )?; + Ok(()) + } + + pub fn set_cost(&self, id: i64, mc: i64) -> Result<()> { + self.conn.execute( + "UPDATE researches SET total_cost_mc=?1 WHERE id=?2", + params![mc, id], + )?; + Ok(()) + } + + pub fn set_markdown(&self, id: i64, md: &str) -> Result<()> { + self.conn.execute( + "UPDATE researches SET result_markdown=?1 WHERE id=?2", + params![md, id], + )?; + Ok(()) + } + + pub fn get_research(&self, id: i64) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, query_original, status, result_markdown, total_cost_mc, + created_at, completed_at FROM researches WHERE id=?1", + )?; + let mut rows = stmt.query(params![id])?; + if let Some(r) = rows.next()? { + return Ok(Some(Research { + id: r.get(0)?, query_original: r.get(1)?, status: r.get(2)?, + result_markdown: r.get(3)?, total_cost_mc: r.get(4)?, + created_at: r.get(5)?, completed_at: r.get(6)?, + })); + } + Ok(None) + } + + pub fn add_source(&self, s: &Source) -> Result { + let now = Utc::now().timestamp(); + self.conn.execute( + "INSERT INTO sources (research_id, url, title, content, provider, + domain, relevance_score, created_at) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", + params![s.research_id, s.url, s.title, s.content, s.provider, + s.domain, s.relevance_score, now], + )?; + Ok(self.conn.last_insert_rowid()) + } + + pub fn add_claim(&self, c: &Claim) -> Result { + let now = Utc::now().timestamp(); + self.conn.execute( + "INSERT INTO claims (research_id, claim_text, support, contradict, + consensus, grade, created_at) + VALUES (?1,?2,?3,?4,?5,?6,?7)", + params![c.research_id, c.claim_text, c.support, c.contradict, + c.consensus, c.grade, now], + )?; + Ok(self.conn.last_insert_rowid()) + } + + pub fn claims_for(&self, research_id: i64) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, research_id, claim_text, support, contradict, + consensus, grade, created_at FROM claims WHERE research_id=?1" + )?; + let rows = stmt.query_map(params![research_id], |r| { + Ok(Claim { + id: r.get(0)?, research_id: r.get(1)?, claim_text: r.get(2)?, + support: r.get(3)?, contradict: r.get(4)?, consensus: r.get(5)?, + grade: r.get(6)?, created_at: r.get(7)?, + }) + })?; + let mut out = Vec::new(); + for r in rows { out.push(r?); } + Ok(out) + } +} diff --git a/_primitives/_rust/kei-search-core/src/types.rs b/_primitives/_rust/kei-search-core/src/types.rs new file mode 100644 index 0000000..89146da --- /dev/null +++ b/_primitives/_rust/kei-search-core/src/types.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Research { + pub id: i64, + pub query_original: String, + pub status: String, + pub result_markdown: String, + pub total_cost_mc: i64, + pub created_at: i64, + pub completed_at: i64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Source { + pub id: i64, + pub research_id: i64, + pub url: String, + pub title: String, + pub content: String, + pub provider: String, + pub domain: String, + pub relevance_score: f64, + pub created_at: i64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Claim { + pub id: i64, + pub research_id: i64, + pub claim_text: String, + pub support: f64, + pub contradict: f64, + pub consensus: f64, + pub grade: String, + pub created_at: i64, +} diff --git a/_primitives/_rust/kei-search-core/tests/integration.rs b/_primitives/_rust/kei-search-core/tests/integration.rs new file mode 100644 index 0000000..4c6679e --- /dev/null +++ b/_primitives/_rust/kei-search-core/tests/integration.rs @@ -0,0 +1,80 @@ +use kei_search_core::budget::Budget; +use kei_search_core::export::{export, Format}; +use kei_search_core::fetch::{SourceFetcher, StubFetcher}; +use kei_search_core::pipeline::run_research; +use kei_search_core::types::Source; +use kei_search_core::ResearchStore; + +fn mk() -> ResearchStore { ResearchStore::open_memory().unwrap() } + +struct FakeFetcher; +impl SourceFetcher for FakeFetcher { + fn fetch(&self, claim: &str) -> (Vec, i64) { + (vec![Source { + url: "https://example.test".into(), + title: format!("source for: {claim}"), + content: "body".into(), + provider: "fake".into(), + domain: "example.test".into(), + relevance_score: 0.8, + ..Default::default() + }], 10) + } +} + +#[test] +fn budget_enforcement() { + let mut b = Budget::new(100); + b.charge(50).unwrap(); + b.charge(40).unwrap(); + assert!(b.charge(20).is_err(), "must reject overspend"); +} + +#[test] +fn wave_progression_creates_research() { + let s = mk(); + let id = run_research(&s, &FakeFetcher, + "Rust is memory-safe. Python is dynamic.", 10_000).unwrap(); + let r = s.get_research(id).unwrap().unwrap(); + assert_eq!(r.status, "completed"); + assert!(r.total_cost_mc > 0); + assert!(s.claims_for(id).unwrap().len() >= 2); +} + +#[test] +fn consensus_scoring_applies_grade() { + let s = mk(); + let id = run_research(&s, &FakeFetcher, "One claim here.", 10_000).unwrap(); + let claims = s.claims_for(id).unwrap(); + assert!(!claims.is_empty()); + assert!(!claims[0].grade.is_empty()); +} + +#[test] +fn export_markdown_and_json() { + let s = mk(); + let id = run_research(&s, &FakeFetcher, "Claim A. Claim B.", 10_000).unwrap(); + let md = export(&s, id, Format::Markdown).unwrap(); + assert!(md.contains("# Research")); + let js = export(&s, id, Format::Json).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&js).unwrap(); + assert!(parsed.get("claims").is_some()); +} + +#[test] +fn stop_mid_run_marks_status() { + let s = mk(); + let id = run_research(&s, &StubFetcher, "x. y.", 10_000).unwrap(); + s.set_status(id, "stopped").unwrap(); + let r = s.get_research(id).unwrap().unwrap(); + assert_eq!(r.status, "stopped"); +} + +#[test] +fn budget_exhausted_rejects_run() { + let s = mk(); + // 3 claims × 100mc + 50mc wave2 = 350mc; budget 100 → must overspend. + let err = run_research(&s, &StubFetcher, + "alpha claim one. beta claim two. gamma claim three.", 100); + assert!(err.is_err(), "small budget vs 3 claims must overspend"); +} diff --git a/_primitives/_rust/kei-social-store/Cargo.toml b/_primitives/_rust/kei-social-store/Cargo.toml new file mode 100644 index 0000000..544cc6f --- /dev/null +++ b/_primitives/_rust/kei-social-store/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "kei-social-store" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "People + interaction CRM (lite). Port of LBM internal/social." + +[[bin]] +name = "kei-social-store" +path = "src/main.rs" + +[lib] +name = "kei_social_store" +path = "src/lib.rs" + +[dependencies] +rusqlite = { version = "0.31", features = ["bundled"] } +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/kei-social-store/src/graph.rs b/_primitives/_rust/kei-social-store/src/graph.rs new file mode 100644 index 0000000..b081a3a --- /dev/null +++ b/_primitives/_rust/kei-social-store/src/graph.rs @@ -0,0 +1,31 @@ +//! Relationship graph — who interacted with whom, grouped by channel. + +use crate::store::Store; +use anyhow::Result; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct Pair { + pub person_id: i64, + pub target_id: i64, + pub channel: String, + pub count: i64, +} + +pub fn relationship_graph(store: &Store) -> Result> { + let mut stmt = store.conn().prepare( + "SELECT person_id, target_id, channel, COUNT(*) FROM interactions + WHERE target_id > 0 GROUP BY person_id, target_id, channel", + )?; + let rows = stmt.query_map([], |r| { + Ok(Pair { + person_id: r.get(0)?, + target_id: r.get(1)?, + channel: r.get(2)?, + count: r.get(3)?, + }) + })?; + let mut out = Vec::new(); + for r in rows { out.push(r?); } + Ok(out) +} diff --git a/_primitives/_rust/kei-social-store/src/interactions.rs b/_primitives/_rust/kei-social-store/src/interactions.rs new file mode 100644 index 0000000..ea710ef --- /dev/null +++ b/_primitives/_rust/kei-social-store/src/interactions.rs @@ -0,0 +1,47 @@ +use crate::store::Store; +use anyhow::Result; +use chrono::Utc; +use rusqlite::params; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Interaction { + pub id: i64, + pub person_id: i64, + pub target_id: i64, + pub interaction_type: String, + pub channel: String, + pub content: String, + pub timestamp: i64, +} + +pub fn log_interaction(store: &Store, i: &Interaction) -> Result { + let now = Utc::now().timestamp(); + let ts = if i.timestamp == 0 { now } else { i.timestamp }; + let channel = if i.channel.is_empty() { "manual" } else { &i.channel }; + store.conn().execute( + "INSERT INTO interactions (person_id, target_id, interaction_type, + channel, content, timestamp, created_at) + VALUES (?1,?2,?3,?4,?5,?6,?7)", + params![i.person_id, i.target_id, i.interaction_type, + channel, i.content, ts, now], + )?; + Ok(store.conn().last_insert_rowid()) +} + +pub fn interactions_for(store: &Store, person_id: i64) -> Result> { + let mut stmt = store.conn().prepare( + "SELECT id, person_id, target_id, interaction_type, channel, content, timestamp + FROM interactions WHERE person_id=?1 ORDER BY timestamp DESC", + )?; + let rows = stmt.query_map(params![person_id], |r| { + Ok(Interaction { + id: r.get(0)?, person_id: r.get(1)?, target_id: r.get(2)?, + interaction_type: r.get(3)?, channel: r.get(4)?, + content: r.get(5)?, timestamp: r.get(6)?, + }) + })?; + let mut out = Vec::new(); + for r in rows { out.push(r?); } + Ok(out) +} diff --git a/_primitives/_rust/kei-social-store/src/lib.rs b/_primitives/_rust/kei-social-store/src/lib.rs new file mode 100644 index 0000000..c650c5a --- /dev/null +++ b/_primitives/_rust/kei-social-store/src/lib.rs @@ -0,0 +1,11 @@ +//! kei-social-store — people + organizations + interactions. + +pub mod graph; +pub mod interactions; +pub mod people; +pub mod schema; +pub mod search; +pub mod store; + +pub use people::{Organization, Person}; +pub use store::Store; diff --git a/_primitives/_rust/kei-social-store/src/main.rs b/_primitives/_rust/kei-social-store/src/main.rs new file mode 100644 index 0000000..b3fef03 --- /dev/null +++ b/_primitives/_rust/kei-social-store/src/main.rs @@ -0,0 +1,100 @@ +use clap::{Parser, Subcommand}; +use kei_social_store::graph::relationship_graph; +use kei_social_store::interactions::{log_interaction, Interaction}; +use kei_social_store::people::{add_org, add_person, Organization, Person}; +use kei_social_store::search::search_people; +use kei_social_store::Store; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser)] +#[command(name = "kei-social-store", version)] +struct Cli { + #[arg(long)] db: Option, + #[command(subcommand)] cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + SearchPeople { query: String, #[arg(long, default_value_t = 20)] limit: i64 }, + AddPerson { name: String, + #[arg(long, default_value = "")] email: String, + #[arg(long, default_value = "")] handle: String, + #[arg(long, default_value = "manual")] source: String }, + AddOrg { name: String, #[arg(long, default_value = "company")] org_type: String }, + LogInteraction { person_id: i64, interaction_type: String, + #[arg(long, default_value = "")] content: String, + #[arg(long, default_value = "manual")] channel: String, + #[arg(long, default_value_t = 0)] target_id: i64 }, + RelationshipGraph, +} + +fn db_path(o: Option) -> PathBuf { + if let Some(p) = o { return p; } + if let Ok(e) = std::env::var("KEI_SOCIAL_DB") { return PathBuf::from(e); } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".claude/social/social.sqlite") +} + +fn run() -> anyhow::Result<()> { + let cli = Cli::parse(); + let s = Store::open(&db_path(cli.db))?; + dispatch(&s, cli.cmd) +} + +fn dispatch(s: &Store, cmd: Cmd) -> anyhow::Result<()> { + match cmd { + Cmd::SearchPeople { query, limit } => cmd_search(s, &query, limit), + Cmd::AddPerson { name, email, handle, source } => + cmd_add_person(s, name, email, handle, source), + Cmd::AddOrg { name, org_type } => cmd_add_org(s, name, org_type), + Cmd::LogInteraction { person_id, interaction_type, content, channel, target_id } => + cmd_log(s, person_id, target_id, interaction_type, channel, content), + Cmd::RelationshipGraph => cmd_graph(s), + } +} + +fn cmd_search(s: &Store, query: &str, limit: i64) -> anyhow::Result<()> { + for p in search_people(s, query, limit)? { + println!("{}\t{}\t{}", p.id, p.name, p.email); + } + Ok(()) +} + +fn cmd_add_person(s: &Store, name: String, email: String, + handle: String, source: String) -> anyhow::Result<()> { + let id = add_person(s, &Person { name, email, handle, source, ..Default::default() })?; + println!("{}", id); + Ok(()) +} + +fn cmd_add_org(s: &Store, name: String, org_type: String) -> anyhow::Result<()> { + let id = add_org(s, &Organization { name, org_type, ..Default::default() })?; + println!("{}", id); + Ok(()) +} + +fn cmd_log(s: &Store, person_id: i64, target_id: i64, interaction_type: String, + channel: String, content: String) -> anyhow::Result<()> { + let id = log_interaction(s, &Interaction { + person_id, target_id, interaction_type, channel, content, + ..Default::default() + })?; + println!("{}", id); + Ok(()) +} + +fn cmd_graph(s: &Store) -> anyhow::Result<()> { + for p in relationship_graph(s)? { + println!("{}\t-[{}]->\t{}\t({}x)", + p.person_id, p.channel, p.target_id, p.count); + } + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { eprintln!("kei-social-store: {e:#}"); ExitCode::from(1) } + } +} diff --git a/_primitives/_rust/kei-social-store/src/people.rs b/_primitives/_rust/kei-social-store/src/people.rs new file mode 100644 index 0000000..2605f6c --- /dev/null +++ b/_primitives/_rust/kei-social-store/src/people.rs @@ -0,0 +1,76 @@ +use crate::store::Store; +use anyhow::Result; +use chrono::Utc; +use rusqlite::params; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Person { + pub id: i64, + pub name: String, + pub email: String, + pub handle: String, + pub role: String, + pub organization: String, + pub source: String, + pub bio: String, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Organization { + pub id: i64, + pub name: String, + pub org_type: String, + pub description: String, + pub created_at: i64, +} + +pub fn add_person(store: &Store, p: &Person) -> Result { + let now = Utc::now().timestamp(); + let source = if p.source.is_empty() { "manual" } else { &p.source }; + store.conn().execute( + "INSERT INTO people (name, email, handle, role, organization, + source, bio, created_at, updated_at) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?8)", + params![p.name, p.email, p.handle, p.role, p.organization, + source, p.bio, now], + )?; + let id = store.conn().last_insert_rowid(); + store.conn().execute( + "INSERT INTO fts_social (person_id, name, email, bio) VALUES (?1,?2,?3,?4)", + params![id, p.name, p.email, p.bio], + )?; + Ok(id) +} + +pub fn get_person(store: &Store, id: i64) -> Result> { + let mut stmt = store.conn().prepare( + "SELECT id, name, email, handle, role, organization, source, bio, + created_at, updated_at FROM people WHERE id=?1", + )?; + let mut rows = stmt.query(params![id])?; + if let Some(r) = rows.next()? { + return Ok(Some(Person { + id: r.get(0)?, name: r.get(1)?, email: r.get(2)?, handle: r.get(3)?, + role: r.get(4)?, organization: r.get(5)?, source: r.get(6)?, + bio: r.get(7)?, created_at: r.get(8)?, updated_at: r.get(9)?, + })); + } + Ok(None) +} + +pub fn add_org(store: &Store, o: &Organization) -> Result { + let now = Utc::now().timestamp(); + let ot = if o.org_type.is_empty() { "company" } else { &o.org_type }; + store.conn().execute( + "INSERT OR IGNORE INTO organizations (name, org_type, description, created_at) + VALUES (?1,?2,?3,?4)", + params![o.name, ot, o.description, now], + )?; + let id: i64 = store.conn().query_row( + "SELECT id FROM organizations WHERE name=?1", + params![o.name], |r| r.get(0))?; + Ok(id) +} diff --git a/_primitives/_rust/kei-social-store/src/schema.rs b/_primitives/_rust/kei-social-store/src/schema.rs new file mode 100644 index 0000000..3aa31ff --- /dev/null +++ b/_primitives/_rust/kei-social-store/src/schema.rs @@ -0,0 +1,51 @@ +use rusqlite::{Connection, Result}; + +const DDL_MAIN: &str = r#" + CREATE TABLE IF NOT EXISTS people ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT DEFAULT '', + handle TEXT DEFAULT '', + role TEXT DEFAULT '', + organization TEXT DEFAULT '', + source TEXT NOT NULL DEFAULT 'manual', + bio TEXT DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_people_email + ON people(email) WHERE email != ''; + CREATE UNIQUE INDEX IF NOT EXISTS idx_people_handle_source + ON people(handle, source) WHERE handle != ''; + + CREATE TABLE IF NOT EXISTS organizations ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + org_type TEXT DEFAULT 'company', + description TEXT DEFAULT '', + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS interactions ( + id INTEGER PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE, + target_id INTEGER NOT NULL DEFAULT 0, + interaction_type TEXT NOT NULL, + channel TEXT NOT NULL DEFAULT 'manual', + content TEXT DEFAULT '', + timestamp INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_int_person ON interactions(person_id); +"#; + +const DDL_FTS: &str = r#" + CREATE VIRTUAL TABLE IF NOT EXISTS fts_social + USING fts5(person_id UNINDEXED, name, email, bio, tokenize='porter unicode61'); +"#; + +pub fn create_schema(conn: &Connection) -> Result<()> { + conn.execute_batch(DDL_MAIN)?; + conn.execute_batch(DDL_FTS)?; + Ok(()) +} diff --git a/_primitives/_rust/kei-social-store/src/search.rs b/_primitives/_rust/kei-social-store/src/search.rs new file mode 100644 index 0000000..fa9590e --- /dev/null +++ b/_primitives/_rust/kei-social-store/src/search.rs @@ -0,0 +1,25 @@ +use crate::people::Person; +use crate::store::Store; +use anyhow::Result; +use rusqlite::params; + +pub fn search_people(store: &Store, q: &str, limit: i64) -> Result> { + let lim = if limit <= 0 { 20 } else { limit }; + let mut stmt = store.conn().prepare( + "SELECT p.id, p.name, p.email, p.handle, p.role, p.organization, + p.source, p.bio, p.created_at, p.updated_at + FROM fts_social f + JOIN people p ON p.id = f.person_id + WHERE fts_social MATCH ?1 ORDER BY rank LIMIT ?2", + )?; + let rows = stmt.query_map(params![q, lim], |r| { + Ok(Person { + id: r.get(0)?, name: r.get(1)?, email: r.get(2)?, handle: r.get(3)?, + role: r.get(4)?, organization: r.get(5)?, source: r.get(6)?, + bio: r.get(7)?, created_at: r.get(8)?, updated_at: r.get(9)?, + }) + })?; + let mut out = Vec::new(); + for r in rows { out.push(r?); } + Ok(out) +} diff --git a/_primitives/_rust/kei-social-store/src/store.rs b/_primitives/_rust/kei-social-store/src/store.rs new file mode 100644 index 0000000..f4c30de --- /dev/null +++ b/_primitives/_rust/kei-social-store/src/store.rs @@ -0,0 +1,22 @@ +use crate::schema::create_schema; +use anyhow::{Context, Result}; +use rusqlite::Connection; +use std::path::Path; + +pub struct Store { conn: Connection } + +impl Store { + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } + let conn = Connection::open(path).context("open sqlite")?; + conn.pragma_update(None, "journal_mode", "WAL").ok(); + create_schema(&conn)?; + Ok(Self { conn }) + } + pub fn open_memory() -> Result { + let conn = Connection::open_in_memory()?; + create_schema(&conn)?; + Ok(Self { conn }) + } + pub fn conn(&self) -> &Connection { &self.conn } +} diff --git a/_primitives/_rust/kei-social-store/tests/integration.rs b/_primitives/_rust/kei-social-store/tests/integration.rs new file mode 100644 index 0000000..eb9fed4 --- /dev/null +++ b/_primitives/_rust/kei-social-store/tests/integration.rs @@ -0,0 +1,68 @@ +use kei_social_store::graph::relationship_graph; +use kei_social_store::interactions::{interactions_for, log_interaction, Interaction}; +use kei_social_store::people::{add_org, add_person, get_person, Organization, Person}; +use kei_social_store::search::search_people; +use kei_social_store::Store; + +fn mk() -> Store { Store::open_memory().unwrap() } + +#[test] +fn people_crud() { + let s = mk(); + let id = add_person(&s, &Person { + name: "Alice".into(), email: "alice@example.com".into(), + ..Default::default() + }).unwrap(); + let p = get_person(&s, id).unwrap().unwrap(); + assert_eq!(p.name, "Alice"); +} + +#[test] +fn orgs_idempotent() { + let s = mk(); + let a = add_org(&s, &Organization { name: "Acme".into(), ..Default::default() }).unwrap(); + let b = add_org(&s, &Organization { name: "Acme".into(), ..Default::default() }).unwrap(); + assert_eq!(a, b); +} + +#[test] +fn interactions_tracked() { + let s = mk(); + let p = add_person(&s, &Person { name: "Bob".into(), ..Default::default() }).unwrap(); + log_interaction(&s, &Interaction { + person_id: p, interaction_type: "email".into(), + content: "hi".into(), channel: "gmail".into(), + ..Default::default() + }).unwrap(); + let hist = interactions_for(&s, p).unwrap(); + assert_eq!(hist.len(), 1); + assert_eq!(hist[0].interaction_type, "email"); +} + +#[test] +fn search_finds_person() { + let s = mk(); + add_person(&s, &Person { + name: "Carol Chang".into(), bio: "rust async".into(), + ..Default::default() + }).unwrap(); + let hits = search_people(&s, "rust", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].name, "Carol Chang"); +} + +#[test] +fn relationship_graph_groups() { + let s = mk(); + let a = add_person(&s, &Person { name: "A".into(), ..Default::default() }).unwrap(); + let b = add_person(&s, &Person { name: "B".into(), ..Default::default() }).unwrap(); + for _ in 0..3 { + log_interaction(&s, &Interaction { + person_id: a, target_id: b, interaction_type: "msg".into(), + channel: "slack".into(), ..Default::default() + }).unwrap(); + } + let pairs = relationship_graph(&s).unwrap(); + assert_eq!(pairs.len(), 1); + assert_eq!(pairs[0].count, 3); +} diff --git a/_primitives/_rust/kei-store/src/factory.rs b/_primitives/_rust/kei-store/src/factory.rs index 301b8f3..2f4f047 100644 --- a/_primitives/_rust/kei-store/src/factory.rs +++ b/_primitives/_rust/kei-store/src/factory.rs @@ -1,10 +1,15 @@ //! Factory — construct a `Box` from a Config. +//! +//! v0.14.1: the S3 backend is gated behind `KEI_STORE_ALLOW_S3_STUB=1` +//! because it does NOT push to S3 yet — it's a local-manifest stub. +//! Previous behaviour silently stored data locally, confusing users who +//! thought their traces were uploaded. use crate::config::{expand_tilde, Config}; use crate::{filesystem::FilesystemStore, forgejo::ForgejoStore, gitea::GiteaStore, github::GitHubStore, s3::S3Store}; use crate::store_trait::MemoryStore; -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, bail, Context, Result}; use std::path::PathBuf; pub fn build_store(cfg: &Config) -> Result> { @@ -18,17 +23,30 @@ pub fn build_store(cfg: &Config) -> Result> { "github" => Ok(Box::new(GitHubStore::new(local, cfg.github.clone())?)), "forgejo" => Ok(Box::new(ForgejoStore::new(local, cfg.forgejo.clone())?)), "gitea" => Ok(Box::new(GiteaStore::new(local, cfg.gitea.clone())?)), - "s3" => { - let cache = cfg - .s3 - .cache_path - .as_deref() - .map(expand_tilde) - .map(PathBuf::from) - .ok_or_else(|| anyhow!("s3 backend requires s3.cache_path"))?; - Ok(Box::new(S3Store::new(cache, cfg.s3.clone())?)) - } + "s3" => build_s3(cfg), other => Err(anyhow!("unknown backend: {other}")) .context("supported: filesystem | github | forgejo | gitea | s3"), } } + +fn build_s3(cfg: &Config) -> Result> { + if std::env::var("KEI_STORE_ALLOW_S3_STUB").is_err() { + bail!( + "S3 backend is a local-only MVP stub (no upload to S3/R2/MinIO yet). \ + Set KEI_STORE_ALLOW_S3_STUB=1 to proceed; data will be stored in the \ + configured cache_path only. Production S3 support is planned for v0.15." + ); + } + eprintln!( + "[kei-store] WARNING: S3 backend is a local-only stub — data stored \ + at cache_path only, not pushed to any object store." + ); + let cache = cfg + .s3 + .cache_path + .as_deref() + .map(expand_tilde) + .map(PathBuf::from) + .ok_or_else(|| anyhow!("s3 backend requires s3.cache_path"))?; + Ok(Box::new(S3Store::new(cache, cfg.s3.clone())?)) +} diff --git a/_primitives/_rust/kei-store/src/filesystem.rs b/_primitives/_rust/kei-store/src/filesystem.rs index b14629b..4792d6b 100644 --- a/_primitives/_rust/kei-store/src/filesystem.rs +++ b/_primitives/_rust/kei-store/src/filesystem.rs @@ -2,11 +2,14 @@ //! //! Reuses git2 for branch/commit so behavior parity with remote stores is //! maintained. `push`/`pull` are intentional no-ops. +//! +//! v0.14.1 hardening: `full()` now rejects absolute paths and `..` components +//! (CVE-class: path traversal via MCP `write`/`read` tool inputs). use crate::store_trait::MemoryStore; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; pub struct FilesystemStore { pub root: PathBuf, @@ -20,11 +23,34 @@ impl FilesystemStore { Ok(Self { root }) } - fn full(&self, rel: &str) -> PathBuf { - self.root.join(rel) + fn full(&self, rel: &str) -> Result { + safe_join(&self.root, rel) } } +/// Reject absolute paths and any `..` component BEFORE joining. +/// `PathBuf::join("/etc/passwd")` would otherwise replace the base +/// entirely — that turned kei-store's MCP `write` tool into an +/// unrestricted filesystem writer. +pub(crate) fn safe_join(root: &Path, rel: &str) -> Result { + let p = Path::new(rel); + if p.is_absolute() { + bail!("path traversal rejected: absolute path {:?}", rel); + } + for component in p.components() { + match component { + Component::ParentDir => { + bail!("path traversal rejected: parent-dir component in {:?}", rel); + } + Component::Prefix(_) | Component::RootDir => { + bail!("path traversal rejected: root/prefix component in {:?}", rel); + } + _ => {} + } + } + Ok(root.join(rel)) +} + fn ensure_repo(root: &Path) -> Result<()> { if root.join(".git").exists() { return Ok(()); @@ -35,11 +61,11 @@ fn ensure_repo(root: &Path) -> Result<()> { impl MemoryStore for FilesystemStore { fn read(&self, path: &str) -> Result> { - fs::read(self.full(path)).with_context(|| format!("read {}", path)) + fs::read(self.full(path)?).with_context(|| format!("read {}", path)) } fn write(&self, path: &str, bytes: &[u8]) -> Result<()> { - let full = self.full(path); + let full = self.full(path)?; if let Some(parent) = full.parent() { fs::create_dir_all(parent)?; } @@ -47,7 +73,7 @@ impl MemoryStore for FilesystemStore { } fn list(&self, dir: &str) -> Result> { - let full = self.full(dir); + let full = self.full(dir)?; if !full.exists() { return Ok(Vec::new()); } @@ -103,3 +129,44 @@ impl MemoryStore for FilesystemStore { "filesystem" } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_absolute_path_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let store = FilesystemStore::new(tmp.path().join("root")).unwrap(); + let err = store.write("/etc/passwd", b"nope").unwrap_err(); + let s = format!("{err:#}"); + assert!(s.contains("absolute"), "unexpected err: {s}"); + } + + #[test] + fn test_parent_dir_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let store = FilesystemStore::new(tmp.path().join("root")).unwrap(); + let err = store.write("../../.ssh/authorized_keys", b"nope").unwrap_err(); + let s = format!("{err:#}"); + assert!(s.contains("parent-dir"), "unexpected err: {s}"); + } + + #[test] + fn test_normal_path_ok() { + let tmp = tempfile::tempdir().unwrap(); + let store = FilesystemStore::new(tmp.path().join("root")).unwrap(); + store.write("traces/session.jsonl", b"ok").unwrap(); + let bytes = store.read("traces/session.jsonl").unwrap(); + assert_eq!(&bytes, b"ok"); + } + + #[test] + fn test_read_absolute_path_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let store = FilesystemStore::new(tmp.path().join("root")).unwrap(); + let err = store.read("/etc/passwd").unwrap_err(); + let s = format!("{err:#}"); + assert!(s.contains("absolute"), "unexpected err: {s}"); + } +} diff --git a/_primitives/_rust/kei-store/src/github.rs b/_primitives/_rust/kei-store/src/github.rs index eb3a356..8abdca6 100644 --- a/_primitives/_rust/kei-store/src/github.rs +++ b/_primitives/_rust/kei-store/src/github.rs @@ -4,11 +4,16 @@ //! remote. SSH auth via `KEI_MEMORY_SSH_KEY` (path to key); HTTPS via //! `KEI_MEMORY_PAT` (token). Exactly the pattern used in v0.11 //! `kei-sleep-setup.sh`. +//! +//! v0.14.1: pushes to `github.com` are blocked by default under RULE 0.1 +//! (patent-IP protection). Forks on Forgejo / Gitea / self-hosted are +//! unaffected since they do not resolve to `github.com`. Override for a +//! genuinely public repo: `KEI_STORE_ALLOW_GITHUB_PUSH=1`. use crate::config::GitRemoteCfg; use crate::filesystem::FilesystemStore; use crate::store_trait::MemoryStore; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use std::path::PathBuf; pub struct GitHubStore { @@ -75,8 +80,9 @@ impl MemoryStore for GitHubStore { } fn push(&self, branch: &str) -> Result<()> { - let repo = git2::Repository::open(&self.inner.root)?; let url = self.remote_url()?; + enforce_github_push_guard(url)?; + let repo = git2::Repository::open(&self.inner.root)?; let mut remote = match repo.find_remote("origin") { Ok(r) => r, Err(_) => repo.remote("origin", url)?, @@ -105,3 +111,61 @@ impl MemoryStore for GitHubStore { self.name } } + +/// RULE 0.1 enforcement point for the kei-store push path. +/// +/// Blocks pushes whose URL contains `github.com` unless the caller +/// explicitly opts-in via `KEI_STORE_ALLOW_GITHUB_PUSH=1`. Forks on +/// Forgejo / Gitea / self-hosted remain unaffected — only the literal +/// `github.com` host is gated. +pub(crate) fn enforce_github_push_guard(url: &str) -> Result<()> { + if !url.contains("github.com") { + return Ok(()); + } + if std::env::var("KEI_STORE_ALLOW_GITHUB_PUSH").is_ok() { + return Ok(()); + } + bail!( + "push to github.com blocked by RULE 0.1 (patent-IP protection). \ + Set KEI_STORE_ALLOW_GITHUB_PUSH=1 if this is a public-safe release. \ + See ~/.claude/rules/security.md for banned-project criteria." + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Serialise tests that read/write KEI_STORE_ALLOW_GITHUB_PUSH so + // parallel cargo-test runners don't race on process env. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn test_github_push_blocked_without_env_var() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + std::env::remove_var("KEI_STORE_ALLOW_GITHUB_PUSH"); + let err = enforce_github_push_guard("git@github.com:owner/repo.git").unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.contains("github.com"), "unexpected err: {msg}"); + assert!(msg.contains("RULE 0.1"), "unexpected err: {msg}"); + } + + #[test] + fn test_github_push_allowed_with_env_var() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + std::env::set_var("KEI_STORE_ALLOW_GITHUB_PUSH", "1"); + let ok = enforce_github_push_guard("git@github.com:owner/repo.git"); + std::env::remove_var("KEI_STORE_ALLOW_GITHUB_PUSH"); + assert!(ok.is_ok(), "should allow with opt-in env var"); + } + + #[test] + fn test_non_github_push_always_allowed() { + // Non-github URLs should always pass regardless of env state, but we + // still take the lock so we don't observe a half-set var mid-test. + let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + enforce_github_push_guard("ssh://git@forgejo.local:2222/user/repo.git").unwrap(); + enforce_github_push_guard("https://gitea.example.com/user/repo.git").unwrap(); + } +} diff --git a/_primitives/_rust/kei-store/src/s3.rs b/_primitives/_rust/kei-store/src/s3.rs index fa37dd1..08f0c0f 100644 --- a/_primitives/_rust/kei-store/src/s3.rs +++ b/_primitives/_rust/kei-store/src/s3.rs @@ -1,16 +1,26 @@ -//! S3Store — object-storage backend (MVP stub). +//! S3Store — object-storage backend (MVP stub; v0.14.1 local-only). //! //! This is a local-manifest-based implementation intended as an offline MVP. //! Reads/writes go to `cache_path`; `commit` serialises a //! `manifest-.json` listing the current file tree + content hash; //! `push`/`pull` are NO-OPs in stub mode. //! +//! v0.14.1: because the backend does NOT actually reach S3, the factory +//! now refuses to build an `S3Store` unless `KEI_STORE_ALLOW_S3_STUB=1` +//! is set. Previously users who configured S3 were silently writing to a +//! local cache with no remote push. See `factory.rs` for the guard. +//! +//! v0.14.1 hardening: `full()` rejects absolute paths and `..` components +//! (same CVE class as `filesystem.rs` — user-supplied `rel` could escape +//! the cache root). +//! //! Production S3/R2/MinIO support is planned via `aws-sdk-s3` behind a //! feature flag — see README §Store backends. This stub keeps the trait //! surface honest so downstream code can exercise the full kei-store //! API without pulling a ~20 MB AWS SDK at install time. use crate::config::S3Cfg; +use crate::filesystem::safe_join; use crate::store_trait::MemoryStore; use anyhow::{Context, Result}; use std::fs; @@ -27,18 +37,18 @@ impl S3Store { Ok(Self { cache, cfg }) } - fn full(&self, rel: &str) -> PathBuf { - self.cache.join(rel) + fn full(&self, rel: &str) -> Result { + safe_join(&self.cache, rel) } } impl MemoryStore for S3Store { fn read(&self, path: &str) -> Result> { - fs::read(self.full(path)).with_context(|| format!("read {}", path)) + fs::read(self.full(path)?).with_context(|| format!("read {}", path)) } fn write(&self, path: &str, bytes: &[u8]) -> Result<()> { - let full = self.full(path); + let full = self.full(path)?; if let Some(parent) = full.parent() { fs::create_dir_all(parent)?; } @@ -47,7 +57,7 @@ impl MemoryStore for S3Store { } fn list(&self, dir: &str) -> Result> { - let full = self.full(dir); + let full = self.full(dir)?; if !full.exists() { return Ok(Vec::new()); } @@ -65,8 +75,11 @@ impl MemoryStore for S3Store { } fn branch(&self, name: &str) -> Result<()> { - // Logical snapshot namespace — stored under cache// - fs::create_dir_all(self.cache.join(name))?; + // Logical snapshot namespace — stored under cache//. + // Also guarded against traversal so a malicious branch name cannot + // escape the cache root. + let dir = self.full(name)?; + fs::create_dir_all(dir)?; Ok(()) } @@ -88,7 +101,7 @@ impl MemoryStore for S3Store { } fn backend_name(&self) -> &'static str { - "s3-stub" + "s3-local-stub" } } @@ -120,3 +133,39 @@ fn short_hash(s: &str) -> String { } format!("{:x}", h) } + +#[cfg(test)] +mod tests { + use super::*; + + fn store(root: PathBuf) -> S3Store { + S3Store::new(root, S3Cfg::default()).unwrap() + } + + #[test] + fn test_absolute_path_rejected_s3() { + let tmp = tempfile::tempdir().unwrap(); + let s = store(tmp.path().join("cache")); + let err = s.write("/etc/passwd", b"nope").unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.contains("absolute"), "unexpected err: {msg}"); + } + + #[test] + fn test_parent_dir_rejected_s3() { + let tmp = tempfile::tempdir().unwrap(); + let s = store(tmp.path().join("cache")); + let err = s.write("../../secret", b"nope").unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.contains("parent-dir"), "unexpected err: {msg}"); + } + + #[test] + fn test_normal_path_ok_s3() { + let tmp = tempfile::tempdir().unwrap(); + let s = store(tmp.path().join("cache")); + s.write("a/b.txt", b"ok").unwrap(); + let bytes = s.read("a/b.txt").unwrap(); + assert_eq!(&bytes, b"ok"); + } +} diff --git a/_primitives/_rust/kei-store/tests/integration.rs b/_primitives/_rust/kei-store/tests/integration.rs index 86400ee..bd04301 100644 --- a/_primitives/_rust/kei-store/tests/integration.rs +++ b/_primitives/_rust/kei-store/tests/integration.rs @@ -23,6 +23,15 @@ fn run(args: &[&str]) -> std::process::Output { std::process::Command::new(bin()).args(args).output().unwrap() } +fn run_with_env(args: &[&str], env: &[(&str, &str)]) -> std::process::Output { + let mut cmd = std::process::Command::new(bin()); + cmd.args(args); + for (k, v) in env { + cmd.env(k, v); + } + cmd.output().unwrap() +} + #[test] fn init_writes_config() { let tmp = TempDir::new().unwrap(); @@ -111,8 +120,15 @@ fn s3_stub_commit_writes_manifest() { let cfg = write_config(&tmp, "s3", &local); let file = tmp.path().join("x"); fs::write(&file, b"x").unwrap(); - run(&["--config", cfg.to_str().unwrap(), "write", "a.txt", file.to_str().unwrap()]); - let out = run(&["--config", cfg.to_str().unwrap(), "commit", "--message", "first"]); + // v0.14.1: S3 stub requires explicit opt-in env var. + run_with_env( + &["--config", cfg.to_str().unwrap(), "write", "a.txt", file.to_str().unwrap()], + &[("KEI_STORE_ALLOW_S3_STUB", "1")], + ); + let out = run_with_env( + &["--config", cfg.to_str().unwrap(), "commit", "--message", "first"], + &[("KEI_STORE_ALLOW_S3_STUB", "1")], + ); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); let entries: Vec<_> = fs::read_dir(&local) .unwrap() @@ -122,6 +138,21 @@ fn s3_stub_commit_writes_manifest() { assert_eq!(entries.len(), 1); } +#[test] +fn s3_backend_requires_env_optin() { + let tmp = TempDir::new().unwrap(); + let local = tmp.path().join("cache"); + let cfg = write_config(&tmp, "s3", &local); + // Without KEI_STORE_ALLOW_S3_STUB, status must fail with a clear message. + let mut cmd = std::process::Command::new(bin()); + cmd.args(["--config", cfg.to_str().unwrap(), "status"]); + cmd.env_remove("KEI_STORE_ALLOW_S3_STUB"); + let out = cmd.output().unwrap(); + assert!(!out.status.success()); + let msg = String::from_utf8_lossy(&out.stderr); + assert!(msg.contains("KEI_STORE_ALLOW_S3_STUB"), "expected stub-gate message, got: {msg}"); +} + #[test] fn status_reports_backend() { let tmp = TempDir::new().unwrap(); diff --git a/_primitives/_rust/kei-task/Cargo.toml b/_primitives/_rust/kei-task/Cargo.toml new file mode 100644 index 0000000..7b57690 --- /dev/null +++ b/_primitives/_rust/kei-task/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "kei-task" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Task DAG with deps + milestones (SQLite). Port of LBM internal/task." + +[[bin]] +name = "kei-task" +path = "src/main.rs" + +[lib] +name = "kei_task" +path = "src/lib.rs" + +[dependencies] +rusqlite = { version = "0.31", features = ["bundled"] } +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/kei-task/src/deps.rs b/_primitives/_rust/kei-task/src/deps.rs new file mode 100644 index 0000000..1e8eb84 --- /dev/null +++ b/_primitives/_rust/kei-task/src/deps.rs @@ -0,0 +1,66 @@ +//! Dependency edges + cycle detection + dependency-chain traversal. + +use crate::store::Store; +use crate::types::is_valid_dep; +use anyhow::{anyhow, Result}; +use rusqlite::params; +use std::collections::HashSet; + +/// Add a dependency. Refuses a cycle (taskId -> dependsOn -> ... -> taskId). +pub fn add_dependency(store: &Store, task_id: i64, depends_on: i64, dep_type: &str) -> Result<()> { + if task_id == depends_on { + return Err(anyhow!("self-dependency forbidden")); + } + let dt = if dep_type.is_empty() { "blocks" } else { dep_type }; + if !is_valid_dep(dt) { + return Err(anyhow!("invalid dep type: {dt}")); + } + if creates_cycle(store, task_id, depends_on)? { + return Err(anyhow!("cycle: {task_id} -> {depends_on} would close a loop")); + } + store.conn().execute( + "INSERT OR IGNORE INTO task_deps (task_id, depends_on, dep_type) VALUES (?1,?2,?3)", + params![task_id, depends_on, dt], + )?; + Ok(()) +} + +/// True if adding task_id -> depends_on would create a cycle. +fn creates_cycle(store: &Store, task_id: i64, depends_on: i64) -> Result { + // If depends_on reaches task_id via existing deps, cycle would close. + let mut stack = vec![depends_on]; + let mut seen: HashSet = HashSet::new(); + while let Some(cur) = stack.pop() { + if cur == task_id { + return Ok(true); + } + if !seen.insert(cur) { + continue; + } + let mut stmt = store.conn().prepare("SELECT depends_on FROM task_deps WHERE task_id=?1")?; + let rows = stmt.query_map(params![cur], |r| r.get::<_, i64>(0))?; + for row in rows { + stack.push(row?); + } + } + Ok(false) +} + +/// Full dependency chain (BFS transitive closure). +pub fn dependency_chain(store: &Store, task_id: i64) -> Result> { + let mut seen: HashSet = HashSet::new(); + let mut frontier = vec![task_id]; + let mut chain: Vec = Vec::new(); + while let Some(cur) = frontier.pop() { + let mut stmt = store.conn().prepare("SELECT depends_on FROM task_deps WHERE task_id=?1")?; + let rows = stmt.query_map(params![cur], |r| r.get::<_, i64>(0))?; + for row in rows { + let id = row?; + if seen.insert(id) { + chain.push(id); + frontier.push(id); + } + } + } + Ok(chain) +} diff --git a/_primitives/_rust/kei-task/src/graph.rs b/_primitives/_rust/kei-task/src/graph.rs new file mode 100644 index 0000000..f318b45 --- /dev/null +++ b/_primitives/_rust/kei-task/src/graph.rs @@ -0,0 +1,28 @@ +//! Adjacency view — returns task graph as edge-list for visualisation. + +use crate::store::Store; +use anyhow::Result; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct TaskEdge { + pub task_id: i64, + pub depends_on: i64, + pub dep_type: String, +} + +pub fn list_edges(store: &Store) -> Result> { + let mut stmt = store.conn().prepare( + "SELECT task_id, depends_on, dep_type FROM task_deps" + )?; + let rows = stmt.query_map([], |r| { + Ok(TaskEdge { + task_id: r.get(0)?, + depends_on: r.get(1)?, + dep_type: r.get(2)?, + }) + })?; + let mut out = Vec::new(); + for r in rows { out.push(r?); } + Ok(out) +} diff --git a/_primitives/_rust/kei-task/src/lib.rs b/_primitives/_rust/kei-task/src/lib.rs new file mode 100644 index 0000000..2e955a5 --- /dev/null +++ b/_primitives/_rust/kei-task/src/lib.rs @@ -0,0 +1,12 @@ +//! kei-task — tasks with typed deps (DAG, cycle-detected), milestones, FTS search. + +pub mod deps; +pub mod graph; +pub mod milestones; +pub mod schema; +pub mod search; +pub mod store; +pub mod types; + +pub use store::Store; +pub use types::{Milestone, Task}; diff --git a/_primitives/_rust/kei-task/src/main.rs b/_primitives/_rust/kei-task/src/main.rs new file mode 100644 index 0000000..fa21250 --- /dev/null +++ b/_primitives/_rust/kei-task/src/main.rs @@ -0,0 +1,120 @@ +//! kei-task CLI — create / update / add-dep / graph / dependency-chain. + +use clap::{Parser, Subcommand}; +use kei_task::deps::{add_dependency, dependency_chain}; +use kei_task::graph::list_edges; +use kei_task::milestones::{create_milestone, link_task_to_milestone}; +use kei_task::search::search; +use kei_task::{Milestone, Store, Task}; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser)] +#[command(name = "kei-task", version, about = "Task DAG CLI")] +struct Cli { + #[arg(long)] db: Option, + #[command(subcommand)] cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + Create { title: String, #[arg(long, default_value = "")] description: String, + #[arg(long, default_value = "medium")] priority: String }, + Update { id: i64, #[arg(long)] status: Option, #[arg(long)] title: Option }, + AddDependency { from_id: i64, to_id: i64, + #[arg(long, default_value = "blocks")] dep_type: String }, + Graph, + DependencyChain { id: i64 }, + Search { query: String, #[arg(long, default_value_t = 20)] limit: i64 }, + Milestone { name: String, #[arg(long, default_value = "")] description: String }, + LinkMilestone { task_id: i64, milestone_id: i64 }, +} + +fn db_path(cli_db: Option) -> PathBuf { + if let Some(p) = cli_db { return p; } + if let Ok(e) = std::env::var("KEI_TASK_DB") { return PathBuf::from(e); } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".claude/task/task.sqlite") +} + +fn run() -> anyhow::Result<()> { + let cli = Cli::parse(); + let s = Store::open(&db_path(cli.db))?; + dispatch(&s, cli.cmd) +} + +fn dispatch(s: &Store, cmd: Cmd) -> anyhow::Result<()> { + match cmd { + Cmd::Create { title, description, priority } => + cmd_create(s, title, description, priority), + Cmd::Update { id, status, title } => cmd_update(s, id, status, title), + Cmd::AddDependency { from_id, to_id, dep_type } => + cmd_add_dep(s, from_id, to_id, &dep_type), + Cmd::Graph => cmd_graph(s), + Cmd::DependencyChain { id } => cmd_chain(s, id), + Cmd::Search { query, limit } => cmd_search(s, &query, limit), + Cmd::Milestone { name, description } => cmd_milestone(s, name, description), + Cmd::LinkMilestone { task_id, milestone_id } => + cmd_link_milestone(s, task_id, milestone_id), + } +} + +fn cmd_create(s: &Store, title: String, description: String, priority: String) -> anyhow::Result<()> { + let id = s.create_task(&Task { title, description, priority, ..Default::default() })?; + println!("{}", id); + Ok(()) +} + +fn cmd_update(s: &Store, id: i64, status: Option, title: Option) -> anyhow::Result<()> { + let mut t = s.get_task(id)?.ok_or_else(|| anyhow::anyhow!("id {id} not found"))?; + if let Some(st) = status { t.status = st; } + if let Some(ti) = title { t.title = ti; } + s.update_task(&t)?; + println!("updated {}", id); + Ok(()) +} + +fn cmd_add_dep(s: &Store, from_id: i64, to_id: i64, dep_type: &str) -> anyhow::Result<()> { + add_dependency(s, from_id, to_id, dep_type)?; + println!("dep: {} -> {} ({})", from_id, to_id, dep_type); + Ok(()) +} + +fn cmd_graph(s: &Store) -> anyhow::Result<()> { + for e in list_edges(s)? { + println!("{}\t-[{}]->\t{}", e.task_id, e.dep_type, e.depends_on); + } + Ok(()) +} + +fn cmd_chain(s: &Store, id: i64) -> anyhow::Result<()> { + for did in dependency_chain(s, id)? { println!("{}", did); } + Ok(()) +} + +fn cmd_search(s: &Store, query: &str, limit: i64) -> anyhow::Result<()> { + for t in search(s, query, limit)? { + println!("{}\t{}\t{}", t.id, t.status, t.title); + } + Ok(()) +} + +fn cmd_milestone(s: &Store, name: String, description: String) -> anyhow::Result<()> { + let id = create_milestone(s, &Milestone { + name, description, ..Default::default() })?; + println!("{}", id); + Ok(()) +} + +fn cmd_link_milestone(s: &Store, task_id: i64, milestone_id: i64) -> anyhow::Result<()> { + link_task_to_milestone(s, task_id, milestone_id)?; + println!("linked {} -> milestone {}", task_id, milestone_id); + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { eprintln!("kei-task: {e:#}"); ExitCode::from(1) } + } +} diff --git a/_primitives/_rust/kei-task/src/milestones.rs b/_primitives/_rust/kei-task/src/milestones.rs new file mode 100644 index 0000000..fd8f4ef --- /dev/null +++ b/_primitives/_rust/kei-task/src/milestones.rs @@ -0,0 +1,39 @@ +//! Milestone CRUD + task→milestone linking. + +use crate::store::Store; +use crate::types::Milestone; +use anyhow::Result; +use chrono::Utc; +use rusqlite::params; + +pub fn create_milestone(store: &Store, m: &Milestone) -> Result { + let now = Utc::now().timestamp(); + let created = if m.created_at == 0 { now } else { m.created_at }; + let status = if m.status.is_empty() { "open" } else { &m.status }; + store.conn().execute( + "INSERT INTO milestones (name, description, target_date, status, created_at) + VALUES (?1,?2,?3,?4,?5)", + params![m.name, m.description, m.target_date, status, created], + )?; + Ok(store.conn().last_insert_rowid()) +} + +pub fn link_task_to_milestone(store: &Store, task_id: i64, milestone_id: i64) -> Result<()> { + store.conn().execute( + "INSERT OR IGNORE INTO task_milestones (task_id, milestone_id) VALUES (?1,?2)", + params![task_id, milestone_id], + )?; + Ok(()) +} + +pub fn tasks_in_milestone(store: &Store, milestone_id: i64) -> Result> { + let mut stmt = store.conn().prepare( + "SELECT task_id FROM task_milestones WHERE milestone_id=?1", + )?; + let rows = stmt.query_map(params![milestone_id], |r| r.get::<_, i64>(0))?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) +} diff --git a/_primitives/_rust/kei-task/src/schema.rs b/_primitives/_rust/kei-task/src/schema.rs new file mode 100644 index 0000000..6bf1990 --- /dev/null +++ b/_primitives/_rust/kei-task/src/schema.rs @@ -0,0 +1,57 @@ +//! SQLite schema for tasks + milestones + deps. Port of LBM internal/task/schema.go. + +use rusqlite::{Connection, Result}; + +const DDL_MAIN: &str = r#" + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + description TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + priority TEXT DEFAULT 'medium', + task_type TEXT DEFAULT '', + parent_id INTEGER DEFAULT 0, + assigned_to TEXT DEFAULT '', + due_date INTEGER DEFAULT 0, + completed_at INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_task_status ON tasks(status); + CREATE INDEX IF NOT EXISTS idx_task_priority ON tasks(priority); + CREATE INDEX IF NOT EXISTS idx_task_parent ON tasks(parent_id); + + CREATE TABLE IF NOT EXISTS milestones ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + description TEXT DEFAULT '', + target_date INTEGER DEFAULT 0, + status TEXT DEFAULT 'open', + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS task_deps ( + task_id INTEGER NOT NULL, + depends_on INTEGER NOT NULL, + dep_type TEXT DEFAULT 'blocks', + PRIMARY KEY(task_id, depends_on) + ); + CREATE INDEX IF NOT EXISTS idx_dep_depends ON task_deps(depends_on); + + CREATE TABLE IF NOT EXISTS task_milestones ( + task_id INTEGER NOT NULL, + milestone_id INTEGER NOT NULL, + PRIMARY KEY(task_id, milestone_id) + ); +"#; + +const DDL_FTS: &str = r#" + CREATE VIRTUAL TABLE IF NOT EXISTS fts_tasks + USING fts5(task_id UNINDEXED, title, description, tokenize='porter unicode61'); +"#; + +pub fn create_schema(conn: &Connection) -> Result<()> { + conn.execute_batch(DDL_MAIN)?; + conn.execute_batch(DDL_FTS)?; + Ok(()) +} diff --git a/_primitives/_rust/kei-task/src/search.rs b/_primitives/_rust/kei-task/src/search.rs new file mode 100644 index 0000000..b23f546 --- /dev/null +++ b/_primitives/_rust/kei-task/src/search.rs @@ -0,0 +1,29 @@ +//! FTS5 search over tasks (title + description). + +use crate::store::Store; +use crate::types::Task; +use anyhow::Result; +use rusqlite::params; + +pub fn search(store: &Store, query: &str, limit: i64) -> Result> { + let lim = if limit <= 0 { 20 } else { limit }; + let mut stmt = store.conn().prepare( + "SELECT t.id, t.title, t.description, t.status, t.priority, t.task_type, + t.parent_id, t.assigned_to, t.due_date, t.completed_at, + t.created_at, t.updated_at + FROM fts_tasks f + JOIN tasks t ON t.id = f.task_id + WHERE fts_tasks MATCH ?1 ORDER BY rank LIMIT ?2", + )?; + let rows = stmt.query_map(params![query, lim], |r| { + Ok(Task { + id: r.get(0)?, title: r.get(1)?, description: r.get(2)?, status: r.get(3)?, + priority: r.get(4)?, task_type: r.get(5)?, parent_id: r.get(6)?, + assigned_to: r.get(7)?, due_date: r.get(8)?, completed_at: r.get(9)?, + created_at: r.get(10)?, updated_at: r.get(11)?, + }) + })?; + let mut out = Vec::new(); + for r in rows { out.push(r?); } + Ok(out) +} diff --git a/_primitives/_rust/kei-task/src/store.rs b/_primitives/_rust/kei-task/src/store.rs new file mode 100644 index 0000000..3ed1c43 --- /dev/null +++ b/_primitives/_rust/kei-task/src/store.rs @@ -0,0 +1,94 @@ +//! Task store — open, CRUD, FTS reindex. + +use crate::schema::create_schema; +use crate::types::Task; +use anyhow::{Context, Result}; +use chrono::Utc; +use rusqlite::{params, Connection}; +use std::path::Path; + +pub struct Store { + conn: Connection, +} + +impl Store { + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let conn = Connection::open(path).context("open sqlite")?; + conn.pragma_update(None, "journal_mode", "WAL").ok(); + create_schema(&conn)?; + Ok(Self { conn }) + } + + pub fn open_memory() -> Result { + let conn = Connection::open_in_memory()?; + create_schema(&conn)?; + Ok(Self { conn }) + } + + pub fn conn(&self) -> &Connection { &self.conn } + + pub fn create_task(&self, t: &Task) -> Result { + let now = Utc::now().timestamp(); + let created = if t.created_at == 0 { now } else { t.created_at }; + let status = if t.status.is_empty() { "pending" } else { &t.status }; + let priority = if t.priority.is_empty() { "medium" } else { &t.priority }; + self.conn.execute( + "INSERT INTO tasks (title, description, status, priority, task_type, + parent_id, assigned_to, due_date, completed_at, created_at, updated_at) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)", + params![t.title, t.description, status, priority, t.task_type, + t.parent_id, t.assigned_to, t.due_date, t.completed_at, created, now], + )?; + let id = self.conn.last_insert_rowid(); + self.reindex_fts(id, &t.title, &t.description)?; + Ok(id) + } + + pub fn get_task(&self, id: i64) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, title, description, status, priority, task_type, parent_id, + assigned_to, due_date, completed_at, created_at, updated_at + FROM tasks WHERE id=?1", + )?; + let mut rows = stmt.query(params![id])?; + if let Some(r) = rows.next()? { + return Ok(Some(row_to_task(r)?)); + } + Ok(None) + } + + pub fn update_task(&self, t: &Task) -> Result<()> { + let now = Utc::now().timestamp(); + let completed = if t.status == "completed" && t.completed_at == 0 { now } else { t.completed_at }; + self.conn.execute( + "UPDATE tasks SET title=?1, description=?2, status=?3, priority=?4, + task_type=?5, parent_id=?6, assigned_to=?7, due_date=?8, + completed_at=?9, updated_at=?10 WHERE id=?11", + params![t.title, t.description, t.status, t.priority, t.task_type, + t.parent_id, t.assigned_to, t.due_date, completed, now, t.id], + )?; + self.reindex_fts(t.id, &t.title, &t.description)?; + Ok(()) + } + + fn reindex_fts(&self, id: i64, title: &str, description: &str) -> Result<()> { + self.conn.execute("DELETE FROM fts_tasks WHERE task_id=?1", params![id])?; + self.conn.execute( + "INSERT INTO fts_tasks (task_id, title, description) VALUES (?1,?2,?3)", + params![id, title, description], + )?; + Ok(()) + } +} + +fn row_to_task(r: &rusqlite::Row) -> rusqlite::Result { + Ok(Task { + id: r.get(0)?, title: r.get(1)?, description: r.get(2)?, status: r.get(3)?, + priority: r.get(4)?, task_type: r.get(5)?, parent_id: r.get(6)?, + assigned_to: r.get(7)?, due_date: r.get(8)?, completed_at: r.get(9)?, + created_at: r.get(10)?, updated_at: r.get(11)?, + }) +} diff --git a/_primitives/_rust/kei-task/src/types.rs b/_primitives/_rust/kei-task/src/types.rs new file mode 100644 index 0000000..a894895 --- /dev/null +++ b/_primitives/_rust/kei-task/src/types.rs @@ -0,0 +1,45 @@ +//! Task + Milestone value types and enum validation. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Task { + pub id: i64, + pub title: String, + pub description: String, + pub status: String, + pub priority: String, + pub task_type: String, + pub parent_id: i64, + pub assigned_to: String, + pub due_date: i64, + pub completed_at: i64, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Milestone { + pub id: i64, + pub name: String, + pub description: String, + pub target_date: i64, + pub status: String, + pub created_at: i64, +} + +pub const VALID_STATUSES: &[&str] = + &["pending", "in_progress", "completed", "cancelled", "blocked"]; +pub const VALID_PRIORITIES: &[&str] = &["critical", "high", "medium", "low"]; +pub const VALID_DEP_TYPES: &[&str] = + &["blocks", "feeds_into", "subtask_of", "milestone_of", "assigned_to", "depends_on"]; + +pub fn is_valid_status(s: &str) -> bool { + VALID_STATUSES.contains(&s) +} +pub fn is_valid_priority(s: &str) -> bool { + VALID_PRIORITIES.contains(&s) +} +pub fn is_valid_dep(s: &str) -> bool { + VALID_DEP_TYPES.contains(&s) +} diff --git a/_primitives/_rust/kei-task/tests/integration.rs b/_primitives/_rust/kei-task/tests/integration.rs new file mode 100644 index 0000000..30e473e --- /dev/null +++ b/_primitives/_rust/kei-task/tests/integration.rs @@ -0,0 +1,95 @@ +//! kei-task integration tests. + +use kei_task::deps::{add_dependency, dependency_chain}; +use kei_task::graph::list_edges; +use kei_task::milestones::{create_milestone, link_task_to_milestone, tasks_in_milestone}; +use kei_task::search::search; +use kei_task::{Milestone, Store, Task}; + +fn mk() -> Store { Store::open_memory().unwrap() } + +fn mktask(title: &str) -> Task { + Task { title: title.into(), priority: "high".into(), ..Default::default() } +} + +#[test] +fn create_and_get() { + let s = mk(); + let id = s.create_task(&mktask("a")).unwrap(); + let t = s.get_task(id).unwrap().unwrap(); + assert_eq!(t.title, "a"); + assert_eq!(t.status, "pending"); +} + +#[test] +fn update_persists() { + let s = mk(); + let id = s.create_task(&mktask("a")).unwrap(); + let mut t = s.get_task(id).unwrap().unwrap(); + t.status = "in_progress".into(); + s.update_task(&t).unwrap(); + let u = s.get_task(id).unwrap().unwrap(); + assert_eq!(u.status, "in_progress"); +} + +#[test] +fn cycle_detected() { + let s = mk(); + let a = s.create_task(&mktask("a")).unwrap(); + let b = s.create_task(&mktask("b")).unwrap(); + let c = s.create_task(&mktask("c")).unwrap(); + add_dependency(&s, a, b, "blocks").unwrap(); + add_dependency(&s, b, c, "blocks").unwrap(); + // a -> b -> c; now c -> a would be a cycle + let err = add_dependency(&s, c, a, "blocks"); + assert!(err.is_err(), "cycle detection must reject"); +} + +#[test] +fn milestone_linking() { + let s = mk(); + let t = s.create_task(&mktask("design")).unwrap(); + let ms_id = create_milestone(&s, &Milestone { + name: "v1".into(), ..Default::default() }).unwrap(); + link_task_to_milestone(&s, t, ms_id).unwrap(); + let tasks = tasks_in_milestone(&s, ms_id).unwrap(); + assert_eq!(tasks, vec![t]); +} + +#[test] +fn dependency_chain_traversal() { + let s = mk(); + let a = s.create_task(&mktask("a")).unwrap(); + let b = s.create_task(&mktask("b")).unwrap(); + let c = s.create_task(&mktask("c")).unwrap(); + add_dependency(&s, a, b, "blocks").unwrap(); + add_dependency(&s, b, c, "blocks").unwrap(); + let chain = dependency_chain(&s, a).unwrap(); + assert!(chain.contains(&b)); + assert!(chain.contains(&c)); + assert_eq!(chain.len(), 2); +} + +#[test] +fn task_graph_edges() { + let s = mk(); + let a = s.create_task(&mktask("a")).unwrap(); + let b = s.create_task(&mktask("b")).unwrap(); + add_dependency(&s, a, b, "blocks").unwrap(); + let edges = list_edges(&s).unwrap(); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].task_id, a); + assert_eq!(edges[0].depends_on, b); +} + +#[test] +fn search_finds_task() { + let s = mk(); + s.create_task(&Task { + title: "refactor router".into(), + description: "split monolith".into(), + ..Default::default() + }).unwrap(); + let hits = search(&s, "refactor", 10).unwrap(); + assert_eq!(hits.len(), 1); +} diff --git a/_primitives/_rust/mock-render/src/cli_args.rs b/_primitives/_rust/mock-render/src/cli_args.rs new file mode 100644 index 0000000..e6cfdbd --- /dev/null +++ b/_primitives/_rust/mock-render/src/cli_args.rs @@ -0,0 +1,33 @@ +//! Shared CLI-arg helpers for every mock-render subcommand. +//! +//! Extracted from `main.rs` in v0.14.1 to keep that dispatcher ≤40 LOC +//! per Constructor Pattern. + +use std::path::PathBuf; + +/// Look up a `--name ` pair in the arg slice. +pub fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.windows(2) + .find(|w| w[0] == name) + .map(|w| w[1].as_str()) +} + +/// Parse `WxH` viewport (e.g. `1280x800`). +pub fn parse_viewport(s: &str) -> Option<(u32, u32)> { + let (w, h) = s.split_once('x')?; + Some((w.parse().ok()?, h.parse().ok()?)) +} + +/// Require `--project` (default `.`) and `--section `. +pub fn require_project_section(args: &[String]) -> Result<(PathBuf, PathBuf), String> { + let project = flag(args, "--project") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let section = flag(args, "--section") + .map(PathBuf::from) + .ok_or_else(|| "--section required".to_string())?; + if !section.exists() { + return Err(format!("section file not found: {}", section.display())); + } + Ok((project, section)) +} diff --git a/_primitives/_rust/mock-render/src/cmd_lock.rs b/_primitives/_rust/mock-render/src/cmd_lock.rs new file mode 100644 index 0000000..4523b86 --- /dev/null +++ b/_primitives/_rust/mock-render/src/cmd_lock.rs @@ -0,0 +1,51 @@ +//! `mock-render lock --project --section [--screenshot ]` +//! +//! Extracted from `main.rs` in v0.14.1 per Constructor Pattern. + +use crate::cli_args::{flag, require_project_section}; +use crate::hash; +use crate::state::{Section, SiteState}; +use std::process::ExitCode; + +pub fn run(args: &[String]) -> ExitCode { + let (project, section) = match require_project_section(args) { + Ok(v) => v, + Err(e) => { + eprintln!("lock: {e}"); + return ExitCode::from(1); + } + }; + let screenshot = flag(args, "--screenshot"); + + let Ok(hash_now) = hash::hash_file(§ion) else { + eprintln!("lock: cannot hash {}", section.display()); + return ExitCode::from(2); + }; + + let mut st = match SiteState::load(&project) { + Ok(s) => s, + Err(e) => { + eprintln!("lock: {e}"); + return ExitCode::from(2); + } + }; + + let key = SiteState::key_for(§ion); + st.sections.insert( + key.clone(), + Section { + path: section.display().to_string(), + sha256: hash_now.clone(), + locked: true, + screenshot: screenshot.map(String::from), + }, + ); + + if let Err(e) = st.save(&project) { + eprintln!("lock: {e}"); + return ExitCode::from(2); + } + + println!("locked {key} ({})", &hash_now[..12]); + ExitCode::SUCCESS +} diff --git a/_primitives/_rust/mock-render/src/cmd_screenshot.rs b/_primitives/_rust/mock-render/src/cmd_screenshot.rs new file mode 100644 index 0000000..3505ab5 --- /dev/null +++ b/_primitives/_rust/mock-render/src/cmd_screenshot.rs @@ -0,0 +1,34 @@ +//! `mock-render screenshot --out [--viewport WxH]` +//! +//! Extracted from `main.rs` in v0.14.1 per Constructor Pattern. + +use crate::cli_args::{flag, parse_viewport}; +use crate::render; +use std::path::PathBuf; +use std::process::ExitCode; + +pub fn run(args: &[String]) -> ExitCode { + let Some(url) = args.first().cloned() else { + eprintln!("screenshot: required"); + return ExitCode::from(1); + }; + let out = match flag(args, "--out") { + Some(p) => PathBuf::from(p), + None => { + eprintln!("screenshot: --out required"); + return ExitCode::from(1); + } + }; + let viewport = flag(args, "--viewport").and_then(parse_viewport); + + match render::screenshot(&url, &out, viewport) { + Ok(()) => { + println!("{}", out.display()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("mock-render: {e}"); + ExitCode::from(1) + } + } +} diff --git a/_primitives/_rust/mock-render/src/cmd_verify.rs b/_primitives/_rust/mock-render/src/cmd_verify.rs new file mode 100644 index 0000000..5adfe4e --- /dev/null +++ b/_primitives/_rust/mock-render/src/cmd_verify.rs @@ -0,0 +1,91 @@ +//! `mock-render verify --project --section ` +//! `mock-render status --project ` +//! +//! Two closely-related subcommands extracted from `main.rs` in v0.14.1. +//! They share state-loading + hash-comparison logic. + +use crate::cli_args::{flag, require_project_section}; +use crate::hash; +use crate::state::SiteState; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +pub fn run_verify(args: &[String]) -> ExitCode { + let (project, section) = match require_project_section(args) { + Ok(v) => v, + Err(e) => { + eprintln!("verify: {e}"); + return ExitCode::from(1); + } + }; + + let st = match SiteState::load(&project) { + Ok(s) => s, + Err(e) => { + eprintln!("verify: {e}"); + return ExitCode::from(2); + } + }; + + let key = SiteState::key_for(§ion); + let Some(entry) = st.sections.get(&key) else { + eprintln!("verify: section '{key}' not in site-state.json (not locked yet)"); + return ExitCode::SUCCESS; + }; + if !entry.locked { + return ExitCode::SUCCESS; + } + + let Ok(hash_now) = hash::hash_file(§ion) else { + eprintln!("verify: cannot hash {}", section.display()); + return ExitCode::from(2); + }; + + if hash_now != entry.sha256 { + eprintln!( + "WYSIWYD VIOLATION: {key} drifted\n locked : {}\n current: {}\nThe screenshot user approved no longer matches the source.\nRerun render + user-approval before deploy.", + &entry.sha256[..12], + &hash_now[..12] + ); + return ExitCode::from(2); + } + println!("ok {key} ({})", &hash_now[..12]); + ExitCode::SUCCESS +} + +pub fn run_status(args: &[String]) -> ExitCode { + let project = flag(args, "--project") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + + let st = match SiteState::load(&project) { + Ok(s) => s, + Err(e) => { + eprintln!("status: {e}"); + return ExitCode::from(2); + } + }; + + if st.sections.is_empty() { + println!("(no sections tracked)"); + return ExitCode::SUCCESS; + } + + for (name, sec) in &st.sections { + let lock = if sec.locked { "LOCKED" } else { "open" }; + let drift = match hash::hash_file(Path::new(&sec.path)) { + Ok(h) if h == sec.sha256 => "clean", + Ok(_) => "DRIFT", + Err(_) => "missing", + }; + println!( + "{:<20} {:>6} {:<7} {} ({})", + name, + lock, + drift, + sec.path, + &sec.sha256[..12] + ); + } + ExitCode::SUCCESS +} diff --git a/_primitives/_rust/mock-render/src/main.rs b/_primitives/_rust/mock-render/src/main.rs index ed9a091..6510162 100644 --- a/_primitives/_rust/mock-render/src/main.rs +++ b/_primitives/_rust/mock-render/src/main.rs @@ -8,22 +8,24 @@ //! mock-render verify --project --section //! mock-render status --project +mod cli_args; +mod cmd_lock; +mod cmd_screenshot; +mod cmd_verify; mod hash; mod render; mod state; -use state::{Section, SiteState}; use std::env; -use std::path::{Path, PathBuf}; use std::process::ExitCode; fn main() -> ExitCode { let args: Vec = env::args().skip(1).collect(); match args.first().map(String::as_str) { - Some("screenshot") => cmd_screenshot(&args[1..]), - Some("lock") => cmd_lock(&args[1..]), - Some("verify") => cmd_verify(&args[1..]), - Some("status") => cmd_status(&args[1..]), + Some("screenshot") => cmd_screenshot::run(&args[1..]), + Some("lock") => cmd_lock::run(&args[1..]), + Some("verify") => cmd_verify::run_verify(&args[1..]), + Some("status") => cmd_verify::run_status(&args[1..]), Some("--help") | Some("-h") | None => { print_help(); ExitCode::SUCCESS @@ -51,176 +53,3 @@ EXIT 2 WYSIWYD invariant violated (file drift / hash mismatch)" ); } - -fn cmd_screenshot(args: &[String]) -> ExitCode { - let Some(url) = args.first().cloned() else { - eprintln!("screenshot: required"); - return ExitCode::from(1); - }; - let out = match flag(args, "--out") { - Some(p) => PathBuf::from(p), - None => { - eprintln!("screenshot: --out required"); - return ExitCode::from(1); - } - }; - let viewport = flag(args, "--viewport").and_then(parse_viewport); - - match render::screenshot(&url, &out, viewport) { - Ok(()) => { - println!("{}", out.display()); - ExitCode::SUCCESS - } - Err(e) => { - eprintln!("mock-render: {e}"); - ExitCode::from(1) - } - } -} - -fn cmd_lock(args: &[String]) -> ExitCode { - let (project, section) = match require_project_section(args) { - Ok(v) => v, - Err(e) => { - eprintln!("lock: {e}"); - return ExitCode::from(1); - } - }; - let screenshot = flag(args, "--screenshot"); - - let Ok(hash_now) = hash::hash_file(§ion) else { - eprintln!("lock: cannot hash {}", section.display()); - return ExitCode::from(2); - }; - - let mut st = match SiteState::load(&project) { - Ok(s) => s, - Err(e) => { - eprintln!("lock: {e}"); - return ExitCode::from(2); - } - }; - - let key = SiteState::key_for(§ion); - st.sections.insert( - key.clone(), - Section { - path: section.display().to_string(), - sha256: hash_now.clone(), - locked: true, - screenshot: screenshot.map(String::from), - }, - ); - - if let Err(e) = st.save(&project) { - eprintln!("lock: {e}"); - return ExitCode::from(2); - } - - println!("locked {key} ({})", &hash_now[..12]); - ExitCode::SUCCESS -} - -fn cmd_verify(args: &[String]) -> ExitCode { - let (project, section) = match require_project_section(args) { - Ok(v) => v, - Err(e) => { - eprintln!("verify: {e}"); - return ExitCode::from(1); - } - }; - - let st = match SiteState::load(&project) { - Ok(s) => s, - Err(e) => { - eprintln!("verify: {e}"); - return ExitCode::from(2); - } - }; - - let key = SiteState::key_for(§ion); - let Some(entry) = st.sections.get(&key) else { - eprintln!("verify: section '{key}' not in site-state.json (not locked yet)"); - return ExitCode::SUCCESS; - }; - if !entry.locked { - return ExitCode::SUCCESS; - } - - let Ok(hash_now) = hash::hash_file(§ion) else { - eprintln!("verify: cannot hash {}", section.display()); - return ExitCode::from(2); - }; - - if hash_now != entry.sha256 { - eprintln!( - "WYSIWYD VIOLATION: {key} drifted\n locked : {}\n current: {}\nThe screenshot user approved no longer matches the source.\nRerun render + user-approval before deploy.", - &entry.sha256[..12], - &hash_now[..12] - ); - return ExitCode::from(2); - } - println!("ok {key} ({})", &hash_now[..12]); - ExitCode::SUCCESS -} - -fn cmd_status(args: &[String]) -> ExitCode { - let project = flag(args, "--project") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")); - - let st = match SiteState::load(&project) { - Ok(s) => s, - Err(e) => { - eprintln!("status: {e}"); - return ExitCode::from(2); - } - }; - - if st.sections.is_empty() { - println!("(no sections tracked)"); - return ExitCode::SUCCESS; - } - - for (name, sec) in &st.sections { - let lock = if sec.locked { "LOCKED" } else { "open" }; - let drift = match hash::hash_file(Path::new(&sec.path)) { - Ok(h) if h == sec.sha256 => "clean", - Ok(_) => "DRIFT", - Err(_) => "missing", - }; - println!( - "{:<20} {:>6} {:<7} {} ({})", - name, - lock, - drift, - sec.path, - &sec.sha256[..12] - ); - } - ExitCode::SUCCESS -} - -fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { - args.windows(2) - .find(|w| w[0] == name) - .map(|w| w[1].as_str()) -} - -fn parse_viewport(s: &str) -> Option<(u32, u32)> { - let (w, h) = s.split_once('x')?; - Some((w.parse().ok()?, h.parse().ok()?)) -} - -fn require_project_section(args: &[String]) -> Result<(PathBuf, PathBuf), String> { - let project = flag(args, "--project") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")); - let section = flag(args, "--section") - .map(PathBuf::from) - .ok_or_else(|| "--section required".to_string())?; - if !section.exists() { - return Err(format!("section file not found: {}", section.display())); - } - Ok((project, section)) -} diff --git a/_primitives/templates/deep-sleep-trigger-prompt.md b/_primitives/templates/deep-sleep-trigger-prompt.md index 2ee7573..7282bff 100644 --- a/_primitives/templates/deep-sleep-trigger-prompt.md +++ b/_primitives/templates/deep-sleep-trigger-prompt.md @@ -48,17 +48,27 @@ v0.12.0 rules AND Phase C is skipped too — the marathon owns the night. 3. **Optional fork (only if `WITH_FORK=1`):** a. `kei-refactor-engine --input - --apply-to-branch - deep-sleep/YYYY-MM-DD --patch-out sync-repo/sleep-deep/YYYY-MM-DD.patch` - (re-run on same JSON; patch file lists auto-apply items only). + deep-sleep/YYYY-MM-DD --patch-out sync-repo/sleep-deep/YYYY-MM-DD-autoresolve.md` + (re-run on same JSON; the auto-resolve markdown lists auto-apply + items only — NOT a unified diff, see note below). - b. Apply the patch to a new local branch: - `git checkout -b deep-sleep/YYYY-MM-DD && git apply ` + b. Review the auto-resolve markdown and apply each change manually + in a new local branch: + `git checkout -b deep-sleep/YYYY-MM-DD` + Open `.md`, edit the listed files accordingly, then + `git add && git commit`. - c. Gate: `kei-graph-check --path sync-repo/ --after-diff `. - If broken refs → abort fork, delete branch, append "graph check - failed — fork aborted, plan kept" note to the plan file. + c. Gate: `kei-graph-check --path sync-repo/`. If broken refs after + your edits → delete branch, append "graph check failed — fork + aborted, plan kept" note to the plan file. - d. If clean → commit the applied changes on the fork branch. + d. If clean → push the fork branch for morning review. + +> NOTE (v0.14.1 retraction): earlier docs claimed the engine emits a +> `git apply`-ready patch. It does not — see `patch.rs` header for +> the reason (engine cannot synthesise file-content hunks without +> reading source files, which risks RULE 0.4 hallucination). The +> companion file is a markdown summary reviewed and applied by hand. 4. **Commit + push.** The plan markdown is always committed to `main` with message `NREM: deep-sleep YYYY-MM-DD`. If a fork branch was @@ -86,6 +96,7 @@ No silent auto-apply of ambiguous changes. - `kei-conflict-scan` fails → record the error in the plan body and skip fork. - `kei-refactor-engine` fails → same; keep any partial plan markdown. -- `git apply` rejects → delete fork branch; append reject to the plan. +- Manual edits in step 3b produce merge conflicts → delete fork branch; + append the conflict summary to the plan. - Push fails → retry once; on second failure leave local commit and exit 1. Local state is recoverable on next run. diff --git a/_ts_packages/.gitignore b/_ts_packages/.gitignore new file mode 100644 index 0000000..02191d9 --- /dev/null +++ b/_ts_packages/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store +coverage/ diff --git a/_ts_packages/README.md b/_ts_packages/README.md new file mode 100644 index 0000000..a9d603a --- /dev/null +++ b/_ts_packages/README.md @@ -0,0 +1,111 @@ +# KeiSeiKit TypeScript Packages + +> v0.14.0 part B: MCP server layer + external-API adapters. + +## RULE 0.2 exception + +TypeScript is chosen here under **RULE 0.2 exception #4 (Browser/DOM adjacent)** because: + +1. The official Model Context Protocol SDK is TypeScript-native; Rust MCP + libraries are immature (as of 2026-04). +2. The API adapters rely on JS-native SDKs with no Rust equivalents: + - `grammy` (type-safe Telegram bot) + - `googleapis` (official Google API SDK for Gmail + YouTube) + - `youtube-transcript` (Tier-1 free transcript extractor) +3. Async, JSON-heavy glue code is TypeScript's sweet spot. + +**Core primitives (signing, ledger, graph, memory, refactor, etc.) remain +Rust** in `../_primitives/_rust/`. This TS layer is a THIN wrapper: it +spawns the Rust CLIs as subprocesses and exposes them as MCP tools, plus +the six adapters above that have no Rust equivalent. + +## Layout + +``` +_ts_packages/ +├── package.json npm workspace root +├── tsconfig.base.json strict TS 5.x +└── packages/ + ├── mcp-server/ @keisei/mcp-server + ├── telegram-adapter/ @keisei/telegram-adapter + ├── recall-adapter/ @keisei/recall-adapter (Zoom via Recall.ai) + ├── grok-adapter/ @keisei/grok-adapter (xAI) + ├── gmail-adapter/ @keisei/gmail-adapter + └── youtube-adapter/ @keisei/youtube-adapter +``` + +## Install (for end users) + +### 1. Install workspace deps + +```bash +cd _ts_packages +npm install +npm run build +``` + +### 2. Link each package as a global CLI (optional) + +```bash +npm i -g ./packages/mcp-server +npm i -g ./packages/telegram-adapter +# ... etc +``` + +Or install into a Claude agent directory: + +```bash +npm i --prefix ~/.claude/agents/_ts_packages/packages/mcp-server \ + ./_ts_packages/packages/mcp-server +``` + +## Environment variables (RULE 0.8 — secrets in `~/.claude/secrets/.env`) + +| Var | Package | Purpose | +|---|---|---| +| `TELEGRAM_BOT_TOKEN` | telegram-adapter | Bot API token | +| `RECALL_API_KEY` | recall-adapter | Recall.ai API key (Zoom meetings) | +| `XAI_API_KEY` | grok-adapter | xAI Grok API key | +| `GMAIL_CLIENT_ID` | gmail-adapter | Google OAuth2 client id | +| `GMAIL_CLIENT_SECRET` | gmail-adapter | Google OAuth2 client secret | +| `GMAIL_REFRESH_TOKEN` | gmail-adapter | Long-lived OAuth2 refresh token | +| `YOUTUBE_API_KEY` | youtube-adapter | YouTube Data API v3 key | +| `KEI_MCP_AUTH_TOKEN` | mcp-server | HMAC token for tool callers | +| `KEI_RUST_BIN_DIR` | mcp-server | Override directory holding Rust primitive CLIs | + +All are read via `process.env`. Hardcoding tokens is **forbidden** (RULE 0.8). + +## MCP server integration + +The `@keisei/mcp-server` exposes the Rust primitive CLIs as MCP tools. The +pattern is one Rust binary = one MCP tool, with the `kei` meta-tool on +top that routes natural-language queries via `kei-router`. + +Stdio mode (for Claude Code native integration): + +```bash +npx @keisei/mcp-server --stdio +``` + +HTTP mode: + +```bash +npx @keisei/mcp-server --port 3000 --auth-token-file ~/.claude/mcp-token +``` + +## Verification + +```bash +npm install +npm run build --workspaces +npm run test --workspaces +``` + +All six packages compile under `strict: true`. Total new LOC: see commit. + +## Migration notes + +- Zero impact on existing KeiSeiKit users unless they opt into the MCP + server (planned v0.14.1 installer flag `--enable-mcp`). +- The Rust primitives are unchanged; this layer only **wraps** them. +- Gmail and YouTube adapters are **new** (gaps in LBM). diff --git a/_ts_packages/package-lock.json b/_ts_packages/package-lock.json new file mode 100644 index 0000000..c767c2a --- /dev/null +++ b/_ts_packages/package-lock.json @@ -0,0 +1,3776 @@ +{ + "name": "@keisei/ts-packages", + "version": "0.14.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@keisei/ts-packages", + "version": "0.14.0", + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "tsx": "^4.16.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@grammyjs/types": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.26.0.tgz", + "integrity": "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A==", + "license": "MIT" + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@keisei/gmail-adapter": { + "resolved": "packages/gmail-adapter", + "link": true + }, + "node_modules/@keisei/grok-adapter": { + "resolved": "packages/grok-adapter", + "link": true + }, + "node_modules/@keisei/mcp-server": { + "resolved": "packages/mcp-server", + "link": true + }, + "node_modules/@keisei/recall-adapter": { + "resolved": "packages/recall-adapter", + "link": true + }, + "node_modules/@keisei/telegram-adapter": { + "resolved": "packages/telegram-adapter", + "link": true + }, + "node_modules/@keisei/youtube-adapter": { + "resolved": "packages/youtube-adapter", + "link": true + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "144.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-144.0.0.tgz", + "integrity": "sha512-ELcWOXtJxjPX4vsKMh+7V+jZvgPwYMlEhQFiu2sa9Qmt5veX8nwXPksOWGGN6Zk4xCiLygUyaz7xGtcMO+Onxw==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.0.0", + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", + "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^6.0.3", + "google-auth-library": "^9.7.0", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/grammy": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.42.0.tgz", + "integrity": "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g==", + "license": "MIT", + "dependencies": { + "@grammyjs/types": "3.26.0", + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/youtube-transcript": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/youtube-transcript/-/youtube-transcript-1.3.0.tgz", + "integrity": "sha512-laWv9RcKIWh6rZUH3hVnOngEvtKAhFMV5UepUO6AgevPYqe2zv8KW/uCkZJDSnPwf5/AdVu0Q66/1RDblKsp6Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "packages/gmail-adapter": { + "name": "@keisei/gmail-adapter", + "version": "0.14.0", + "dependencies": { + "googleapis": "^144.0.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/grok-adapter": { + "name": "@keisei/grok-adapter", + "version": "0.14.0", + "dependencies": { + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/mcp-server": { + "name": "@keisei/mcp-server", + "version": "0.14.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "execa": "^9.0.0", + "zod": "^3.23.0" + }, + "bin": { + "keisei-mcp-server": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/recall-adapter": { + "name": "@keisei/recall-adapter", + "version": "0.14.0", + "dependencies": { + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/telegram-adapter": { + "name": "@keisei/telegram-adapter", + "version": "0.14.0", + "dependencies": { + "grammy": "^1.28.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/youtube-adapter": { + "name": "@keisei/youtube-adapter", + "version": "0.14.0", + "dependencies": { + "googleapis": "^144.0.0", + "youtube-transcript": "^1.2.1", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + } + } +} diff --git a/_ts_packages/package.json b/_ts_packages/package.json new file mode 100644 index 0000000..848e27d --- /dev/null +++ b/_ts_packages/package.json @@ -0,0 +1,24 @@ +{ + "name": "@keisei/ts-packages", + "private": true, + "version": "0.14.0", + "description": "KeiSeiKit TypeScript layer — MCP server and external-API adapters", + "type": "module", + "workspaces": [ + "packages/*" + ], + "scripts": { + "build": "npm run build --workspaces --if-present", + "test": "npm run test --workspaces --if-present", + "lint": "npm run lint --workspaces --if-present", + "clean": "rm -rf packages/*/dist packages/*/*.tsbuildinfo" + }, + "engines": { + "node": ">=18.0.0" + }, + "devDependencies": { + "typescript": "^5.5.0", + "vitest": "^2.0.0", + "tsx": "^4.16.0" + } +} diff --git a/_ts_packages/packages/gmail-adapter/package.json b/_ts_packages/packages/gmail-adapter/package.json new file mode 100644 index 0000000..0783137 --- /dev/null +++ b/_ts_packages/packages/gmail-adapter/package.json @@ -0,0 +1,33 @@ +{ + "name": "@keisei/gmail-adapter", + "version": "0.14.0", + "description": "Gmail API adapter for the KeiSei MCP server", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b", + "test": "vitest run" + }, + "dependencies": { + "googleapis": "^144.0.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/_ts_packages/packages/gmail-adapter/src/client.ts b/_ts_packages/packages/gmail-adapter/src/client.ts new file mode 100644 index 0000000..34dd11d --- /dev/null +++ b/_ts_packages/packages/gmail-adapter/src/client.ts @@ -0,0 +1,101 @@ +// Gmail API client via googleapis. One class that owns an OAuth2 client +// plus a gmail.users surface. All methods return plain data (no gapi types +// leak outward). Tests inject a mock surface via the `gmailSurface` param. + +import { google } from "googleapis"; +import type { MessageSummary } from "./types.js"; + +export interface GmailClientConfig { + clientId: string; + clientSecret: string; + refreshToken: string; + gmailSurface?: GmailSurface; +} + +// Narrow shape we actually use. Everything googleapis exposes is optional. +export interface GmailSurface { + list: (q: string | undefined, max: number) => Promise>; + get: (id: string) => Promise<{ id?: string | null; threadId?: string | null; snippet?: string | null; payload?: { headers?: Array<{ name?: string | null; value?: string | null }> | null } | null }>; + modify: (id: string, addIds: string[], removeIds: string[]) => Promise; + trash: (id: string) => Promise; +} + +export class GmailClient { + private readonly surface: GmailSurface; + + constructor(cfg: GmailClientConfig) { + this.surface = cfg.gmailSurface ?? buildDefaultSurface(cfg); + } + + async listUnread(max: number): Promise { + const ids = await this.surface.list("is:unread", max); + return Promise.all(ids.map(async (row) => this.summarize(row.id ?? ""))); + } + + async search(query: string, max: number): Promise { + const ids = await this.surface.list(query, max); + return Promise.all(ids.map(async (row) => this.summarize(row.id ?? ""))); + } + + async getMessage(id: string): Promise { + return this.summarize(id); + } + + async labelMessage(id: string, label: string): Promise { + await this.surface.modify(id, [label], []); + } + + async archive(id: string): Promise { + await this.surface.modify(id, [], ["INBOX"]); + } + + async trash(id: string): Promise { + await this.surface.trash(id); + } + + private async summarize(id: string): Promise { + if (!id) return { id: "" }; + const msg = await this.surface.get(id); + const headers = msg.payload?.headers ?? []; + const pick = (name: string): string | undefined => headers.find((h) => h.name?.toLowerCase() === name)?.value ?? undefined; + return { + id: msg.id ?? id, + threadId: msg.threadId ?? undefined, + subject: pick("subject"), + from: pick("from"), + date: pick("date"), + snippet: msg.snippet ?? undefined, + }; + } +} + +function buildDefaultSurface(cfg: GmailClientConfig): GmailSurface { + if (!cfg.clientId || !cfg.clientSecret || !cfg.refreshToken) { + throw new Error("GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN all required"); + } + const oauth = new google.auth.OAuth2(cfg.clientId, cfg.clientSecret); + oauth.setCredentials({ refresh_token: cfg.refreshToken }); + const gmail = google.gmail({ version: "v1", auth: oauth }); + return { + list: async (q, max) => { + const params: { userId: string; maxResults: number; q?: string } = { userId: "me", maxResults: max }; + if (q !== undefined) params.q = q; + const res = await gmail.users.messages.list(params); + const items = res.data.messages ?? []; + return items.map((m: { id?: string | null; threadId?: string | null }) => ({ + id: m.id ?? null, + threadId: m.threadId ?? null, + })); + }, + get: async (id) => { + const res = await gmail.users.messages.get({ userId: "me", id, format: "metadata" }); + return res.data; + }, + modify: async (id, addIds, removeIds) => { + await gmail.users.messages.modify({ userId: "me", id, requestBody: { addLabelIds: addIds, removeLabelIds: removeIds } }); + }, + trash: async (id) => { + await gmail.users.messages.trash({ userId: "me", id }); + }, + }; +} diff --git a/_ts_packages/packages/gmail-adapter/src/index.ts b/_ts_packages/packages/gmail-adapter/src/index.ts new file mode 100644 index 0000000..9ce55b2 --- /dev/null +++ b/_ts_packages/packages/gmail-adapter/src/index.ts @@ -0,0 +1,22 @@ +import { GmailClient } from "./client.js"; +import { buildGmailTools, type GmailTool } from "./tools.js"; + +export { GmailClient } from "./client.js"; +export { buildGmailTools } from "./tools.js"; +export type { GmailTool } from "./tools.js"; +export * from "./types.js"; + +type Registrar = (tool: GmailTool) => void; + +export function registerAdapter(register: Registrar): void { + const clientId = process.env["GMAIL_CLIENT_ID"]; + const clientSecret = process.env["GMAIL_CLIENT_SECRET"]; + const refreshToken = process.env["GMAIL_REFRESH_TOKEN"]; + if (!clientId || !clientSecret || !refreshToken) { + throw new Error( + "GMAIL_{CLIENT_ID,CLIENT_SECRET,REFRESH_TOKEN} env vars required; see ~/.claude/secrets/.env (RULE 0.8).", + ); + } + const client = new GmailClient({ clientId, clientSecret, refreshToken }); + for (const tool of buildGmailTools(client)) register(tool); +} diff --git a/_ts_packages/packages/gmail-adapter/src/tools.ts b/_ts_packages/packages/gmail-adapter/src/tools.ts new file mode 100644 index 0000000..1e56be4 --- /dev/null +++ b/_ts_packages/packages/gmail-adapter/src/tools.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; +import { GmailClient } from "./client.js"; +import { + GetMessageArgs, + LabelArgs, + ListUnreadArgs, + ModifyOnlyArgs, + SearchArgs, + type MessageSummary, +} from "./types.js"; + +export interface GmailTool { + name: string; + description: string; + inputSchema: z.ZodObject>; + handler: (args: Record) => Promise; +} + +export function buildGmailTools(client: GmailClient): GmailTool[] { + return [ + { + name: "gmail_list_unread", + description: "List unread messages (up to 500).", + inputSchema: ListUnreadArgs, + handler: async (raw) => { + const args = ListUnreadArgs.parse(raw); + return formatList(await client.listUnread(args.max)); + }, + }, + { + name: "gmail_get_message", + description: "Fetch one message by id; returns headers + snippet.", + inputSchema: GetMessageArgs, + handler: async (raw) => { + const args = GetMessageArgs.parse(raw); + return formatOne(await client.getMessage(args.id)); + }, + }, + { + name: "gmail_search", + description: "Search mailbox using Gmail operators (e.g. 'from:alice has:attachment').", + inputSchema: SearchArgs, + handler: async (raw) => { + const args = SearchArgs.parse(raw); + return formatList(await client.search(args.query, args.max)); + }, + }, + { + name: "gmail_label_message", + description: "Apply a Gmail label id to a message.", + inputSchema: LabelArgs, + handler: async (raw) => { + const args = LabelArgs.parse(raw); + await client.labelMessage(args.id, args.label); + return `labeled ${args.id} with ${args.label}`; + }, + }, + { + name: "gmail_archive", + description: "Archive a message (removes INBOX label).", + inputSchema: ModifyOnlyArgs, + handler: async (raw) => { + const args = ModifyOnlyArgs.parse(raw); + await client.archive(args.id); + return `archived ${args.id}`; + }, + }, + { + name: "gmail_trash", + description: "Move a message to Trash.", + inputSchema: ModifyOnlyArgs, + handler: async (raw) => { + const args = ModifyOnlyArgs.parse(raw); + await client.trash(args.id); + return `trashed ${args.id}`; + }, + }, + ]; +} + +function formatList(msgs: MessageSummary[]): string { + if (msgs.length === 0) return "No messages."; + return msgs.map(formatOne).join("\n---\n"); +} + +function formatOne(m: MessageSummary): string { + const parts = [`id: ${m.id}`]; + if (m.subject) parts.push(`subject: ${m.subject}`); + if (m.from) parts.push(`from: ${m.from}`); + if (m.date) parts.push(`date: ${m.date}`); + if (m.snippet) parts.push(`snippet: ${m.snippet}`); + return parts.join("\n"); +} diff --git a/_ts_packages/packages/gmail-adapter/src/types.ts b/_ts_packages/packages/gmail-adapter/src/types.ts new file mode 100644 index 0000000..ad3a57b --- /dev/null +++ b/_ts_packages/packages/gmail-adapter/src/types.ts @@ -0,0 +1,40 @@ +// Gmail API tool I/O types. Types live in their own file so tests can +// exercise schemas without importing googleapis. + +import { z } from "zod"; + +export const ListUnreadArgs = z.object({ + max: z.number().int().positive().max(500).default(20), +}); +export type ListUnreadArgs = z.infer; + +export const GetMessageArgs = z.object({ + id: z.string().min(1), +}); +export type GetMessageArgs = z.infer; + +export const SearchArgs = z.object({ + query: z.string().min(1), + max: z.number().int().positive().max(500).default(20), +}); +export type SearchArgs = z.infer; + +export const LabelArgs = z.object({ + id: z.string().min(1), + label: z.string().min(1), +}); +export type LabelArgs = z.infer; + +export const ModifyOnlyArgs = z.object({ + id: z.string().min(1), +}); +export type ModifyOnlyArgs = z.infer; + +export interface MessageSummary { + id: string; + threadId?: string | undefined; + subject?: string | undefined; + from?: string | undefined; + snippet?: string | undefined; + date?: string | undefined; +} diff --git a/_ts_packages/packages/gmail-adapter/test/client.test.ts b/_ts_packages/packages/gmail-adapter/test/client.test.ts new file mode 100644 index 0000000..35c5006 --- /dev/null +++ b/_ts_packages/packages/gmail-adapter/test/client.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi } from "vitest"; +import { GmailClient, type GmailSurface } from "../src/client.js"; + +function makeSurface(): GmailSurface { + return { + list: vi.fn(async () => [{ id: "m1", threadId: "t1" }, { id: "m2" }]), + get: vi.fn(async (id: string) => ({ + id, + snippet: `snip-${id}`, + payload: { + headers: [ + { name: "Subject", value: `subj-${id}` }, + { name: "From", value: "alice@example.com" }, + ], + }, + })), + modify: vi.fn(async () => undefined), + trash: vi.fn(async () => undefined), + }; +} + +describe("GmailClient", () => { + it("listUnread returns summarized messages", async () => { + const surface = makeSurface(); + const c = new GmailClient({ clientId: "", clientSecret: "", refreshToken: "", gmailSurface: surface }); + const out = await c.listUnread(10); + expect(out).toHaveLength(2); + expect(out[0]?.subject).toBe("subj-m1"); + }); + + it("labelMessage calls modify with addIds only", async () => { + const surface = makeSurface(); + const c = new GmailClient({ clientId: "", clientSecret: "", refreshToken: "", gmailSurface: surface }); + await c.labelMessage("m1", "IMPORTANT"); + expect(surface.modify).toHaveBeenCalledWith("m1", ["IMPORTANT"], []); + }); + + it("archive removes INBOX label", async () => { + const surface = makeSurface(); + const c = new GmailClient({ clientId: "", clientSecret: "", refreshToken: "", gmailSurface: surface }); + await c.archive("m1"); + expect(surface.modify).toHaveBeenCalledWith("m1", [], ["INBOX"]); + }); +}); diff --git a/_ts_packages/packages/gmail-adapter/test/tools.test.ts b/_ts_packages/packages/gmail-adapter/test/tools.test.ts new file mode 100644 index 0000000..8b2d56a --- /dev/null +++ b/_ts_packages/packages/gmail-adapter/test/tools.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from "vitest"; +import { GmailClient, type GmailSurface } from "../src/client.js"; +import { buildGmailTools } from "../src/tools.js"; + +function mkSurface(): GmailSurface { + return { + list: vi.fn(async () => [{ id: "m1" }]), + get: vi.fn(async () => ({ id: "m1", snippet: "hello", payload: { headers: [] } })), + modify: vi.fn(async () => undefined), + trash: vi.fn(async () => undefined), + }; +} + +describe("gmail tool surface", () => { + it("registers 6 tools", () => { + const c = new GmailClient({ clientId: "", clientSecret: "", refreshToken: "", gmailSurface: mkSurface() }); + const names = buildGmailTools(c).map((t) => t.name); + expect(names).toEqual([ + "gmail_list_unread", + "gmail_get_message", + "gmail_search", + "gmail_label_message", + "gmail_archive", + "gmail_trash", + ]); + }); + + it("gmail_list_unread formats empty list", async () => { + const surface: GmailSurface = { ...mkSurface(), list: vi.fn(async () => []) }; + const c = new GmailClient({ clientId: "", clientSecret: "", refreshToken: "", gmailSurface: surface }); + const tool = buildGmailTools(c).find((t) => t.name === "gmail_list_unread"); + const out = await tool!.handler({}); + expect(out).toBe("No messages."); + }); + + it("gmail_trash returns ok string", async () => { + const surface = mkSurface(); + const c = new GmailClient({ clientId: "", clientSecret: "", refreshToken: "", gmailSurface: surface }); + const tool = buildGmailTools(c).find((t) => t.name === "gmail_trash"); + const out = await tool!.handler({ id: "m1" }); + expect(out).toContain("trashed m1"); + }); +}); diff --git a/_ts_packages/packages/gmail-adapter/test/types.test.ts b/_ts_packages/packages/gmail-adapter/test/types.test.ts new file mode 100644 index 0000000..b318f9d --- /dev/null +++ b/_ts_packages/packages/gmail-adapter/test/types.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { ListUnreadArgs, SearchArgs, LabelArgs, GetMessageArgs } from "../src/types.js"; + +describe("gmail schemas", () => { + it("ListUnreadArgs defaults max to 20", () => { + const r = ListUnreadArgs.safeParse({}); + expect(r.success).toBe(true); + if (r.success) expect(r.data.max).toBe(20); + }); + + it("ListUnreadArgs rejects max=0", () => { + const r = ListUnreadArgs.safeParse({ max: 0 }); + expect(r.success).toBe(false); + }); + + it("SearchArgs rejects empty query", () => { + const r = SearchArgs.safeParse({ query: "" }); + expect(r.success).toBe(false); + }); + + it("LabelArgs requires both id and label", () => { + expect(LabelArgs.safeParse({ id: "x" }).success).toBe(false); + expect(LabelArgs.safeParse({ id: "x", label: "L" }).success).toBe(true); + }); + + it("GetMessageArgs requires non-empty id", () => { + expect(GetMessageArgs.safeParse({ id: "" }).success).toBe(false); + }); +}); diff --git a/_ts_packages/packages/gmail-adapter/tsconfig.json b/_ts_packages/packages/gmail-adapter/tsconfig.json new file mode 100644 index 0000000..2c00eac --- /dev/null +++ b/_ts_packages/packages/gmail-adapter/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "test/**/*"] +} diff --git a/_ts_packages/packages/gmail-adapter/vitest.config.ts b/_ts_packages/packages/gmail-adapter/vitest.config.ts new file mode 100644 index 0000000..d9ebefd --- /dev/null +++ b/_ts_packages/packages/gmail-adapter/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/_ts_packages/packages/grok-adapter/package.json b/_ts_packages/packages/grok-adapter/package.json new file mode 100644 index 0000000..cd4c278 --- /dev/null +++ b/_ts_packages/packages/grok-adapter/package.json @@ -0,0 +1,32 @@ +{ + "name": "@keisei/grok-adapter", + "version": "0.14.0", + "description": "xAI Grok adapter (deep research + image gen) for the KeiSei MCP server", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b", + "test": "vitest run" + }, + "dependencies": { + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/_ts_packages/packages/grok-adapter/src/client.ts b/_ts_packages/packages/grok-adapter/src/client.ts new file mode 100644 index 0000000..f251723 --- /dev/null +++ b/_ts_packages/packages/grok-adapter/src/client.ts @@ -0,0 +1,78 @@ +// Minimal xAI Grok client. The public endpoints follow the OpenAI +// compatible shape: https://api.x.ai/v1/chat/completions and /images. +// Shapes verified against https://docs.x.ai/api (2026-04). + +export type FetchFn = typeof fetch; + +export interface GrokClientConfig { + apiKey: string; + baseUrl?: string; + fetchImpl?: FetchFn; + researchModel?: string; + imageModel?: string; +} + +const DEFAULT_BASE = "https://api.x.ai/v1"; + +export class GrokClient { + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly fetchImpl: FetchFn; + private readonly researchModel: string; + private readonly imageModel: string; + + constructor(cfg: GrokClientConfig) { + if (!cfg.apiKey) throw new Error("XAI_API_KEY is required"); + this.apiKey = cfg.apiKey; + this.baseUrl = cfg.baseUrl ?? DEFAULT_BASE; + this.fetchImpl = cfg.fetchImpl ?? fetch; + this.researchModel = cfg.researchModel ?? "grok-4-heavy"; + this.imageModel = cfg.imageModel ?? "grok-2-image"; + } + + async deepResearch(query: string): Promise { + const body = { + model: this.researchModel, + messages: [{ role: "user", content: query }], + }; + const data = (await this.postJson("/chat/completions", body)) as ChatCompletionResponse; + const first = data.choices[0]; + return first?.message?.content ?? ""; + } + + async imageGenerate(prompt: string, pro = false): Promise { + const body = { + model: this.imageModel, + prompt, + n: 1, + quality: pro ? "pro" : "standard", + }; + const data = (await this.postJson("/images/generations", body)) as ImageResponse; + return (data.data ?? []).map((d) => d.url).filter((u): u is string => typeof u === "string"); + } + + private async postJson(path: string, body: unknown): Promise { + const res = await this.fetchImpl(`${this.baseUrl}${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`grok ${path} -> ${res.status}: ${text}`); + } + return res.json(); + } +} + +interface ChatCompletionResponse { + choices: Array<{ message: { content: string } }>; +} + +interface ImageResponse { + data: Array<{ url?: string }>; +} diff --git a/_ts_packages/packages/grok-adapter/src/index.ts b/_ts_packages/packages/grok-adapter/src/index.ts new file mode 100644 index 0000000..a810425 --- /dev/null +++ b/_ts_packages/packages/grok-adapter/src/index.ts @@ -0,0 +1,19 @@ +import { GrokClient } from "./client.js"; +import { buildGrokTools, type GrokTool } from "./tools.js"; + +export { GrokClient } from "./client.js"; +export { buildGrokTools } from "./tools.js"; +export type { GrokTool } from "./tools.js"; + +type Registrar = (tool: GrokTool) => void; + +export function registerAdapter(register: Registrar): void { + const apiKey = process.env["XAI_API_KEY"]; + if (!apiKey) { + throw new Error( + "XAI_API_KEY env var is missing; set it in ~/.claude/secrets/.env (RULE 0.8).", + ); + } + const client = new GrokClient({ apiKey }); + for (const tool of buildGrokTools(client)) register(tool); +} diff --git a/_ts_packages/packages/grok-adapter/src/tools.ts b/_ts_packages/packages/grok-adapter/src/tools.ts new file mode 100644 index 0000000..004d1d7 --- /dev/null +++ b/_ts_packages/packages/grok-adapter/src/tools.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { GrokClient } from "./client.js"; + +export interface GrokTool { + name: string; + description: string; + inputSchema: z.ZodObject>; + handler: (args: Record) => Promise; +} + +const ResearchArgs = z.object({ query: z.string().min(1) }); +const ImagineArgs = z.object({ + prompt: z.string().min(1), + quality: z.enum(["standard", "pro"]).default("standard"), +}); + +export function buildGrokTools(client: GrokClient): GrokTool[] { + return [ + { + name: "grok_research", + description: "Deep research via Grok heavy model. Returns assistant message content.", + inputSchema: ResearchArgs, + handler: async (raw) => { + const args = ResearchArgs.parse(raw); + return client.deepResearch(args.query); + }, + }, + { + name: "grok_imagine", + description: "Generate an image from a prompt via Grok Imagine.", + inputSchema: ImagineArgs, + handler: async (raw) => { + const args = ImagineArgs.parse(raw); + const urls = await client.imageGenerate(args.prompt, args.quality === "pro"); + if (urls.length === 0) return "No image returned."; + return urls.join("\n"); + }, + }, + ]; +} diff --git a/_ts_packages/packages/grok-adapter/test/client.test.ts b/_ts_packages/packages/grok-adapter/test/client.test.ts new file mode 100644 index 0000000..dec82a3 --- /dev/null +++ b/_ts_packages/packages/grok-adapter/test/client.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from "vitest"; +import { GrokClient } from "../src/client.js"; + +function makeFetchMock(payload: unknown, ok = true, status = 200): typeof fetch { + return vi.fn(async () => ({ + ok, + status, + async text() { return JSON.stringify(payload); }, + async json() { return payload; }, + } as unknown as Response)) as unknown as typeof fetch; +} + +describe("GrokClient", () => { + it("rejects empty API key", () => { + expect(() => new GrokClient({ apiKey: "" })).toThrow(/XAI_API_KEY/); + }); + + it("deepResearch returns assistant content", async () => { + const fetchImpl = makeFetchMock({ choices: [{ message: { content: "hello" } }] }); + const c = new GrokClient({ apiKey: "k", fetchImpl }); + const out = await c.deepResearch("q"); + expect(out).toBe("hello"); + }); + + it("imageGenerate extracts URLs from response", async () => { + const fetchImpl = makeFetchMock({ data: [{ url: "https://x/img.png" }] }); + const c = new GrokClient({ apiKey: "k", fetchImpl }); + const urls = await c.imageGenerate("a cat", true); + expect(urls).toEqual(["https://x/img.png"]); + }); +}); diff --git a/_ts_packages/packages/grok-adapter/test/tools.test.ts b/_ts_packages/packages/grok-adapter/test/tools.test.ts new file mode 100644 index 0000000..5545fe6 --- /dev/null +++ b/_ts_packages/packages/grok-adapter/test/tools.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi } from "vitest"; +import { GrokClient } from "../src/client.js"; +import { buildGrokTools } from "../src/tools.js"; + +function makeFetchMock(payload: unknown): typeof fetch { + return vi.fn(async () => ({ + ok: true, + status: 200, + async text() { return JSON.stringify(payload); }, + async json() { return payload; }, + } as unknown as Response)) as unknown as typeof fetch; +} + +describe("grok tools", () => { + it("exposes 2 tools", () => { + const c = new GrokClient({ apiKey: "k", fetchImpl: makeFetchMock({}) }); + const tools = buildGrokTools(c); + expect(tools.map((t) => t.name)).toEqual(["grok_research", "grok_imagine"]); + }); + + it("grok_research validates non-empty query", async () => { + const c = new GrokClient({ apiKey: "k", fetchImpl: makeFetchMock({}) }); + const tool = buildGrokTools(c).find((t) => t.name === "grok_research"); + await expect(tool!.handler({ query: "" })).rejects.toBeTruthy(); + }); + + it("grok_imagine defaults quality to standard", async () => { + const c = new GrokClient({ + apiKey: "k", + fetchImpl: makeFetchMock({ data: [{ url: "u" }] }), + }); + const tool = buildGrokTools(c).find((t) => t.name === "grok_imagine"); + const out = await tool!.handler({ prompt: "x" }); + expect(out).toContain("u"); + }); +}); diff --git a/_ts_packages/packages/grok-adapter/tsconfig.json b/_ts_packages/packages/grok-adapter/tsconfig.json new file mode 100644 index 0000000..2c00eac --- /dev/null +++ b/_ts_packages/packages/grok-adapter/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "test/**/*"] +} diff --git a/_ts_packages/packages/grok-adapter/vitest.config.ts b/_ts_packages/packages/grok-adapter/vitest.config.ts new file mode 100644 index 0000000..d9ebefd --- /dev/null +++ b/_ts_packages/packages/grok-adapter/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/_ts_packages/packages/mcp-server/package.json b/_ts_packages/packages/mcp-server/package.json new file mode 100644 index 0000000..04f6adc --- /dev/null +++ b/_ts_packages/packages/mcp-server/package.json @@ -0,0 +1,38 @@ +{ + "name": "@keisei/mcp-server", + "version": "0.14.0", + "description": "MCP server exposing KeiSeiKit Rust primitives as Model Context Protocol tools", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "keisei-mcp-server": "./dist/index.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b", + "test": "vitest run", + "dev": "tsx src/index.ts --stdio" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "execa": "^9.0.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/_ts_packages/packages/mcp-server/src/adapters.ts b/_ts_packages/packages/mcp-server/src/adapters.ts new file mode 100644 index 0000000..6a8cbc0 --- /dev/null +++ b/_ts_packages/packages/mcp-server/src/adapters.ts @@ -0,0 +1,53 @@ +// Register external API adapters (Telegram, Recall, Grok, Gmail, YouTube) +// dynamically IF the sibling packages are installed in the runtime. Each +// adapter exports `registerAdapter(register)` by convention. + +import type { ToolDefinition } from "./tool-registry.js"; + +export type AdapterRegistrar = (tool: ToolDefinition) => void; + +interface AdapterModule { + registerAdapter: (register: AdapterRegistrar) => void; +} + +const ADAPTER_PACKAGES: readonly string[] = [ + "@keisei/telegram-adapter", + "@keisei/recall-adapter", + "@keisei/grok-adapter", + "@keisei/gmail-adapter", + "@keisei/youtube-adapter", +]; + +export async function loadAllAdapters( + register: AdapterRegistrar, + logger: (msg: string) => void = () => {}, +): Promise<{ loaded: string[]; skipped: string[] }> { + const loaded: string[] = []; + const skipped: string[] = []; + for (const pkg of ADAPTER_PACKAGES) { + const ok = await tryLoadOne(pkg, register, logger); + if (ok) loaded.push(pkg); + else skipped.push(pkg); + } + return { loaded, skipped }; +} + +async function tryLoadOne( + pkg: string, + register: AdapterRegistrar, + logger: (msg: string) => void, +): Promise { + try { + const mod = (await import(pkg)) as AdapterModule; + if (typeof mod.registerAdapter !== "function") { + logger(`adapter ${pkg}: missing registerAdapter()`); + return false; + } + mod.registerAdapter(register); + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger(`adapter ${pkg}: not installed (${msg})`); + return false; + } +} diff --git a/_ts_packages/packages/mcp-server/src/errors.ts b/_ts_packages/packages/mcp-server/src/errors.ts new file mode 100644 index 0000000..509d4ec --- /dev/null +++ b/_ts_packages/packages/mcp-server/src/errors.ts @@ -0,0 +1,61 @@ +// Typed error hierarchy for MCP server. One class per failure mode. +// Keeps the main handler branches flat and the JSON-RPC error codes consistent. + +export class McpServerError extends Error { + public readonly code: number; + public readonly data: unknown; + + constructor(message: string, code: number, data?: unknown) { + super(message); + this.name = new.target.name; + this.code = code; + this.data = data; + } +} + +export class AuthError extends McpServerError { + constructor(message = "unauthorized", data?: unknown) { + super(message, -32001, data); + } +} + +export class ToolNotFoundError extends McpServerError { + constructor(toolName: string) { + super(`tool not found: ${toolName}`, -32601, { tool: toolName }); + } +} + +export class RustBridgeError extends McpServerError { + constructor(message: string, data?: unknown) { + super(`rust bridge: ${message}`, -32002, data); + } +} + +export class SchemaError extends McpServerError { + constructor(message: string, data?: unknown) { + super(`schema: ${message}`, -32602, data); + } +} + +export class TimeoutError extends McpServerError { + constructor(toolName: string, ms: number) { + super(`tool ${toolName} timed out after ${ms}ms`, -32003, { tool: toolName, ms }); + } +} + +export function isMcpError(err: unknown): err is McpServerError { + return err instanceof McpServerError; +} + +export function toErrorPayload(err: unknown): { code: number; message: string; data?: unknown } { + if (isMcpError(err)) { + const payload: { code: number; message: string; data?: unknown } = { + code: err.code, + message: err.message, + }; + if (err.data !== undefined) payload.data = err.data; + return payload; + } + const message = err instanceof Error ? err.message : String(err); + return { code: -32000, message }; +} diff --git a/_ts_packages/packages/mcp-server/src/index.ts b/_ts_packages/packages/mcp-server/src/index.ts new file mode 100644 index 0000000..22dbb58 --- /dev/null +++ b/_ts_packages/packages/mcp-server/src/index.ts @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// Entry point: parse argv, select transport (stdio or HTTP), start McpServer. + +import fs from "node:fs/promises"; +import path from "node:path"; +import { McpServer } from "./server.js"; + +interface CliArgs { + stdio: boolean; + port?: number; + authTokenFile?: string; + rustBinDir: string; +} + +function parseArgv(argv: readonly string[]): CliArgs { + const out: CliArgs = { + stdio: false, + rustBinDir: process.env["KEI_RUST_BIN_DIR"] ?? defaultBinDir(), + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--stdio") out.stdio = true; + else if (a === "--port") out.port = Number(argv[++i] ?? ""); + else if (a === "--auth-token-file") { + const v = argv[++i]; + if (v !== undefined) out.authTokenFile = v; + } else if (a === "--rust-bin-dir") { + const v = argv[++i]; + if (v !== undefined) out.rustBinDir = v; + } + } + return out; +} + +function defaultBinDir(): string { + const home = process.env["HOME"] ?? ""; + return path.join(home, ".claude", "agents", "_primitives", "_rust", "target", "release"); +} + +async function readTokenFile(p: string | undefined): Promise { + if (!p) return process.env["KEI_MCP_AUTH_TOKEN"]; + const raw = await fs.readFile(p, "utf8"); + return raw.trim(); +} + +async function main(): Promise { + const args = parseArgv(process.argv.slice(2)); + const token = args.stdio ? undefined : await readTokenFile(args.authTokenFile); + const server = new McpServer({ + rustBinDir: args.rustBinDir, + ...(token !== undefined ? { authToken: token } : {}), + }); + await server.loadAdapters((m) => process.stderr.write(`[adapters] ${m}\n`)); + if (args.stdio) await runStdio(server); + else await runHttp(server, args.port ?? 3000); +} + +async function runStdio(server: McpServer): Promise { + process.stderr.write(`[keisei-mcp] stdio mode; ${server.listTools().length} tools\n`); + process.stdin.setEncoding("utf8"); + for await (const chunk of process.stdin) { + for (const line of String(chunk).split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const resp = await dispatchStdioLine(server, trimmed); + process.stdout.write(resp + "\n"); + } + } +} + +async function dispatchStdioLine(server: McpServer, line: string): Promise { + try { + const payload = JSON.parse(line) as { tool: string; args?: Record }; + const call = payload.args !== undefined + ? { tool: payload.tool, args: payload.args } + : { tool: payload.tool }; + const resp = await server.handle(call); + return JSON.stringify(resp); + } catch (err) { + return JSON.stringify({ ok: false, error: { code: -32700, message: String(err) } }); + } +} + +async function runHttp(server: McpServer, port: number): Promise { + const http = await import("node:http"); + const srv = http.createServer((req, res) => void handleHttp(server, req, res)); + srv.listen(port, () => + process.stderr.write(`[keisei-mcp] http :${port}; ${server.listTools().length} tools\n`), + ); +} + +async function handleHttp(server: McpServer, req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse): Promise { + if (req.method !== "POST") { + res.writeHead(405); + res.end(); + return; + } + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + try { + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { + tool: string; + args?: Record; + }; + const authHeader = req.headers["authorization"]; + const header = typeof authHeader === "string" ? authHeader.replace(/^Bearer\s+/i, "") : undefined; + const resp = await server.handle({ + tool: body.tool, + ...(body.args !== undefined ? { args: body.args } : {}), + ...(header !== undefined ? { authHeader: header } : {}), + }); + res.writeHead(resp.ok ? 200 : 400, { "content-type": "application/json" }); + res.end(JSON.stringify(resp)); + } catch (err) { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: { code: -32700, message: String(err) } })); + } +} + +main().catch((err: unknown) => { + process.stderr.write(`[keisei-mcp] fatal: ${String(err)}\n`); + process.exit(1); +}); diff --git a/_ts_packages/packages/mcp-server/src/rust-bridge.ts b/_ts_packages/packages/mcp-server/src/rust-bridge.ts new file mode 100644 index 0000000..0d8111b --- /dev/null +++ b/_ts_packages/packages/mcp-server/src/rust-bridge.ts @@ -0,0 +1,83 @@ +// Bridge layer: spawn Rust primitive CLIs and marshal JSON args <-> CLI flags. +// One Rust binary = one MCP tool. Subprocess lifecycle is isolated per call. + +import { execa } from "execa"; +import path from "node:path"; +import { RustBridgeError, TimeoutError } from "./errors.js"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +export interface RustCallRequest { + binary: string; + args: readonly string[]; + stdin?: string; + timeoutMs?: number; +} + +export interface RustCallResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export interface RustBridgeConfig { + binDir: string; + defaultTimeoutMs?: number; +} + +export class RustBridge { + private readonly binDir: string; + private readonly defaultTimeoutMs: number; + + constructor(cfg: RustBridgeConfig) { + this.binDir = cfg.binDir; + this.defaultTimeoutMs = cfg.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS; + } + + async call(req: RustCallRequest): Promise { + const binPath = this.resolveBin(req.binary); + const timeoutMs = req.timeoutMs ?? this.defaultTimeoutMs; + try { + const opts = { + timeout: timeoutMs, + reject: false as const, + env: process.env, + ...(req.stdin !== undefined ? { input: req.stdin } : {}), + }; + const child = execa(binPath, [...req.args], opts); + const result = await child; + if (result.timedOut) throw new TimeoutError(req.binary, timeoutMs); + return { + stdout: typeof result.stdout === "string" ? result.stdout : "", + stderr: typeof result.stderr === "string" ? result.stderr : "", + exitCode: result.exitCode ?? -1, + }; + } catch (err) { + if (err instanceof TimeoutError) throw err; + const msg = err instanceof Error ? err.message : String(err); + throw new RustBridgeError(msg, { binary: req.binary }); + } + } + + private resolveBin(binary: string): string { + if (!/^[a-z0-9][a-z0-9_-]*$/i.test(binary)) { + throw new RustBridgeError(`invalid binary name: ${binary}`); + } + return path.join(this.binDir, binary); + } +} + +// Convert a JSON object of named args to CLI flags: {foo_bar: "v"} => ["--foo-bar", "v"] +export function jsonArgsToCli(args: Record): string[] { + const out: string[] = []; + for (const [key, raw] of Object.entries(args)) { + if (raw === undefined || raw === null) continue; + const flag = `--${key.replace(/_/g, "-")}`; + if (typeof raw === "boolean") { + if (raw) out.push(flag); + continue; + } + out.push(flag, String(raw)); + } + return out; +} diff --git a/_ts_packages/packages/mcp-server/src/server.ts b/_ts_packages/packages/mcp-server/src/server.ts new file mode 100644 index 0000000..eba0805 --- /dev/null +++ b/_ts_packages/packages/mcp-server/src/server.ts @@ -0,0 +1,91 @@ +// MCP server assembly: wire registry + adapters + auth into a JSON-RPC dispatcher. +// Transport-agnostic; index.ts chooses stdio or HTTP. + +import crypto from "node:crypto"; +import { z } from "zod"; +import { buildRegistry, lookupTool, type ToolDefinition } from "./tool-registry.js"; +import { RustBridge } from "./rust-bridge.js"; +import { loadAllAdapters } from "./adapters.js"; +import { AuthError, SchemaError, toErrorPayload } from "./errors.js"; + +export interface ServerConfig { + rustBinDir: string; + authToken?: string; + timeoutMs?: number; +} + +export interface JsonRpcCall { + tool: string; + args?: Record; + authHeader?: string | undefined; +} + +export interface JsonRpcResponse { + ok: boolean; + result?: string; + error?: { code: number; message: string; data?: unknown }; +} + +export class McpServer { + private readonly registry: Map; + private readonly authToken: string | undefined; + + constructor(cfg: ServerConfig) { + const bridge = new RustBridge({ + binDir: cfg.rustBinDir, + ...(cfg.timeoutMs !== undefined ? { defaultTimeoutMs: cfg.timeoutMs } : {}), + }); + this.registry = buildRegistry(bridge); + this.authToken = cfg.authToken; + } + + async loadAdapters(logger?: (msg: string) => void): Promise<{ loaded: string[]; skipped: string[] }> { + return loadAllAdapters((tool) => this.registry.set(tool.name, tool), logger); + } + + listTools(): Array<{ name: string; description: string }> { + return Array.from(this.registry.values()).map((t) => ({ + name: t.name, + description: t.description, + })); + } + + async handle(call: JsonRpcCall): Promise { + try { + this.checkAuth(call.authHeader); + const tool = lookupTool(this.registry, call.tool); + const args = this.validateArgs(tool, call.args ?? {}); + const out = await tool.handler(args); + return { ok: true, result: out }; + } catch (err) { + return { ok: false, error: toErrorPayload(err) }; + } + } + + private checkAuth(header: string | undefined): void { + if (!this.authToken) return; // auth disabled (stdio mode) + if (!header) throw new AuthError("missing auth token"); + if (!safeEqual(header, this.authToken)) throw new AuthError("invalid auth token"); + } + + private validateArgs( + tool: ToolDefinition, + raw: Record, + ): Record { + const parsed = tool.inputSchema.safeParse(raw); + if (!parsed.success) { + throw new SchemaError(parsed.error.message, { tool: tool.name }); + } + return parsed.data as Record; + } +} + +function safeEqual(a: string, b: string): boolean { + const ba = Buffer.from(a); + const bb = Buffer.from(b); + if (ba.length !== bb.length) return false; + return crypto.timingSafeEqual(ba, bb); +} + +// Exported for tests +export const __testing__ = { safeEqual, schema: z }; diff --git a/_ts_packages/packages/mcp-server/src/tool-registry.ts b/_ts_packages/packages/mcp-server/src/tool-registry.ts new file mode 100644 index 0000000..8ff06d0 --- /dev/null +++ b/_ts_packages/packages/mcp-server/src/tool-registry.ts @@ -0,0 +1,88 @@ +// Tool registry: auto-register each Rust primitive CLI as one MCP tool. +// Plus the meta-tool kei(query) that routes natural language via kei-router. + +import { z } from "zod"; +import { jsonArgsToCli, RustBridge } from "./rust-bridge.js"; +import { ToolNotFoundError } from "./errors.js"; + +export interface ToolDefinition { + name: string; + description: string; + inputSchema: z.ZodObject>; + handler: (args: Record) => Promise; +} + +// Primitive CLIs exposed 1:1 as tools. Each Rust binary accepts flags as +// --kebab-case; tool names stay snake_case for MCP convention. +export const RUST_PRIMITIVE_TOOLS: ReadonlyArray<{ binary: string; desc: string }> = [ + { binary: "kei-ledger", desc: "Append-only event ledger; sign, verify, append, list." }, + { binary: "kei-memory", desc: "Local key-value memory store with SQLite backend." }, + { binary: "kei-store", desc: "Content-addressed blob store." }, + { binary: "kei-graph-check", desc: "Validate graph invariants in a project." }, + { binary: "kei-refactor-engine", desc: "Apply structural refactors from a plan file." }, + { binary: "kei-conflict-scan", desc: "Scan a tree for merge/rebase conflict markers." }, + { binary: "kei-migrate", desc: "Run schema or directory migrations." }, + { binary: "kei-changelog", desc: "Generate changelog from commit/tag history." }, + { binary: "genesis-scan", desc: "Scan a tree for Genesis/patent-sensitive patterns." }, + { binary: "ssh-check", desc: "Validate SSH config + known_hosts consistency." }, + { binary: "firewall-diff", desc: "Diff two firewall rule dumps." }, + { binary: "tokens-sync", desc: "Sync design tokens from Figma export to code." }, + { binary: "visual-diff", desc: "Compare rendered screenshots pixel-wise." }, + { binary: "mock-render", desc: "Render HTML mock templates for preview." }, +]; + +export function buildRegistry(bridge: RustBridge): Map { + const map = new Map(); + for (const t of RUST_PRIMITIVE_TOOLS) map.set(t.binary, wrapPrimitive(bridge, t)); + map.set("kei", buildKeiMetaTool(bridge)); + return map; +} + +function wrapPrimitive( + bridge: RustBridge, + entry: { binary: string; desc: string }, +): ToolDefinition { + return { + name: entry.binary, + description: entry.desc, + inputSchema: z.object({ args: z.record(z.unknown()).optional() }), + handler: async (rawArgs) => { + const parsed = (rawArgs["args"] as Record | undefined) ?? {}; + const cli = jsonArgsToCli(parsed); + const result = await bridge.call({ binary: entry.binary, args: cli }); + if (result.exitCode !== 0) { + return `exit=${result.exitCode}\nstderr=${result.stderr}\nstdout=${result.stdout}`; + } + return result.stdout; + }, + }; +} + +function buildKeiMetaTool(bridge: RustBridge): ToolDefinition { + return { + name: "kei", + description: + "Meta-tool: routes a natural-language query to the right primitive via kei-router.", + inputSchema: z.object({ query: z.string().min(1) }), + handler: async (rawArgs) => { + const query = String(rawArgs["query"] ?? ""); + const result = await bridge.call({ + binary: "kei-router", + args: ["--query", query], + }); + if (result.exitCode !== 0) { + return `router failed exit=${result.exitCode}\nstderr=${result.stderr}`; + } + return result.stdout; + }, + }; +} + +export function lookupTool( + registry: ReadonlyMap, + name: string, +): ToolDefinition { + const t = registry.get(name); + if (!t) throw new ToolNotFoundError(name); + return t; +} diff --git a/_ts_packages/packages/mcp-server/test/errors.test.ts b/_ts_packages/packages/mcp-server/test/errors.test.ts new file mode 100644 index 0000000..680a69d --- /dev/null +++ b/_ts_packages/packages/mcp-server/test/errors.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { + AuthError, + McpServerError, + RustBridgeError, + SchemaError, + ToolNotFoundError, + TimeoutError, + isMcpError, + toErrorPayload, +} from "../src/errors.js"; + +describe("errors hierarchy", () => { + it("AuthError has JSON-RPC code -32001", () => { + const e = new AuthError(); + expect(e).toBeInstanceOf(McpServerError); + expect(e.code).toBe(-32001); + expect(isMcpError(e)).toBe(true); + }); + + it("ToolNotFoundError carries the tool name in data", () => { + const e = new ToolNotFoundError("kei-foo"); + expect(e.code).toBe(-32601); + expect((e.data as { tool: string }).tool).toBe("kei-foo"); + }); + + it("RustBridgeError prefixes message", () => { + const e = new RustBridgeError("spawn failed"); + expect(e.message).toContain("rust bridge"); + }); + + it("SchemaError has JSON-RPC code -32602", () => { + const e = new SchemaError("bad input"); + expect(e.code).toBe(-32602); + }); + + it("TimeoutError records ms and tool", () => { + const e = new TimeoutError("kei-ledger", 1234); + expect(e.code).toBe(-32003); + expect((e.data as { ms: number }).ms).toBe(1234); + }); + + it("toErrorPayload handles MCP errors", () => { + const p = toErrorPayload(new AuthError("nope")); + expect(p.code).toBe(-32001); + expect(p.message).toBe("nope"); + }); + + it("toErrorPayload handles plain Errors", () => { + const p = toErrorPayload(new Error("boom")); + expect(p.code).toBe(-32000); + expect(p.message).toBe("boom"); + }); +}); diff --git a/_ts_packages/packages/mcp-server/test/kei-routing.test.ts b/_ts_packages/packages/mcp-server/test/kei-routing.test.ts new file mode 100644 index 0000000..528adcc --- /dev/null +++ b/_ts_packages/packages/mcp-server/test/kei-routing.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { McpServer } from "../src/server.js"; + +describe("kei() meta-tool routing", () => { + it("rejects empty query via zod validation", async () => { + const srv = new McpServer({ rustBinDir: "/tmp/stub" }); + const resp = await srv.handle({ tool: "kei", args: { query: "" } }); + expect(resp.ok).toBe(false); + expect(resp.error?.code).toBe(-32602); + }); + + it("rejects missing query via zod validation", async () => { + const srv = new McpServer({ rustBinDir: "/tmp/stub" }); + const resp = await srv.handle({ tool: "kei", args: {} }); + expect(resp.ok).toBe(false); + expect(resp.error?.code).toBe(-32602); + }); + + it("accepts a non-empty query and routes via kei-router (resolves with non-zero exit)", async () => { + const srv = new McpServer({ rustBinDir: "/tmp/stub" }); + const resp = await srv.handle({ tool: "kei", args: { query: "list ledger entries" } }); + // Schema passes → meta-tool runs → router binary missing → handler formats result string. + expect(resp.ok).toBe(true); + expect(resp.result).toContain("router failed"); + }); +}); diff --git a/_ts_packages/packages/mcp-server/test/rust-bridge.test.ts b/_ts_packages/packages/mcp-server/test/rust-bridge.test.ts new file mode 100644 index 0000000..8b56d09 --- /dev/null +++ b/_ts_packages/packages/mcp-server/test/rust-bridge.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { jsonArgsToCli, RustBridge } from "../src/rust-bridge.js"; +import { RustBridgeError } from "../src/errors.js"; + +describe("jsonArgsToCli", () => { + it("converts snake_case keys to --kebab-case flags", () => { + expect(jsonArgsToCli({ foo_bar: "value" })).toEqual(["--foo-bar", "value"]); + }); + + it("emits booleans as presence-only flags", () => { + expect(jsonArgsToCli({ verbose: true })).toEqual(["--verbose"]); + expect(jsonArgsToCli({ verbose: false })).toEqual([]); + }); + + it("skips null and undefined values", () => { + expect(jsonArgsToCli({ a: null, b: undefined, c: "x" })).toEqual(["--c", "x"]); + }); + + it("stringifies numeric values", () => { + expect(jsonArgsToCli({ count: 42 })).toEqual(["--count", "42"]); + }); +}); + +describe("RustBridge binary resolution", () => { + it("rejects illegal binary names", async () => { + const bridge = new RustBridge({ binDir: "/tmp" }); + await expect(bridge.call({ binary: "../etc/passwd", args: [] })).rejects.toBeInstanceOf( + RustBridgeError, + ); + }); + + it("accepts valid snake_case and kebab-case names (resolves with non-zero exit on ENOENT)", async () => { + const bridge = new RustBridge({ binDir: "/tmp" }); + const result = await bridge.call({ binary: "kei-ledger", args: [], timeoutMs: 500 }); + // execa is configured with reject:false → a missing binary resolves with exitCode != 0 + // (validation passed — this was the assertion under test). + expect(result.exitCode).not.toBe(0); + }); +}); diff --git a/_ts_packages/packages/mcp-server/test/server-auth.test.ts b/_ts_packages/packages/mcp-server/test/server-auth.test.ts new file mode 100644 index 0000000..d84a0c1 --- /dev/null +++ b/_ts_packages/packages/mcp-server/test/server-auth.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { McpServer } from "../src/server.js"; + +describe("server auth", () => { + it("rejects calls without a token when auth is enabled", async () => { + const srv = new McpServer({ rustBinDir: "/tmp/stub", authToken: "secret" }); + const resp = await srv.handle({ tool: "kei-ledger", args: { args: {} } }); + expect(resp.ok).toBe(false); + expect(resp.error?.code).toBe(-32001); + }); + + it("rejects calls with a wrong token", async () => { + const srv = new McpServer({ rustBinDir: "/tmp/stub", authToken: "secret" }); + const resp = await srv.handle({ + tool: "kei-ledger", + args: { args: {} }, + authHeader: "wrong", + }); + expect(resp.ok).toBe(false); + expect(resp.error?.code).toBe(-32001); + }); + + it("allows calls when auth is disabled (stdio mode)", async () => { + const srv = new McpServer({ rustBinDir: "/tmp/stub" }); + const resp = await srv.handle({ tool: "does-not-exist", args: {} }); + // auth passes → fails on tool lookup instead + expect(resp.ok).toBe(false); + expect(resp.error?.code).toBe(-32601); + }); +}); diff --git a/_ts_packages/packages/mcp-server/test/server-handshake.test.ts b/_ts_packages/packages/mcp-server/test/server-handshake.test.ts new file mode 100644 index 0000000..b419f85 --- /dev/null +++ b/_ts_packages/packages/mcp-server/test/server-handshake.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { McpServer } from "../src/server.js"; + +describe("server handshake + tool listing", () => { + it("listTools returns every primitive plus kei", () => { + const srv = new McpServer({ rustBinDir: "/tmp/stub" }); + const tools = srv.listTools(); + const names = new Set(tools.map((t) => t.name)); + expect(names.has("kei")).toBe(true); + expect(names.has("kei-ledger")).toBe(true); + expect(names.has("genesis-scan")).toBe(true); + expect(tools.length).toBeGreaterThanOrEqual(15); + }); + + it("every listed tool has a non-empty description", () => { + const srv = new McpServer({ rustBinDir: "/tmp/stub" }); + for (const t of srv.listTools()) { + expect(t.description.length).toBeGreaterThan(0); + } + }); +}); diff --git a/_ts_packages/packages/mcp-server/test/tool-registry.test.ts b/_ts_packages/packages/mcp-server/test/tool-registry.test.ts new file mode 100644 index 0000000..db234e3 --- /dev/null +++ b/_ts_packages/packages/mcp-server/test/tool-registry.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { buildRegistry, lookupTool, RUST_PRIMITIVE_TOOLS } from "../src/tool-registry.js"; +import { RustBridge } from "../src/rust-bridge.js"; +import { ToolNotFoundError } from "../src/errors.js"; + +describe("tool registry", () => { + const bridge = new RustBridge({ binDir: "/tmp/stub" }); + const registry = buildRegistry(bridge); + + it("registers one tool per Rust primitive", () => { + for (const t of RUST_PRIMITIVE_TOOLS) { + expect(registry.has(t.binary)).toBe(true); + } + }); + + it("registers the kei meta-tool", () => { + const t = lookupTool(registry, "kei"); + expect(t.name).toBe("kei"); + expect(t.description).toContain("Meta-tool"); + }); + + it("lookupTool throws ToolNotFoundError for unknown names", () => { + expect(() => lookupTool(registry, "nonexistent-tool")).toThrow(ToolNotFoundError); + }); + + it("tool description is non-empty for each primitive", () => { + for (const t of RUST_PRIMITIVE_TOOLS) { + expect(t.desc.length).toBeGreaterThan(10); + } + }); +}); diff --git a/_ts_packages/packages/mcp-server/tsconfig.json b/_ts_packages/packages/mcp-server/tsconfig.json new file mode 100644 index 0000000..2c00eac --- /dev/null +++ b/_ts_packages/packages/mcp-server/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "test/**/*"] +} diff --git a/_ts_packages/packages/mcp-server/vitest.config.ts b/_ts_packages/packages/mcp-server/vitest.config.ts new file mode 100644 index 0000000..d9ebefd --- /dev/null +++ b/_ts_packages/packages/mcp-server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/_ts_packages/packages/recall-adapter/package.json b/_ts_packages/packages/recall-adapter/package.json new file mode 100644 index 0000000..568de61 --- /dev/null +++ b/_ts_packages/packages/recall-adapter/package.json @@ -0,0 +1,32 @@ +{ + "name": "@keisei/recall-adapter", + "version": "0.14.0", + "description": "Recall.ai adapter (Zoom meeting capture) for the KeiSei MCP server", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b", + "test": "vitest run" + }, + "dependencies": { + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/_ts_packages/packages/recall-adapter/src/client.ts b/_ts_packages/packages/recall-adapter/src/client.ts new file mode 100644 index 0000000..f56409b --- /dev/null +++ b/_ts_packages/packages/recall-adapter/src/client.ts @@ -0,0 +1,63 @@ +// Minimal client for the Recall.ai v1 REST API. +// Docs: https://docs.recall.ai/reference (verified 2026-04). + +export type FetchFn = typeof fetch; + +export interface RecallClientConfig { + apiKey: string; + baseUrl?: string; + fetchImpl?: FetchFn; +} + +const DEFAULT_BASE_URL = "https://api.recall.ai/api/v1"; + +export class RecallClient { + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly fetchImpl: FetchFn; + + constructor(cfg: RecallClientConfig) { + if (!cfg.apiKey) throw new Error("RECALL_API_KEY is required"); + this.apiKey = cfg.apiKey; + this.baseUrl = cfg.baseUrl ?? DEFAULT_BASE_URL; + this.fetchImpl = cfg.fetchImpl ?? fetch; + } + + async listBots(): Promise { + return this.request("GET", "/bot/"); + } + + async getBot(botId: string): Promise { + return this.request("GET", `/bot/${encodeURIComponent(botId)}/`); + } + + async joinMeeting(meetingUrl: string, botName = "KeiSei"): Promise { + return this.request("POST", "/bot/", { meeting_url: meetingUrl, bot_name: botName }); + } + + async leaveMeeting(botId: string): Promise { + return this.request("POST", `/bot/${encodeURIComponent(botId)}/leave_call/`); + } + + async getTranscript(botId: string): Promise { + return this.request("GET", `/bot/${encodeURIComponent(botId)}/transcript/`); + } + + private async request(method: string, path: string, body?: unknown): Promise { + const headers: Record = { + Authorization: `Token ${this.apiKey}`, + Accept: "application/json", + }; + const init: RequestInit = { method, headers }; + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + const res = await this.fetchImpl(`${this.baseUrl}${path}`, init); + if (!res.ok) { + const text = await res.text(); + throw new Error(`recall ${method} ${path} -> ${res.status}: ${text}`); + } + return res.json(); + } +} diff --git a/_ts_packages/packages/recall-adapter/src/index.ts b/_ts_packages/packages/recall-adapter/src/index.ts new file mode 100644 index 0000000..9a190e1 --- /dev/null +++ b/_ts_packages/packages/recall-adapter/src/index.ts @@ -0,0 +1,19 @@ +import { RecallClient } from "./client.js"; +import { buildRecallTools, type RecallTool } from "./tools.js"; + +export { RecallClient } from "./client.js"; +export { buildRecallTools } from "./tools.js"; +export type { RecallTool } from "./tools.js"; + +type Registrar = (tool: RecallTool) => void; + +export function registerAdapter(register: Registrar): void { + const apiKey = process.env["RECALL_API_KEY"]; + if (!apiKey) { + throw new Error( + "RECALL_API_KEY env var is missing; set it in ~/.claude/secrets/.env (RULE 0.8).", + ); + } + const client = new RecallClient({ apiKey }); + for (const tool of buildRecallTools(client)) register(tool); +} diff --git a/_ts_packages/packages/recall-adapter/src/tools.ts b/_ts_packages/packages/recall-adapter/src/tools.ts new file mode 100644 index 0000000..8917157 --- /dev/null +++ b/_ts_packages/packages/recall-adapter/src/tools.ts @@ -0,0 +1,69 @@ +// Recall.ai tool definitions. Each tool wraps one client method and returns +// JSON-stringified output for the MCP transport. + +import { z } from "zod"; +import { RecallClient } from "./client.js"; + +export interface RecallTool { + name: string; + description: string; + inputSchema: z.ZodObject>; + handler: (args: Record) => Promise; +} + +const BotIdArgs = z.object({ bot_id: z.string().min(1) }); +const JoinArgs = z.object({ + meeting_url: z.string().url(), + bot_name: z.string().optional(), +}); + +export function buildRecallTools(client: RecallClient): RecallTool[] { + return [ + { + name: "zoom_status", + description: "Status of a deployed Recall.ai bot (bot_id required).", + inputSchema: BotIdArgs, + handler: async (raw) => { + const args = BotIdArgs.parse(raw); + return pretty(await client.getBot(args.bot_id)); + }, + }, + { + name: "zoom_bots", + description: "List all Recall.ai bots for this account.", + inputSchema: z.object({}), + handler: async () => pretty(await client.listBots()), + }, + { + name: "zoom_join", + description: "Deploy a Recall.ai bot to a meeting URL.", + inputSchema: JoinArgs, + handler: async (raw) => { + const args = JoinArgs.parse(raw); + return pretty(await client.joinMeeting(args.meeting_url, args.bot_name)); + }, + }, + { + name: "zoom_leave", + description: "Recall an active bot from a meeting.", + inputSchema: BotIdArgs, + handler: async (raw) => { + const args = BotIdArgs.parse(raw); + return pretty(await client.leaveMeeting(args.bot_id)); + }, + }, + { + name: "zoom_chat", + description: "Fetch transcript for a bot's meeting.", + inputSchema: BotIdArgs, + handler: async (raw) => { + const args = BotIdArgs.parse(raw); + return pretty(await client.getTranscript(args.bot_id)); + }, + }, + ]; +} + +function pretty(x: unknown): string { + return JSON.stringify(x, null, 2); +} diff --git a/_ts_packages/packages/recall-adapter/test/client.test.ts b/_ts_packages/packages/recall-adapter/test/client.test.ts new file mode 100644 index 0000000..718aee8 --- /dev/null +++ b/_ts_packages/packages/recall-adapter/test/client.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; +import { RecallClient } from "../src/client.js"; + +function makeFetchMock(payload: unknown, ok = true, status = 200): typeof fetch { + return vi.fn(async () => { + return { + ok, + status, + async text() { return JSON.stringify(payload); }, + async json() { return payload; }, + } as unknown as Response; + }) as unknown as typeof fetch; +} + +describe("RecallClient", () => { + it("rejects empty API key", () => { + expect(() => new RecallClient({ apiKey: "" })).toThrow(/RECALL_API_KEY/); + }); + + it("listBots calls GET /bot/", async () => { + const fetchImpl = makeFetchMock([{ id: "b1" }]); + const c = new RecallClient({ apiKey: "k", fetchImpl }); + const out = (await c.listBots()) as Array<{ id: string }>; + expect(out[0]?.id).toBe("b1"); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it("joinMeeting POSTs meeting_url", async () => { + const fetchImpl = makeFetchMock({ id: "new" }); + const c = new RecallClient({ apiKey: "k", fetchImpl }); + const out = await c.joinMeeting("https://zoom.us/j/123"); + expect((out as { id: string }).id).toBe("new"); + }); + + it("propagates non-2xx responses as Error", async () => { + const fetchImpl = makeFetchMock({ detail: "forbidden" }, false, 403); + const c = new RecallClient({ apiKey: "k", fetchImpl }); + await expect(c.getBot("b1")).rejects.toThrow(/403/); + }); +}); diff --git a/_ts_packages/packages/recall-adapter/test/tools.test.ts b/_ts_packages/packages/recall-adapter/test/tools.test.ts new file mode 100644 index 0000000..942b153 --- /dev/null +++ b/_ts_packages/packages/recall-adapter/test/tools.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from "vitest"; +import { RecallClient } from "../src/client.js"; +import { buildRecallTools } from "../src/tools.js"; + +function makeFetchMock(payload: unknown): typeof fetch { + return vi.fn(async () => ({ + ok: true, + status: 200, + async text() { return JSON.stringify(payload); }, + async json() { return payload; }, + } as unknown as Response)) as unknown as typeof fetch; +} + +describe("recall tool surface", () => { + it("exposes 5 tools", () => { + const c = new RecallClient({ apiKey: "k", fetchImpl: makeFetchMock({}) }); + const tools = buildRecallTools(c); + expect(tools.map((t) => t.name)).toEqual([ + "zoom_status", + "zoom_bots", + "zoom_join", + "zoom_leave", + "zoom_chat", + ]); + }); + + it("zoom_join validates meeting_url as URL", async () => { + const c = new RecallClient({ apiKey: "k", fetchImpl: makeFetchMock({}) }); + const tool = buildRecallTools(c).find((t) => t.name === "zoom_join"); + await expect(tool!.handler({ meeting_url: "not a url" })).rejects.toBeTruthy(); + }); + + it("zoom_status returns JSON string", async () => { + const c = new RecallClient({ apiKey: "k", fetchImpl: makeFetchMock({ id: "x" }) }); + const tool = buildRecallTools(c).find((t) => t.name === "zoom_status"); + const out = await tool!.handler({ bot_id: "x" }); + expect(out).toContain('"id": "x"'); + }); + + it("zoom_bots hits list endpoint", async () => { + const fetchImpl = makeFetchMock([{ id: "a" }]); + const c = new RecallClient({ apiKey: "k", fetchImpl }); + const tool = buildRecallTools(c).find((t) => t.name === "zoom_bots"); + const out = await tool!.handler({}); + expect(out).toContain("a"); + }); +}); diff --git a/_ts_packages/packages/recall-adapter/tsconfig.json b/_ts_packages/packages/recall-adapter/tsconfig.json new file mode 100644 index 0000000..2c00eac --- /dev/null +++ b/_ts_packages/packages/recall-adapter/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "test/**/*"] +} diff --git a/_ts_packages/packages/recall-adapter/vitest.config.ts b/_ts_packages/packages/recall-adapter/vitest.config.ts new file mode 100644 index 0000000..d9ebefd --- /dev/null +++ b/_ts_packages/packages/recall-adapter/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/_ts_packages/packages/telegram-adapter/package.json b/_ts_packages/packages/telegram-adapter/package.json new file mode 100644 index 0000000..1856eb7 --- /dev/null +++ b/_ts_packages/packages/telegram-adapter/package.json @@ -0,0 +1,33 @@ +{ + "name": "@keisei/telegram-adapter", + "version": "0.14.0", + "description": "Telegram Bot API adapter for the KeiSei MCP server", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b", + "test": "vitest run" + }, + "dependencies": { + "grammy": "^1.28.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/_ts_packages/packages/telegram-adapter/src/client.ts b/_ts_packages/packages/telegram-adapter/src/client.ts new file mode 100644 index 0000000..c9736fd --- /dev/null +++ b/_ts_packages/packages/telegram-adapter/src/client.ts @@ -0,0 +1,74 @@ +// Thin wrapper over grammy's Bot class. One class = one responsibility: +// own the Bot instance, expose a narrow surface used by tool handlers. + +import { Bot, InputFile } from "grammy"; +import type { ContactRecord, GroupRecord } from "./types.js"; + +export interface TelegramClientConfig { + token: string; +} + +export class TelegramClient { + private readonly bot: Bot; + private readonly contactsCache: Map = new Map(); + private readonly groupsCache: Map = new Map(); + + constructor(cfg: TelegramClientConfig) { + if (!cfg.token) throw new Error("TELEGRAM_BOT_TOKEN is required"); + this.bot = new Bot(cfg.token); + } + + async status(): Promise<{ username: string; id: number }> { + const me = await this.bot.api.getMe(); + return { username: me.username, id: me.id }; + } + + async chatInfo(chat: string | number): Promise<{ id: number; title: string; type: string }> { + const info = await this.bot.api.getChat(chat); + const title = "title" in info && info.title ? info.title : + "first_name" in info && info.first_name ? info.first_name : String(info.id); + return { id: info.id, title, type: info.type }; + } + + async sendText(chat: string | number, text: string): Promise { + const msg = await this.bot.api.sendMessage(chat, text); + return msg.message_id; + } + + async sendDocument(chat: string | number, filePath: string, caption?: string): Promise { + const msg = await this.bot.api.sendDocument(chat, new InputFile(filePath), caption !== undefined ? { caption } : {}); + return msg.message_id; + } + + async sendPhoto(chat: string | number, filePath: string, caption?: string): Promise { + const msg = await this.bot.api.sendPhoto(chat, new InputFile(filePath), caption !== undefined ? { caption } : {}); + return msg.message_id; + } + + async sendVideo(chat: string | number, filePath: string, caption?: string): Promise { + const msg = await this.bot.api.sendVideo(chat, new InputFile(filePath), caption !== undefined ? { caption } : {}); + return msg.message_id; + } + + async sendVoice(chat: string | number, filePath: string, caption?: string): Promise { + const msg = await this.bot.api.sendVoice(chat, new InputFile(filePath), caption !== undefined ? { caption } : {}); + return msg.message_id; + } + + listGroups(): GroupRecord[] { + return Array.from(this.groupsCache.values()); + } + + listContacts(): ContactRecord[] { + return Array.from(this.contactsCache.values()); + } + + // Test helpers to seed cache; kept internal via underscore prefix. + _seedContact(c: ContactRecord): void { + this.contactsCache.set(c.userId, c); + } + + _seedGroup(g: GroupRecord): void { + this.groupsCache.set(g.chatId, g); + } +} diff --git a/_ts_packages/packages/telegram-adapter/src/index.ts b/_ts_packages/packages/telegram-adapter/src/index.ts new file mode 100644 index 0000000..8307940 --- /dev/null +++ b/_ts_packages/packages/telegram-adapter/src/index.ts @@ -0,0 +1,23 @@ +// Public entry: exports registerAdapter() for the MCP server loader, +// plus the class + tool builder for programmatic use. + +import { TelegramClient } from "./client.js"; +import { buildTelegramTools, type TelegramTool } from "./tools.js"; + +export { TelegramClient } from "./client.js"; +export { buildTelegramTools } from "./tools.js"; +export type { TelegramTool } from "./tools.js"; +export * from "./types.js"; + +type Registrar = (tool: TelegramTool) => void; + +export function registerAdapter(register: Registrar): void { + const token = process.env["TELEGRAM_BOT_TOKEN"]; + if (!token) { + throw new Error( + "TELEGRAM_BOT_TOKEN env var is missing; set it in ~/.claude/secrets/.env (RULE 0.8).", + ); + } + const client = new TelegramClient({ token }); + for (const tool of buildTelegramTools(client)) register(tool); +} diff --git a/_ts_packages/packages/telegram-adapter/src/tools.ts b/_ts_packages/packages/telegram-adapter/src/tools.ts new file mode 100644 index 0000000..52d345e --- /dev/null +++ b/_ts_packages/packages/telegram-adapter/src/tools.ts @@ -0,0 +1,112 @@ +// Tool definitions for the Telegram adapter. Each tool is a small wrapper +// around TelegramClient + a zod schema, returning a string to MCP. + +import { z } from "zod"; +import { TelegramClient } from "./client.js"; +import { + ChatInfoArgs, + SendFileArgs, + SendTextArgs, + SendVoiceArgs, +} from "./types.js"; + +export interface TelegramTool { + name: string; + description: string; + inputSchema: z.ZodObject>; + handler: (args: Record) => Promise; +} + +export function buildTelegramTools(client: TelegramClient): TelegramTool[] { + return [ + { + name: "telegram_status", + description: "Telegram bot identity and connectivity.", + inputSchema: z.object({}), + handler: async () => { + const s = await client.status(); + return `bot=@${s.username} id=${s.id}`; + }, + }, + { + name: "telegram_groups", + description: "List groups the bot has observed.", + inputSchema: z.object({}), + handler: async () => { + const gs = client.listGroups(); + if (gs.length === 0) return "No groups tracked yet."; + return gs.map((g) => `${g.chatId} | ${g.title} [${g.type}]`).join("\n"); + }, + }, + { + name: "telegram_contacts", + description: "List known Telegram contacts.", + inputSchema: z.object({}), + handler: async () => { + const cs = client.listContacts(); + if (cs.length === 0) return "No contacts yet."; + return cs.map(formatContact).join("\n"); + }, + }, + { + name: "telegram_chat_info", + description: "Chat metadata for a given chat ID or @username.", + inputSchema: ChatInfoArgs, + handler: async (raw) => { + const args = ChatInfoArgs.parse(raw); + const info = await client.chatInfo(args.chat); + return `id=${info.id}\ntitle=${info.title}\ntype=${info.type}`; + }, + }, + { + name: "telegram_send", + description: "Send a text message.", + inputSchema: SendTextArgs, + handler: async (raw) => { + const args = SendTextArgs.parse(raw); + const id = await client.sendText(args.chat, args.text); + return `sent message_id=${id}`; + }, + }, + { + name: "telegram_send_file", + description: "Send a document, photo, or video file.", + inputSchema: SendFileArgs, + handler: async (raw) => { + const args = SendFileArgs.parse(raw); + const id = await dispatchFile(client, args); + return `sent ${args.kind} message_id=${id}`; + }, + }, + { + name: "telegram_send_voice", + description: "Send a pre-recorded voice note file.", + inputSchema: SendVoiceArgs, + handler: async (raw) => { + const args = SendVoiceArgs.parse(raw); + const id = await client.sendVoice(args.chat, args.file, args.caption); + return `sent voice message_id=${id}`; + }, + }, + ]; +} + +function formatContact(c: { + userId: number; + firstName: string; + lastName?: string | undefined; + username?: string | undefined; +}): string { + const name = c.lastName ? `${c.firstName} ${c.lastName}` : c.firstName; + const handle = c.username ? ` @${c.username}` : ""; + return `${c.userId} | ${name}${handle}`; +} + +async function dispatchFile( + client: TelegramClient, + args: { chat: string | number; file: string; kind: "document" | "photo" | "video"; caption?: string | undefined }, +): Promise { + if (args.kind === "photo") return client.sendPhoto(args.chat, args.file, args.caption); + if (args.kind === "video") return client.sendVideo(args.chat, args.file, args.caption); + return client.sendDocument(args.chat, args.file, args.caption); +} diff --git a/_ts_packages/packages/telegram-adapter/src/types.ts b/_ts_packages/packages/telegram-adapter/src/types.ts new file mode 100644 index 0000000..ab77be4 --- /dev/null +++ b/_ts_packages/packages/telegram-adapter/src/types.ts @@ -0,0 +1,51 @@ +// Tool I/O types for the Telegram adapter. Kept separate so both the +// adapter and consumers can import schemas without pulling grammy. + +import { z } from "zod"; + +export const TelegramChatRef = z.union([ + z.number().int(), + z.string().min(1), +]); +export type TelegramChatRef = z.infer; + +export const SendTextArgs = z.object({ + chat: TelegramChatRef, + text: z.string().min(1), +}); +export type SendTextArgs = z.infer; + +export const SendFileArgs = z.object({ + chat: TelegramChatRef, + file: z.string().min(1), + kind: z.enum(["document", "photo", "video"]).default("document"), + caption: z.string().optional(), +}); +export type SendFileArgs = z.infer; + +export const SendVoiceArgs = z.object({ + chat: TelegramChatRef, + file: z.string().min(1), + caption: z.string().optional(), +}); +export type SendVoiceArgs = z.infer; + +export const ChatInfoArgs = z.object({ + chat: TelegramChatRef, +}); +export type ChatInfoArgs = z.infer; + +export interface ContactRecord { + userId: number; + firstName: string; + lastName?: string | undefined; + username?: string | undefined; + lastSeen?: number | undefined; +} + +export interface GroupRecord { + chatId: number; + title: string; + type: string; + lastMsg?: number | undefined; +} diff --git a/_ts_packages/packages/telegram-adapter/test/client.test.ts b/_ts_packages/packages/telegram-adapter/test/client.test.ts new file mode 100644 index 0000000..ff8dcd0 --- /dev/null +++ b/_ts_packages/packages/telegram-adapter/test/client.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { TelegramClient } from "../src/client.js"; + +describe("TelegramClient cache", () => { + it("rejects empty token at construction", () => { + expect(() => new TelegramClient({ token: "" })).toThrow(/TELEGRAM_BOT_TOKEN/); + }); + + it("listGroups is empty by default", () => { + const c = new TelegramClient({ token: "123:ABC" }); + expect(c.listGroups()).toEqual([]); + }); + + it("listContacts is empty by default", () => { + const c = new TelegramClient({ token: "123:ABC" }); + expect(c.listContacts()).toEqual([]); + }); + + it("_seedContact and _seedGroup populate caches", () => { + const c = new TelegramClient({ token: "123:ABC" }); + c._seedContact({ userId: 99, firstName: "Alice" }); + c._seedGroup({ chatId: -100, title: "Test", type: "supergroup" }); + expect(c.listContacts()).toHaveLength(1); + expect(c.listGroups()).toHaveLength(1); + }); +}); diff --git a/_ts_packages/packages/telegram-adapter/test/tools.test.ts b/_ts_packages/packages/telegram-adapter/test/tools.test.ts new file mode 100644 index 0000000..04ce562 --- /dev/null +++ b/_ts_packages/packages/telegram-adapter/test/tools.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { TelegramClient } from "../src/client.js"; +import { buildTelegramTools } from "../src/tools.js"; + +describe("telegram tool surface", () => { + const c = new TelegramClient({ token: "123:ABC" }); + const tools = buildTelegramTools(c); + + it("exposes 7 tools total", () => { + expect(tools.map((t) => t.name)).toEqual([ + "telegram_status", + "telegram_groups", + "telegram_contacts", + "telegram_chat_info", + "telegram_send", + "telegram_send_file", + "telegram_send_voice", + ]); + }); + + it("telegram_groups returns placeholder text when empty", async () => { + const tool = tools.find((t) => t.name === "telegram_groups"); + const out = await tool!.handler({}); + expect(out).toContain("No groups"); + }); + + it("telegram_contacts formats seeded contact", async () => { + c._seedContact({ userId: 42, firstName: "Bob", username: "bobby" }); + const tool = tools.find((t) => t.name === "telegram_contacts"); + const out = await tool!.handler({}); + expect(out).toContain("42"); + expect(out).toContain("Bob"); + expect(out).toContain("@bobby"); + }); + + it("telegram_send rejects missing args via schema", async () => { + const tool = tools.find((t) => t.name === "telegram_send"); + await expect(tool!.handler({})).rejects.toBeTruthy(); + }); + + it("tool descriptions are non-empty", () => { + for (const t of tools) expect(t.description.length).toBeGreaterThan(0); + }); +}); diff --git a/_ts_packages/packages/telegram-adapter/test/types.test.ts b/_ts_packages/packages/telegram-adapter/test/types.test.ts new file mode 100644 index 0000000..b2e6cab --- /dev/null +++ b/_ts_packages/packages/telegram-adapter/test/types.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { SendTextArgs, SendFileArgs, SendVoiceArgs, ChatInfoArgs } from "../src/types.js"; + +describe("zod schemas", () => { + it("SendTextArgs accepts numeric chat id", () => { + const r = SendTextArgs.safeParse({ chat: 12345, text: "hi" }); + expect(r.success).toBe(true); + }); + + it("SendTextArgs accepts string chat handle", () => { + const r = SendTextArgs.safeParse({ chat: "@username", text: "hi" }); + expect(r.success).toBe(true); + }); + + it("SendTextArgs rejects empty text", () => { + const r = SendTextArgs.safeParse({ chat: 1, text: "" }); + expect(r.success).toBe(false); + }); + + it("SendFileArgs defaults kind to document", () => { + const r = SendFileArgs.safeParse({ chat: 1, file: "/x" }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.kind).toBe("document"); + }); + + it("SendFileArgs rejects unknown kind", () => { + const r = SendFileArgs.safeParse({ chat: 1, file: "/x", kind: "sticker" }); + expect(r.success).toBe(false); + }); + + it("SendVoiceArgs requires file path", () => { + const r = SendVoiceArgs.safeParse({ chat: 1 }); + expect(r.success).toBe(false); + }); + + it("ChatInfoArgs requires chat", () => { + const r = ChatInfoArgs.safeParse({}); + expect(r.success).toBe(false); + }); +}); diff --git a/_ts_packages/packages/telegram-adapter/tsconfig.json b/_ts_packages/packages/telegram-adapter/tsconfig.json new file mode 100644 index 0000000..2c00eac --- /dev/null +++ b/_ts_packages/packages/telegram-adapter/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "test/**/*"] +} diff --git a/_ts_packages/packages/telegram-adapter/vitest.config.ts b/_ts_packages/packages/telegram-adapter/vitest.config.ts new file mode 100644 index 0000000..d9ebefd --- /dev/null +++ b/_ts_packages/packages/telegram-adapter/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/_ts_packages/packages/youtube-adapter/package.json b/_ts_packages/packages/youtube-adapter/package.json new file mode 100644 index 0000000..e1363b2 --- /dev/null +++ b/_ts_packages/packages/youtube-adapter/package.json @@ -0,0 +1,34 @@ +{ + "name": "@keisei/youtube-adapter", + "version": "0.14.0", + "description": "YouTube Data API v3 adapter for the KeiSei MCP server", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b", + "test": "vitest run" + }, + "dependencies": { + "googleapis": "^144.0.0", + "youtube-transcript": "^1.2.1", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/_ts_packages/packages/youtube-adapter/src/client.ts b/_ts_packages/packages/youtube-adapter/src/client.ts new file mode 100644 index 0000000..e74796e --- /dev/null +++ b/_ts_packages/packages/youtube-adapter/src/client.ts @@ -0,0 +1,112 @@ +// YouTube Data API v3 client wrapper. The surface is intentionally narrow — +// subscriptions list, search, videos.list(statistics), plus a transcript +// helper using the `youtube-transcript` package. + +import { google } from "googleapis"; +import type { TranscriptLine, VideoStats, VideoSummary } from "./types.js"; + +export interface YouTubeClientConfig { + apiKey: string; + surface?: YouTubeSurface; + transcriptFn?: TranscriptFn; +} + +export type TranscriptFn = (videoId: string) => Promise; + +export interface YouTubeSurface { + subscriptions: (max: number) => Promise; + channelVideos: (channelId: string, since: string | undefined, max: number) => Promise; + search: (query: string, max: number) => Promise; + stats: (videoId: string) => Promise; +} + +export class YouTubeClient { + private readonly surface: YouTubeSurface; + private readonly transcriptFn: TranscriptFn; + + constructor(cfg: YouTubeClientConfig) { + this.surface = cfg.surface ?? buildDefaultSurface(cfg.apiKey); + this.transcriptFn = cfg.transcriptFn ?? defaultTranscriptFn; + } + + subscriptions(max: number): Promise { + return this.surface.subscriptions(max); + } + + newVideos(channelId: string, since: string | undefined, max: number): Promise { + return this.surface.channelVideos(channelId, since, max); + } + + search(query: string, max: number): Promise { + return this.surface.search(query, max); + } + + stats(videoId: string): Promise { + return this.surface.stats(videoId); + } + + transcript(videoId: string): Promise { + return this.transcriptFn(videoId); + } +} + +interface TranscriptModule { + YoutubeTranscript: { + fetchTranscript: (videoId: string) => Promise>; + }; +} + +async function defaultTranscriptFn(videoId: string): Promise { + // Deferred import: the upstream package ships dual-module with a broken + // CJS entry, so eager `import` at top-level fails under ESM + vitest. + const mod = (await import("youtube-transcript")) as unknown as TranscriptModule; + const rows = await mod.YoutubeTranscript.fetchTranscript(videoId); + return rows.map((r) => ({ text: r.text, offset: r.offset, duration: r.duration })); +} + +function buildDefaultSurface(apiKey: string): YouTubeSurface { + if (!apiKey) throw new Error("YOUTUBE_API_KEY is required"); + const yt = google.youtube({ version: "v3", auth: apiKey }); + return { + subscriptions: async (max) => { + const res = await yt.subscriptions.list({ part: ["snippet"], mine: true, maxResults: max }); + return (res.data.items ?? []).map(itemToSummary); + }, + channelVideos: async (channelId, since, max) => { + const res = await yt.search.list({ + part: ["snippet"], + channelId, + order: "date", + maxResults: max, + ...(since !== undefined ? { publishedAfter: since } : {}), + }); + return (res.data.items ?? []).map(itemToSummary); + }, + search: async (query, max) => { + const res = await yt.search.list({ part: ["snippet"], q: query, maxResults: max }); + return (res.data.items ?? []).map(itemToSummary); + }, + stats: async (videoId) => { + const res = await yt.videos.list({ part: ["statistics"], id: [videoId] }); + const s = res.data.items?.[0]?.statistics ?? {}; + return { + videoId, + viewCount: s.viewCount ?? undefined, + likeCount: s.likeCount ?? undefined, + commentCount: s.commentCount ?? undefined, + }; + }, + }; +} + +function itemToSummary(item: { id?: { videoId?: string | null } | string | null; snippet?: { title?: string | null; channelTitle?: string | null; resourceId?: { videoId?: string | null } | null; publishedAt?: string | null } | null }): VideoSummary { + const vid = typeof item.id === "object" && item.id !== null + ? (item.id.videoId ?? "") + : (item.snippet?.resourceId?.videoId ?? ""); + return { + videoId: vid, + title: item.snippet?.title ?? undefined, + channel: item.snippet?.channelTitle ?? undefined, + publishedAt: item.snippet?.publishedAt ?? undefined, + }; +} diff --git a/_ts_packages/packages/youtube-adapter/src/index.ts b/_ts_packages/packages/youtube-adapter/src/index.ts new file mode 100644 index 0000000..3e76dfb --- /dev/null +++ b/_ts_packages/packages/youtube-adapter/src/index.ts @@ -0,0 +1,20 @@ +import { YouTubeClient } from "./client.js"; +import { buildYouTubeTools, type YouTubeTool } from "./tools.js"; + +export { YouTubeClient } from "./client.js"; +export { buildYouTubeTools } from "./tools.js"; +export type { YouTubeTool } from "./tools.js"; +export * from "./types.js"; + +type Registrar = (tool: YouTubeTool) => void; + +export function registerAdapter(register: Registrar): void { + const apiKey = process.env["YOUTUBE_API_KEY"]; + if (!apiKey) { + throw new Error( + "YOUTUBE_API_KEY env var is missing; set it in ~/.claude/secrets/.env (RULE 0.8).", + ); + } + const client = new YouTubeClient({ apiKey }); + for (const tool of buildYouTubeTools(client)) register(tool); +} diff --git a/_ts_packages/packages/youtube-adapter/src/tools.ts b/_ts_packages/packages/youtube-adapter/src/tools.ts new file mode 100644 index 0000000..d2805c8 --- /dev/null +++ b/_ts_packages/packages/youtube-adapter/src/tools.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; +import { YouTubeClient } from "./client.js"; +import { + NewVideosArgs, + SearchArgs, + SubscriptionsArgs, + VideoIdArgs, + type TranscriptLine, + type VideoStats, + type VideoSummary, +} from "./types.js"; + +export interface YouTubeTool { + name: string; + description: string; + inputSchema: z.ZodObject>; + handler: (args: Record) => Promise; +} + +export function buildYouTubeTools(client: YouTubeClient): YouTubeTool[] { + return [ + { + name: "youtube_subscriptions", + description: "List the authenticated user's channel subscriptions.", + inputSchema: SubscriptionsArgs, + handler: async (raw) => { + const args = SubscriptionsArgs.parse(raw); + return formatList(await client.subscriptions(args.max)); + }, + }, + { + name: "youtube_new_videos", + description: "Latest videos from a given channel (optional --since ISO8601).", + inputSchema: NewVideosArgs, + handler: async (raw) => { + const args = NewVideosArgs.parse(raw); + return formatList(await client.newVideos(args.channel_id, args.since, args.max)); + }, + }, + { + name: "youtube_search", + description: "Search YouTube for a query string.", + inputSchema: SearchArgs, + handler: async (raw) => { + const args = SearchArgs.parse(raw); + return formatList(await client.search(args.query, args.max)); + }, + }, + { + name: "youtube_transcript", + description: "Fetch the transcript (captions) of a video as plain text.", + inputSchema: VideoIdArgs, + handler: async (raw) => { + const args = VideoIdArgs.parse(raw); + return formatTranscript(await client.transcript(args.video_id)); + }, + }, + { + name: "youtube_video_stats", + description: "View/like/comment counts for a given video.", + inputSchema: VideoIdArgs, + handler: async (raw) => { + const args = VideoIdArgs.parse(raw); + return formatStats(await client.stats(args.video_id)); + }, + }, + ]; +} + +function formatList(items: VideoSummary[]): string { + if (items.length === 0) return "No results."; + return items.map((v) => `${v.videoId} | ${v.channel ?? "?"} | ${v.title ?? "?"}`).join("\n"); +} + +function formatTranscript(lines: TranscriptLine[]): string { + if (lines.length === 0) return "No transcript available."; + return lines.map((l) => l.text).join(" "); +} + +function formatStats(s: VideoStats): string { + return [ + `video: ${s.videoId}`, + `views: ${s.viewCount ?? "?"}`, + `likes: ${s.likeCount ?? "?"}`, + `comments: ${s.commentCount ?? "?"}`, + ].join("\n"); +} diff --git a/_ts_packages/packages/youtube-adapter/src/types.ts b/_ts_packages/packages/youtube-adapter/src/types.ts new file mode 100644 index 0000000..041cd37 --- /dev/null +++ b/_ts_packages/packages/youtube-adapter/src/types.ts @@ -0,0 +1,46 @@ +// YouTube Data API v3 tool I/O types. + +import { z } from "zod"; + +export const SubscriptionsArgs = z.object({ + max: z.number().int().positive().max(50).default(25), +}); +export type SubscriptionsArgs = z.infer; + +export const NewVideosArgs = z.object({ + channel_id: z.string().min(1), + since: z.string().optional(), + max: z.number().int().positive().max(50).default(10), +}); +export type NewVideosArgs = z.infer; + +export const SearchArgs = z.object({ + query: z.string().min(1), + max: z.number().int().positive().max(50).default(10), +}); +export type SearchArgs = z.infer; + +export const VideoIdArgs = z.object({ + video_id: z.string().min(1), +}); +export type VideoIdArgs = z.infer; + +export interface VideoSummary { + videoId: string; + title?: string | undefined; + channel?: string | undefined; + publishedAt?: string | undefined; +} + +export interface VideoStats { + videoId: string; + viewCount?: string | undefined; + likeCount?: string | undefined; + commentCount?: string | undefined; +} + +export interface TranscriptLine { + text: string; + offset: number; + duration: number; +} diff --git a/_ts_packages/packages/youtube-adapter/test/client.test.ts b/_ts_packages/packages/youtube-adapter/test/client.test.ts new file mode 100644 index 0000000..5199427 --- /dev/null +++ b/_ts_packages/packages/youtube-adapter/test/client.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from "vitest"; +import { YouTubeClient, type YouTubeSurface } from "../src/client.js"; + +function makeSurface(): YouTubeSurface { + return { + subscriptions: vi.fn(async () => [{ videoId: "v1", title: "Sub channel", channel: "c1" }]), + channelVideos: vi.fn(async () => [{ videoId: "v2", title: "Latest" }]), + search: vi.fn(async () => [{ videoId: "v3", title: "Result" }]), + stats: vi.fn(async () => ({ videoId: "v1", viewCount: "100", likeCount: "5", commentCount: "1" })), + }; +} + +describe("YouTubeClient", () => { + it("subscriptions delegates to surface", async () => { + const s = makeSurface(); + const c = new YouTubeClient({ apiKey: "k", surface: s }); + const out = await c.subscriptions(10); + expect(out[0]?.videoId).toBe("v1"); + expect(s.subscriptions).toHaveBeenCalledWith(10); + }); + + it("transcript uses injected fn", async () => { + const c = new YouTubeClient({ + apiKey: "k", + surface: makeSurface(), + transcriptFn: async () => [{ text: "hi", offset: 0, duration: 1 }], + }); + const out = await c.transcript("vid"); + expect(out).toHaveLength(1); + }); + + it("stats returns video statistics", async () => { + const c = new YouTubeClient({ apiKey: "k", surface: makeSurface() }); + const out = await c.stats("v1"); + expect(out.viewCount).toBe("100"); + }); +}); diff --git a/_ts_packages/packages/youtube-adapter/test/tools.test.ts b/_ts_packages/packages/youtube-adapter/test/tools.test.ts new file mode 100644 index 0000000..57917e9 --- /dev/null +++ b/_ts_packages/packages/youtube-adapter/test/tools.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from "vitest"; +import { YouTubeClient, type YouTubeSurface } from "../src/client.js"; +import { buildYouTubeTools } from "../src/tools.js"; + +function makeSurface(): YouTubeSurface { + return { + subscriptions: vi.fn(async () => []), + channelVideos: vi.fn(async () => []), + search: vi.fn(async () => [{ videoId: "v1", title: "T", channel: "C" }]), + stats: vi.fn(async () => ({ videoId: "v1", viewCount: "9" })), + }; +} + +describe("youtube tool surface", () => { + it("registers 5 tools", () => { + const c = new YouTubeClient({ apiKey: "k", surface: makeSurface(), transcriptFn: async () => [] }); + const names = buildYouTubeTools(c).map((t) => t.name); + expect(names).toEqual([ + "youtube_subscriptions", + "youtube_new_videos", + "youtube_search", + "youtube_transcript", + "youtube_video_stats", + ]); + }); + + it("youtube_subscriptions handles empty list", async () => { + const c = new YouTubeClient({ apiKey: "k", surface: makeSurface(), transcriptFn: async () => [] }); + const tool = buildYouTubeTools(c).find((t) => t.name === "youtube_subscriptions"); + const out = await tool!.handler({}); + expect(out).toBe("No results."); + }); + + it("youtube_search formats result line", async () => { + const c = new YouTubeClient({ apiKey: "k", surface: makeSurface(), transcriptFn: async () => [] }); + const tool = buildYouTubeTools(c).find((t) => t.name === "youtube_search"); + const out = await tool!.handler({ query: "rust" }); + expect(out).toContain("v1"); + expect(out).toContain("T"); + }); + + it("youtube_transcript joins lines with spaces", async () => { + const c = new YouTubeClient({ + apiKey: "k", + surface: makeSurface(), + transcriptFn: async () => [ + { text: "hello", offset: 0, duration: 1 }, + { text: "world", offset: 1, duration: 1 }, + ], + }); + const tool = buildYouTubeTools(c).find((t) => t.name === "youtube_transcript"); + const out = await tool!.handler({ video_id: "x" }); + expect(out).toBe("hello world"); + }); +}); diff --git a/_ts_packages/packages/youtube-adapter/test/types.test.ts b/_ts_packages/packages/youtube-adapter/test/types.test.ts new file mode 100644 index 0000000..7884377 --- /dev/null +++ b/_ts_packages/packages/youtube-adapter/test/types.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { SubscriptionsArgs, NewVideosArgs, SearchArgs, VideoIdArgs } from "../src/types.js"; + +describe("youtube schemas", () => { + it("SubscriptionsArgs defaults max to 25", () => { + const r = SubscriptionsArgs.safeParse({}); + expect(r.success).toBe(true); + if (r.success) expect(r.data.max).toBe(25); + }); + + it("SubscriptionsArgs rejects max > 50", () => { + const r = SubscriptionsArgs.safeParse({ max: 51 }); + expect(r.success).toBe(false); + }); + + it("NewVideosArgs requires channel_id", () => { + expect(NewVideosArgs.safeParse({}).success).toBe(false); + expect(NewVideosArgs.safeParse({ channel_id: "UC1" }).success).toBe(true); + }); + + it("SearchArgs rejects empty query", () => { + expect(SearchArgs.safeParse({ query: "" }).success).toBe(false); + }); + + it("VideoIdArgs requires non-empty id", () => { + expect(VideoIdArgs.safeParse({ video_id: "" }).success).toBe(false); + expect(VideoIdArgs.safeParse({ video_id: "abc" }).success).toBe(true); + }); +}); diff --git a/_ts_packages/packages/youtube-adapter/tsconfig.json b/_ts_packages/packages/youtube-adapter/tsconfig.json new file mode 100644 index 0000000..2c00eac --- /dev/null +++ b/_ts_packages/packages/youtube-adapter/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "test/**/*"] +} diff --git a/_ts_packages/packages/youtube-adapter/vitest.config.ts b/_ts_packages/packages/youtube-adapter/vitest.config.ts new file mode 100644 index 0000000..d9ebefd --- /dev/null +++ b/_ts_packages/packages/youtube-adapter/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/_ts_packages/tsconfig.base.json b/_ts_packages/tsconfig.base.json new file mode 100644 index 0000000..b22475f --- /dev/null +++ b/_ts_packages/tsconfig.base.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "noImplicitAny": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true, + "incremental": true + } +} diff --git a/install.sh b/install.sh index 474fe3f..63a1608 100755 --- a/install.sh +++ b/install.sh @@ -4,7 +4,7 @@ # # Usage: # ./install.sh # interactive menu on TTY; profile=minimal on non-TTY -# ./install.sh --profile= # minimal|core|frontend|ops|dev|full (skips menu) +# ./install.sh --profile= # minimal|core|frontend|ops|dev|mcp|full (skips menu) # ./install.sh --add=[,] # install one or more primitives on top of current state # ./install.sh --remove= # remove a single primitive # ./install.sh --list # list installed primitives (name | kind | desc | path) @@ -54,11 +54,12 @@ Usage: ./install.sh [flags] --profile= set installed-primitive set to one of: minimal (no primitives) - core (tomd) + core (tomd, genesis-scan) frontend (8 site tools: mock-render / visual-diff / ...) ops (8 infra tools: kei-ledger / ssh-check / ...) - dev (4 dev tools: kei-migrate / kei-changelog / ...) - full (all 21 primitives) + dev (9 dev tools: kei-migrate / kei-memory / deep-sleep quartet / ...) + mcp (10 LBM-port tools: kei-router / kei-sage / kei-auth / ...) + full (all 37 primitives — MANIFEST source of truth) --add=[,,...] add one or more primitives on top of current install. Name must match [primitive.] in _primitives/MANIFEST.toml. @@ -337,13 +338,14 @@ menu_should_skip() { menu_whiptail_profile() { local tool="$1" # whiptail or dialog "$tool" --title "KeiSeiKit Installer" --radiolist \ - "Choose install profile (SPACE to select, ENTER to confirm):" 20 78 7 \ + "Choose install profile (SPACE to select, ENTER to confirm):" 22 78 8 \ "minimal" "agents + hooks + skills + bridges (~5s)" ON \ - "core" "+ tomd (~5s)" OFF \ + "core" "+ tomd + genesis-scan (~5s)" OFF \ "frontend" "+ 8 site tools (~60s, 80 MB)" OFF \ "ops" "+ 8 infra tools (~90s, 50 MB)" OFF \ - "dev" "+ 4 dev tools (~60s, 40 MB)" OFF \ - "full" "all 21 primitives (~5 min, 200 MB)" OFF \ + "dev" "+ 9 dev tools (~60s, 40 MB)" OFF \ + "mcp" "+ 10 LBM-port MCP tools (~90s, 50 MB)" OFF \ + "full" "all 37 primitives (~5 min, 200 MB)" OFF \ "custom" "pick individual primitives" OFF \ 3>&1 1>&2 2>&3 } @@ -376,15 +378,16 @@ menu_plain_profile() { echo "Choose install profile:" >&2 echo >&2 echo " 1) minimal — agents + hooks + skills + bridges only (~5s)" >&2 - echo " 2) core — + tomd (~5s)" >&2 + echo " 2) core — + tomd + genesis-scan (~5s)" >&2 echo " 3) frontend — + 8 site tools (~60s, 80 MB)" >&2 echo " 4) ops — + 8 infra tools (~90s, 50 MB)" >&2 - echo " 5) dev — + 4 dev tools (~60s, 40 MB)" >&2 - echo " 6) full — all 21 primitives (~5 min, 200 MB)" >&2 - echo " 7) custom — pick individual primitives" >&2 + echo " 5) dev — + 9 dev tools (~60s, 40 MB)" >&2 + echo " 6) mcp — + 10 LBM-port MCP tools (~90s, 50 MB)" >&2 + echo " 7) full — all 37 primitives (~5 min, 200 MB)" >&2 + echo " 8) custom — pick individual primitives" >&2 echo >&2 local reply - printf 'Enter choice [1-7] (default 1): ' >&2 + printf 'Enter choice [1-8] (default 1): ' >&2 read -r reply || return 1 case "${reply:-1}" in 1) echo minimal ;; @@ -392,8 +395,9 @@ menu_plain_profile() { 3) echo frontend ;; 4) echo ops ;; 5) echo dev ;; - 6) echo full ;; - 7) echo custom ;; + 6) echo mcp ;; + 7) echo full ;; + 8) echo custom ;; *) err "invalid choice: $reply"; return 1 ;; esac } @@ -868,7 +872,7 @@ if ! menu_should_skip; then if echo "$menu_out" | grep -q ','; then CUSTOM_PRIMS="$menu_out" PROFILE="custom" - elif echo "$menu_out" | grep -qE '^(minimal|core|frontend|ops|dev|full)$'; then + elif echo "$menu_out" | grep -qE '^(minimal|core|frontend|ops|dev|mcp|full)$'; then PROFILE="$menu_out" else # Single name from custom-with-one-item — treat as CUSTOM_PRIMS @@ -881,9 +885,9 @@ fi # Default profile is minimal. PROFILE="${PROFILE:-minimal}" case "$PROFILE" in - minimal|core|frontend|ops|dev|full|custom) ;; + minimal|core|frontend|ops|dev|mcp|full|custom) ;; *) - err "unknown profile: $PROFILE. Valid: minimal | core | frontend | ops | dev | full" + err "unknown profile: $PROFILE. Valid: minimal | core | frontend | ops | dev | mcp | full" exit 1 ;; esac @@ -1210,9 +1214,10 @@ else NEXT STEP: merge settings-snippet.json into ~/.claude/settings.json ========================================================================== - KeiSeiKit ships 6 hooks (assemble-agents, assemble-validate, no-hand-edit, - tomd-preread, agent-fork-logger, site-wysiwyd-check). To activate them, - merge entries from: + KeiSeiKit ships 10 hooks (assemble-agents, assemble-validate, no-hand-edit, + tomd-preread, agent-fork-logger, site-wysiwyd-check, session-end-dump, + milestone-commit-hook, error-spike-detector, git-pre-commit-genesis). + To activate them, merge entries from: $KIT_DIR/settings-snippet.json into your: $SETTINGS_FILE