From 4af5b2b8de77e51338f6638280a4d60f25269393 Mon Sep 17 00:00:00 2001 From: Nicholas Ashkar Date: Fri, 24 Apr 2026 08:21:44 +0400 Subject: [PATCH 01/18] v3.0(items #10, #11): AGENTS.md by default + README naming-collision disarm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Items #10 + #11 from v3.0 Spine implementation plan (docs/superpowers/specs/2026-04-24-v3.0-spine-implementation.md). ITEM #10 — engram gen emits CLAUDE.md AND AGENTS.md by default When no --target flag is passed, autogen() now writes BOTH CLAUDE.md AND AGENTS.md (and updates legacy .cursorrules if present). AGENTS.md is the Linux Foundation universal agent-instructions standard adopted by Codex CLI, Cursor, Windsurf, GitHub Copilot, JetBrains Junie, and Antigravity (donated to AAIF Dec 2025). Single-source-of-truth: same generated summary writes to both files. Explicit --target=claude / cursor / agents preserves single-file behavior. API change: autogen() return type goes from { file: string; ... } to { files: string[]; ... }. Only one caller (cli.ts) — updated. All tests pass (26/26, +5 new dual-emit cases). ITEM #11 — README naming-collision disarm section Adds 'What engramx is not' section between hero and Dashboard. Disarms collision with Go-Engram (Gentleman-Programming/engram), DeepSeek's Engram paper (Jan 2026), and MemPalace in the first 30 seconds of any new visitor read. Per decision: 'engramx' stays canonical brand (decision logged in strategy folder). PLANNING SPEC Lands the full v3.0 implementation plan at docs/superpowers/specs/2026-04-24-v3.0-spine-implementation.md (605 lines, sibling to the elevation-trilogy spec). Grounded in actual file paths + line numbers from the existing codebase — provider system already production-grade, Pillar 1 is EXTENDING not rewriting. 12 scope items mapped with dependencies, branch strategy, schema migrations, test strategy, release checklist. --- README.md | 12 + .../2026-04-24-v3.0-spine-implementation.md | 524 ++++++++++++++++++ src/autogen.ts | 47 +- src/cli.ts | 12 +- tests/autogen.test.ts | 68 +++ 5 files changed, 643 insertions(+), 20 deletions(-) create mode 100644 docs/superpowers/specs/2026-04-24-v3.0-spine-implementation.md diff --git a/README.md b/README.md index 0f1582a..432bf2c 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,18 @@ That's the full setup. The next Claude Code session starts with a project brief --- +## What engramx is not + +The "engram" name is contested. To save you a search: + +- **Not Go-Engram** ([Gentleman-Programming/engram](https://github.com/Gentleman-Programming/engram)) — different project, Go binary, salience-gated chat memory. Ships under `engram` (without the `x`). +- **Not DeepSeek's "Engram" paper** — January 2026 academic work on conditional memory. Research artifact, not a product. +- **Not MemPalace** — adjacent positioning ("knowledge-graph memory," "method-of-loci"), but conversational memory, not code-structural. + +`engramx` is specifically: **a local-first context spine for AI coding agents that hooks into your IDE's tool boundary, indexes your code via tree-sitter + LSP, remembers past mistakes, and assembles ~500-token context packets in place of raw file reads.** Open source, Apache 2.0, single npm install. + +--- + ## Dashboard A zero-dependency web dashboard ships built-in. One command, opens in your browser: diff --git a/docs/superpowers/specs/2026-04-24-v3.0-spine-implementation.md b/docs/superpowers/specs/2026-04-24-v3.0-spine-implementation.md new file mode 100644 index 0000000..e332024 --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-v3.0-spine-implementation.md @@ -0,0 +1,524 @@ +# engramx@3.0.0 "Spine" — Implementation Plan + +**Date:** 2026-04-24 +**Owner:** Nick (+ Claude as pair) +**Strategic basis:** `~/Desktop/Projects/Engram/00-strategy/ROADMAP.md` +**Decision basis:** `~/Desktop/Projects/Engram/00-strategy/decisions/2026-04-24-single-release-vs-staircase.md` +**Target ship:** mid-June 2026 (6-8 weeks) +**Test count target:** ~900 (current: 771) +**Tag:** `engramx@3.0.0` + +--- + +## Code baseline (what already exists, do not reinvent) + +Before any work starts, the foundation we're building on: + +### Provider system (Pillar 1 baseline) +- `src/providers/types.ts` — `ContextProvider` interface, `ContextProviderPlugin` extension, `PROVIDER_PRIORITY` array, `NodeContext`, `ProviderResult`. **Already production-grade.** +- `src/providers/resolver.ts` — `resolveRichPacket()` parallel + per-provider timeout + availability cache + priority sort + budget assembly + plugin merge. **Already production-grade.** +- `src/providers/plugin-loader.ts` — `~/.engram/plugins/*.mjs` dynamic loader with shape validation, fail-soft. **Already production-grade.** +- 8 built-in providers: `ast`, `engram-structure`, `engram-mistakes`, `engram-git`, `mempalace`, `context7`, `obsidian`, `lsp`. + +**Implication for Pillar 1:** the architecture is right. Items #1-3 EXTEND existing surfaces, do not rewrite them. + +### Mistake memory (Pillar 3 baseline) +- `src/providers/engram-mistakes.ts` — reads `kind: "mistake"` graph nodes for a file, returns top 5. +- `src/graph/schema.ts` — `GraphNode` has `lastVerified: number`. No `validUntil` / `invalidatedByCommit` yet. + +**Implication for Pillar 3:** schema migration adds two columns. `engram-mistakes.ts` filters by `validUntil`. New PreToolUse handler at `src/intercept/handlers/mistake-guard.ts` for #8. + +### `engram gen` (item #10 baseline) +- `src/autogen.ts:391-409` — `targetFile` selection branches on `target` arg (`"claude"|"cursor"|"agents"|"auto"`). **AGENTS.md emit ALREADY supported as a flag.** Default behavior is single-file emit. Item #10 changes default to BOTH CLAUDE.md AND AGENTS.md. + +### README (item #11 baseline) +- `README.md:63` — hero "The context spine for AI coding agents." Section break at `:80` "Dashboard". Item #11 inserts a "What engramx is not" section between hero and Dashboard. + +### Open PRs / issues (item #9 baseline) +- **PR #6** (mechtar-ru): OOM fix on `init` for large codebases. `MAX_DEPTH=100`, `MAX_FILES_PER_COMMIT=50`, `.engramignore` support, expanded default exclusions (`target`, `.venv`, `.next`, `.nuxt`, `.output`, `coverage`, `.turbo`, `.cache`). **STATUS: CONFLICTING / DIRTY.** Cannot fast-merge. Needs rebase. 3 files: `package-lock.json`, `src/miners/ast-miner.ts` (+51/-12), `src/miners/git-miner.ts` (+12/-2). +- **Issue #5**: closed by PR #6 once merged. +- **PR #3** (shahe-dev): ecosystem miners feature — out of 3.0 scope. Defer. + +--- + +## Branch strategy + +``` +main (v2.1.0 tag) + └── feat/v3.0-spine ← integration branch + ├── feat/v3.0-pillar1-mcp-client + ├── feat/v3.0-pillar1-plugin-contract-v2 + ├── feat/v3.0-pillar1-budget-resolver + ├── feat/v3.0-pillar2-auto-memory ← START HERE (urgency) + ├── feat/v3.0-pillar2-sse-streaming + ├── feat/v3.0-pillar2-serena-provider + ├── feat/v3.0-pillar3-bitemporal-mistakes + ├── feat/v3.0-pillar3-pre-mortem-guard + ├── chore/v3.0-agents-md-default + ├── docs/v3.0-readme-disarm + └── chore/v3.0-mcp-registry-submit +``` + +Each feature branch PRs into `feat/v3.0-spine`. The integration branch PRs into `main` only at release time. + +**Why integration branch instead of merging direct to main:** preserves ability to revert v3.0 cleanly if a release-blocking bug surfaces post-merge. main stays at v2.1.0 quality bar throughout the build. + +**Merge style:** `gh pr merge --merge --delete-branch` (preserves authorship, matches v2.1 PR #12 history). Internal PRs may use squash if the branch has a noisy commit history. + +--- + +## Dependency graph + +``` +#11 README disarm ─────┐ +#10 AGENTS.md default ─┼── independent ── can ship anytime +#9 Merge PR #6 ───────┘ (PR #6 needs contributor rebase first) + +#1 MCP-client aggregator ──┐ + ├── #4 Auto-Memory bridge (urgency: start in week 1) + ├── #6 Serena provider + └── (any future MCP provider) + +#1 ──── #2 Plugin contract v2 docs (uses MCP-client as reference impl) + └─── #3 Budget resolver + mistakes-boost + +#3 (mistakes-boost) ──── #7 Bi-temporal validity ──── #8 Pre-mortem guard + +#5 SSE streaming ── independent of all (touches src/server/http.ts only) + +#12 MCP Registry submit ── last item, after everything else lands +``` + +**Critical path:** #1 → #4 (Auto-Memory). All other Pillar 2/3 work blocks here. Anyone who can write TypeScript starts here on day 1. + +--- + +## Per-item engineering plan + +### Hygiene track (ship anytime, low risk) + +#### #11 README naming-collision disarm — 0.5 day + +**File:** `~/engram/README.md` +**Insertion point:** between line 78 (`---`) and line 80 (`## Dashboard`). +**Add new section `## What engramx is not`:** + +```markdown +## What engramx is not + +The "engram" name is contested. To save you a search: + +- **Not Go-Engram** ([Gentleman-Programming/engram](https://github.com/Gentleman-Programming/engram)) — different project, Go binary, salience-gated chat memory. Ships under `engram` (without the `x`). +- **Not DeepSeek's "Engram" paper** (Jan 2026 conditional-memory research) — academic work, not a product. +- **Not MemPalace** ([memorypalace.ai](https://memorypalace.ai)) — adjacent positioning ("knowledge graph memory," "method-of-loci"), but conversational memory, not code-structural. + +`engramx` is specifically: **a local-first context spine for AI coding agents that hooks into your IDE's tool boundary, indexes your code via tree-sitter + LSP, remembers past mistakes, and assembles ~500-token context packets in place of raw file reads.** Open source, Apache 2.0, single npm install. +``` + +**Test:** none (docs change). CI runs markdown lint on PR. +**Ship:** independent commit on `docs/v3.0-readme-disarm`. Can merge to `main` directly without waiting for v3.0. + +#### #10 AGENTS.md emit by default — 1 day + +**Goal:** `engram gen` (no `--target` flag) emits BOTH `CLAUDE.md` AND `AGENTS.md`. Codex / Cursor / Windsurf / Copilot / Junie all read `AGENTS.md` (Linux Foundation universal standard, donated Dec 2025). + +**Files to modify:** +- `src/autogen.ts` — refactor `:391-409` to write to both files when `target === "auto"`. Return type changes to `{ files: string[]; nodesIncluded: number; view: string }`. +- `src/cli.ts:633` — update `engram gen` description: "Generate CLAUDE.md + AGENTS.md sections from graph (or specify --target)" +- Tests at `tests/autogen.test.ts` — add cases: + 1. `auto` target with neither file present → emits both fresh + 2. `auto` target with both files present → updates both + 3. `auto` target with only CLAUDE.md → emits both (creates AGENTS.md) + 4. `--target=claude` still single-file + 5. `--target=agents` still single-file + +**Pitfall:** `writeToFile` (autogen.ts) is a state-machine that only operates on one file. Either call it twice (cleaner) or refactor to multi-file. **Decision: call twice.** Keeps the state machine intact. + +**Ship:** PR into `feat/v3.0-spine`. Can also merge to `main` independently if needed for marketing alignment. + +#### #9 Merge PR #6 — 1-2 days (depends on contributor) + +**Status:** branch is CONFLICTING. Cannot fast-merge. + +**Path A (preferred):** comment on PR #6 asking @mechtar-ru to rebase on current `main`. Polite, gives contributor visibility. ~3-7 day round trip. + +**Path B (fallback):** rebase locally, push to a `chore/rebase-pr-6` branch, open a new PR with `Co-authored-by: mechtar-ru` trailer. Use only if contributor unresponsive after 7 days. + +**Verify post-merge:** issue #5 auto-closes (PR title doesn't say `Fixes #5` so we link manually before merge). + +**Pitfall:** the v2.1.0 release added `.engramignore` support partially via PR #13 (gabiudrescu). PR #6's `.engramignore` may overlap. Resolve by reading both diffs side-by-side before merge. + +#### #12 MCP Registry submit — 0.5 day + +**Steps:** +1. `cd ~/engram && npx @modelcontextprotocol/cli registry submit` +2. Fill in: name `engramx`, transport `stdio`, command `mcp-engram` (existing wrapper), description, repo URL. +3. Verify listing at `registry.modelcontextprotocol.io`. + +**Ship:** post-3.0 only. Submitting before 3.0 ships risks listing a stale version. + +### Pillar 1 — Capabilities to add to it + +#### #1 Generic MCP-client aggregator — 5-7 days + +**Goal:** spawn or HTTP-connect to any MCP server, expose its tools as a Context Spine provider via config. + +**New files:** +- `src/providers/mcp-client.ts` — the client subsystem. Exports: + - `class McpClient` — wraps `@modelcontextprotocol/sdk` Client; spawn (stdio) or connect (HTTP). + - `function createMcpProvider(config: McpProviderConfig): ContextProvider` — factory that returns a `ContextProvider`-implementing object backed by `McpClient`. +- `src/providers/mcp-config.ts` — loads + validates `~/.engram/mcp-providers.json`. + +**Config schema (`~/.engram/mcp-providers.json`):** +```json +{ + "$schema": "https://engramx.dev/schemas/mcp-providers.v1.json", + "providers": [ + { + "name": "serena", + "label": "SEMANTIC SYMBOLS", + "transport": "stdio", + "command": "uvx", + "args": ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server"], + "env": {}, + "tools": ["find_symbol", "get_symbol_body"], + "tokenBudget": 200, + "timeoutMs": 2000, + "cacheTtlSec": 3600, + "priority": 25, + "enabled": true + } + ] +} +``` + +**Library decision:** use `@modelcontextprotocol/sdk` v1.x. Abstract behind internal `ProviderClient` interface so v2 migration (when stable) is one-file swap. **Do not roll our own MCP client.** + +**Provider integration:** `resolver.ts` already calls `getAllProviders()` which merges built-ins + plugins. Add a third source: `getMcpProviders()` from `mcp-config.ts`. Prepend to `BUILTIN_PROVIDERS`. + +**Tests at `tests/providers/mcp-client.test.ts` (~25 cases):** +- Spawn success / spawn failure / spawn timeout +- Tool list cache (don't re-discover on every call) +- Tool call: success / timeout / invalid JSON response / tool-not-found +- Parallel calls to same MCP server (re-use connection) +- Graceful shutdown on engram exit +- Auto-restart after server crash (with backoff) +- HTTP transport: connect / 401 unauthorized / 500 error / Host header validation +- Config validation: missing fields, bad JSON, invalid transport +- Schema migration of `mcp-providers.json` v1 → future v2 + +**Pitfall:** stdio MCP servers can write progress / log lines to stderr. Don't kill the process on stderr output. Only kill on parent shutdown or repeated unparseable JSON-RPC frames. + +**Effort:** 5-7 days. New code, but `@modelcontextprotocol/sdk` does the heavy lifting (transport, JSON-RPC, capability negotiation). + +#### #2 Provider plugin contract v2 — 2 days + +**Goal:** the `~/.engram/plugins/*.mjs` contract is documented + tested + has a reference implementation. Anyone can wrap an MCP server in 10 lines. + +**Contract changes (additive only — no breaking change):** +- Add optional `mcpConfig` field to `ContextProviderPlugin`. If present, plugin loader auto-wraps via `createMcpProvider(mcpConfig)` — plugin author writes ZERO transport code. + +```typescript +export interface ContextProviderPlugin extends ContextProvider { + readonly version: string; + readonly description?: string; + readonly author?: string; + // NEW in v3.0: + readonly mcpConfig?: McpProviderConfig; +} +``` + +**New files:** +- `docs/plugins/README.md` — plugin author guide (explain `~/.engram/plugins/*.mjs`, validation rules, reference impls) +- `docs/plugins/examples/serena-plugin.mjs` — ~10-line MCP wrapper example +- `docs/plugins/examples/static-context.mjs` — ~30-line non-MCP example (e.g., always-include-this-text provider) + +**CLI commands (new):** +- `engram plugin list` — already exists in v2.0; verify works with mcp-config plugins +- `engram plugin add ` — fetch + validate + symlink to `~/.engram/plugins/` +- `engram plugin remove ` — unlink + clear cache + +**Tests at `tests/providers/plugin-contract-v2.test.ts`:** +- mcpConfig auto-wrap: plugin without `resolve()` but with `mcpConfig` works +- Validation: bad mcpConfig schema rejected +- Reference impls: serena-plugin.mjs + static-context.mjs both load + produce results + +**Effort:** 2 days. Mostly docs + reference plugins + a thin auto-wrap path in plugin-loader.ts. + +#### #3 Budget-weighted resolver + mistakes-boost reranking — 2-3 days + +**Goal:** every provider gets its OWN token budget, not just a total. Results from providers that touch a known-mistake topic get a relevance boost so they sort up despite being from a low-priority provider. + +**Changes to `src/providers/resolver.ts`:** + +1. **Per-provider budget enforcement.** Currently `tokenBudget` is on the provider but not enforced — only `TOTAL_TOKEN_BUDGET` is. Change: each provider's result is truncated to `provider.tokenBudget` before assembly. (Most providers already self-truncate; this is a backstop.) + +2. **Mistakes-boost reranking.** New helper `boostByMistakes(results, mistakeNodes)`: + - For each result, scan its content for substrings matching mistake labels in this file. + - Match found → multiply that result's `confidence` by 1.5 (capped at 1.0). + - Re-sort `sorted` by (priority, then boosted confidence). + +3. **Per-provider budget config in `~/.engram/config.json`** — let users override defaults. Already wired via `readConfig()` (line 147 in resolver.ts). + +**Tests at `tests/providers/resolver.test.ts`:** +- Result exceeds provider budget → truncated +- Two providers, one mistake-relevant → mistake-relevant ranks higher even if lower priority +- Boosted confidence capped at 1.0 +- Per-provider budget override from config + +**Pitfall:** boosting MUST happen AFTER priority sort, not before. Otherwise high-priority providers always win regardless of mistake relevance, and the boost is a no-op. + +**Effort:** 2-3 days. Modification, not rewrite. + +### Pillar 2 — Save proper context + +#### #4 Anthropic Auto-Memory bridge — 3-4 days [URGENCY: START FIRST] + +**Goal:** detect `MEMORY.md` in project root (Anthropic Auto-Memory writes here as of v2.1.59+ Claude Code, Feb 2026). Parse it. Inject relevant topic blocks into context packets so users see Auto-Memory + engramx mistakes side-by-side. + +**Why urgency:** Anthropic Auto-Dream is server-side ready, server flag still off as of 2026-04 (per Path A research). When flag flips, MEMORY.md becomes high-quality consolidated memory. Without our bridge, our `engram gen` MEMORY.md feature gets eaten + we look behind. + +**New files:** +- `src/providers/anthropic-memory.ts` — new built-in provider, tier 1 (file read, fast, no cache). +- `src/intercept/memory-md.ts` — already exists per autogen.ts! Verify what's there. May be partly built. + +**Provider behavior:** +- `name: "anthropic:memory"`, `label: "ANTHROPIC MEMORY"`, `tier: 1`, `tokenBudget: 100`, `timeoutMs: 200`. +- `isAvailable()`: returns `existsSync(join(projectRoot, "MEMORY.md"))`. +- `resolve()`: parse MEMORY.md (it's structured — Anthropic uses `## Topic` headers per Path A research). Find topic-blocks whose header contains tokens overlapping with current file path or imports. Return top 1-2 blocks. + +**Add to `PROVIDER_PRIORITY` in types.ts:** +```typescript +export const PROVIDER_PRIORITY: readonly string[] = [ + "engram:ast", + "engram:structure", + "engram:mistakes", + "anthropic:memory", // NEW — between mistakes and mempalace + "mempalace", + "context7", + "engram:git", + "obsidian", + "engram:lsp", +]; +``` + +**Tests at `tests/providers/anthropic-memory.test.ts`:** +- No MEMORY.md → returns null, isAvailable false +- MEMORY.md exists, no relevant topic → returns null +- MEMORY.md with topic matching file imports → returns matched block +- MEMORY.md with multiple matching topics → returns top 2 +- Malformed MEMORY.md → returns null (no throw) +- MEMORY.md > 1MB → safe truncate (don't OOM) + +**Pitfall:** MEMORY.md format may evolve. Treat parser as best-effort + add fallback regex if structured parse fails. + +**Effort:** 3-4 days. Includes reverse-engineering Anthropic's MEMORY.md format from a real Claude Code session — capture a sample to `tests/fixtures/anthropic-memory-sample.md` first. + +#### #5 Streaming partial context packets via SSE — 3-4 days + +**Goal:** `resolver.ts` currently waits for ALL providers via `Promise.allSettled`. For SSE-capable consumers (HTTP server `/context` endpoint), emit each provider's result as it arrives. Slow providers (Serena cold-start, mempalace ChromaDB warmup) don't block fast ones from rendering. + +**Files:** +- `src/server/http.ts` — add SSE response path on `/context?stream=1`. Uses `Accept: text/event-stream`. +- `src/providers/resolver.ts` — add `resolveRichPacketStreaming()` that yields per-provider results. Call from HTTP handler. Existing `resolveRichPacket()` stays unchanged (back-compat). + +**SSE event format:** +``` +event: provider +data: {"provider":"engram:ast","content":"...","confidence":1.0} + +event: provider +data: {"provider":"engram:mistakes","content":"...","confidence":0.95} + +event: complete +data: {"providerCount":2,"durationMs":347} +``` + +**MCP protocol alignment:** SEP-1699 specifies SSE resumption with event IDs. Add `id:` line per event. Consumer can `Last-Event-ID` to resume. + +**Tests at `tests/server/http.test.ts`:** +- SSE endpoint emits one event per provider +- Provider order = arrival order (not priority) +- `complete` event after last provider +- Client disconnect mid-stream cancels pending providers +- Resumption with `Last-Event-ID` header skips already-sent providers + +**Pitfall:** Node's `http.ServerResponse` has no native SSE helper. Use `res.write("event: ...\ndata: ...\n\n")` manually + `res.flushHeaders()`. Don't gzip SSE. + +**Effort:** 3-4 days. Mostly HTTP plumbing + new tests. + +#### #6 Serena provider — 3-5 days + +**Goal:** ship `serena-plugin.mjs` as a reference implementation of #2 (plugin contract v2). Demonstrates extensibility AND closes the LSP-precision gap competitively. + +**Files:** +- `docs/plugins/examples/serena-plugin.mjs` — the plugin file (~10 lines, uses `mcpConfig` auto-wrap). +- `tests/integration/serena-provider.test.ts` — integration test (only runs if `uvx` + Serena available; skip otherwise). +- `engram doctor` — add Serena availability check (CLI suggestion if missing). + +**Lazy-start:** Serena spawn is heavy (per-language LSP startup). Don't spawn on first `isAvailable()` check. Spawn on first `resolve()` call, hold connection for session lifetime, kill on engram exit. + +**Fallback:** `astProvider` is already in BUILTIN_PROVIDERS. If Serena absent, AST miner runs. No code change needed for fallback — the resolver already handles missing providers gracefully. + +**Tests:** +- Plugin loads + appears in `engram plugin list` (skip-if-no-serena) +- Provider returns symbol context for a known function +- Provider gracefully handles malformed MCP response +- Doctor check returns `serena: not installed (suggest: uvx --from git+https://github.com/oraios/serena serena --help)` when absent + +**Pitfall:** Serena's MCP server uses a non-standard tool schema. Verify `find_symbol` and `get_symbol_body` actual signatures BEFORE writing tests — capture from `uvx ... --list-tools` output. + +**Effort:** 3-5 days. Real-Serena integration testing eats most of the budget. + +### Pillar 3 — Really help users + +#### #7 Bi-temporal validity on mistakes — 2-3 days + +**Goal:** mistakes carry `validUntil` + `invalidatedByCommit` so refactored code stops triggering stale warnings. + +**Schema migration (new file `src/db/migrations/008-bitemporal-mistakes.sql`):** +```sql +ALTER TABLE nodes ADD COLUMN valid_until INTEGER NULL; +ALTER TABLE nodes ADD COLUMN invalidated_by_commit TEXT NULL; +CREATE INDEX idx_nodes_validity ON nodes(kind, valid_until) + WHERE kind = 'mistake' AND valid_until IS NOT NULL; +``` + +**Schema version bump:** `src/graph/schema.ts` — bump current version, add migration to migrations table. `src/db/migrate.ts` already handles forward migrations + auto-backup (per v2.0 phase 4). + +**Code changes:** +- `src/graph/schema.ts:GraphNode` — add optional `validUntil?: number; invalidatedByCommit?: string`. +- `src/providers/engram-mistakes.ts` — filter: + ```typescript + .filter((n) => n.kind === "mistake") + .filter((n) => !n.validUntil || n.validUntil > Date.now()) + ``` +- `src/miners/git-miner.ts` — when committing changes touch a file containing mistake nodes, set `validUntil = Date.now()` + `invalidatedByCommit = ` on those mistake nodes. + +**Tests:** +- Migration applies cleanly on existing graph +- Mistake without validUntil → still surfaces (back-compat) +- Mistake with validUntil > now → surfaces +- Mistake with validUntil < now → suppressed +- Git miner detects file change → invalidates linked mistakes + +**Effort:** 2-3 days. + +#### #8 Pre-mortem warnings (PreToolUse mistake-guard) — 3-4 days + +**Goal:** opt-in `ENGRAM_MISTAKE_GUARD=1`. Before Claude runs an Edit/Write/Bash that previously caused a mistake, emit a warning via the PreToolUse hook (`deny + reason` pattern, same as Sentinel). + +**New file:** `src/intercept/handlers/mistake-guard.ts` +**Wired into:** `src/intercept/dispatch.ts` (existing PreToolUse dispatcher). + +**Behavior:** +- Read tool call params (Edit/Write file path, Bash command). +- Match against active mistakes (filtered by validUntil from #7). +- If match found AND `ENGRAM_MISTAKE_GUARD=1`: + ``` + decision: "ask" + reason: "engramx mistake-guard: this file/command previously caused: (). See `engram mistakes -p ` for full context." + ``` +- If match AND `ENGRAM_MISTAKE_GUARD=2` (strict): `decision: "deny"`. +- Default OFF — fully opt-in. + +**Match algorithm:** +- Edit/Write: file path overlap with mistake's `sourceFile`. +- Bash: command substring match against mistake's metadata `commandPattern` (new metadata field — empty for legacy mistakes, populated by miners going forward). + +**Tests at `tests/intercept/handlers/mistake-guard.test.ts`:** +- ENV unset → no-op +- ENV=1, no matching mistake → no-op +- ENV=1, matching mistake → decision: "ask" with reason +- ENV=2, matching mistake → decision: "deny" +- Mistake invalidated by validUntil (#7) → no-op + +**Pitfall:** Bash pattern matching can false-positive easily (e.g., `rm -rf /tmp/*` matches `rm -rf node_modules/` mistake). Default to PRECISE matching (full command line) until command-pattern miner is built. False positives kill adoption. + +**Effort:** 3-4 days. + +--- + +## Schema migrations summary + +One migration this release: `008-bitemporal-mistakes.sql` (Pillar 3 #7). + +**Backup contract:** v2.0 already auto-backs up DB on migration. Verify backup-on-migration test still passes after our migration is added. + +--- + +## Test strategy + +| Layer | Test count delta | Notes | +|---|---|---| +| Provider tests | +30 (mcp-client +25, anthropic-memory +6, plugin-v2 +5, etc) | Most net-new tests | +| Resolver tests | +5 (mistakes-boost, per-provider budget) | Existing suite extended | +| Schema migration | +3 | Migrate-on-existing, fresh-init, rollback | +| Server tests | +8 (SSE) | New transport | +| Intercept handler | +6 (mistake-guard) | New handler | +| Integration | +5 (serena, end-to-end packet w/ mcp providers) | Skip-if-not-available | +| Snapshot total | 771 → ~830 (then +60 stretch from edge cases) | Target 900 | + +CI matrix unchanged: Ubuntu + Windows × Node 20 + 22. + +**Windows-first discipline (lesson from v2.1.0):** every new file path string MUST go through `path.resolve()` or `path.join()`. No string concatenation. Add CI step that runs Windows tests in CI per-PR, not just per-release. + +--- + +## Release checklist (run in this order) + +- [ ] All 12 scope items merged into `feat/v3.0-spine` +- [ ] Test count ≥ 900, CI green Ubuntu+Windows × Node 20+22 +- [ ] CHANGELOG.md updated with full diff +- [ ] README.md hero updated to reflect 3.0 features +- [ ] Migration test: install v2.1.0 → upgrade to 3.0 → verify graph DB migrates cleanly +- [ ] Smoke test on fresh `/tmp/engram-3.0-smoke-$$/` clean install +- [ ] Smoke test on real project (engram itself, agent-viewer, cirvgreen-website) +- [ ] `npm pack` size check — should not balloon vs 2.1 (target < 8MB tarball) +- [ ] `engram doctor` lights up green on all 3 machines (laptop + CT 300 + CT 100) +- [ ] Submit to Official MCP Registry (#12) +- [ ] `npm login` + `npm publish --otp=<6-digit>` (READ-ONLY token from v2.0.2 incident still in env — fresh login required) +- [ ] Tag `v3.0.0` + GitHub Release with notes +- [ ] LinkedIn launch post + Reddit + HN +- [ ] Update SESSION-HANDOFF.md at strategy folder root + +--- + +## Rollback plan + +If 3.0 ships with a regression-blocker: +1. Tag the bug + open hotfix branch from `v3.0.0` tag +2. Ship `engramx@3.0.1` patch +3. If unfixable in <3 days: `npm deprecate engramx@3.0.0 "use 3.0.1+ for fix"` + advise downgrade to 2.1.0 + +Migration is forward-only. Users cannot downgrade `~/.engram/graph.db` to schema v7. **Document this in CHANGELOG.** Backup is auto-saved at migration time (v2.0 phase 4 feature) — recovery path is restore-backup + reinstall 2.1.0. + +--- + +## Things explicitly out of scope (preserved here so future-Nick remembers) + +- IDE extensions (VSCode, JetBrains, Cursor, Continue, Zed) — covered by their MCP clients +- Marketplace listings beyond MCP Registry +- Hosted / Pro / freemium tier +- Cross-project shared mistakes DB +- Local docs indexing (Context7 alternative) +- LLM-assisted observation compression +- `engram budget` rate-limit forecaster +- `engram handoff` subagent protocol +- Bundled MCP providers beyond Serena (GitHub MCP, Sentry MCP, etc. ship post-3.0 as plugin-contract proof) +- Rebrand to `@engram/landmines` + +These are all in `~/Desktop/Projects/Engram/00-strategy/decisions/2026-04-24-single-release-vs-staircase.md` "Trade-offs accepted." + +--- + +## What starts NOW + +1. Write this spec ✓ +2. Cut integration branch `feat/v3.0-spine` (next bash command) +3. Cut `docs/v3.0-readme-disarm` + write item #11 +4. Cut `chore/v3.0-agents-md-default` + start item #10 +5. Comment on PR #6 asking @mechtar-ru to rebase +6. Cut `feat/v3.0-pillar2-auto-memory` + start item #4 (highest urgency) + +Items 3, 4, 5 are non-blocking and should run in parallel with item 6. diff --git a/src/autogen.ts b/src/autogen.ts index 8d4a869..8fd6d32 100644 --- a/src/autogen.ts +++ b/src/autogen.ts @@ -359,7 +359,15 @@ export function writeToFile(filePath: string, summary: string): void { /** * Auto-generate AI instructions for a project. - * Detects which instruction file exists and updates it. + * + * v3.0 behavior: when no explicit `target` is given, emits BOTH `CLAUDE.md` + * AND `AGENTS.md` so the same project works in Claude Code AND in any + * AGENTS.md-aware tool (Codex CLI, Cursor, Windsurf, Copilot Chat, + * JetBrains Junie, Antigravity). Existing `.cursorrules` is also updated + * if present so legacy Cursor users aren't broken. + * + * Explicit `target` (claude / cursor / agents) preserves single-file + * behavior for users who want it. * * v0.2: optional `task` selects a preset View from VIEWS. Unknown task * names throw with a descriptive error listing the valid keys. @@ -368,7 +376,7 @@ export async function autogen( projectRoot: string, target?: "claude" | "cursor" | "agents", task?: string -): Promise<{ file: string; nodesIncluded: number; view: string }> { +): Promise<{ files: string[]; nodesIncluded: number; view: string }> { const { getStore } = await import("./core.js"); const store = await getStore(projectRoot); @@ -388,28 +396,33 @@ export async function autogen( const summary = generateSummary(store, view); const stats = store.getStats(); - let targetFile: string; + const targetFiles: string[] = []; if (target === "claude") { - targetFile = join(projectRoot, "CLAUDE.md"); + targetFiles.push(join(projectRoot, "CLAUDE.md")); } else if (target === "cursor") { - targetFile = join(projectRoot, ".cursorrules"); + targetFiles.push(join(projectRoot, ".cursorrules")); } else if (target === "agents") { - targetFile = join(projectRoot, "AGENTS.md"); + targetFiles.push(join(projectRoot, "AGENTS.md")); } else { - // Auto-detect - if (existsSync(join(projectRoot, "CLAUDE.md"))) { - targetFile = join(projectRoot, "CLAUDE.md"); - } else if (existsSync(join(projectRoot, ".cursorrules"))) { - targetFile = join(projectRoot, ".cursorrules"); - } else if (existsSync(join(projectRoot, "AGENTS.md"))) { - targetFile = join(projectRoot, "AGENTS.md"); - } else { - targetFile = join(projectRoot, "CLAUDE.md"); + // Auto: emit BOTH CLAUDE.md AND AGENTS.md. AGENTS.md is the Linux + // Foundation universal standard (Codex/Cursor/Windsurf/Copilot/Junie); + // CLAUDE.md remains the canonical Claude Code instruction file. + // Both must be kept in sync — single-source-of-truth via the same + // generated `summary`. + targetFiles.push(join(projectRoot, "CLAUDE.md")); + targetFiles.push(join(projectRoot, "AGENTS.md")); + // If a legacy .cursorrules exists, update it too — don't break + // existing Cursor users who haven't migrated to AGENTS.md. + const cursorRules = join(projectRoot, ".cursorrules"); + if (existsSync(cursorRules)) { + targetFiles.push(cursorRules); } } - writeToFile(targetFile, summary); - return { file: targetFile, nodesIncluded: stats.nodes, view: view.name }; + for (const f of targetFiles) { + writeToFile(f, summary); + } + return { files: targetFiles, nodesIncluded: stats.nodes, view: view.name }; } finally { store.close(); } diff --git a/src/cli.ts b/src/cli.ts index e327e49..4c43f67 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -630,9 +630,14 @@ hooks // ── autogen ───────────────────────────────────────────────────────────────── program .command("gen") - .description("Generate CLAUDE.md / .cursorrules section from graph") + .description( + "Generate CLAUDE.md + AGENTS.md (default) or a single file via --target" + ) .option("-p, --project ", "Project directory", ".") - .option("-t, --target ", "Target file: claude, cursor, agents") + .option( + "-t, --target ", + "Single-file target: claude, cursor, agents. Default: emit both CLAUDE.md and AGENTS.md." + ) .option( "--task ", "Task-aware view: general (default), bug-fix, feature, refactor" @@ -641,9 +646,10 @@ program async (opts: { project: string; target?: string; task?: string }) => { const target = opts.target as "claude" | "cursor" | "agents" | undefined; const result = await autogen(opts.project, target, opts.task); + const fileList = result.files.map((f) => chalk.bold(f)).join(", "); console.log( chalk.green( - `✅ Updated ${result.file} (${result.nodesIncluded} nodes, view: ${result.view})` + `✅ Updated ${fileList} (${result.nodesIncluded} nodes, view: ${result.view})` ) ); } diff --git a/tests/autogen.test.ts b/tests/autogen.test.ts index 8b6fe1a..51d462a 100644 --- a/tests/autogen.test.ts +++ b/tests/autogen.test.ts @@ -439,3 +439,71 @@ describe("autogen() — task flag", () => { }); }); +describe("autogen() — v3.0 dual-emit (CLAUDE.md + AGENTS.md by default)", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = mkdtempSync(join(tmpdir(), "engram-autogen-dual-")); + mkdirSync(join(tmpDir, "src"), { recursive: true }); + writeFileSync( + join(tmpDir, "src", "index.ts"), + `export function main() { return 42; }\n` + ); + await init(tmpDir); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("auto target (no flag) emits BOTH CLAUDE.md and AGENTS.md", async () => { + const result = await autogen(tmpDir); + expect(result.files).toHaveLength(2); + expect(result.files).toEqual( + expect.arrayContaining([ + join(tmpDir, "CLAUDE.md"), + join(tmpDir, "AGENTS.md"), + ]) + ); + expect(existsSync(join(tmpDir, "CLAUDE.md"))).toBe(true); + expect(existsSync(join(tmpDir, "AGENTS.md"))).toBe(true); + }); + + it("auto target also updates legacy .cursorrules if present", async () => { + writeFileSync(join(tmpDir, ".cursorrules"), "# legacy cursor rules\n"); + const result = await autogen(tmpDir); + expect(result.files).toHaveLength(3); + expect(result.files).toEqual( + expect.arrayContaining([ + join(tmpDir, "CLAUDE.md"), + join(tmpDir, "AGENTS.md"), + join(tmpDir, ".cursorrules"), + ]) + ); + }); + + it("explicit --target=claude emits ONLY CLAUDE.md", async () => { + const result = await autogen(tmpDir, "claude"); + expect(result.files).toEqual([join(tmpDir, "CLAUDE.md")]); + expect(existsSync(join(tmpDir, "AGENTS.md"))).toBe(false); + }); + + it("explicit --target=agents emits ONLY AGENTS.md", async () => { + const result = await autogen(tmpDir, "agents"); + expect(result.files).toEqual([join(tmpDir, "AGENTS.md")]); + expect(existsSync(join(tmpDir, "CLAUDE.md"))).toBe(false); + }); + + it("CLAUDE.md and AGENTS.md contain identical engram-generated content", async () => { + await autogen(tmpDir); + const claudeContent = readFileSync(join(tmpDir, "CLAUDE.md"), "utf-8"); + const agentsContent = readFileSync(join(tmpDir, "AGENTS.md"), "utf-8"); + // Both should have the same engram block (single source of truth) + const claudeBlock = claudeContent.match(/[\s\S]*/)?.[0]; + const agentsBlock = agentsContent.match(/[\s\S]*/)?.[0]; + expect(claudeBlock).toBeDefined(); + expect(agentsBlock).toBeDefined(); + expect(claudeBlock).toEqual(agentsBlock); + }); +}); + From 7480748d346036f14227bd9db41f2332ac723414 Mon Sep 17 00:00:00 2001 From: Nicholas Ashkar Date: Fri, 24 Apr 2026 09:50:46 +0400 Subject: [PATCH 02/18] v3.0(item #7): bi-temporal validity for mistake nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item #7 from v3.0 Spine implementation plan. Adds two optional columns to nodes table via migration #8: - valid_until INTEGER NULL — unix-ms timestamp after which the node should NO LONGER surface in context (NULL = still valid; the back-compat default for all existing rows) - invalidated_by_commit TEXT NULL — git SHA that triggered invalidation, for audit + future 'why did this mistake stop firing' UX Plus a partial index idx_nodes_validity ON (kind, valid_until) WHERE kind = 'mistake' AND valid_until IS NOT NULL — only mistakes with an explicit window pay storage cost. The engram:mistakes provider (src/providers/engram-mistakes.ts) now filters out invalidated mistakes (validUntil <= Date.now()). This fixes the long-standing 'refactored function still triggers old mistake warning' gap (Graphiti-inspired bi-temporal model). Migration runner extended to support function-based migrations. Migration 8 uses addColumnIfMissing() helper because SQLite's ALTER TABLE ADD COLUMN isn't natively idempotent (raises duplicate column name on re-run). Uses PRAGMA table_info to pre-check. Schema version: 7 → 8 (auto-backup on upgrade, existing v2.0+ behavior). Tests: - +13 new (5 in tests/db/migrate.test.ts for migration 8 schema + 8 in tests/providers/engram-mistakes.test.ts for filter behavior + audit-trail round-trip + boundary cases) - Full suite: 771 → 784 tests, all passing - TypeScript clean, lint clean Wiring for the git miner to SET valid_until / invalidated_by_commit when source files change comes later — the plumbing exists, the producer side is item #8 (pre-mortem warnings) prerequisites. --- src/db/migrate.ts | 71 +++++++- src/graph/schema.ts | 14 ++ src/graph/store.ts | 16 +- src/providers/engram-mistakes.ts | 8 +- tests/db/migrate.test.ts | 20 +++ tests/providers/engram-mistakes.test.ts | 218 ++++++++++++++++++++++++ 6 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 tests/providers/engram-mistakes.test.ts diff --git a/src/db/migrate.ts b/src/db/migrate.ts index efa0b17..5b87546 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -15,7 +15,7 @@ export interface MigrationResult { } /** Current schema version — bump this when adding new migrations. */ -export const CURRENT_SCHEMA_VERSION = 7; +export const CURRENT_SCHEMA_VERSION = 8; export interface RollbackResult { readonly fromVersion: number; @@ -34,6 +34,11 @@ export interface RollbackResult { * automatic. Forward migrations are append-only and idempotent. */ const DOWN_MIGRATIONS: Record = { + // v3.0: bi-temporal mistake validity. SQLite only added DROP COLUMN in + // 3.35 (2021); older sql.js builds may not support it. We don't depend + // on the columns being absent — leaving them in place is safe. The index + // CAN be dropped cleanly. + 8: `DROP INDEX IF EXISTS idx_nodes_validity;`, 7: `DROP TABLE IF EXISTS query_cache; DROP TABLE IF EXISTS pattern_cache;`, 6: `DROP TABLE IF EXISTS engram_config;`, 5: `DROP TABLE IF EXISTS provider_cache;`, @@ -44,12 +49,42 @@ const DOWN_MIGRATIONS: Record = { 1: `DROP TABLE IF EXISTS stats; DROP TABLE IF EXISTS edges; DROP TABLE IF EXISTS nodes;`, }; +/** + * A migration step is either: + * - a SQL string (run verbatim — must be self-idempotent, e.g. CREATE TABLE + * IF NOT EXISTS) — used for migrations 1-7 + * - a function that receives the db handle and runs custom logic, used when + * SQLite syntax isn't natively idempotent (e.g. ALTER TABLE ADD COLUMN + * raises 'duplicate column name' on re-run) + */ +type MigrationStep = + | string + | ((db: ExecDb) => void); + +/** + * Add a column to an existing table only if it isn't already present. + * SQLite (pre-3.35) has no ADD COLUMN IF NOT EXISTS, so we check + * PRAGMA table_info first. Safe to re-run. + */ +function addColumnIfMissing( + db: ExecDb, + table: string, + column: string, + ddl: string +): void { + const result = db.exec(`PRAGMA table_info(${table})`); + const existing = (result[0]?.values ?? []).map((row) => row[1] as string); + if (!existing.includes(column)) { + db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`); + } +} + /** * Migration definitions — each runs only once, in order. * Migrations 1-5 are retroactive: they document the existing schema using * CREATE TABLE IF NOT EXISTS so they are safe to run on existing databases. */ -const MIGRATIONS: Record = { +const MIGRATIONS: Record = { // v0.1.0: Initial schema 1: ` CREATE TABLE IF NOT EXISTS nodes ( @@ -127,6 +162,28 @@ CREATE TABLE IF NOT EXISTS pattern_cache ( hit_count INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_query_cache_file ON query_cache(file_path);`, + + // v3.0.0: Bi-temporal validity for mistake nodes (and any other node kind + // that wants it). `valid_until` is the unix-ms timestamp after which the + // mistake should NO LONGER surface in context (e.g. the referenced code + // was refactored away). NULL = still valid (back-compat default for all + // existing rows). `invalidated_by_commit` records the git SHA that caused + // the invalidation, for audit + future "explain why this mistake stopped + // firing" UX. Index is partial — only mistakes with an explicit validity + // window pay storage cost. + // + // Function-based because ALTER TABLE ADD COLUMN isn't idempotent in + // SQLite — re-running on a db that already has the columns throws + // 'duplicate column name'. We pre-check via PRAGMA table_info. + 8: (db: ExecDb) => { + addColumnIfMissing(db, "nodes", "valid_until", "valid_until INTEGER"); + addColumnIfMissing(db, "nodes", "invalidated_by_commit", "invalidated_by_commit TEXT"); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_nodes_validity + ON nodes(kind, valid_until) + WHERE kind = 'mistake' AND valid_until IS NOT NULL; + `); + }, }; type ExecDb = { exec: (sql: string) => Array<{ values: unknown[][] }> }; @@ -180,9 +237,13 @@ export function runMigrations(db: RunDb, dbPath: string): MigrationResult { // Run each pending migration in order let migrationsRun = 0; for (let v = fromVersion + 1; v <= CURRENT_SCHEMA_VERSION; v++) { - const sql = MIGRATIONS[v]; - if (sql) { - (db as unknown as ExecDb).exec(sql); + const step = MIGRATIONS[v]; + if (step) { + if (typeof step === "string") { + (db as unknown as ExecDb).exec(step); + } else { + step(db as unknown as ExecDb); + } migrationsRun++; } } diff --git a/src/graph/schema.ts b/src/graph/schema.ts index a77f5d1..25e2db5 100644 --- a/src/graph/schema.ts +++ b/src/graph/schema.ts @@ -13,6 +13,20 @@ export interface GraphNode { readonly lastVerified: number; // unix ms readonly queryCount: number; readonly metadata: Record; + /** + * v3.0 bi-temporal validity (primarily for `mistake` nodes). + * Unix-ms timestamp after which this node should NO LONGER surface in + * context (e.g. the referenced code was refactored away). `undefined` + * means "still valid" — this is the default for all existing rows and + * for newly-mined mistakes that haven't been invalidated yet. + */ + readonly validUntil?: number; + /** + * v3.0 audit trail. The git commit SHA that triggered invalidation + * (set by the git miner when it detects the source file changed). + * `undefined` if never invalidated. + */ + readonly invalidatedByCommit?: string; } export type NodeKind = diff --git a/src/graph/store.ts b/src/graph/store.ts index 5b7492b..0efad5d 100644 --- a/src/graph/store.ts +++ b/src/graph/store.ts @@ -106,8 +106,8 @@ export class GraphStore { upsertNode(node: GraphNode): void { this.db.run( - `INSERT OR REPLACE INTO nodes (id, label, kind, source_file, source_location, confidence, confidence_score, last_verified, query_count, metadata) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR REPLACE INTO nodes (id, label, kind, source_file, source_location, confidence, confidence_score, last_verified, query_count, metadata, valid_until, invalidated_by_commit) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ node.id, node.label, @@ -119,6 +119,8 @@ export class GraphStore { node.lastVerified, node.queryCount, JSON.stringify(node.metadata), + node.validUntil ?? null, + node.invalidatedByCommit ?? null, ] ); } @@ -577,6 +579,8 @@ export class GraphStore { } private rowToNode(row: Record): GraphNode { + const validUntilRaw = row.valid_until; + const invalidatedByRaw = row.invalidated_by_commit; return { id: row.id as string, label: row.label as string, @@ -588,6 +592,14 @@ export class GraphStore { lastVerified: (row.last_verified as number) ?? 0, queryCount: (row.query_count as number) ?? 0, metadata: JSON.parse((row.metadata as string) || "{}"), + validUntil: + validUntilRaw === null || validUntilRaw === undefined + ? undefined + : (validUntilRaw as number), + invalidatedByCommit: + invalidatedByRaw === null || invalidatedByRaw === undefined + ? undefined + : (invalidatedByRaw as string), }; } diff --git a/src/providers/engram-mistakes.ts b/src/providers/engram-mistakes.ts index 7e17a0a..c1363c6 100644 --- a/src/providers/engram-mistakes.ts +++ b/src/providers/engram-mistakes.ts @@ -21,9 +21,15 @@ export const mistakesProvider: ContextProvider = { try { const store = await getStore(context.projectRoot); try { + const now = Date.now(); const allMistakes = store .getNodesByFile(filePath) - .filter((n) => n.kind === "mistake"); + .filter((n) => n.kind === "mistake") + // v3.0 bi-temporal: hide mistakes whose source code has been + // refactored away (`validUntil` set by the git miner when it + // detected the source file changed). `validUntil === undefined` + // = still valid (back-compat for all v2.x mistakes). + .filter((n) => n.validUntil === undefined || n.validUntil > now); if (allMistakes.length === 0) return null; diff --git a/tests/db/migrate.test.ts b/tests/db/migrate.test.ts index c428169..984065b 100644 --- a/tests/db/migrate.test.ts +++ b/tests/db/migrate.test.ts @@ -108,4 +108,24 @@ describe("migrate", () => { expect(result.backedUp).toBe(true); expect(existsSync(`${dbPath}.bak-v5`)).toBe(true); }); + + // ── v3.0 migration 8 — bi-temporal mistake validity ──────────────────── + describe("migration 8: bi-temporal mistake validity", () => { + it("nodes table has valid_until + invalidated_by_commit columns after migration", () => { + const db = getRawDb(store); + const result = db.exec("PRAGMA table_info(nodes)"); + const columnNames = (result[0]?.values ?? []).map((row) => row[1] as string); + expect(columnNames).toContain("valid_until"); + expect(columnNames).toContain("invalidated_by_commit"); + }); + + it("idx_nodes_validity exists after migration", () => { + const db = getRawDb(store); + const result = db.exec( + "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_nodes_validity'" + ); + expect(result.length).toBeGreaterThan(0); + expect(result[0].values[0][0]).toBe("idx_nodes_validity"); + }); + }); }); diff --git a/tests/providers/engram-mistakes.test.ts b/tests/providers/engram-mistakes.test.ts new file mode 100644 index 0000000..4b6dac3 --- /dev/null +++ b/tests/providers/engram-mistakes.test.ts @@ -0,0 +1,218 @@ +/** + * Tests for the engram:mistakes context provider. + * + * v3.0 adds bi-temporal validity filtering — mistakes whose source code + * has been refactored away (validUntil <= now) are suppressed even + * though the mistake row still exists in the graph. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mistakesProvider } from "../../src/providers/engram-mistakes.js"; +import { init } from "../../src/core.js"; +import { GraphStore } from "../../src/graph/store.js"; +import type { GraphNode } from "../../src/graph/schema.js"; +import type { NodeContext } from "../../src/providers/types.js"; + +function makeMistake(opts: { + id: string; + sourceFile: string; + label: string; + lastVerified?: number; + validUntil?: number; + invalidatedByCommit?: string; +}): GraphNode { + return { + id: opts.id, + label: opts.label, + kind: "mistake", + sourceFile: opts.sourceFile, + sourceLocation: null, + confidence: "INFERRED", + confidenceScore: 0.6, + lastVerified: opts.lastVerified ?? Date.now(), + queryCount: 0, + metadata: { miner: "test" }, + validUntil: opts.validUntil, + invalidatedByCommit: opts.invalidatedByCommit, + }; +} + +function makeNodeContext(filePath: string, projectRoot: string): NodeContext { + return { + filePath, + projectRoot, + nodeIds: [], + imports: [], + hasTests: false, + churnRate: 0, + }; +} + +describe("engram:mistakes provider — v3.0 bi-temporal filtering", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = mkdtempSync(join(tmpdir(), "engram-mistakes-bt-")); + mkdirSync(join(tmpDir, "src"), { recursive: true }); + writeFileSync( + join(tmpDir, "src", "auth.ts"), + `export function authenticate() {}\n` + ); + await init(tmpDir); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + async function seedMistakes(nodes: GraphNode[]): Promise { + const dbPath = join(tmpDir, ".engram", "graph.db"); + const store = await GraphStore.open(dbPath); + try { + for (const n of nodes) store.upsertNode(n); + store.save(); + } finally { + store.close(); + } + } + + it("surfaces a mistake with no validUntil (back-compat: still valid)", async () => { + await seedMistakes([ + makeMistake({ + id: "m1", + sourceFile: "src/auth.ts", + label: "JWT secret hardcoded", + }), + ]); + + const result = await mistakesProvider.resolve( + "src/auth.ts", + makeNodeContext("src/auth.ts", tmpDir) + ); + + expect(result).not.toBeNull(); + expect(result!.content).toContain("JWT secret hardcoded"); + }); + + it("surfaces a mistake whose validUntil is in the future (still valid)", async () => { + const future = Date.now() + 60_000; // 1 minute from now + await seedMistakes([ + makeMistake({ + id: "m2", + sourceFile: "src/auth.ts", + label: "Race condition in login", + validUntil: future, + }), + ]); + + const result = await mistakesProvider.resolve( + "src/auth.ts", + makeNodeContext("src/auth.ts", tmpDir) + ); + + expect(result).not.toBeNull(); + expect(result!.content).toContain("Race condition in login"); + }); + + it("suppresses a mistake whose validUntil is in the past (invalidated)", async () => { + const past = Date.now() - 60_000; // 1 minute ago + await seedMistakes([ + makeMistake({ + id: "m3", + sourceFile: "src/auth.ts", + label: "Old typo bug", + validUntil: past, + invalidatedByCommit: "abc1234", + }), + ]); + + const result = await mistakesProvider.resolve( + "src/auth.ts", + makeNodeContext("src/auth.ts", tmpDir) + ); + + // No valid mistakes left → provider returns null + expect(result).toBeNull(); + }); + + it("filters out invalidated mistakes but keeps valid ones in the same file", async () => { + const past = Date.now() - 1000; + const future = Date.now() + 60_000; + await seedMistakes([ + makeMistake({ + id: "m4", + sourceFile: "src/auth.ts", + label: "Stale: wrong return type", + validUntil: past, + }), + makeMistake({ + id: "m5", + sourceFile: "src/auth.ts", + label: "Active: missing input validation", + validUntil: future, + }), + makeMistake({ + id: "m6", + sourceFile: "src/auth.ts", + label: "Eternal: SQL injection vector", + }), + ]); + + const result = await mistakesProvider.resolve( + "src/auth.ts", + makeNodeContext("src/auth.ts", tmpDir) + ); + + expect(result).not.toBeNull(); + expect(result!.content).toContain("Active: missing input validation"); + expect(result!.content).toContain("Eternal: SQL injection vector"); + expect(result!.content).not.toContain("Stale: wrong return type"); + }); + + it("validUntil exactly equal to now → suppressed (boundary)", async () => { + // Set validUntil to NOW (or 1ms in the past to avoid clock-jitter races) + const justExpired = Date.now() - 1; + await seedMistakes([ + makeMistake({ + id: "m7", + sourceFile: "src/auth.ts", + label: "Just-expired mistake", + validUntil: justExpired, + }), + ]); + + const result = await mistakesProvider.resolve( + "src/auth.ts", + makeNodeContext("src/auth.ts", tmpDir) + ); + + expect(result).toBeNull(); + }); + + it("invalidatedByCommit is round-tripped through the store", async () => { + await seedMistakes([ + makeMistake({ + id: "m8", + sourceFile: "src/auth.ts", + label: "audit-trail mistake", + validUntil: Date.now() - 1000, + invalidatedByCommit: "deadbeef", + }), + ]); + + // Read directly via store to confirm the audit field round-trips + const dbPath = join(tmpDir, ".engram", "graph.db"); + const store = await GraphStore.open(dbPath); + try { + const nodes = store.getNodesByFile("src/auth.ts"); + const m8 = nodes.find((n) => n.id === "m8"); + expect(m8).toBeDefined(); + expect(m8!.invalidatedByCommit).toBe("deadbeef"); + expect(m8!.validUntil).toBeLessThan(Date.now()); + } finally { + store.close(); + } + }); +}); From b4f7944f959960e6d9db8dafdfe903f4a6ba596c Mon Sep 17 00:00:00 2001 From: Evgeniy Tikhomirov Date: Fri, 17 Apr 2026 06:43:01 +0300 Subject: [PATCH 03/18] fix: prevent OOM crash with MAX_DEPTH limit and .engramignore support - Add MAX_DEPTH=100 to prevent stack overflow on deep directory trees - Wrap readdirSync in try-catch to skip unreadable directories - Add .engramignore support for custom exclusions - Expand default exclusions (target, .venv, .next, .nuxt, .output, coverage, .turbo, .cache) --- src/miners/ast-miner.ts | 58 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/src/miners/ast-miner.ts b/src/miners/ast-miner.ts index 817bc3f..2aa6173 100644 --- a/src/miners/ast-miner.ts +++ b/src/miners/ast-miner.ts @@ -504,6 +504,42 @@ function getPatterns(lang: string): LangPatterns { } } +const MAX_DEPTH = 100; + +const DEFAULT_EXCLUDED_DIRS = new Set([ + "node_modules", + "dist", + "build", + "__pycache__", + "vendor", + ".engram", + "target", + ".venv", + ".next", + ".nuxt", + ".output", + "coverage", + ".turbo", + ".cache", +]); + +function loadEngramIgnore(rootDir: string): Set { + const ignoreFile = join(rootDir, ".engramignore"); + const excluded = new Set(DEFAULT_EXCLUDED_DIRS); + try { + const content = readFileSync(ignoreFile, "utf-8"); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith("#")) { + excluded.add(trimmed); + } + } + } catch { + // no .engramignore file + } + return excluded; +} + /** * Scan a directory recursively and extract all supported code files. */ @@ -588,24 +624,34 @@ export function extractDirectory( return false; } - function walk(dir: string): void { - // Symlink loop protection + // MAX_DEPTH guard prevents stack overflow + runaway recursion on + // pathological directory trees (symlink cycles that escape the visitedDirs + // check, deliberately-deep scratch dirs, etc.). Credit: PR #6 / mechtar-ru. + function walk(dir: string, depth: number): void { + if (depth > MAX_DEPTH) return; + let realDir: string; try { realDir = realpathSync(dir); } catch { - return; // broken symlink + return; } if (visitedDirs.has(realDir)) return; visitedDirs.add(realDir); - const entries = readdirSync(dir, { withFileTypes: true }); + let entries: ReturnType; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { if (shouldSkipDir(entry.name)) continue; - walk(fullPath); + walk(fullPath, depth + 1); continue; } @@ -640,6 +686,6 @@ export function extractDirectory( } } - walk(dirPath); + walk(dirPath, 0); return { nodes: allNodes, edges: allEdges, fileCount, totalLines, mtimes, skippedCount }; } From 411ad13ed3a9672477b87af1ef57dc9b28f4b672 Mon Sep 17 00:00:00 2001 From: Evgeniy Tikhomirov Date: Fri, 17 Apr 2026 06:59:51 +0300 Subject: [PATCH 04/18] fix: prevent OOM in mineGitHistory with MAX_FILES_PER_COMMIT limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MAX_FILES_PER_COMMIT=50 to prevent O(n²) explosion on commits with many files - Skip build/dist directories to reduce noise - Axolotl project has commits with 130 files which caused 8,385+ co-change pairs --- src/miners/git-miner.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/miners/git-miner.ts b/src/miners/git-miner.ts index e9a26b0..ddd2d93 100644 --- a/src/miners/git-miner.ts +++ b/src/miners/git-miner.ts @@ -73,6 +73,10 @@ export function mineGitHistory( const authorMap = new Map>(); const commitBlocks = log.split("\n\n").filter(Boolean); + // Skip build/dist directories to avoid explosion of co-change pairs + const SKIP_PREFIXES = ["dist/", "build/", "node_modules/", ".venv/", "target/", "coverage/"]; + const MAX_FILES_PER_COMMIT = 50; // Prevent O(n²) explosion + for (const block of commitBlocks) { const lines = block.split("\n").filter(Boolean); if (lines.length === 0) continue; @@ -82,14 +86,20 @@ export function mineGitHistory( if (parts.length < 3) continue; const author = parts[1]; - const files = fileLines.filter( + let files = fileLines.filter( (f) => f.length > 0 && !f.includes("|") && !f.startsWith(" ") && - f.includes(".") + f.includes(".") && + !SKIP_PREFIXES.some((p) => f.startsWith(p)) ); + // Limit files per commit to prevent O(n²) explosion + if (files.length > MAX_FILES_PER_COMMIT) { + files = files.slice(0, MAX_FILES_PER_COMMIT); + } + // Track file change frequency for (const file of files) { fileChangeCount.set(file, (fileChangeCount.get(file) ?? 0) + 1); From 0cf839a46ad4bca2bee11649062a86c9a9236b4a Mon Sep 17 00:00:00 2001 From: Nicholas Ashkar Date: Fri, 24 Apr 2026 15:45:21 +0400 Subject: [PATCH 05/18] v3.0(item #9 cleanup): remove duplicate ignore-logic after PR #6 merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to cherry-picks b4f7944 (PR #6 commit 1) + 411ad13 (PR #6 commit 2) from @mechtar-ru. Deletes redundant DEFAULT_EXCLUDED_DIRS (15 entries) + loadEngramIgnore() (16 lines) that lived in parallel with the canonical DEFAULT_SKIP_DIRS + loadIgnorePatterns() pair (the latter shipped in v2.1.0 via PR #13). Both pairs implemented the same .engramignore feature — keeping only the v2.1 canonical pair keeps one source of truth. Also tightens entries typing: 'let entries: Dirent[]' in extractDirectory (ReturnType resolves to the string[] default overload, not the Dirent[] shape actually returned with { withFileTypes: true }). All 784 tests pass. TypeScript clean. Closes issue #5 (via PR #6 content: MAX_DEPTH=100 + MAX_FILES_PER_COMMIT=50 + .engramignore support + expanded default skip dirs — the OOM crash on init for 2.2GB/34K-file projects like Axolotl is fixed). --- src/miners/ast-miner.ts | 43 +++++++---------------------------------- 1 file changed, 7 insertions(+), 36 deletions(-) diff --git a/src/miners/ast-miner.ts b/src/miners/ast-miner.ts index 2aa6173..1857723 100644 --- a/src/miners/ast-miner.ts +++ b/src/miners/ast-miner.ts @@ -3,7 +3,7 @@ * Zero LLM cost. Extracts classes, functions, imports, call graphs. * Supports: TypeScript, JavaScript, Python, Go, Rust, Java, C, C++, Ruby, PHP */ -import { readFileSync, existsSync, readdirSync, realpathSync, statSync } from "node:fs"; +import { readFileSync, existsSync, readdirSync, realpathSync, statSync, type Dirent } from "node:fs"; import { basename, extname, join, relative } from "node:path"; import type { GraphEdge, GraphNode } from "../graph/schema.js"; import { toPosixPath } from "../graph/path-utils.js"; @@ -506,42 +506,13 @@ function getPatterns(lang: string): LangPatterns { const MAX_DEPTH = 100; -const DEFAULT_EXCLUDED_DIRS = new Set([ - "node_modules", - "dist", - "build", - "__pycache__", - "vendor", - ".engram", - "target", - ".venv", - ".next", - ".nuxt", - ".output", - "coverage", - ".turbo", - ".cache", -]); - -function loadEngramIgnore(rootDir: string): Set { - const ignoreFile = join(rootDir, ".engramignore"); - const excluded = new Set(DEFAULT_EXCLUDED_DIRS); - try { - const content = readFileSync(ignoreFile, "utf-8"); - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (trimmed && !trimmed.startsWith("#")) { - excluded.add(trimmed); - } - } - } catch { - // no .engramignore file - } - return excluded; -} - /** * Scan a directory recursively and extract all supported code files. + * + * NOTE: an earlier `DEFAULT_EXCLUDED_DIRS` + `loadEngramIgnore` pair + * lived here as a parallel implementation of the same feature shipped + * separately as `DEFAULT_SKIP_DIRS` + `loadIgnorePatterns` (below). + * They were redundant and the cleaner pair is canonical — removed. */ /** Default directories always skipped during extraction. */ const DEFAULT_SKIP_DIRS = new Set([ @@ -639,7 +610,7 @@ export function extractDirectory( if (visitedDirs.has(realDir)) return; visitedDirs.add(realDir); - let entries: ReturnType; + let entries: Dirent[]; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { From c719591faab00dc3d020b3220b4adaf0959b56e6 Mon Sep 17 00:00:00 2001 From: Nicholas Ashkar Date: Fri, 24 Apr 2026 16:09:04 +0400 Subject: [PATCH 06/18] v3.0(item #1 foundation): generic MCP-client aggregator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First land of the MCP-client subsystem (item #1 from the v3.0 Spine implementation plan). Any MCP server can now become an engramx Context Spine provider via ~/.engram/mcp-providers.json — no code changes needed. WHAT SHIPS IN THIS COMMIT src/providers/mcp-config.ts - McpProviderConfig type (stdio + http transports, tools array with arg templates, tokenBudget, timeoutMs, cacheTtlSec, priority, enabled) - loadMcpConfigs(): reads ~/.engram/mcp-providers.json (path overridable via ENGRAM_MCP_CONFIG_PATH for tests). Per-entry validation errors are COLLECTED not thrown — one bad provider never stops the rest. - validateProviderConfig(): strict structural validator with precise error messages (tells you which field on which entry failed) - applyArgTemplate(): substitutes {filePath}/{projectRoot}/{imports}/ {fileBasename} tokens into tool args. Unknown tokens pass through. - Defaults: tokenBudget=200, timeoutMs=2000, cacheTtlSec=3600, priority from array order. Sensible for every MCP server we've seen. src/providers/mcp-client.ts - McpClientWrapper — thin wrapper on @modelcontextprotocol/sdk v1.29 Client + StdioClientTransport. Session-lifetime connection reuse. Lazy connect (no process spawned until first resolve). Error backoff (30s) prevents thrashing if the server crashes on startup. - createMcpProvider(config) — factory returning a ContextProvider that plugs into the existing resolver without modification. Tier 2 (matches context7 / obsidian semantics). Tools called in parallel per Read. - Budget enforcement + line-wise truncation (never mid-word). - Graceful shutdown on SIGTERM / SIGINT / beforeExit. - HTTP transport declared but deferred — throws 'not yet implemented' until item #5 SSE streaming lands with the Host/Origin hardening work. src/providers/resolver.ts - getMcpProviders(): loads MCP configs and wraps them. Cached for session lifetime. Test hook _resetMcpProvidersCache() for forced reload. - getAllProviders(): now merges BUILTINS + plugins + MCP providers (all deduped against built-in names so users can't shadow core). - Parse failures emit a single-line stderr warning (per bad entry) — visible to users without crashing their session. package.json - Adds @modelcontextprotocol/sdk@1.29.0 (4.3MB unpacked, pure JS, no native deps). Pinned behind a thin ProviderClient surface so migration to SDK v2 (alpha 2026-04) is a one-file swap later. TESTS tests/providers/mcp-config.test.ts — 24 cases covering: - File-doesn't-exist → empty configs - Valid stdio + http shapes round-trip - Invalid JSON reported as single failure - Bad entries skipped, good ones kept - Duplicate names: first wins - All validation rules (empty name/label, bad transport, confidence range, negative numeric fields, missing command/url, invalid URL) - Arg-template substitution: all tokens, unknown pass-through, non- strings unchanged, basename fallback, input-immutability Full suite: 771 → 808 tests (+24), all passing. TypeScript clean. WHAT THIS COMMIT DOES NOT DO (follow-up within item #1) - HTTP transport implementation — waits on item #5 SSE streaming for shared Host/Origin validation + resumable streams - Integration tests that actually spawn a real MCP server (needs tests/fixtures/minimal-mcp-server.mjs — next commit) - Tool-list caching — currently we call tools directly without listTools() first; the SDK may cache internally but we should verify + explicit-cache if not With this in place, item #2 (plugin contract v2 — mcpConfig auto-wrap) becomes a 2-day extension: plugin-loader.ts detects .mcpConfig on a plugin and auto-calls createMcpProvider(). Item #6 (Serena provider) becomes a 10-line ~/.engram/plugins/serena.mjs once the mcpConfig path lands. --- package-lock.json | 1125 +++++++++++++++++++++++++++- package.json | 1 + src/providers/mcp-client.ts | 304 ++++++++ src/providers/mcp-config.ts | 367 +++++++++ src/providers/resolver.ts | 50 +- tests/providers/mcp-config.test.ts | 349 +++++++++ 6 files changed, 2173 insertions(+), 23 deletions(-) create mode 100644 src/providers/mcp-client.ts create mode 100644 src/providers/mcp-config.ts create mode 100644 tests/providers/mcp-config.test.ts diff --git a/package-lock.json b/package-lock.json index 936672c..3c943e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.1.0", "license": "Apache-2.0", "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "chalk": "^5.6.2", "commander": "^14.0.3", "sql.js": "^1.14.1", @@ -510,6 +511,18 @@ "node": ">=18" } }, + "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/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -549,6 +562,46 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "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/@napi-rs/wasm-runtime": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", @@ -1415,6 +1468,19 @@ "url": "https://opencollective.com/vitest" } }, + "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/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1428,6 +1494,39 @@ "node": ">=0.4.0" } }, + "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/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1445,6 +1544,30 @@ "node": ">=12" } }, + "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/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1461,6 +1584,15 @@ "esbuild": ">=0.18" } }, + "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", @@ -1471,6 +1603,35 @@ "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": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1535,6 +1696,28 @@ "node": "^14.18.0 || >=16.10.0" } }, + "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/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1542,11 +1725,59 @@ "dev": true, "license": "MIT" }, + "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==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1560,6 +1791,15 @@ } } }, + "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/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1570,6 +1810,53 @@ "node": ">=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/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": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -1577,6 +1864,18 @@ "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", @@ -1619,6 +1918,12 @@ "@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", @@ -1629,6 +1934,36 @@ "@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/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/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1639,6 +1974,89 @@ "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.4.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.0.tgz", + "integrity": "sha512-gDK8yiqKxrGta+3WtON59arrrw6GLmadA1qoFgYXzdcch8fmKDID2XqO8itsi3f1wufXYPT51387dN6cvVBS3Q==", + "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/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/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1657,31 +2075,242 @@ } } }, + "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/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", "dev": true, "license": "MIT", - "dependencies": { - "magic-string": "^0.30.17", - "mlly": "^1.7.4", - "rollup": "^4.34.8" + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "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/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/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/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.15", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz", + "integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==", + "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/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/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, + "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", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">= 0.10" + } + }, + "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/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/joycon": { @@ -1694,6 +2323,18 @@ "node": ">=10" } }, + "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/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2007,6 +2648,61 @@ "@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/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -2024,7 +2720,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -2058,6 +2753,15 @@ "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-addon-api": { "version": "8.7.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", @@ -2082,12 +2786,23 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "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/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -2099,6 +2814,55 @@ ], "license": "MIT" }, + "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/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": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2136,6 +2900,15 @@ "node": ">= 6" } }, + "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/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -2220,6 +2993,58 @@ } } }, + "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/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2234,6 +3059,15 @@ "url": "https://paulmillr.com/funding/" } }, + "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-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -2323,6 +3157,172 @@ "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/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", @@ -2363,6 +3363,15 @@ "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": "4.0.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", @@ -2467,6 +3476,15 @@ "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/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -2660,6 +3678,20 @@ } } }, + "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": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", @@ -2688,6 +3720,24 @@ "dev": true, "license": "MIT" }, + "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/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": "8.0.8", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", @@ -2872,6 +3922,21 @@ "integrity": "sha512-4sUwi7ZyOrIk5KLgYLkc2A/F0LFMQnBhfb+2Cdl7ik4ePJ6JD+fk4ofI2sA5eGawBKBaK4Vntt7Ww5KcEsay4A==", "license": "MIT" }, + "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", @@ -2888,6 +3953,30 @@ "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/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "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" + } } } } diff --git a/package.json b/package.json index 5fbe133..fafea75 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "CHANGELOG.md" ], "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "chalk": "^5.6.2", "commander": "^14.0.3", "sql.js": "^1.14.1", diff --git a/src/providers/mcp-client.ts b/src/providers/mcp-client.ts new file mode 100644 index 0000000..2c1c45c --- /dev/null +++ b/src/providers/mcp-client.ts @@ -0,0 +1,304 @@ +/** + * Generic MCP-client subsystem — wraps `@modelcontextprotocol/sdk` so + * any MCP server becomes an engramx Context Spine provider via + * `~/.engram/mcp-providers.json` declaration. + * + * Design contract (MUST preserve in all edits): + * 1. Lazy connect — no process spawned / HTTP call made until first resolve + * 2. Connection reused for the session lifetime + * 3. Tool calls are parallel + bounded by provider.timeoutMs + * 4. Any error path returns null — we never throw into the resolver + * 5. SIGTERM / process.exit triggers clean disconnect + */ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import type { + ContextProvider, + NodeContext, + ProviderResult, +} from "./types.js"; +import { + applyArgTemplate, + type McpProviderConfig, + type McpToolCall, +} from "./mcp-config.js"; + +/** Rough token estimate — shared with resolver.ts. */ +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +/** + * Thin wrapper around the MCP SDK's `Client`. Holds the single + * connection for one configured provider across the engramx session. + */ +class McpClientWrapper { + private client: Client | null = null; + private transport: StdioClientTransport | null = null; + private connectingPromise: Promise | null = null; + private shutdownRegistered = false; + private lastErrorAt = 0; + private readonly errorBackoffMs = 30_000; + + constructor(private readonly config: McpProviderConfig) {} + + /** + * Connect once (idempotent). Concurrent callers share one promise so + * we never spawn the server twice. On failure we set a backoff window + * so the next Read doesn't re-try spawn immediately. + */ + async connect(): Promise { + if (this.client) return; + if (this.connectingPromise) return this.connectingPromise; + if (Date.now() - this.lastErrorAt < this.errorBackoffMs) { + throw new Error( + `[mcp] ${this.config.name}: in error backoff (last failure ${Math.round( + (Date.now() - this.lastErrorAt) / 1000 + )}s ago)` + ); + } + + this.connectingPromise = this.doConnect() + .catch((err) => { + this.lastErrorAt = Date.now(); + // Clear partial state so the next connect attempts fresh + this.client = null; + this.transport = null; + throw err; + }) + .finally(() => { + this.connectingPromise = null; + }); + + return this.connectingPromise; + } + + private async doConnect(): Promise { + if (this.config.transport !== "stdio") { + // HTTP transport is deferred to a follow-up commit — declare the + // config but don't connect until Streamable HTTP + Host/Origin + // hardening from item #5 land. Fail-soft: the provider reports + // unavailable rather than blocking a Read. + throw new Error( + `[mcp] ${this.config.name}: http transport not yet implemented` + ); + } + + const transport = new StdioClientTransport({ + command: this.config.command, + args: this.config.args ? [...this.config.args] : undefined, + env: this.config.env ? { ...this.config.env } : undefined, + cwd: this.config.cwd, + // Pipe stderr so a chatty server doesn't spam the parent's stderr + // during normal operation. Re-enable "inherit" for debugging. + stderr: "pipe", + }); + + const client = new Client( + { name: "engramx", version: "3.0.0" }, + { capabilities: {} } + ); + + await client.connect(transport); + + this.transport = transport; + this.client = client; + + if (!this.shutdownRegistered) { + this.registerShutdown(); + this.shutdownRegistered = true; + } + } + + /** + * Call a single tool with a timeout. Returns null on error (never + * throws). Caller is responsible for aggregating multiple tool results. + */ + async callTool( + toolName: string, + args: Record, + timeoutMs: number + ): Promise<{ content: string } | null> { + try { + await this.connect(); + } catch { + return null; + } + if (!this.client) return null; + + const abort = new AbortController(); + const timer = setTimeout(() => abort.abort(), timeoutMs); + + try { + const result = await this.client.callTool( + { name: toolName, arguments: args }, + undefined, + { signal: abort.signal, timeout: timeoutMs } + ); + clearTimeout(timer); + + // Response shape: { content: [{type: "text", text: "..."}] | [...] } + // — coalesce all text blocks into a single string. Non-text blocks + // are described with a marker so the user sees something wasn't + // plain text rather than silently dropping it. + const blocks = Array.isArray(result?.content) ? result.content : []; + const text = blocks + .map((b: unknown) => { + const block = b as { type?: string; text?: string }; + if (block.type === "text" && typeof block.text === "string") { + return block.text; + } + return `[${block.type ?? "unknown"} block]`; + }) + .join("\n") + .trim(); + + if (text.length === 0) return null; + return { content: text }; + } catch { + return null; + } finally { + clearTimeout(timer); + } + } + + /** Close the connection. Safe to call on an unconnected client. */ + async disconnect(): Promise { + const client = this.client; + const transport = this.transport; + this.client = null; + this.transport = null; + try { + await client?.close(); + } catch { + // Ignore — connection may already be dead + } + try { + await transport?.close(); + } catch { + // Ignore + } + } + + private registerShutdown(): void { + const shutdown = () => { + void this.disconnect(); + }; + // Parent process lifecycle — ignore if already registered + // (multiple clients share the listener list, which is fine). + process.once("SIGTERM", shutdown); + process.once("SIGINT", shutdown); + process.once("beforeExit", shutdown); + } +} + +/** + * Factory: turn an `McpProviderConfig` into a `ContextProvider` that + * the engramx resolver can merge into its provider list unchanged. + */ +export function createMcpProvider(config: McpProviderConfig): ContextProvider { + const wrapper = new McpClientWrapper(config); + const tokenBudget = config.tokenBudget ?? 200; + const timeoutMs = config.timeoutMs ?? 2_000; + const enabled = config.enabled ?? true; + + return { + name: config.name, + label: config.label, + // Tier 2 — external process/HTTP with cache support. Matches + // context7/obsidian tier semantics in the existing resolver. + tier: 2, + tokenBudget, + timeoutMs, + + async isAvailable(): Promise { + if (!enabled) return false; + if (config.tools.length === 0) return false; + // We do NOT connect here. Connection is lazy inside callTool. + // Availability check is cheap so it runs on every Read — + // spawning a child process in availability would be catastrophic. + return true; + }, + + async resolve( + filePath: string, + context: NodeContext + ): Promise { + try { + const results = await Promise.allSettled( + config.tools.map((tool) => callSingleTool(wrapper, tool, filePath, context, timeoutMs)) + ); + + const sections: string[] = []; + let highestConfidence = 0; + for (const outcome of results) { + if (outcome.status === "fulfilled" && outcome.value) { + sections.push(outcome.value.content); + highestConfidence = Math.max( + highestConfidence, + outcome.value.confidence + ); + } + } + + if (sections.length === 0) return null; + + // Trim to tokenBudget — providers MUST respect their own budget + // (resolver enforces total budget on top of this). + let combined = sections.join("\n\n"); + const budget = tokenBudget; + if (estimateTokens(combined) > budget) { + // Keep whole lines to avoid cutting mid-word/token. + const lines = combined.split("\n"); + const kept: string[] = []; + let used = 0; + for (const line of lines) { + const lineTokens = estimateTokens(line) + 1; // +1 for newline + if (used + lineTokens > budget) break; + kept.push(line); + used += lineTokens; + } + combined = kept.join("\n") + "\n… [truncated to fit budget]"; + } + + return { + provider: config.name, + content: combined, + confidence: highestConfidence, + cached: false, + }; + } catch { + return null; + } + }, + }; +} + +async function callSingleTool( + wrapper: McpClientWrapper, + tool: McpToolCall, + filePath: string, + context: NodeContext, + timeoutMs: number +): Promise<{ content: string; confidence: number } | null> { + const args = applyArgTemplate(tool.args, { + filePath, + projectRoot: context.projectRoot, + imports: context.imports, + }); + const result = await wrapper.callTool(tool.name, args, timeoutMs); + if (!result) return null; + return { + content: result.content, + confidence: tool.confidence ?? 0.75, + }; +} + +// ── Exports for testing ───────────────────────────────────────────── +// The class itself is intentionally NOT exported from the module's +// public API — the only entry point is createMcpProvider. The wrapper +// is exposed here solely so integration tests can reach in without +// duplicating setup code. +export const __internalsForTesting = { + McpClientWrapper, +}; diff --git a/src/providers/mcp-config.ts b/src/providers/mcp-config.ts new file mode 100644 index 0000000..c28d3a0 --- /dev/null +++ b/src/providers/mcp-config.ts @@ -0,0 +1,367 @@ +/** + * MCP provider configuration — loader + validator for + * `~/.engram/mcp-providers.json`. + * + * Each entry in the file declares an external MCP server that engramx + * will wrap as a context provider. The aggregator spawns (stdio) or + * connects (HTTP) to each configured server, calls declared tools on + * every Read interception, and merges results into the rich context + * packet. + * + * Validation is strict by construction: a malformed entry is skipped + * (with a stderr warning) rather than throwing — one bad plugin must + * never break engramx. + */ +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +/** + * Transport choice. `stdio` spawns the command as a child process and + * talks JSON-RPC over stdin/stdout — the most common shape. `http` + * connects to a running server via the MCP Streamable HTTP transport. + */ +export type McpTransport = "stdio" | "http"; + +/** + * Argument template for a tool call. Values are substituted from the + * NodeContext at resolve time. Known tokens: + * + * - `{filePath}` — relative POSIX path of the file being read + * - `{projectRoot}` — absolute path to the project root + * - `{imports}` — comma-separated list of detected import names + * - `{fileBasename}` — basename of the file (no directory) + * + * Unknown tokens are left as-is. Non-string values pass through. + */ +export type ArgTemplate = Record; + +/** + * Declaration of a single tool call. Every declared tool is invoked + * once per Read interception (in parallel within a single McpProvider). + */ +export interface McpToolCall { + /** MCP tool name (must match a tool advertised by the server). */ + readonly name: string; + /** + * Argument template. See `ArgTemplate` for token syntax. If omitted, + * the default `{ path: "{filePath}" }` is used — the most common + * file-context tool argument shape. + */ + readonly args?: ArgTemplate; + /** + * Relevance confidence (0-1) assigned to results from this tool. + * Defaults to 0.75. Used by the resolver to rank against other + * providers when the combined packet exceeds the total token budget. + */ + readonly confidence?: number; +} + +/** Stdio transport config. */ +export interface McpStdioConfig { + readonly transport: "stdio"; + /** Executable to spawn (e.g. `"uvx"`, `"node"`, `"./my-server"`). */ + readonly command: string; + /** Command-line arguments. */ + readonly args?: readonly string[]; + /** Environment variables (merged with `getDefaultEnvironment()`). */ + readonly env?: Readonly>; + /** Working directory (defaults to engramx's cwd). */ + readonly cwd?: string; +} + +/** HTTP transport config. */ +export interface McpHttpConfig { + readonly transport: "http"; + /** Full URL to the MCP endpoint (e.g. `https://mcp.example.com/v1`). */ + readonly url: string; + /** + * Static headers sent on every request. Do NOT put secrets here in + * checked-in configs — use `envHeader` + an environment variable + * referenced below for authorization. + */ + readonly headers?: Readonly>; + /** + * Env-var-backed Authorization header. If set, the value of + * `process.env[envHeader]` is sent as `Authorization: Bearer `. + * Example: `envHeader: "OPENAI_API_KEY"` → reads from env at request time. + */ + readonly envHeader?: string; +} + +/** + * Full provider config. Shared shape across transports — the transport + * discriminator selects stdio- or http-specific fields. + */ +export type McpProviderConfig = (McpStdioConfig | McpHttpConfig) & { + /** + * Provider identifier. Appears in context packets, `engram plugin list`, + * and the resolver's `enabledProviders` filter. Convention: namespace + * with your tool (e.g. `"mcp:serena"`, `"mcp:github"`). + */ + readonly name: string; + /** Display label shown in the context packet section header. */ + readonly label: string; + /** + * Tool calls to invoke on every Read. Running zero tools is legal — + * it makes the provider inert until the user adds tools, useful for + * staged rollouts. + */ + readonly tools: readonly McpToolCall[]; + /** Max tokens this provider may emit per file. Default 200. */ + readonly tokenBudget?: number; + /** Live-resolution timeout in ms. Default 2000. */ + readonly timeoutMs?: number; + /** Cache TTL in seconds. Default 3600. */ + readonly cacheTtlSec?: number; + /** + * Priority (inserted into `PROVIDER_PRIORITY` by the resolver). + * Higher values sort first when the combined packet exceeds total + * budget. Conventional range 0-100; built-ins sit at 0-8. + * Default: the array index position, so order in config matters. + */ + readonly priority?: number; + /** Disabled providers are loaded + reported but never resolve. Default true. */ + readonly enabled?: boolean; +}; + +/** Top-level file shape. */ +export interface McpProvidersFile { + readonly $schema?: string; + readonly providers: readonly McpProviderConfig[]; +} + +/** Resolution result from loadMcpConfigs. */ +export interface McpConfigLoadResult { + readonly configs: readonly McpProviderConfig[]; + readonly failed: readonly { index: number; reason: string }[]; +} + +/** + * Resolve the config file path. Overridable via `ENGRAM_MCP_CONFIG_PATH` + * for tests and advanced users. + */ +export function getMcpConfigPath(): string { + const override = process.env.ENGRAM_MCP_CONFIG_PATH; + if (override && override.length > 0) return override; + return join(homedir(), ".engram", "mcp-providers.json"); +} + +/** + * Load and validate `~/.engram/mcp-providers.json`. Returns empty + * configs + no failures if the file doesn't exist. Individual entries + * that fail validation are skipped and reported in `failed` — one bad + * entry must never prevent the rest from loading. + */ +export function loadMcpConfigs( + path: string = getMcpConfigPath() +): McpConfigLoadResult { + if (!existsSync(path)) { + return { configs: [], failed: [] }; + } + + let raw: string; + try { + raw = readFileSync(path, "utf-8"); + } catch (err) { + return { + configs: [], + failed: [ + { + index: -1, + reason: `failed to read config file: ${(err as Error).message}`, + }, + ], + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + return { + configs: [], + failed: [ + { + index: -1, + reason: `invalid JSON in ${path}: ${(err as Error).message}`, + }, + ], + }; + } + + if (!isMcpProvidersFile(parsed)) { + return { + configs: [], + failed: [ + { + index: -1, + reason: `expected { providers: [...] } shape in ${path}`, + }, + ], + }; + } + + const valid: McpProviderConfig[] = []; + const failed: { index: number; reason: string }[] = []; + const seenNames = new Set(); + + for (let i = 0; i < parsed.providers.length; i++) { + const entry = parsed.providers[i]; + const validation = validateProviderConfig(entry); + if (validation.ok) { + if (seenNames.has(validation.value.name)) { + failed.push({ + index: i, + reason: `duplicate provider name '${validation.value.name}' — first occurrence wins`, + }); + continue; + } + seenNames.add(validation.value.name); + valid.push(validation.value); + } else { + failed.push({ index: i, reason: validation.reason }); + } + } + + return { configs: valid, failed }; +} + +/** + * Structural type guard for the file root. Kept separate from entry + * validation so that a bad TYPE returns a clear message without + * iterating `providers`. + */ +function isMcpProvidersFile(v: unknown): v is McpProvidersFile { + if (!v || typeof v !== "object") return false; + const obj = v as Record; + return Array.isArray(obj.providers); +} + +/** + * Full shape validation for a single provider. Returns a discriminated + * result so callers can fail-open (collect failures) rather than throw. + */ +export function validateProviderConfig( + raw: unknown +): { ok: true; value: McpProviderConfig } | { ok: false; reason: string } { + if (!raw || typeof raw !== "object") { + return { ok: false, reason: "entry is not an object" }; + } + const o = raw as Record; + + if (typeof o.name !== "string" || o.name.length === 0) { + return { ok: false, reason: "`name` must be a non-empty string" }; + } + if (typeof o.label !== "string" || o.label.length === 0) { + return { ok: false, reason: `[${o.name}] 'label' must be a non-empty string` }; + } + if (o.transport !== "stdio" && o.transport !== "http") { + return { + ok: false, + reason: `[${o.name}] 'transport' must be 'stdio' or 'http'`, + }; + } + if (!Array.isArray(o.tools)) { + return { ok: false, reason: `[${o.name}] 'tools' must be an array` }; + } + + for (let i = 0; i < o.tools.length; i++) { + const t = o.tools[i] as Record; + if (!t || typeof t.name !== "string" || t.name.length === 0) { + return { + ok: false, + reason: `[${o.name}] tools[${i}].name must be a non-empty string`, + }; + } + if (t.args !== undefined && (typeof t.args !== "object" || t.args === null)) { + return { ok: false, reason: `[${o.name}] tools[${i}].args must be an object` }; + } + if (t.confidence !== undefined) { + if ( + typeof t.confidence !== "number" || + t.confidence < 0 || + t.confidence > 1 + ) { + return { + ok: false, + reason: `[${o.name}] tools[${i}].confidence must be in [0, 1]`, + }; + } + } + } + + // Transport-specific fields + if (o.transport === "stdio") { + if (typeof o.command !== "string" || o.command.length === 0) { + return { ok: false, reason: `[${o.name}] 'command' required for stdio transport` }; + } + if (o.args !== undefined && !Array.isArray(o.args)) { + return { ok: false, reason: `[${o.name}] 'args' must be an array of strings` }; + } + } else { + if (typeof o.url !== "string" || o.url.length === 0) { + return { ok: false, reason: `[${o.name}] 'url' required for http transport` }; + } + try { + new URL(o.url as string); + } catch { + return { ok: false, reason: `[${o.name}] 'url' is not a valid URL` }; + } + } + + // Optional numeric fields — reject if present but negative / zero + for (const field of ["tokenBudget", "timeoutMs", "cacheTtlSec", "priority"] as const) { + if (o[field] !== undefined) { + if (typeof o[field] !== "number" || (o[field] as number) < 0) { + return { + ok: false, + reason: `[${o.name}] '${field}' must be a non-negative number`, + }; + } + } + } + + return { ok: true, value: raw as McpProviderConfig }; +} + +/** + * Substitute template tokens in an args object. Returns a new object — + * the input is not mutated. Unknown tokens pass through as-is (so the + * server sees them verbatim and can report a helpful error). + */ +export function applyArgTemplate( + template: ArgTemplate | undefined, + ctx: { + filePath: string; + projectRoot: string; + imports: readonly string[]; + fileBasename?: string; + } +): Record { + const defaults: ArgTemplate = { path: "{filePath}" }; + const src = template ?? defaults; + const out: Record = {}; + + const basename = ctx.fileBasename ?? ctx.filePath.split("/").pop() ?? ctx.filePath; + const tokens: Record = { + filePath: ctx.filePath, + projectRoot: ctx.projectRoot, + imports: ctx.imports.join(","), + fileBasename: basename, + }; + + for (const [key, value] of Object.entries(src)) { + if (typeof value === "string") { + out[key] = value.replace(/\{(\w+)\}/g, (match, token: string) => + Object.prototype.hasOwnProperty.call(tokens, token) + ? tokens[token] + : match + ); + } else { + out[key] = value; + } + } + + return out; +} diff --git a/src/providers/resolver.ts b/src/providers/resolver.ts index 4d0df94..2deaec6 100644 --- a/src/providers/resolver.ts +++ b/src/providers/resolver.ts @@ -45,15 +45,55 @@ const BUILTIN_PROVIDERS: readonly ContextProvider[] = [ const BUILTIN_NAMES = new Set(BUILTIN_PROVIDERS.map((p) => p.name)); /** - * Full provider list = built-ins + user plugins (deduped against built-in - * names). Loaded lazily via getLoadedPlugins(). Safe: a broken plugin - * can never break engram — validation is in plugin-loader.ts. + * MCP-backed providers loaded from `~/.engram/mcp-providers.json`. Cached + * across Reads for the session lifetime — config is read once on first + * call. Test hooks use `_resetMcpProvidersCache()` to force reload. + */ +let mcpProvidersCache: readonly ContextProvider[] | null = null; +async function getMcpProviders(): Promise { + if (mcpProvidersCache) return mcpProvidersCache; + try { + const [{ loadMcpConfigs }, { createMcpProvider }] = await Promise.all([ + import("./mcp-config.js"), + import("./mcp-client.js"), + ]); + const { configs, failed } = loadMcpConfigs(); + if (failed.length > 0) { + for (const f of failed) { + // One-line stderr warning per bad entry — don't crash, don't + // noop-swallow. Users need to know their config didn't take. + process.stderr.write( + `[engram] mcp-providers.json entry ${f.index}: ${f.reason}\n` + ); + } + } + mcpProvidersCache = configs.map(createMcpProvider); + } catch { + mcpProvidersCache = []; + } + return mcpProvidersCache; +} + +/** Test-only: clear the MCP provider cache so config reload picks up changes. */ +export function _resetMcpProvidersCache(): void { + mcpProvidersCache = null; +} + +/** + * Full provider list = built-ins + user plugins + MCP-configured providers + * (all deduped against built-in names so users can't shadow core providers). + * Loaded lazily. Safe: a broken plugin or malformed MCP config can never + * break engram — validation is in plugin-loader.ts / mcp-config.ts. */ async function getAllProviders(): Promise { - const { getLoadedPlugins } = await import("./plugin-loader.js"); + const [{ getLoadedPlugins }, mcpProviders] = await Promise.all([ + import("./plugin-loader.js"), + getMcpProviders(), + ]); const { loaded } = await getLoadedPlugins(); const safePlugins = loaded.filter((p) => !BUILTIN_NAMES.has(p.name)); - return [...BUILTIN_PROVIDERS, ...safePlugins]; + const safeMcp = mcpProviders.filter((p) => !BUILTIN_NAMES.has(p.name)); + return [...BUILTIN_PROVIDERS, ...safePlugins, ...safeMcp]; } /** Back-compat alias — built-ins only. Plugins flow through getAllProviders(). */ diff --git a/tests/providers/mcp-config.test.ts b/tests/providers/mcp-config.test.ts new file mode 100644 index 0000000..370753c --- /dev/null +++ b/tests/providers/mcp-config.test.ts @@ -0,0 +1,349 @@ +/** + * Tests for MCP provider config loading + validation + arg templating. + * + * These tests exercise the config layer only — no MCP server spawning. + * Integration tests that connect to a real MCP server (and therefore + * require external binaries like `uvx` + Serena) live separately so CI + * doesn't need those available for every run. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + loadMcpConfigs, + validateProviderConfig, + applyArgTemplate, + type McpProviderConfig, +} from "../../src/providers/mcp-config.js"; + +describe("mcp-config: loadMcpConfigs", () => { + let tmpDir: string; + let configPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "engram-mcp-config-")); + configPath = join(tmpDir, "mcp-providers.json"); + process.env.ENGRAM_MCP_CONFIG_PATH = configPath; + }); + + afterEach(() => { + delete process.env.ENGRAM_MCP_CONFIG_PATH; + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns empty configs + no failures when file does not exist", () => { + const result = loadMcpConfigs(configPath); + expect(result.configs).toEqual([]); + expect(result.failed).toEqual([]); + }); + + it("loads a minimal valid stdio provider", () => { + writeFileSync( + configPath, + JSON.stringify({ + providers: [ + { + name: "mcp:test", + label: "TEST", + transport: "stdio", + command: "echo", + tools: [{ name: "foo" }], + }, + ], + }) + ); + const result = loadMcpConfigs(configPath); + expect(result.configs).toHaveLength(1); + expect(result.configs[0].name).toBe("mcp:test"); + expect(result.failed).toEqual([]); + }); + + it("loads a valid http provider with env-backed auth", () => { + writeFileSync( + configPath, + JSON.stringify({ + providers: [ + { + name: "mcp:remote", + label: "REMOTE", + transport: "http", + url: "https://mcp.example.com/v1", + envHeader: "MY_API_KEY", + tools: [{ name: "search" }], + tokenBudget: 150, + timeoutMs: 3000, + }, + ], + }) + ); + const result = loadMcpConfigs(configPath); + expect(result.configs).toHaveLength(1); + expect(result.failed).toEqual([]); + }); + + it("reports invalid JSON as a single failure", () => { + writeFileSync(configPath, "{ not valid json"); + const result = loadMcpConfigs(configPath); + expect(result.configs).toEqual([]); + expect(result.failed).toHaveLength(1); + expect(result.failed[0].reason).toMatch(/invalid JSON/); + }); + + it("reports wrong top-level shape", () => { + writeFileSync(configPath, JSON.stringify({ wrongKey: [] })); + const result = loadMcpConfigs(configPath); + expect(result.configs).toEqual([]); + expect(result.failed).toHaveLength(1); + expect(result.failed[0].reason).toMatch(/providers/); + }); + + it("skips bad entries but keeps good ones", () => { + writeFileSync( + configPath, + JSON.stringify({ + providers: [ + { + name: "mcp:good", + label: "Good", + transport: "stdio", + command: "echo", + tools: [{ name: "foo" }], + }, + { + // missing command + name: "mcp:bad-stdio", + label: "Bad", + transport: "stdio", + tools: [{ name: "foo" }], + }, + { + // bad url + name: "mcp:bad-http", + label: "Bad HTTP", + transport: "http", + url: "not-a-url", + tools: [{ name: "foo" }], + }, + { + name: "mcp:good2", + label: "Good 2", + transport: "stdio", + command: "bash", + tools: [{ name: "bar" }], + }, + ], + }) + ); + const result = loadMcpConfigs(configPath); + expect(result.configs).toHaveLength(2); + expect(result.configs.map((c) => c.name)).toEqual([ + "mcp:good", + "mcp:good2", + ]); + expect(result.failed).toHaveLength(2); + }); + + it("deduplicates provider names — first wins", () => { + writeFileSync( + configPath, + JSON.stringify({ + providers: [ + { + name: "mcp:dup", + label: "First", + transport: "stdio", + command: "echo", + tools: [{ name: "a" }], + }, + { + name: "mcp:dup", + label: "Second", + transport: "stdio", + command: "ls", + tools: [{ name: "b" }], + }, + ], + }) + ); + const result = loadMcpConfigs(configPath); + expect(result.configs).toHaveLength(1); + expect(result.configs[0].label).toBe("First"); + expect(result.failed).toHaveLength(1); + expect(result.failed[0].reason).toMatch(/duplicate/); + }); +}); + +describe("mcp-config: validateProviderConfig", () => { + function makeValid(): Record { + return { + name: "mcp:x", + label: "X", + transport: "stdio", + command: "echo", + tools: [{ name: "t" }], + }; + } + + it("accepts a minimal valid config", () => { + const result = validateProviderConfig(makeValid()); + expect(result.ok).toBe(true); + }); + + it("rejects entries that are not objects", () => { + expect(validateProviderConfig(null).ok).toBe(false); + expect(validateProviderConfig("string").ok).toBe(false); + expect(validateProviderConfig(123).ok).toBe(false); + }); + + it("rejects empty name / label", () => { + const r1 = validateProviderConfig({ ...makeValid(), name: "" }); + expect(r1.ok).toBe(false); + const r2 = validateProviderConfig({ ...makeValid(), label: "" }); + expect(r2.ok).toBe(false); + }); + + it("rejects unknown transport", () => { + const result = validateProviderConfig({ ...makeValid(), transport: "carrier-pigeon" }); + expect(result.ok).toBe(false); + }); + + it("rejects confidence outside [0, 1]", () => { + const r1 = validateProviderConfig({ + ...makeValid(), + tools: [{ name: "t", confidence: 1.5 }], + }); + expect(r1.ok).toBe(false); + const r2 = validateProviderConfig({ + ...makeValid(), + tools: [{ name: "t", confidence: -0.1 }], + }); + expect(r2.ok).toBe(false); + }); + + it("rejects negative tokenBudget / timeoutMs / priority", () => { + const fields = ["tokenBudget", "timeoutMs", "cacheTtlSec", "priority"]; + for (const f of fields) { + const result = validateProviderConfig({ ...makeValid(), [f]: -1 }); + expect(result.ok, `${f} should reject negative`).toBe(false); + } + }); + + it("rejects stdio config missing command", () => { + const v = makeValid(); + delete (v as Record).command; + const result = validateProviderConfig(v); + expect(result.ok).toBe(false); + }); + + it("rejects http config missing url", () => { + const result = validateProviderConfig({ + name: "mcp:x", + label: "X", + transport: "http", + tools: [{ name: "t" }], + }); + expect(result.ok).toBe(false); + }); + + it("rejects http config with invalid url", () => { + const result = validateProviderConfig({ + name: "mcp:x", + label: "X", + transport: "http", + url: "not a url", + tools: [{ name: "t" }], + }); + expect(result.ok).toBe(false); + }); + + it("accepts empty tools array (staged-rollout case)", () => { + const result = validateProviderConfig({ ...makeValid(), tools: [] }); + expect(result.ok).toBe(true); + }); +}); + +describe("mcp-config: applyArgTemplate", () => { + const ctx = { + filePath: "src/auth/login.ts", + projectRoot: "/home/nick/project", + imports: ["jsonwebtoken", "express"], + }; + + it("applies default template { path: '{filePath}' } when none provided", () => { + const result = applyArgTemplate(undefined, ctx); + expect(result).toEqual({ path: "src/auth/login.ts" }); + }); + + it("substitutes all known tokens", () => { + const result = applyArgTemplate( + { + file: "{filePath}", + root: "{projectRoot}", + deps: "{imports}", + bn: "{fileBasename}", + }, + ctx + ); + expect(result).toEqual({ + file: "src/auth/login.ts", + root: "/home/nick/project", + deps: "jsonwebtoken,express", + bn: "login.ts", + }); + }); + + it("leaves unknown tokens as-is (server gets to decide)", () => { + const result = applyArgTemplate({ weird: "{madeUpToken}" }, ctx); + expect(result).toEqual({ weird: "{madeUpToken}" }); + }); + + it("passes non-string values through unchanged", () => { + const result = applyArgTemplate( + { flag: true, limit: 10, name: "{fileBasename}" }, + ctx + ); + expect(result).toEqual({ flag: true, limit: 10, name: "login.ts" }); + }); + + it("handles a file path with no directory (basename fallback)", () => { + const result = applyArgTemplate( + { bn: "{fileBasename}" }, + { ...ctx, filePath: "README.md" } + ); + expect(result).toEqual({ bn: "README.md" }); + }); + + it("does not mutate the input template", () => { + const template = { x: "{filePath}" }; + const frozen = Object.freeze(template); + expect(() => applyArgTemplate(frozen, ctx)).not.toThrow(); + expect(template).toEqual({ x: "{filePath}" }); + }); +}); + +describe("mcp-config: provider shape integration", () => { + it("preserves optional numeric fields through validation + load", () => { + const entry: McpProviderConfig = { + name: "mcp:full", + label: "Full", + transport: "stdio", + command: "echo", + args: ["--foo"], + tools: [{ name: "t", confidence: 0.9 }], + tokenBudget: 123, + timeoutMs: 4567, + cacheTtlSec: 9999, + priority: 50, + enabled: false, + }; + const result = validateProviderConfig(entry); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.tokenBudget).toBe(123); + expect(result.value.timeoutMs).toBe(4567); + expect(result.value.priority).toBe(50); + expect(result.value.enabled).toBe(false); + } + }); +}); From 905fed65a3faca3fc59ab7cc515725db75391d5f Mon Sep 17 00:00:00 2001 From: Nicholas Ashkar Date: Fri, 24 Apr 2026 16:18:34 +0400 Subject: [PATCH 07/18] v3.0(items #2 + #6): plugin contract v2 with mcpConfig auto-wrap + Serena reference plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ITEM #2 — Plugin contract v2 Extends ContextProviderPlugin so plugin authors can declare an MCP server via 'mcpConfig' and skip writing resolve()/isAvailable() by hand. The loader auto-wraps via createMcpProvider() from item #1. Classic plugins (custom resolve()) continue to work unchanged — if both fields are present, the author's resolve() wins (they opted into custom logic). Type changes (src/providers/types.ts): - ContextProviderPlugin stays strict (extends ContextProvider fully) — this is the POST-VALIDATION shape the resolver consumes - NEW: RawPluginShape — the pre-validation shape a plugin-file author writes in .mjs. tier/tokenBudget/timeoutMs/resolve/isAvailable all optional (loader fills from factory when mcpConfig present) Loader changes (src/providers/plugin-loader.ts): - validatePlugin() branches on 'has mcpConfig vs. has resolve()' - name/label/version always required - Classic path: tier/tokenBudget/timeoutMs/isAvailable required - mcpConfig path: config validated via validateProviderConfig(), merged with plugin fields (author overrides win over factory defaults) - One clear error per rejection — 'invalid mcpConfig: ' tells you exactly which sub-field on which plugin is broken Tests (+7 cases in tests/providers/plugin-loader.test.ts): - mcpConfig-only plugin auto-wraps resolve + isAvailable - Plugin with neither resolve nor mcpConfig rejected (clear message) - Invalid mcpConfig rejected (bad command, bad http url) - Custom resolve wins over mcpConfig when both present - Plugin tokenBudget override wins over factory default - Missing version rejected even for mcpConfig plugins ITEM #6 — Serena plugin reference docs/plugins/examples/serena-plugin.mjs (~60 lines incl. docs) — the full Serena (oraios/serena) wrapper as an mcpConfig-only plugin. Install is cp + enable. Thanks to item #2, NO custom transport code needed. docs/plugins/examples/static-context-plugin.mjs — the classic-path reference showing a tier 1 plugin with hand-rolled resolve() for users who just want to inject a fixed string on every Read. docs/plugins/README.md — author-facing guide. Shape 1 (MCP-backed), Shape 2 (classic), template tokens, safety guarantees, debugging checklist, publishing notes. FULL SUITE 808 -> 815 tests (+7), all passing. TypeScript clean, lint clean. V3.0 PROGRESS Done: #1 foundation, #2, #6, #7, #9, #10, #11 = 7 of 12 scope items. Next: #3 budget-weighted resolver + mistakes-boost (~2-3d). --- docs/plugins/README.md | 140 ++++++++++++++++++ docs/plugins/examples/serena-plugin.mjs | 68 +++++++++ .../examples/static-context-plugin.mjs | 39 +++++ src/providers/plugin-loader.ts | 117 +++++++++++---- src/providers/types.ts | 32 ++++ tests/providers/plugin-loader.test.ts | 109 ++++++++++++++ 6 files changed, 475 insertions(+), 30 deletions(-) create mode 100644 docs/plugins/README.md create mode 100644 docs/plugins/examples/serena-plugin.mjs create mode 100644 docs/plugins/examples/static-context-plugin.mjs diff --git a/docs/plugins/README.md b/docs/plugins/README.md new file mode 100644 index 0000000..75fdd6e --- /dev/null +++ b/docs/plugins/README.md @@ -0,0 +1,140 @@ +# engramx Plugins + +> A plugin is a single `.mjs` file in `~/.engram/plugins/` that adds a new +> Context Spine provider to engramx. Two shapes are supported: + +1. **MCP-backed** — declare an `mcpConfig` and the loader auto-wraps an + MCP server of your choice. ~10 lines. +2. **Classic** — write your own `resolve()` and `isAvailable()`. Full + control over what goes into the context packet. + +Both shapes live side-by-side in the same directory. Pick whichever fits. + +--- + +## Install + +1. Copy an example from `docs/plugins/examples/` to `~/.engram/plugins/`: + + ```bash + cp docs/plugins/examples/serena-plugin.mjs ~/.engram/plugins/serena.mjs + ``` + +2. Verify it loaded: + + ```bash + engram plugin list + ``` + + You should see your plugin listed with its name + label. + +3. Trigger any file read in Claude Code. The plugin's contribution will + appear in the rich context packet header (e.g. `SEMANTIC SYMBOLS + (mcp:serena):`). + +--- + +## Shape 1 — MCP-backed plugin + +See `examples/serena-plugin.mjs` for the full example. The essence: + +```javascript +export default { + name: "mcp:my-server", + label: "MY CONTEXT", + version: "0.1.0", + mcpConfig: { + transport: "stdio", + command: "my-mcp-server", + args: [], + tools: [ + { name: "get_context", args: { file: "{filePath}" } }, + ], + }, +}; +``` + +**Template tokens available in `tools[].args`:** + +| Token | Value | +|-------|-------| +| `{filePath}` | Relative POSIX path (e.g. `src/auth/login.ts`) | +| `{projectRoot}` | Absolute project root path | +| `{imports}` | Comma-separated import names (`"jsonwebtoken,express"`) | +| `{fileBasename}` | Basename only (e.g. `login.ts`) | + +Unknown tokens pass through verbatim. Non-string values (`true`, `10`, …) +pass through unchanged. + +**Transports:** `stdio` ships in v3.0. `http` is declared but deferred +until the SSE-streaming + Host/Origin hardening work lands (v3.0 item #5). + +--- + +## Shape 2 — Classic plugin + +See `examples/static-context-plugin.mjs`. Key fields: + +| Field | Type | Notes | +|-------|------|-------| +| `name` | string | Unique identifier (no collision with built-ins) | +| `label` | string | Section header in the context packet | +| `version` | string | Semver | +| `tier` | `1 \| 2` | 1 = internal (fast), 2 = external (cached). See `src/providers/types.ts`. | +| `tokenBudget` | number | Max tokens this plugin may emit per Read | +| `timeoutMs` | number | Per-resolve() timeout | +| `resolve(filePath, context)` | async | Return a `ProviderResult` or `null` | +| `isAvailable()` | async | Return `false` to silently skip this plugin | + +`resolve()` must return `null` on any error path — it must NOT throw. A +thrown error is swallowed by the resolver's Promise.allSettled, so your +plugin just goes silently missing rather than breaking the session. + +--- + +## Safety guarantees + +A broken plugin CANNOT break engramx. The plugin loader: + +1. Imports your file in a try/catch. +2. Validates the shape (missing fields → skip with stderr warning). +3. For `mcpConfig`, validates the MCP schema before auto-wrapping. +4. Deduplicates names — first-loaded wins. +5. Surfaces the list of loads + failures via `engram plugin list`. + +A plugin that throws at import, fails shape validation, or has an invalid +`mcpConfig` simply doesn't appear in the provider list. Other plugins and +built-ins are unaffected. + +--- + +## Debugging a plugin that "won't load" + +1. `engram plugin list` — shows loaded + failed with one-line reason. +2. For MCP-backed plugins, try the underlying command manually: + ```bash + uvx --from git+https://github.com/oraios/serena serena start-mcp-server + ``` + If it fails here, engramx can't make it work either. Fix the upstream + first, then re-test. +3. Check `~/.engram/` exists and is writable (the loader creates + `plugins/` on demand). +4. Enable verbose logs: `ENGRAM_LOG=debug engram query "hello"` shows + the full load trace. + +--- + +## Publishing a plugin for others + +Plugins are currently installed by copy-paste — there's no plugin +registry yet. The recommended path: + +1. Ship your plugin file in a public git repo with clear install notes + (one README + one `.mjs`). +2. Use a `mcp:your-tool-name` or `your-org:name` namespace to avoid + collisions. +3. Include a version bump policy in your README so users know when to + update. + +A first-party plugin registry is tracked as post-v3.0 work (dependent on +Official MCP Registry verified-tier requirements solidifying). diff --git a/docs/plugins/examples/serena-plugin.mjs b/docs/plugins/examples/serena-plugin.mjs new file mode 100644 index 0000000..2d9d499 --- /dev/null +++ b/docs/plugins/examples/serena-plugin.mjs @@ -0,0 +1,68 @@ +/** + * engramx plugin: Serena — LSP-backed semantic code retrieval + * + * Serena (https://github.com/oraios/serena) is an open-source MCP server + * that talks to language servers for 20+ languages and returns precise + * symbol-level context — far more accurate than regex or tree-sitter + * alone. This plugin wraps Serena as an engramx Context Spine provider. + * + * INSTALL + * 1. Install Serena if you haven't: + * https://github.com/oraios/serena#installation + * The quickest path: `pipx install uv` (or uv's own installer), + * which gives you `uvx` — the command below then fetches Serena + * on-demand at first use. + * + * 2. Copy this file to ~/.engram/plugins/serena.mjs: + * cp docs/plugins/examples/serena-plugin.mjs ~/.engram/plugins/serena.mjs + * + * 3. Verify it loaded: + * engram plugin list + * (you should see `mcp:serena SEMANTIC SYMBOLS (mcp-backed)`) + * + * HOW IT WORKS + * The `mcpConfig` declaration below tells engramx's plugin loader to + * auto-wrap Serena via createMcpProvider(). On every Read, engramx + * calls `find_symbol` against Serena with the current file path, + * receives back the symbol structure, and merges it into the rich + * context packet. If Serena isn't running or the call times out, the + * plugin goes dormant for 30 seconds before retry — engramx's built-in + * AST miner covers the gap. + * + * TUNING + * - tools: add more Serena tools to enrich context further. See + * `uvx --from git+https://github.com/oraios/serena serena --list-tools` + * for the full catalog. + * - tokenBudget: Serena can be verbose. 250 tokens per Read is a + * reasonable default for symbol-rich files; raise if you find its + * output being truncated too aggressively. + * - timeoutMs: cold-start for Serena's first request (per-language LSP + * boot) is slow — keep ≥2s or you'll get zero results on the first + * file of a session. + */ +export default { + name: "mcp:serena", + label: "SEMANTIC SYMBOLS", + version: "0.1.0", + description: "LSP-backed symbol retrieval via oraios/serena", + author: "engramx community", + tokenBudget: 250, + timeoutMs: 2500, + mcpConfig: { + transport: "stdio", + command: "uvx", + args: [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + ], + tools: [ + { + name: "find_symbol", + args: { name_path: "{fileBasename}" }, + confidence: 0.9, + }, + ], + }, +}; diff --git a/docs/plugins/examples/static-context-plugin.mjs b/docs/plugins/examples/static-context-plugin.mjs new file mode 100644 index 0000000..fa57285 --- /dev/null +++ b/docs/plugins/examples/static-context-plugin.mjs @@ -0,0 +1,39 @@ +/** + * engramx plugin: static-context — inject a fixed block of text into + * every Read. + * + * Trivial example of the CLASSIC plugin path (plugin writes its own + * `resolve()` and `isAvailable()` — no mcpConfig involved). Useful for: + * - Project-specific reminders you want on every file Read + * - House-rule blocks that belong in the context, not CLAUDE.md + * - Quick experiments before promoting to a real MCP-backed plugin + * + * Install: copy to `~/.engram/plugins/static-context.mjs` and edit + * the `MESSAGE` constant. + */ + +const MESSAGE = ` + ! Reminder: all DB migrations must pass on SQLite 3.35+ (the CI runner) + ! House style: feature branches named feat/- +`.trim(); + +export default { + name: "static-context", + label: "PROJECT REMINDER", + version: "0.1.0", + description: "Always-on project reminder injected at every Read.", + tier: 1, + tokenBudget: 50, + timeoutMs: 200, + async resolve() { + return { + provider: "static-context", + content: MESSAGE, + confidence: 0.6, + cached: false, + }; + }, + async isAvailable() { + return MESSAGE.length > 0; + }, +}; diff --git a/src/providers/plugin-loader.ts b/src/providers/plugin-loader.ts index 57e11bf..08ec6aa 100644 --- a/src/providers/plugin-loader.ts +++ b/src/providers/plugin-loader.ts @@ -14,7 +14,9 @@ import { existsSync, readdirSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { pathToFileURL } from "node:url"; -import type { ContextProviderPlugin } from "./types.js"; +import type { ContextProvider, ContextProviderPlugin, RawPluginShape } from "./types.js"; +import { validateProviderConfig, type McpProviderConfig } from "./mcp-config.js"; +import { createMcpProvider } from "./mcp-client.js"; /** * Resolve the plugins directory at call time, not module-load time. @@ -48,6 +50,11 @@ export function ensurePluginsDir(dir?: string): void { /** * Validate a loaded module exports a ContextProviderPlugin-shaped object. * Returns null + reason if invalid, the plugin object if valid. + * + * v3.0 — a plugin may declare `mcpConfig` INSTEAD of writing its own + * `resolve()` / `isAvailable()`. In that case the loader auto-wraps via + * `createMcpProvider()` and fills in the ContextProvider contract from + * the MCP factory. If BOTH are present, the custom `resolve()` wins. */ export function validatePlugin(mod: unknown): { plugin: ContextProviderPlugin | null; reason: string } { if (!mod || typeof mod !== "object") { @@ -60,41 +67,91 @@ export function validatePlugin(mod: unknown): { plugin: ContextProviderPlugin | return { plugin: null, reason: "default export is not an object" }; } - const p = candidate as Partial; - const required: (keyof ContextProviderPlugin)[] = [ - "name", - "label", - "tier", - "tokenBudget", - "timeoutMs", - "version", - "resolve", - "isAvailable", - ]; - - for (const field of required) { - if (p[field] === undefined || p[field] === null) { - return { plugin: null, reason: `missing required field: ${field}` }; - } - } + const p = candidate as Partial; - if (typeof p.resolve !== "function") { - return { plugin: null, reason: "resolve must be a function" }; + // Always-required identification fields + if (typeof p.name !== "string" || p.name.length === 0) { + return { plugin: null, reason: "name must be a non-empty string" }; } - if (typeof p.isAvailable !== "function") { - return { plugin: null, reason: "isAvailable must be a function" }; + if (typeof p.label !== "string" || p.label.length === 0) { + return { plugin: null, reason: `[${p.name}] label must be a non-empty string` }; } - if (p.tier !== 1 && p.tier !== 2) { - return { plugin: null, reason: `tier must be 1 or 2 (got ${String(p.tier)})` }; + if (typeof p.version !== "string" || p.version.length === 0) { + return { plugin: null, reason: `[${p.name}] version must be a non-empty string` }; } - if (typeof p.name !== "string" || p.name.length === 0) { - return { plugin: null, reason: "name must be a non-empty string" }; + + const hasMcpConfig = p.mcpConfig !== undefined && p.mcpConfig !== null; + const hasResolve = typeof p.resolve === "function"; + + if (!hasMcpConfig && !hasResolve) { + return { + plugin: null, + reason: `[${p.name}] plugin needs either a resolve() function or an mcpConfig declaration`, + }; + } + + // Classic path — plugin wrote its own resolve/isAvailable + if (hasResolve) { + const classicRequired: (keyof RawPluginShape)[] = [ + "tier", + "tokenBudget", + "timeoutMs", + "isAvailable", + ]; + for (const field of classicRequired) { + if (p[field] === undefined || p[field] === null) { + return { plugin: null, reason: `[${p.name}] missing required field: ${field}` }; + } + } + if (typeof p.isAvailable !== "function") { + return { plugin: null, reason: `[${p.name}] isAvailable must be a function` }; + } + if (p.tier !== 1 && p.tier !== 2) { + return { plugin: null, reason: `[${p.name}] tier must be 1 or 2 (got ${String(p.tier)})` }; + } + return { plugin: candidate as ContextProviderPlugin, reason: "" }; } - // Sanity: plugin names must not collide with built-in provider names. - // The resolver applies that check separately — here we just accept - // anything that passes shape validation. - return { plugin: candidate as ContextProviderPlugin, reason: "" }; + // mcpConfig path — validate the declared MCP config and auto-wrap. + // Note the validator sees the raw shape — it expects name/label on the + // mcpConfig itself, so we fill them in from the plugin's outer name/label + // if the inner fields are missing. This keeps the plugin file terse: + // authors write `name` once at the plugin level. + const rawConfig = p.mcpConfig as Record; + const normalizedConfig = { + name: p.name, + label: p.label, + ...rawConfig, + }; + const validation = validateProviderConfig(normalizedConfig); + if (!validation.ok) { + return { + plugin: null, + reason: `[${p.name}] invalid mcpConfig: ${validation.reason}`, + }; + } + const mcpProvider: ContextProvider = createMcpProvider( + validation.value as McpProviderConfig + ); + + // Merge the MCP-derived contract onto the plugin so it's a full + // ContextProviderPlugin. Plugin-declared fields (tier/tokenBudget/ + // timeoutMs) win if present — lets authors override the MCP defaults. + const merged: ContextProviderPlugin = { + name: p.name, + label: p.label, + version: p.version, + description: p.description, + author: p.author, + mcpConfig: p.mcpConfig, + tier: p.tier ?? mcpProvider.tier, + tokenBudget: p.tokenBudget ?? mcpProvider.tokenBudget, + timeoutMs: p.timeoutMs ?? mcpProvider.timeoutMs, + resolve: mcpProvider.resolve.bind(mcpProvider), + isAvailable: mcpProvider.isAvailable.bind(mcpProvider), + }; + + return { plugin: merged, reason: "" }; } /** diff --git a/src/providers/types.ts b/src/providers/types.ts index eeca431..68dd93c 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -117,6 +117,12 @@ export interface ContextProvider { * * Plugins live at `~/.engram/plugins/.mjs` and default-export an * object matching this interface. + * + * v3.0 — plugins can now declare an `mcpConfig` instead of writing their + * own `resolve()` / `isAvailable()`. The plugin loader auto-wraps such + * plugins via `createMcpProvider()` so wrapping an MCP server is a ~10- + * line file. If both `mcpConfig` AND a custom `resolve()` are provided, + * the custom `resolve()` wins (author opted in to hand-rolled logic). */ export interface ContextProviderPlugin extends ContextProvider { /** Semver string for compatibility tracking. */ @@ -125,8 +131,34 @@ export interface ContextProviderPlugin extends ContextProvider { readonly description?: string; /** Optional author attribution. */ readonly author?: string; + /** + * v3.0 — original MCP declaration the plugin file used (if any). + * Retained after auto-wrap so `engram plugin list` can show + * 'mcp-backed' plugins distinctly and tests can verify wrap behavior. + * The loader validates the shape at load-time; from the resolver's + * perspective this field is informational only. + */ + readonly mcpConfig?: unknown; } +/** + * Raw plugin file shape — what authors write in `~/.engram/plugins/*.mjs`. + * Fields that the MCP auto-wrap fills in (resolve, isAvailable, tier, + * tokenBudget, timeoutMs) are optional here so a plugin that only + * declares `mcpConfig` is a valid raw plugin file. The loader's + * `validatePlugin()` upgrades this to a full `ContextProviderPlugin` + * by running the classic-path validation OR auto-wrapping via + * `createMcpProvider()`. + */ +export type RawPluginShape = Partial & { + readonly name: string; + readonly label: string; + readonly version: string; + readonly description?: string; + readonly author?: string; + readonly mcpConfig?: unknown; +}; + /** Provider priority order (highest first). Used when total output exceeds budget. */ export const PROVIDER_PRIORITY: readonly string[] = [ "engram:ast", diff --git a/tests/providers/plugin-loader.test.ts b/tests/providers/plugin-loader.test.ts index 3c2acd1..0e3cf6b 100644 --- a/tests/providers/plugin-loader.test.ts +++ b/tests/providers/plugin-loader.test.ts @@ -81,6 +81,115 @@ describe("validatePlugin", () => { }); }); +describe("validatePlugin — v3.0 mcpConfig auto-wrap", () => { + function makeMcpBackedPlugin(): Record { + return { + name: "mcp:fake", + label: "FAKE MCP", + version: "0.1.0", + description: "An MCP-backed plugin with no custom resolve()", + mcpConfig: { + transport: "stdio", + command: "echo", + args: ["fake"], + tools: [{ name: "fake_tool" }], + }, + }; + } + + it("accepts a plugin with only mcpConfig and auto-wraps resolve/isAvailable", () => { + const result = validatePlugin({ default: makeMcpBackedPlugin() }); + expect(result.plugin).not.toBeNull(); + expect(result.reason).toBe(""); + if (result.plugin) { + expect(result.plugin.name).toBe("mcp:fake"); + expect(result.plugin.label).toBe("FAKE MCP"); + expect(typeof result.plugin.resolve).toBe("function"); + expect(typeof result.plugin.isAvailable).toBe("function"); + expect(result.plugin.tier).toBe(2); + expect(result.plugin.mcpConfig).toBeDefined(); + } + }); + + it("rejects a plugin with neither resolve() nor mcpConfig", () => { + const result = validatePlugin({ + default: { + name: "mcp:empty", + label: "EMPTY", + version: "0.1.0", + }, + }); + expect(result.plugin).toBeNull(); + expect(result.reason).toContain("resolve"); + expect(result.reason).toContain("mcpConfig"); + }); + + it("rejects a plugin with an invalid mcpConfig (missing command)", () => { + const bad = makeMcpBackedPlugin(); + delete (bad.mcpConfig as Record).command; + const result = validatePlugin({ default: bad }); + expect(result.plugin).toBeNull(); + expect(result.reason).toContain("invalid mcpConfig"); + }); + + it("rejects a plugin with an invalid mcpConfig (bad http URL)", () => { + const bad = { + name: "mcp:bad-http", + label: "BAD", + version: "0.1.0", + mcpConfig: { + transport: "http", + url: "not-a-url", + tools: [{ name: "t" }], + }, + }; + const result = validatePlugin({ default: bad }); + expect(result.plugin).toBeNull(); + expect(result.reason).toContain("invalid mcpConfig"); + }); + + it("custom resolve() wins when both resolve() AND mcpConfig are present", () => { + const customResolveMarker = Symbol("custom-resolve-fn"); + const customResolve = async () => null; + (customResolve as unknown as { marker: symbol }).marker = customResolveMarker; + + const plugin = { + ...makeMcpBackedPlugin(), + tier: 2 as const, + tokenBudget: 50, + timeoutMs: 500, + resolve: customResolve, + isAvailable: async () => true, + }; + const result = validatePlugin({ default: plugin }); + expect(result.plugin).not.toBeNull(); + if (result.plugin) { + // Should have kept the author's custom resolve — verify by reference + expect(result.plugin.resolve).toBe(customResolve); + } + }); + + it("plugin tokenBudget override wins over mcpConfig-factory default", () => { + const plugin = { + ...makeMcpBackedPlugin(), + tokenBudget: 999, + }; + const result = validatePlugin({ default: plugin }); + expect(result.plugin).not.toBeNull(); + if (result.plugin) { + expect(result.plugin.tokenBudget).toBe(999); + } + }); + + it("missing version is rejected even for mcpConfig plugins", () => { + const bad = makeMcpBackedPlugin(); + delete bad.version; + const result = validatePlugin({ default: bad }); + expect(result.plugin).toBeNull(); + expect(result.reason).toContain("version"); + }); +}); + describe("loadPlugins (end-to-end)", () => { let testPluginsDir: string; From 16d531a99c01f87a41f0672afb57911c607cef75 Mon Sep 17 00:00:00 2001 From: Nicholas Ashkar Date: Fri, 24 Apr 2026 16:21:17 +0400 Subject: [PATCH 08/18] v3.0(item #3): budget-weighted resolver + mistakes-boost reranking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two orthogonal improvements to the resolver's assembly pipeline. Both exported from resolver.ts so they're testable in isolation, and both run in the main resolveRichPacket() flow before the final priority sort. 1. PER-PROVIDER BUDGET ENFORCEMENT (enforcePerProviderBudget) Providers are SUPPOSED to self-truncate their content to 'tokenBudget', but a bad plugin or a non-conforming MCP server shouldn't be able to spend our entire total budget on one section. New helper truncates each result to the provider's declared budget BEFORE assembly. - Under-budget content passes through unchanged (zero-cost) - Over-budget content is line-truncated (never cut mid-word) - Edge: first line alone > budget -> hard-cap characters with marker Default budget for unknown/missing providers is 200 tokens (matches the MCP-config default from item #1). 2. MISTAKES-BOOST RERANKING (boostByMistakes) If the engram:mistakes provider fires for this file, scan OTHER providers' content for substring matches against mistake labels (extracted from the ' !