diff --git a/dnt.ts b/dnt.ts index 23d02cf..0495eba 100644 --- a/dnt.ts +++ b/dnt.ts @@ -2,6 +2,8 @@ import { build } from "jsr:@deno/dnt@^0.42.3"; import manifest from "./deno.json" with { type: "json" }; const version = Deno.env.get("VERSION")?.trim() || manifest.version?.trim(); +const sdkVersionFromDenoJson = manifest.imports["@modelcontextprotocol/sdk"] + .replace("npm:@modelcontextprotocol/sdk@", ""); if (!version) { throw new Error('Specify $VERSION or set "version" in deno.json.'); } @@ -44,6 +46,7 @@ await build({ node: ">=20", }, dependencies: { + "@modelcontextprotocol/sdk": sdkVersionFromDenoJson, cosmiconfig: "^9.0.0", }, devDependencies: { diff --git a/docs/SmokeTests.md b/docs/SmokeTests.md index 50ef56a..2b8bbca 100644 --- a/docs/SmokeTests.md +++ b/docs/SmokeTests.md @@ -1,7 +1,7 @@ # Smoke Tests - Status: Active -- Last Updated: 2026-03-25 +- Last Updated: 2026-04-11 - Replaces: historical native-hook-first test plan This file, `docs/SmokeTests.md`, replaces the retiring @@ -262,15 +262,22 @@ the change that introduces it; do not assume one here, and do not invent a new - **Exact commands:** ```bash - deno test src/services/session-mcp-runtime.test.ts src/services/session-executor.test.ts src/services/session-corpus.test.ts src/index.test.ts + deno test src/services/session-mcp-runtime.test.ts src/services/session-executor.test.ts src/services/session-corpus.test.ts src/services/session-notes.test.ts src/index.test.ts deno task check ``` - **Expected result:** PASS. Coverage must include each public tool: `session_execute`, `session_execute_file`, `session_batch_execute`, `session_index`, `session_search`, `session_fetch_and_index`, `session_stats`, - and `session_doctor`, including required `root_session_id` contract - enforcement. + `session_doctor`, `session_notes_write`, and `session_notes_read`. Public + note/search coverage must prove the root-session identity is derived from the + runtime session context rather than accepted as a caller argument. Note-tool + coverage must prove explicit write outcomes (`created`, `replaced`, + `deleted`), delete-on-miss no-op success returning + `{ action: "deleted", id + }`, exact single-note reads via + `session_notes_read({ id })`, `{ note: null }` for unknown ids, and + status-less response shapes. - **Artifacts/evidence to save:** Full `deno test` output; failing test names if any; bounded serialized examples for each tool response; any type-check output from `deno task check`. @@ -327,27 +334,35 @@ the change that introduces it; do not assume one here, and do not invent a new raw output concatenated into batch summaries. - **Release-gate severity:** Critical. -### 5.4 Suite D — Local corpus search, ranking, and bounded retrieval semantics +### 5.4 Suite D — Local corpus and session-note search, ranking, and bounded retrieval semantics -- **Objective:** Prove local-first corpus behavior, including indexing, lexical - retrieval, ranking, snippet boundedness, and graceful TTL expiry handling. +- **Objective:** Prove local-first corpus behavior plus session-note recall, + including indexing, lexical retrieval, note-hit merging, ranking, snippet + boundedness, and graceful TTL expiry handling. - **Prerequisites:** Same as Suite A. Graphiti must remain irrelevant to PASS for this suite because local corpus behavior is a hot-tier proof target. - **Exact commands:** ```bash - deno test src/services/session-corpus.test.ts src/services/session-mcp-runtime.test.ts src/services/redis-client.test.ts + deno test src/services/session-corpus.test.ts src/services/session-mcp-runtime.test.ts src/services/session-notes.test.ts src/services/redis-client.test.ts deno task check ``` - **Expected result:** PASS. The small-corpus ranking baseline holds, snippets are bounded, partial-string/fuzzy/stemming/proximity behaviors remain covered - in the local corpus tests, and expired local corpus state returns structured - empty or expired results rather than throwing. + in the local corpus tests, `session_search` can merge matching pinned-note + hits with `type: "note"` plus `id`, `root_session_id`, and + `scope: "local" | "project"`, `session_notes_read` can reopen exact note text + from a note `id`, same-project foreign note hits rank below equivalent local + note hits, and expired local corpus state returns structured empty or expired + results rather than throwing. - **Artifacts/evidence to save:** Full test output; any asserted corpus refs, - snippets, and TTL-expiry results; evidence of ranking-order expectations. + snippets, note-hit metadata, exact note-read assertions, and TTL-expiry + results; evidence of ranking-order expectations. - **Common failure signatures:** Wrong top-ranked corpus for the baseline query; - flat unstructured retrieval; snippet overflow; corpus lookup exceptions after + flat unstructured retrieval; missing `type: "note"` / `id` / `root_session_id` + / `scope` metadata for pinned-note hits; project-scoped note hits outranking + equivalent local hits; snippet overflow; corpus lookup exceptions after expiry; search behavior depending on Graphiti availability. - **Release-gate severity:** Critical. @@ -420,13 +435,15 @@ the change that introduces it; do not assume one here, and do not invent a new - **Expected result:** PASS. Child and parent activity shares one canonical root namespace for corpus and continuity state; temporary-root migration behavior remains safe; deleting a child session does not delete root-owned state; - runtime teardown disposes owned resources exactly once. + root-session note state migrates with canonical-root repair; runtime teardown + disposes owned resources exactly once. - **Artifacts/evidence to save:** Full test output; any asserted canonical root - IDs, migrated namespace refs, teardown/dispose assertions, and child-deletion - safety evidence. + IDs, migrated namespace refs including session-note state, teardown/dispose + assertions, and child-deletion safety evidence. - **Common failure signatures:** Child-local instead of root-local state; mismatched `root_session_id` accepted; orphaned provisional-root keys; - duplicate teardown calls; child deletion removing root-owned artifacts. + duplicate teardown calls; child deletion removing root-owned artifacts; + session notes stranded under the provisional root after canonicalization. - **Release-gate severity:** Critical. ### 5.8 Suite H — Hook enforcement and attribution @@ -463,20 +480,23 @@ the change that introduces it; do not assume one here, and do not invent a new - **Exact commands:** ```bash - deno test src/handlers/chat.test.ts src/handlers/messages.test.ts src/handlers/compacting.test.ts src/handlers/event.test.ts src/services/session-snapshot.test.ts src/services/hot-tier-slice.test.ts + deno test src/session.test.ts src/handlers/chat.test.ts src/handlers/messages.test.ts src/handlers/compacting.test.ts src/handlers/event.test.ts src/services/session-snapshot.test.ts src/services/hot-tier-slice.test.ts deno task check ``` - **Expected result:** PASS. Local continuity sections and snapshots are assembled from hot-tier state, optional cached `` is - additive only, stale envelopes are scrubbed, and compaction preserves + additive only, stale envelopes are scrubbed, normal chat-turn injection omits + ``, compaction-only injection includes complete pinned note + bodies inside ``, and compaction preserves continuity for both direct and delegated work. - **Artifacts/evidence to save:** Full test output; representative emitted - `` blocks; compaction-hook assertions; snapshot-related - assertions. + `` blocks with and without `` as applicable; + compaction-hook assertions; snapshot-related assertions. - **Common failure signatures:** Missing or duplicated `` injection; compaction losing `session_*` continuity; stale envelopes left in - message bodies; Graphiti moved onto the synchronous path. + message bodies; notes injected on ordinary chat turns; compaction omitting or + pre-summarizing pinned note bodies; Graphiti moved onto the synchronous path. - **Release-gate severity:** Critical. ### 5.10 Suite J — Async Graphiti drain and cache refresh @@ -780,33 +800,47 @@ envelope or equivalent prompt-body/log export when the runtime exposes it. - **Objective:** Prove delegated work survives compaction and the root agent can resume from preserved continuity without the operator restating the work. -- **Guarantees covered:** RG-4, RG-7, RG-8. +- **Guarantees covered:** RG-4, RG-5, RG-8. - **Topology:** default topology. - **Procedure:** 1. Prompt the root agent to delegate two children that create at least two memorable sentinels and one explicit pending-task list item. - 2. Drive the live runtime to a natural compaction event. Use ordinary + 2. Before compaction, require one child to call `session_notes_write` with a + concise markdown note that pins the pending task, at least one sentinel, + and the intended next step for resumed execution. + 3. Have the root agent or a child confirm the note is readable via + `session_notes_read` before compaction occurs. + 4. Drive the live runtime to a natural compaction event. Use ordinary conversation pressure or the product's normal compaction control; do not use synthetic hook invocation as proof. - 3. After compaction completes, prompt the root agent: + 5. After compaction completes, prompt the root agent: `Resume the delegated task. What were the two sentinels and what work is still pending?` - 4. Require the root agent to spawn child agent A to verify one sentinel via - `session_search` and child agent B to continue one pending task step. + 6. Require the root agent to spawn child agent A to verify one sentinel via + `session_search` and child agent B to reopen the pinned note with + `session_notes_read` before continuing one pending task step. - **Expected runtime observations:** - pre-compaction delegated work appears in the compaction-preserved memory envelope; + - the compaction-time `` evidence includes a + `` section with the complete pinned note + body as input material; - the root resumes correctly after compaction without the operator replaying the history; - the resumed children continue from the preserved state rather than starting - a fresh branch. + a fresh branch, and the reopened note text still matches the pinned + pre-compaction note. - **Evidence to collect:** pre-compaction prompt/evidence; compaction occurrence - note or log; post-compaction root answer; post-compaction child tool results; - post-compaction `` envelope. + note or log; `session_notes_write` and `session_notes_read` responses; + post-compaction root answer; post-compaction child tool results; post- + compaction `` envelope. - **Pass interpretation:** PASS only if delegated continuity survives compaction - and the resumed execution demonstrably uses preserved memory. + and the resumed execution demonstrably uses preserved memory, including the + compaction-fed pinned note contents. - **Common failure signatures:** post-compaction amnesia; missing child-derived - continuity; resumed search cannot find pre-compaction indexed content. + continuity; resumed search cannot find pre-compaction indexed content; pinned + note omitted from compaction input; resumed note read returns empty or + paraphrased content instead of the stored note body. ### 6.7 Scenario L7 — Restart after delegated and indexed work with continuity and corpus recovery @@ -980,22 +1014,23 @@ Every release packet must be able to point from each critical proof target to its automated suite coverage, its live-runtime proof path or justified exception, and the evidence classes required by §4. -| Coverage row | Guarantees covered | Automated proof path | Live proof path | Required evidence focus | Notes | -| -------------------------------------------------------------- | ------------------ | -------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `session_*` primary bounded execution surface | RG-1 | Suites A, C, H | Scenarios L1, L3, L5, L11 | `session_*` responses, command output, logs/warnings when enforcement occurs | Baseline MCP-first proof row; native-tool success paths do not substitute. | -| `session_batch_execute` mixed-step behavior | RG-2 | Suites B, C | Scenarios L1, L3, L8 | Raw batch response with ordered typed results, bounded output evidence, follow-up summary | Must prove mixed command/search ordering and boundedness, not just command-only batching. | -| `session_index` replacement semantics | RG-3 | Suite E | Scenario L2 | Both index responses, replacement search results, root-visible continuity evidence | Required explicit row: same `(rootSessionId, source, label)` logical document must replace, not append. | -| Canonical root-session sharing across parent/child agents | RG-4 | Suite G | Scenarios L1, L2, L6, L7, L9 | Root/child prompts, tool responses, root-session state observations, emitted envelopes | Mocked child routing never closes this row by itself. | -| Local-first bounded corpus behavior | RG-5 | Suites C, D, E | Scenarios L1, L2, L3, L8, L11 | Search results, corpus refs, Redis/FalkorDB observations where persistence is claimed | Graphiti-backed proof is additive only here. | -| `` presence/omission and bounded formatting | RG-7 | Suites F, I, J | Scenarios L4, L8 | Full surrounding `` block with and without ``; bounded formatting evidence | Required explicit row. Presence and omission are both first-class proof targets. | -| Stale-cache behavior | RG-7 | Suites F, J | Scenario L4 (bounded-recall surface only) | Cache metadata, refresh observations, emitted envelope before/after refresh when exposed | Required explicit row. Deterministic stale-cache injection is automated-primary; live proof checks that recall stays additive and bounded rather than forcing a brittle stale-cache setup. | -| Cross-session recall | RG-6, RG-7 | Suites F, I, J, K | Scenario L4 | Phase-A and phase-B evidence, Graphiti drain/cache observations, later emitted `` context | Required explicit row. Proof fails if later recall is claimed without cache/drain evidence or emitted bounded context. | -| Graphiti off the hot path | RG-6 | Suites F, J | Scenarios L4, L8 | Hot-path success evidence plus drain/cache or degraded Graphiti observations | Must show original work succeeded before any fresh Graphiti read was required. | -| Compaction continuity | RG-8 | Suite I | Scenario L6 | Pre- and post-compaction envelopes, post-compaction tool responses, continuity observations | Synthetic hook calls alone do not satisfy this row. | -| Restart and recovery with Redis/FalkorDB intact | RG-9 | Suite K | Scenario L7 | Restart timing note, resumed-session proof, search/stats results, state observations | Requires true stop/start evidence, not same-process simulation only. | -| Graphiti-unavailable degradation | RG-6, RG-7, RG-9 | Suite K | Scenario L8 | Graphiti-down confirmation, warnings or doctor output, emitted omission of `` | Required explicit row. Live proof must show omission without hot-path failure. | -| Redis/FalkorDB degradation and reconnect boundaries | RG-9 | Suite K | Scenario L9 | Before/during/after doctor output, warnings, post-reconnect fresh index/search evidence | Do not overclaim persisted continuity from temporary degraded fallback. | -| Combined-backend degradation boundary | RG-9 | Suite K | Scenario L10 (explicit automated-only exception) | Automated degradation evidence bundle plus written exception note | Required explicit row. This is the one sanctioned automated-only live exception. | +| Coverage row | Guarantees covered | Automated proof path | Live proof path | Required evidence focus | Notes | +| -------------------------------------------------------------- | ------------------ | -------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `session_*` primary bounded execution surface | RG-1 | Suites A, C, H | Scenarios L1, L3, L5, L11 | `session_*` responses, command output, logs/warnings when enforcement occurs | Baseline MCP-first proof row; native-tool success paths do not substitute. | +| `session_batch_execute` mixed-step behavior | RG-2 | Suites B, C | Scenarios L1, L3, L8 | Raw batch response with ordered typed results, bounded output evidence, follow-up summary | Must prove mixed command/search ordering and boundedness, not just command-only batching. | +| `session_index` replacement semantics | RG-3 | Suite E | Scenario L2 | Both index responses, replacement search results, root-visible continuity evidence | Required explicit row: same `(rootSessionId, source, label)` logical document must replace, not append. | +| Canonical root-session sharing across parent/child agents | RG-4 | Suite G | Scenarios L1, L2, L6, L7, L9 | Root/child prompts, tool responses, root-session state observations, emitted envelopes | Mocked child routing never closes this row by itself. | +| Local-first bounded corpus behavior | RG-5 | Suites C, D, E | Scenarios L1, L2, L3, L8, L11 | Search results, corpus refs, Redis/FalkorDB observations where persistence is claimed | Graphiti-backed proof is additive only here. | +| Pinned session notes and compaction-only note injection | RG-4, RG-5, RG-8 | Suites A, D, G, I | Scenario L6 | `session_notes_write` / `session_notes_read` responses, note-tagged `session_search` hits, compaction envelopes with `` | Required explicit row. Proof must show exact note reads plus compaction-only injection of complete note bodies, not note summaries on ordinary chat turns. | +| `` presence/omission and bounded formatting | RG-7 | Suites F, I, J | Scenarios L4, L8 | Full surrounding `` block with and without ``; bounded formatting evidence | Required explicit row. Presence and omission are both first-class proof targets. | +| Stale-cache behavior | RG-7 | Suites F, J | Scenario L4 (bounded-recall surface only) | Cache metadata, refresh observations, emitted envelope before/after refresh when exposed | Required explicit row. Deterministic stale-cache injection is automated-primary; live proof checks that recall stays additive and bounded rather than forcing a brittle stale-cache setup. | +| Cross-session recall | RG-6, RG-7 | Suites F, I, J, K | Scenario L4 | Phase-A and phase-B evidence, Graphiti drain/cache observations, later emitted `` context | Required explicit row. Proof fails if later recall is claimed without cache/drain evidence or emitted bounded context. | +| Graphiti off the hot path | RG-6 | Suites F, J | Scenarios L4, L8 | Hot-path success evidence plus drain/cache or degraded Graphiti observations | Must show original work succeeded before any fresh Graphiti read was required. | +| Compaction continuity | RG-8 | Suite I | Scenario L6 | Pre- and post-compaction envelopes, post-compaction tool responses, continuity observations | Synthetic hook calls alone do not satisfy this row. | +| Restart and recovery with Redis/FalkorDB intact | RG-9 | Suite K | Scenario L7 | Restart timing note, resumed-session proof, search/stats results, state observations | Requires true stop/start evidence, not same-process simulation only. | +| Graphiti-unavailable degradation | RG-6, RG-7, RG-9 | Suite K | Scenario L8 | Graphiti-down confirmation, warnings or doctor output, emitted omission of `` | Required explicit row. Live proof must show omission without hot-path failure. | +| Redis/FalkorDB degradation and reconnect boundaries | RG-9 | Suite K | Scenario L9 | Before/during/after doctor output, warnings, post-reconnect fresh index/search evidence | Do not overclaim persisted continuity from temporary degraded fallback. | +| Combined-backend degradation boundary | RG-9 | Suite K | Scenario L10 (explicit automated-only exception) | Automated degradation evidence bundle plus written exception note | Required explicit row. This is the one sanctioned automated-only live exception. | ## 8. Release Gates diff --git a/docs/superpowers/plans/2026-04-07-package-relative-runtime-resolution-implementation.md b/docs/superpowers/plans/2026-04-07-package-relative-runtime-resolution-implementation.md new file mode 100644 index 0000000..5e0ddcd --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-package-relative-runtime-resolution-implementation.md @@ -0,0 +1,406 @@ +# Package-Relative Runtime Resolution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the installed `opencode-graphiti` package resolve `cosmiconfig` +and `@modelcontextprotocol/sdk` from the plugin package instead of +`process.cwd()`, and prove it works when OpenCode launches from an unrelated +directory. + +**Architecture:** Introduce a package-relative `createRequire(...)` anchor +derived from `import.meta.url` in the two runtime loaders, keep MCP SDK loading +lazy, and update generated npm package metadata so the published package +declares all runtime dependencies it resolves at runtime. Validate the fix with +a Node package-name regression that runs from a bare temp cwd rather than the +repository tree. + +**Tech Stack:** Deno, TypeScript, DNT, Node ESM interop, `cosmiconfig`, +`@modelcontextprotocol/sdk`, OpenCode packaging regression tests. + +**Done when:** `deno test -A packaging.test.ts` passes with the Node +package-name regression running from a bare cwd, and `deno test -A`, +`deno task check`, `deno task lint`, and `deno task fmt` all pass. + +--- + +### File Map + +**Modify:** + +- `packaging.test.ts` Responsibility: package build regression coverage and + runtime dependency assertions +- `src/config.ts` Responsibility: runtime config discovery loading through + package-relative `require` +- `src/services/connection-manager.ts` Responsibility: lazy MCP SDK runtime + loading through package-relative resolution +- `dnt.ts` Responsibility: generated `dist/package.json` dependency metadata + +**Create:** + +- None required unless the implementation needs a very small shared runtime + helper for the package-relative `createRequire(...)` anchor + +**Spec Reference:** + +- `docs/superpowers/specs/2026-04-07-package-relative-runtime-resolution-design.md` + +### Task 1: Add the Failing Packaging Regression First + +**Files:** + +- Modify: `packaging.test.ts` + +- [ ] **Step 1: Add the failing Node package-name import regression** + +In `packaging.test.ts`, add a new Node runner that imports the package by name: + +```js +import * as plugin from "opencode-graphiti"; +console.log(JSON.stringify(Object.keys(plugin).sort())); +``` + +Use a temp `node_modules/opencode-graphiti -> dist` symlink and run Node from a +separate bare temp cwd that is not the repository root. + +The existing Bun runner already imports by package name. Keep it as secondary +coverage, but add a Node package-name runner because the current Node runner +only imports the built entrypoint by absolute `file://` URL and does not +exercise the cwd-sensitive bug. + +Expected initial failure mode before the fix: + +- package import fails because runtime dependency resolution still follows + `process.cwd()` instead of the plugin package + +- [ ] **Step 2: Add the failing OpenCode package-name regression path** + +If `OPENCODE_BIN` is available, update the OpenCode regression setup so it loads +`opencode-graphiti` by package name from isolated config and launches with a cwd +outside the repository tree. + +Example config payload to write into isolated config: + +```jsonc +{ + "plugin": ["opencode-graphiti"] +} +``` + +Keep the cwd pointed at a separate temp directory without matching dependency +entries. + +- [ ] **Step 3: Keep any DNT output inspection diagnostic-only** + +If you inspect emitted `dist/esm/...` files for debugging, do not fail the test +suite solely because DNT emitted `import-meta-ponyfill-esmodule`. + +The required contract is emitted package behavior: + +- Node package-name loading from a bare cwd reproduces the current bug +- the later fix makes package-relative runtime resolution work correctly + +- [ ] **Step 4: Run the targeted packaging test and verify it fails for the + right reason** + +Run: `deno test -A packaging.test.ts` + +Expected: + +- FAIL +- failure proves the package-name runtime regression is real under the installed + package simulation + +- [ ] **Step 5: Commit the red test change** + +```bash +git add packaging.test.ts +git commit -m "test: cover package-relative runtime resolution" +``` + +### Task 2: Fix Config Runtime Resolution + +Status: local implementation started; continue from the current `src/config.ts` +state instead of redoing the old `process.cwd()` anchor change. + +**Files:** + +- Modify: `src/config.ts` + +- [ ] **Step 1: Introduce a package-relative `createRequire(...)` anchor** + +The old code was: + +```ts +const nodeRequire = createRequire( + join(process.cwd(), "graphiti.config.runtime.cjs"), +); +``` + +Continue using a module-relative anchor derived from `import.meta.url`, +targeting the plugin package location rather than the caller cwd. + +Use the same URL-based `createRequire(...)` input form intended for +`connection-manager.ts`. + +- [ ] **Step 2: Keep the implementation minimal** + +Do not change config semantics. Only change how `cosmiconfig` is resolved at +runtime. + +- [ ] **Step 3: Run the targeted packaging test to confirm the config side is no + longer the blocker** + +Run: `deno test -A packaging.test.ts` + +Expected: + +- still FAIL or partially progress because the MCP SDK path is still broken +- config-only runtime loading no longer fails through `process.cwd()` + +Add the smallest targeted check needed to make that intermediate state explicit, +for example a Node snippet that exercises only the config loader path rather +than the full plugin bootstrap. + +Do not require the emitted `config.js` to avoid DNT's `import-meta` helper; only +require the config loader to behave correctly from the installed package shape. + +- [ ] **Step 4: Commit the config fix** + +```bash +git add src/config.ts packaging.test.ts +git commit -m "fix: resolve config runtime deps from package" +``` + +### Task 3: Fix MCP SDK Runtime Resolution + +**Files:** + +- Modify: `src/services/connection-manager.ts` + +- [ ] **Step 1: Replace the cwd-anchored runtime require** + +Replace the current: + +```ts +const nodeRequire = createRequire( + pathToFileURL(join(process.cwd(), "graphiti.runtime.cjs")).href, +); +``` + +with the same package-relative anchor strategy used in `src/config.ts`. + +- [ ] **Step 2: Preserve lazy runtime loading behavior** + +Keep the current shape: + +```ts +const resolvedPath = nodeRequire.resolve(specifier); +return await import(pathToFileURL(resolvedPath).href) as T; +``` + +Do not refactor the MCP connection manager beyond what is needed for package +relative resolution. + +- [ ] **Step 3: Keep JSON manifest behavior unchanged unless it blocks the + test** + +The `deno.json` import for `manifest.name` and `manifest.version` is not part of +this change. Only touch it if the packaging regression proves it is necessary. + +- [ ] **Step 4: Run the targeted packaging test and verify the runtime + regression turns green** + +Run: `deno test -A packaging.test.ts` + +Expected: + +- PASS for the runtime regression coverage added in Task 1 +- Node package-name import succeeds from the bare temp cwd +- optional OpenCode regression succeeds when `OPENCODE_BIN` is present + +- [ ] **Step 5: Commit the MCP SDK resolution fix** + +```bash +git add src/services/connection-manager.ts packaging.test.ts +git commit -m "fix: resolve MCP runtime deps from package" +``` + +### Task 4: Update Generated Package Metadata + +**Files:** + +- Modify: `dnt.ts` + +- [ ] **Step 1: Write the failing dependency metadata assertion** + +Add an assertion that generated `dist/package.json` contains: + +```ts +assertEquals( + builtPackage.dependencies?.["@modelcontextprotocol/sdk"], + expectedSdkVersionFromDenoJson, + "generated npm package must declare the MCP SDK for runtime loading", +); +``` + +Run: `deno test -A packaging.test.ts` + +Expected before the metadata change is applied: + +- FAIL on the missing generated dependency assertion + +- [ ] **Step 2: Add generated runtime dependency metadata for the MCP SDK** + +Update `dnt.ts` package dependencies to include: + +```ts +dependencies: { + "@modelcontextprotocol/sdk": sdkVersionFromDenoJson, + cosmiconfig: "^9.0.0", +}, +``` + +Mirror the version range already declared in `deno.json`. + +- [ ] **Step 3: Keep existing generated metadata intact** + +Do not change: + +- package name/version/entrypoint metadata +- `@types/node` in `devDependencies` +- hook registration metadata + +- [ ] **Step 4: Run the targeted packaging test and verify metadata assertions + pass** + +Run: `deno test -A packaging.test.ts` + +Expected: + +- PASS +- built `dist/package.json` contains both runtime dependencies + +- [ ] **Step 5: Commit the generated package metadata change** + +```bash +git add dnt.ts packaging.test.ts +git commit -m "fix: declare MCP SDK in generated package" +``` + +### Task 5: Refactor Only If the Anchor Logic Is Clearly Duplicated + +**Files:** + +- Modify: `src/config.ts` +- Modify: `src/services/connection-manager.ts` +- Create: only if a tiny shared helper is clearly justified + +- [ ] **Step 1: Compare the final package-relative anchor logic in both files** + +If the logic is identical and awkwardly duplicated, extract the smallest helper +that keeps emitted behavior obvious. + +- [ ] **Step 2: Do not extract a helper unless it simplifies both files** + +Prefer duplication over an unnecessary abstraction if the helper would only save +one or two lines. + +- [ ] **Step 3: Re-run the focused regression after any refactor** + +Run: `deno test -A packaging.test.ts` + +Expected: PASS + +- [ ] **Step 4: Commit the refactor only if one was actually needed** + +```bash +git add src/config.ts src/services/connection-manager.ts +git commit -m "refactor: share package-relative runtime anchor" +``` + +If no refactor was needed, skip this commit. + +### Task 6: Full Verification + +**Files:** + +- No new files + +- [ ] **Step 1: Run the focused regression one more time** + +Run: `deno test -A packaging.test.ts` + +Expected: PASS + +- [ ] **Step 2: Run the full test suite** + +Run: `deno test -A` + +Expected: PASS + +- [ ] **Step 3: Run type checking** + +Run: `deno task check` + +Expected: PASS + +- [ ] **Step 4: Run linting** + +Run: `deno task lint` + +Expected: PASS + +- [ ] **Step 5: Run formatting** + +Run: `deno task fmt` + +Expected: PASS or only intentional formatting updates + +- [ ] **Step 6: If formatting changed files, re-run the focused regression** + +Run: `deno test -A packaging.test.ts` + +Expected: PASS + +- [ ] **Step 7: Commit final verification-safe cleanup** + +```bash +git add packaging.test.ts src/config.ts src/services/connection-manager.ts dnt.ts +git commit -m "fix: anchor plugin runtime deps to package" +``` + +Skip this commit if the earlier per-task commits already cleanly capture the +final state and no additional changes were made. + +### Task 7: Completion Notes + +**Files:** + +- Modify only if needed: `README.md` + +- [ ] **Step 1: Check whether docs need a narrow clarification** + +Only update `README.md` if the local development testing guidance now needs a +small clarification that package-name install simulation is the preferred local +regression path for this bug class. + +- [ ] **Step 2: Keep documentation scope narrow** + +Do not rewrite installation docs unless the implementation changed the +documented contract. + +- [ ] **Step 3: Run affected verification again if docs stayed untouched** + +No command required if code did not change. + +- [ ] **Step 4: Commit doc clarification only if you actually changed docs** + +```bash +git add README.md +git commit -m "docs: clarify local package regression workflow" +``` + +Skip this commit if no doc change was necessary. diff --git a/docs/superpowers/plans/2026-04-11-session-notes-anti-drift.md b/docs/superpowers/plans/2026-04-11-session-notes-anti-drift.md new file mode 100644 index 0000000..053b4f4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-session-notes-anti-drift.md @@ -0,0 +1,956 @@ +# Session Notes Anti-Drift Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an agent-driven session-notes layer that preserves working context +across long sessions, topic switches, and compaction. Three MCP tools +(`session_notes_write`, `session_notes_read`, updated `session_search`) give +agents explicit control over pinning and recalling anti-drift context, with +complete note bodies injected into compaction input. + +**Architecture:** A dedicated Redis-backed note service on the existing hot tier +stores opaque markdown note bodies keyed by canonical root session. Notes +surface through `session_search` result merging and are injected as raw input +into compaction. Per-session `biasState` flags drive dynamic `session_search` +description strengthening via the `tool.definition` hook. + +**Tech Stack:** Deno, TypeScript, Redis (ioredis), Zod, `@opencode-ai/plugin`. + +**Done when:** All existing and new tests pass: + +- `deno test -A src/services/session-notes.test.ts` +- `deno test -A src/services/session-mcp-runtime.test.ts` +- `deno test -A src/handlers/compacting.test.ts` +- `deno test -A src/session.test.ts` +- `deno test -A src/index.test.ts` +- `deno test -A` +- `deno task check` +- `deno task lint` +- `deno task fmt` + +--- + +## Verbatim Tool Descriptions (Ship As-Is) + +These descriptions are deliberately prescriptive to bias agent behavior toward +the intended anti-drift workflows. They ship verbatim in the tool registrations. + +**Multi-line rendering note:** These descriptions are substantially longer and +more structured than the typical one-line tool descriptions. Before shipping, +verify that multi-line descriptions render correctly in the OpenCode tool +surface (the tool picker / description display). See Task 7 Step 4 for the +concrete validation step. + +### `session_notes_write` Description + +> **Ship this description verbatim in the tool registration.** + + + + Pin working context as a session note so it survives topic switches, long tool + loops, and compaction. Use this BEFORE drifting away from important context: + + - Before switching to a different topic or task + - After a user correction changes your assumptions + - When a small task stalls and work shifts elsewhere + - During long tool-calling sequences where key state lives only in your context + - Before compaction is likely (many messages into a session) + + Do NOT use this for ephemeral state that will be irrelevant within a few turns + (e.g., intermediate variable values, transient build errors you are about to + fix, or scratchpad reasoning). Notes are for context you need to survive + across topic switches or compaction — not for every observation. + + Accepts `text` (markdown body) and optional `replace` (a note_id to update one + note, or "*" to replace all notes). The response tells you exactly what + happened: + + - `{ action: "created", note_id }` for a new note + - `{ action: "replaced", note_id }` when replacing one note + - `{ action: "deleted", note_id }` when empty `text` deletes one note + - `{ action: "replaced", note_id, cleared_count }` when replacing all notes + - `{ action: "replaced", cleared_count }` when empty `text` clears all notes + + Always rely on the returned `action` instead of inferring the outcome from the + inputs alone. + + Prefer concise markdown with headings, bullets, and short code snippets: + + ## Current Task: Fix Redis TTL bug + - **File:** `src/services/redis-client.ts` + - **Root cause:** TTL not refreshed on read + - **Next step:** Add EXPIRE call after GET in `refreshEntry()` + - **User correction:** Use seconds not milliseconds for TTL + + + +**Response note:** `session_notes_write` intentionally omits `status` from its +response. This diverges from existing MCP tool responses that typically include +a `status` field. The omission is deliberate. The tool still makes outcomes +explicit by returning `action` and the relevant identifiers/counts directly. + +### `session_notes_read` Description + +> **Ship this description verbatim in the tool registration.** + + + + Reopen exact pinned note text instead of reconstructing it from memory. Use this + when you resume an interrupted topic, need the exact wording of a pinned user + instruction, or want to verify what you previously noted before acting on it. + + If `id` is provided, returns that single note. If `id` is omitted, returns all + notes for the current session. Returns + `{ notes: [{ note_id, text, created_at, updated_at }] }`. + + Always prefer reading a pinned note over reciting its contents from recall — + notes are the source of truth for intentionally preserved context. + + + +**Response note:** `session_notes_read` intentionally omits `status` from its +response. When `id` is omitted and no notes exist, returns `{ notes: [] }` +(empty array). When `id` is provided but the note does not exist, returns +`{ notes: [] }` (empty array, not an error). + +### `session_search` Description (Baseline) + +> **Ship this description verbatim in the tool registration.** + + + + Search local indexed content for the current root session. This is the default + recall path — use it FIRST when you need prior context, especially: + + - At the start of a new session or after compaction + - When resuming a topic you worked on earlier + - Before re-solving a problem that may already have a solution in session history + - To check whether pinned session notes already contain the context you need + + Results may include indexed memory content (type: "memory") and, when pinned + session notes exist, matching notes (type: "note"). Note results include a + `note_id` — use `session_notes_read` with that id to reopen the full note + text. Not every query will return note results; notes only appear when they + match the search query and the session has pinned notes. + + Prefer session_search over reconstructing context from scratch. If search + returns relevant note hits, read the note before duplicating its contents. + + + +### `session_search` Description (Dynamic Bias — New Session / Post-Compaction) + +This strengthened variant is emitted by the `tool.definition` hook when any +tracked session has `biasState` `"new-session"` or `"post-compaction"`. See Task +5 for the Map-based mechanism. + +> **Ship this description verbatim when bias is active.** + + + + Search local indexed content for the current root session. This is the default + recall path — use it FIRST when you need prior context, especially: + + - At the start of a new session or after compaction + - When resuming a topic you worked on earlier + - Before re-solving a problem that may already have a solution in session history + - To check whether pinned session notes already contain the context you need + + Results may include indexed memory content (type: "memory") and, when pinned + session notes exist, matching notes (type: "note"). Note results include a + `note_id` — use `session_notes_read` with that id to reopen the full note + text. Not every query will return note results; notes only appear when they + match the search query and the session has pinned notes. + + Prefer session_search over reconstructing context from scratch. If search + returns relevant note hits, read the note before duplicating its contents. + + ⚠️ This is a new session or a post-compaction turn. Prior context may have been + summarized or is not yet in your working memory. STRONGLY RECOMMENDED: run a + session_search query before starting work to recover earlier decisions, pinned + notes, and task state. This avoids re-solving problems or contradicting earlier + decisions that survived compaction. + + + +--- + +## File Map + +### Create + +| File | Purpose | +| ------------------------------------ | -------------------------------------------------- | +| `src/services/session-notes.ts` | Redis-backed note service: CRUD, TTL, search-merge | +| `src/services/session-notes.test.ts` | TDD test suite for the note service | + +### Modify + +| File | Purpose | +| ------------------------------------------ | --------------------------------------------------------------------------- | +| `src/services/session-mcp-types.ts` | Add note tool names, request/response schemas, extend search result | +| `src/services/session-mcp-runtime.ts` | Register note tools, merge note hits into search, update descriptions | +| `src/services/session-mcp-runtime.test.ts` | Tests for note tool routing, search merge, description bias | +| `src/session.ts` | Internal: extend compaction envelope with `` section | +| `src/session.test.ts` | Tests for note-aware compaction envelope (file already exists) | +| `src/handlers/compacting.ts` | Pass note service to enable note loading for compaction | +| `src/handlers/compacting.test.ts` | Tests for complete note injection in compaction | +| `src/index.ts` | Instantiate note service, wire `biasState`, register `tool.definition` hook | +| `src/index.test.ts` | Tests for note service wiring and `tool.definition` hook | + +**Note:** `src/session.test.ts` already exists with session-manager tests. New +compaction-envelope tests for notes will be added to this existing file. + +**Spec Reference:** +`docs/superpowers/specs/2026-04-11-session-notes-anti-drift-design.md` + +--- + +## Task 1: Note Service Core — Redis CRUD and TTL + +**Files:** + +- Create: `src/services/session-notes.ts` +- Create: `src/services/session-notes.test.ts` + +- [ ] **Step 1: Write failing tests for note append, read, and TTL** + + Write tests that exercise: + - `writeNote(rootSessionId, text)` → returns + `{ action: "created", note_id: string }` + - `readNotes(rootSessionId)` → returns all notes with + `{ note_id, text, created_at, updated_at }` + - `readNotes(rootSessionId, noteId)` → returns single note + - `readNotes(rootSessionId)` when no notes exist → returns `{ notes: [] }` + - `readNotes(rootSessionId, "nonexistent-id")` → returns `{ notes: [] }` + - Notes use Redis key namespace `session:{rootSessionId}:notes` + - Notes expire with `sessionTtlSeconds` TTL + - Note IDs are stable and unique per session + + Test dependencies: Provide a mock or stub Redis that implements only the + methods used by `SessionNotesService` (HSET, HGET, HGETALL, HDEL, DEL, + EXPIRE). Follow the same test-double pattern used in + `session-mcp-runtime.test.ts` — create minimal in-memory stubs rather than + mocking the full `RedisClient` class. + +- [ ] **Step 2: Write failing tests for replace and clear semantics** + + - `writeNote(rootSessionId, text, { replace: noteId })` → + `{ action: "replaced", note_id }` + - `writeNote(rootSessionId, text, { replace: "*" })` → + `{ action: "replaced", note_id, cleared_count }` + - `writeNote(rootSessionId, "", { replace: noteId })` → + `{ action: "deleted", note_id }` + - `writeNote(rootSessionId, "", { replace: "*" })` → + `{ action: "replaced", cleared_count }` + - Replace applies only within the canonical root session + +- [ ] **Step 3: Write failing tests for note search** + + - `searchNotes(rootSessionId, query)` → returns note hits with snippet, score, + note_id + - Note search uses simple substring/token matching on note text + - Results include enough metadata for `session_search` merging + - **Scoring contract:** Scores are `0`–`1` floats where `1.0` = exact full + match. The scoring must be deterministic for the same query/text pair. + Memory-hit scores from the existing `session_search` pipeline are also + `0`–`1` floats, so merged sorting by descending score produces a sensible + interleaved ranking without further normalization. + +- [ ] **Step 4: Implement `SessionNotesService`** + + Implement the minimal service to pass all Step 1–3 tests: + + ```ts + export class SessionNotesService { + constructor( + private readonly redis: RedisClient, + private readonly options: { sessionTtlSeconds: number }, + ) {} + + async writeNote( + rootSessionId: string, + text: string, + options?: { replace?: string }, + ): Promise< + | { action: "created"; note_id: string } + | { action: "replaced"; note_id: string } + | { action: "deleted"; note_id: string } + | { action: "replaced"; note_id?: string; cleared_count: number } + > { ... } + + async readNotes( + rootSessionId: string, + noteId?: string, + ): Promise<{ + notes: Array<{ + note_id: string; + text: string; + created_at: string; + updated_at: string; + }>; + }> { ... } + + async searchNotes( + rootSessionId: string, + query: string, + ): Promise> { ... } + } + ``` + + Storage model: use Redis HSET with `session:{rootSessionId}:notes` hash key. + Each field is a note ID; each value is JSON with + `{ text, created_at, updated_at }`. Set TTL via EXPIRE using + `sessionTtlSeconds`. + +- [ ] **Step 5: Verify** + + ```bash + deno test -A src/services/session-notes.test.ts + deno task check + ``` + +--- + +## Task 2: MCP Schema Extensions + +**Files:** + +- Modify: `src/services/session-mcp-types.ts` + +- [ ] **Step 1: Extend `SESSION_MCP_TOOL_NAMES`** + + Add `"session_notes_write"` and `"session_notes_read"` to + `SESSION_MCP_TOOL_NAMES`. + +- [ ] **Step 2: Add request schemas** + + ```ts + session_notes_write: z.object({ + ...rootSessionIdShape, + text: z.string(), + replace: z.string().optional(), + }).strict(), + + session_notes_read: z.object({ + ...rootSessionIdShape, + id: z.string().optional(), + }).strict(), + ``` + +- [ ] **Step 3: Add response schemas** + + ```ts + session_notes_write: z.object({ + action: z.enum(["created", "replaced", "deleted"]), + note_id: z.string().min(1).optional(), + cleared_count: z.number().int().nonnegative().optional(), + }).strict(), + + session_notes_read: z.object({ + notes: z.array(z.object({ + note_id: z.string().min(1), + text: z.string(), + created_at: z.string(), + updated_at: z.string(), + }).strict()), + }).strict(), + ``` + + **Note:** These response schemas intentionally omit `status`. Existing MCP + tool responses include `status`, but note tools return minimal payloads by + design. `session_notes_write` still makes outcomes explicit through `action` + and optional `note_id` / `cleared_count` so agents do not need to infer + deletion or clear behavior from the request inputs. `replaced` may omit + `note_id` when empty `text` clears all notes. + +- [ ] **Step 4: Extend search result schema** + + Add the new optional fields to `searchResultSchema` **while keeping + `.strict()`**: + + ```ts + const searchResultSchema = z.object({ + corpus_ref: z.string().min(1), + snippet: z.string(), + score: z.number(), + type: z.enum(["memory", "note"]).optional(), + note_id: z.string().min(1).optional(), + }).strict(); + ``` + + The existing `sessionSearchResponseSchema` references this schema, so the + extension propagates automatically. Do NOT remove `.strict()`. + +- [ ] **Step 5: Update type maps** + + Extend `SessionMcpRequestMap` and `SessionMcpResponseMap` to include the new + tool types. Ensure `SessionMcpToolName` union type updates automatically from + the const array. + +- [ ] **Step 6: Verify** + + ```bash + deno task check + deno test -A src/services/session-mcp-runtime.test.ts + ``` + +--- + +## Task 3: Tool Registration and Search Merge in MCP Runtime + +**Files:** + +- Modify: `src/services/session-mcp-runtime.ts` +- Modify: `src/services/session-mcp-runtime.test.ts` + +- [ ] **Step 1: Write failing tests for note tool registration** + + - Verify `session_notes_write` and `session_notes_read` are present in + `runtime.tools` + - Verify tool descriptions match the verbatim descriptions from this plan + - Verify args schemas match the request schemas + +- [ ] **Step 2: Write failing tests for note tool execution** + +- `session_notes_write` with text → returns `{ action: "created", note_id }` +- `session_notes_write` with replace one → returns + `{ action: "replaced", note_id }` +- `session_notes_write` with replace `"*"` → returns + `{ action: "replaced", note_id, cleared_count }` +- `session_notes_write` with empty text + replace one → returns + `{ action: "deleted", note_id }` +- `session_notes_write` with empty text + replace `"*"` → returns + `{ action: "replaced", cleared_count }` + - `session_notes_read` without id → returns all notes + - `session_notes_read` with id → returns single note + - `session_notes_read` with no notes → returns `{ notes: [] }` + - Responses validate against the Zod response schemas + +- [ ] **Step 3: Write failing tests for `session_search` note merge** + + - `session_search` returns note hits with `type: "note"` and `note_id` + - Existing memory results have `type: "memory"` (or undefined for backward + compat) + - Note hits and memory hits coexist in the results array, sorted by score + descending + - Note hits include snippet from note text + - When no notes exist, search returns only memory results (no empty note + entries) + +- [ ] **Step 4: Accept `SessionNotesService` as runtime option** + + Add `notesService?: SessionNotesService` to `SessionMcpRuntimeOptions`. + +- [ ] **Step 5: Register note tool handlers** + + Add `session_notes_write` and `session_notes_read` to `sessionMcpToolArgs`, + `descriptions`, and `defaultHandlers`. Wire handlers through the notes + service. + +- [ ] **Step 6: Merge note hits into `session_search`** + + In the `session_search` handler, after `searchLocalCorpus()`, also call + `notesService.searchNotes()`. Merge results: + - Memory hits: `type: "memory"` (or omit for backward compat) + - Note hits: `type: "note"`, `note_id` set, `corpus_ref` set to note ref + - Sort merged results by score descending — both sources produce `0`–`1` + floats so interleaving by score is meaningful + - Cap total results conservatively to avoid overwhelming output + +- [ ] **Step 7: Update `session_search` baseline description** + + Replace the existing `session_search` description with the verbatim baseline + description from this plan. + +- [ ] **Step 8: Verify** + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts + deno task check + ``` + +--- + +## Task 4: Compaction Note Injection + +**Files:** + +- Modify: `src/session.ts` (internal changes only — see scope note below) +- Modify: `src/session.test.ts` (already exists) +- Modify: `src/handlers/compacting.ts` +- Modify: `src/handlers/compacting.test.ts` + +**Scope note:** `buildPreparedInjectionEnvelope`, +`collectPreparedInjectionData`, and `buildPreparedInjection` are all +**private/internal** functions and methods within `src/session.ts`. Changes here +are internal modifications to the `SessionManager` class, not exported API +changes. The public `prepareInjection` method signature gains one new optional +parameter (see gating mechanism below) but remains backward-compatible. + +**Dependency ordering:** Step 3 wires `SessionNotesService` into +`SessionManager` as an optional constructor dependency. Steps 4 and 5 depend on +this wiring being in place, so Step 3 must complete before Steps 4–5. + +### Compaction-Only Gating Mechanism + +The note injection path must be gated so it only activates when building +compaction input, never during normal chat turns. The mechanism is an explicit +`options` parameter on `prepareInjection`: + +```ts +interface PrepareInjectionOptions { + /** When true, include in the envelope. Only the compaction + * handler should set this flag. Default: false. */ + forCompaction?: boolean; +} + +async prepareInjection( + sessionId: string, + lastRequest?: string, + options?: PrepareInjectionOptions, +): Promise +``` + +- `forCompaction` defaults to `false`. Normal chat-turn callers (`chat.message`, + `messages.transform`) do not pass this parameter, so notes are never loaded or + rendered for them. +- The compacting handler passes `{ forCompaction: true }`. +- `collectPreparedInjectionData` receives the flag and only calls + `notesService.readNotes(rootSessionId)` when `forCompaction === true`. +- `buildPreparedInjectionEnvelope` receives a `notes` parameter (array or + `null`) and only renders the `` section when notes are present. + When `forCompaction` is `false`, no notes data is passed through. + +This design is testable: + +- Call `prepareInjection(id)` → verify no `` in envelope even + when notes exist. +- Call `prepareInjection(id, undefined, { forCompaction: true })` → verify + `` is present when notes exist. +- Call `prepareInjection(id, undefined, { forCompaction: true })` → verify + `` is omitted when no notes exist. + +- [ ] **Step 1: Write failing test for `` in compaction + envelope** + + In `src/session.test.ts`, test that calling + `prepareInjection(id, undefined, { forCompaction: true })` produces an + envelope with a `` section when notes are present. The section + must contain: + - Complete note bodies (not summarized) + - Note boundaries with note IDs + - Provenance annotation indicating note-tool origin + - Separation from `` and `` + + Also test that when no notes exist, the `` section is omitted + entirely (not rendered as an empty tag). + + Example expected shape: + ```xml + + + ## Current Task: Fix Redis TTL bug + - Root cause: TTL not refreshed on read + + + ## Blocked: API schema migration + - Waiting on upstream PR #42 + + + ``` + +- [ ] **Step 2: Write failing negative test — normal chat turns do NOT include + ``** + + In `src/session.test.ts`, verify that `prepareInjection(id)` (no options) and + `prepareInjection(id, undefined, { forCompaction: false })` both produce an + envelope that does NOT include a `` section, even when notes + exist for the session. This confirms the `forCompaction` gate works. + +- [ ] **Step 3: Wire `SessionNotesService` into `SessionManager`** + + Accept `SessionNotesService` as an optional dependency in + `SessionManagerOptions`. Store it as a private field on `SessionManager`. This + step must complete before Steps 4–5 can use it. + + ```ts + // In SessionManagerOptions (internal type): + notesService?: SessionNotesService; + ``` + +- [ ] **Step 4: Extend `collectPreparedInjectionData` for compaction notes** + + Add `forCompaction: boolean` to the internal parameters of + `collectPreparedInjectionData`. When `forCompaction` is `true` and + `notesService` is available, load notes from + `SessionNotesService.readNotes(rootSessionId)` alongside the existing parallel + Redis fetches. Include notes in the returned `PreparedInjectionData`. When + `forCompaction` is `false`, skip the notes fetch entirely (do not load then + discard — avoid the I/O). + + **Critical:** The compaction hook feeds the complete note contents as input. + The compaction agent summarizes both the session and the notes. The plugin + must NOT pre-summarize, compress, or reinterpret note bodies before injecting + them. + +- [ ] **Step 5: Render `` XML section in envelope** + + In `buildPreparedInjectionEnvelope`, add an optional `notes` parameter (the + array from `readNotes`, or `null`/`undefined` when not in compaction mode). + After `` and before ``, render the + `` block if the notes array is non-empty. Use `escapeXml` for + note text. Preserve note boundaries and IDs. + + When notes are empty or the parameter is `null`/`undefined`, omit the + `` section entirely — do not render an empty + `` tag. + + **Scope guard:** The `notes` parameter is only populated when + `forCompaction === true` flows through `collectPreparedInjectionData` → + `buildPreparedInjection` → `buildPreparedInjectionEnvelope`. Normal chat-turn + callers never supply notes because `collectPreparedInjectionData` does not + fetch them unless the flag is set. + +- [ ] **Step 6: Wire note service into compacting handler** + + Update `CompactingHandlerDeps` to accept the note service. Pass it through to + `SessionManager` or ensure `SessionManager` already has it from Step 3. The + compaction handler calls `prepareInjection` with the `{ forCompaction: true }` + option: + + ```ts + const prepared = await sessionManager.prepareInjection( + canonicalSessionId, + undefined, + { forCompaction: true }, + ); + ``` + + No other caller (`chat.message`, `messages.transform`) passes this option, + ensuring notes are loaded and rendered exclusively for compaction input. + +- [ ] **Step 7: Write failing test — compaction handler loads notes** + + In `src/handlers/compacting.test.ts`, verify: + - The compaction handler calls `prepareInjection` with + `{ forCompaction: true }` as the third argument + - The resulting envelope in `output.context` includes the `` + block with pre-seeded notes rendered verbatim + - The mock note service's `readNotes` was called during the compaction path + +- [ ] **Step 8: Verify** + + ```bash + deno test -A src/session.test.ts + deno test -A src/handlers/compacting.test.ts + deno task check + ``` + +--- + +## Task 5: Dynamic `session_search` Description Bias via `tool.definition` + +**Files:** + +- Modify: `src/index.ts` +- Modify: `src/index.test.ts` + +### Design: Map-Based Bias State (No Single-Slot Race) + +The `tool.definition` hook receives only `{ toolID: string }` as input — no +session context. Because OpenCode may run multiple sessions concurrently, a +single-slot `activeBiasSessionId` would race. Instead, the plugin uses a +**Map-based approach**: + +```ts +type BiasState = "normal" | "new-session" | "post-compaction"; +const sessionBiasState = new Map(); +``` + +- `chat.message` sets `biasState = "new-session"` for the canonical session ID + when the session has no prior events. +- `session.compacting` sets `biasState = "post-compaction"` for the canonical + session ID. +- `tool.definition` checks **all tracked sessions** in the Map. If **any** + session has a non-`"normal"` bias state, emit the strengthened description. + After emitting, **delete all consumed (non-`"normal"`) entries** from the Map + to reset them. + +**Tradeoff (intentional):** Because `tool.definition` has no session context, +the strengthened description fires if _any_ tracked session is biased, not just +the one the LLM is currently serving. This means an unrelated session's +compaction could trigger one extra strengthened description for another session. +**This is a deliberate design choice, not an accidental side-effect.** The +alternatives considered were: + +1. _Single-slot bias_ — simpler but races under concurrent sessions. +2. _Suppress emission entirely when ambiguous_ — avoids false positives but + misses the critical post-compaction reminder, which is the higher-cost + failure mode. + +The Map approach was chosen because the bias is advisory ("STRONGLY RECOMMENDED: +run a session_search query") — an unnecessary reminder is harmless, while a +missed reminder after compaction actively hurts context recovery. Implementers +should preserve this "err on the side of reminding" behavior and not add +session-matching heuristics that could suppress a legitimate reminder. + +The actual `tool.definition` hook signature (from `@opencode-ai/plugin` +v1.2.26): + +```ts +"tool.definition"?: ( + input: { toolID: string }, + output: { description: string; parameters: any }, +) => Promise; +``` + +- [ ] **Step 1: Write failing test for `biasState` lifecycle** + + In `src/index.test.ts`, test: + - `sessionBiasState` Map is empty initially (no bias for unknown sessions) + - `biasState` = `"new-session"` is set when `chat.message` fires for a session + with no prior events + - `biasState` = `"post-compaction"` is set when `session.compacting` fires + - Entries are deleted from the Map after `tool.definition` emits the + strengthened description for `session_search` + +- [ ] **Step 2: Write failing test for `tool.definition` hook** + + - When any session has `biasState` `"new-session"` or `"post-compaction"`, + calling `tool.definition` with `{ toolID: "session_search" }` mutates + `output.description` to the strengthened variant + - When no session has non-`"normal"` state, description stays at baseline + - `tool.definition` for non-`session_search` tools is a no-op + - After one strengthened emit, the next call returns baseline (entries were + consumed) + - When multiple sessions are biased, one `tool.definition` call consumes all + of them + +- [ ] **Step 3: Implement per-session `biasState` tracking** + + Add module-scoped (or plugin-context-scoped) state: + + ```ts + type BiasState = "normal" | "new-session" | "post-compaction"; + const sessionBiasState = new Map(); + ``` + + - In `chat.message` handler: if the session has no prior events recorded in + Redis, set `sessionBiasState.set(canonicalSessionId, "new-session")` + - In `session.compacting` handler: set + `sessionBiasState.set(canonicalSessionId, "post-compaction")` + +- [ ] **Step 4: Register `tool.definition` hook** + + In the plugin return object, add: + + ```ts + "tool.definition": async ( + input: { toolID: string }, + output: { description: string; parameters: any }, + ) => { + if (input.toolID !== "session_search") return; + + // Check if any tracked session is biased + let anyBiased = false; + for (const [sessionId, state] of sessionBiasState) { + if (state !== "normal") { + anyBiased = true; + sessionBiasState.delete(sessionId); // consume + } + } + + if (anyBiased) { + output.description = STRENGTHENED_SESSION_SEARCH_DESCRIPTION; + } + }, + ``` + +- [ ] **Step 5: Verify** + + ```bash + deno test -A src/index.test.ts + deno task check + ``` + +--- + +## Task 6: Plugin Wiring + +**Files:** + +- Modify: `src/index.ts` +- Modify: `src/index.test.ts` + +- [ ] **Step 1: Write failing test for note service instantiation** + + Verify the plugin factory creates a `SessionNotesService` with the Redis + client and `sessionTtlSeconds` config, and passes it into + `createSessionMcpRuntime` and `SessionManager`. + +- [ ] **Step 2: Instantiate `SessionNotesService` in plugin factory** + + In the `graphiti` plugin function, after creating the `redisClient`, create: + ```ts + const sessionNotes = new SessionNotesService(redisClient, { + sessionTtlSeconds: config.redis.sessionTtlSeconds, + }); + ``` + + Pass `sessionNotes` to: + - `createSessionMcpRuntime({ ..., notesService: sessionNotes })` + - `new SessionManager(..., { ..., notesService: sessionNotes })` + +- [ ] **Step 3: Add `tool.definition` hook to plugin return** + + Ensure the `tool.definition` hook (from Task 5) is included in the returned + plugin hook map. + +- [ ] **Step 4: Update `GraphitiDependencies` type if needed** + + If `SessionNotesService` is injected via DI, add it to the dependencies type. + Otherwise, instantiate directly. + +- [ ] **Step 5: Verify full integration** + + ```bash + deno test -A src/index.test.ts + deno test -A + deno task check + deno task lint + deno task fmt + ``` + +--- + +## Task 7: End-to-End Validation + +- [ ] **Step 1: Run full test suite** + + ```bash + deno test -A + ``` + + All existing tests must pass. No regressions. + +- [ ] **Step 2: Run quality checks** + + ```bash + deno task check + deno task lint + deno task fmt + ``` + +- [ ] **Step 3: Verify critical evidence** + + Confirm through test output: + - Notes can be written, replaced, deleted, cleared via `replace: "*"`, and + read exactly + - `readNotes` with no notes returns `{ notes: [] }` + - `readNotes` with nonexistent ID returns `{ notes: [] }` + - `session_search` includes note hits with `type: "note"` and `note_id` + - `session_search` description is the verbatim baseline from this plan + - Compaction receives full note contents as input with explicit + `` provenance + - Compaction envelope includes notes as raw material alongside session + snapshot, not pre-summarized + - Empty notes produce no `` section (omitted, not empty tag) + - Normal chat-turn `prepareInjection(id)` does NOT include `` + - `prepareInjection(id, undefined, { forCompaction: false })` does NOT include + `` + - `prepareInjection(id, undefined, { forCompaction: true })` DOES include + `` when notes exist + - `tool.definition` strengthens `session_search` when any tracked session is + biased + - Bias entries are consumed (deleted from Map) after one strengthened emission + - Note tool responses omit `status` field + +- [ ] **Step 4: Validate multi-line tool description rendering** + + The new tool descriptions are multi-line and substantially longer than the + previous one-line descriptions. Run the plugin in a local OpenCode instance + (or inspect the tool registration output in test) and verify: + - `session_notes_write`, `session_notes_read`, and `session_search` + descriptions are rendered in full (not truncated) + - Line breaks, indentation, and markdown formatting survive the tool + registration → display pipeline + - No rendering artifacts (e.g., collapsed whitespace, escaped newlines) appear + in the tool picker or tool description surface + + If the OpenCode tool surface truncates or mangles multi-line descriptions, + file a follow-up issue and fall back to a condensed single-paragraph + description that preserves the core behavioral nudges. + +--- + +## Compaction Behavior — Explicit Contract + +The compaction hook injects the complete, unmodified note contents as input +context to the compaction agent. The spec requires: + +1. The plugin loads all notes for the canonical root session from + `SessionNotesService.readNotes(rootSessionId)`. +2. Note bodies are rendered verbatim inside a `` XML section + within the `` envelope. +3. The plugin does NOT pre-summarize, compress, or reinterpret note bodies. +4. The compaction agent receives both the session conversation/tool history AND + the injected note contents, and summarizes them together. +5. The `` section preserves note boundaries (individual `` + tags with IDs and timestamps) so the compaction agent can attribute + provenance. +6. Note injection is compaction-time only — gated by the `forCompaction` flag on + `prepareInjection`. Normal `chat.message` and `messages.transform` turns do + NOT pass this flag and therefore do NOT inject notes. +7. When no notes exist for the session, the `` section is omitted + entirely from the compaction envelope. + +--- + +## Known Risks and Follow-Ups + +### Note-Body Budget in Compaction + +The compaction envelope has a total size budget. Large or numerous notes could +consume a disproportionate share of the compaction context, potentially crowding +out session event history or persistent memory. The current design does not cap +note injection size separately from the overall envelope budget. + +**Follow-up:** After initial implementation, monitor compaction envelope sizes +in practice. If note bodies routinely exceed a significant fraction of the +compaction context limit, add a dedicated note-body budget (analogous to +`PERSISTENT_MEMORY_BODY_BUDGET`) that truncates the oldest notes first while +preserving the most recently updated ones. + +### Search Score Interoperability + +Note search scores (from `SessionNotesService.searchNotes`) and memory search +scores (from the existing corpus search pipeline) must both be `0`–`1` floats +for merged sorting to produce sensible interleaving. The note service implements +a simple substring/token-match scoring algorithm. The corpus search may use a +different scoring approach. If scoring distributions diverge significantly in +practice (e.g., all note scores cluster near `0.3` while memory scores cluster +near `0.9`), the merged results will be effectively partitioned rather than +interleaved. + +**Follow-up:** After initial implementation, sample merged search results to +verify that the score distributions are reasonably compatible. If not, consider +a lightweight normalization or boosting factor. + +--- + +## Out of Scope + +- TUI or GUI note display surfaces +- Structured note payloads or typed task-state columns +- Note injection into normal chat turns +- A standalone `session_note_search` / `session_notes_search` tool +- Heuristic pre-compaction reminder nudges +- Turn-local reminder nudges outside description shaping diff --git a/docs/superpowers/plans/2026-04-19-session-notes-cross-session-recall.md b/docs/superpowers/plans/2026-04-19-session-notes-cross-session-recall.md new file mode 100644 index 0000000..6f7e47f --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-session-notes-cross-session-recall.md @@ -0,0 +1,790 @@ +# Session Notes Cross-Session Recall Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend session notes so `session_search` can surface same-project +notes from other sessions, `session_notes_read` can reopen any same-project note +by `id`, and note mutation stays ownership-safe while compaction remains +current-session-only. + +**Architecture:** Keep the existing session-scoped note hash for compaction and +local ownership, and add one project-scoped shared note hash keyed by globally +unique `id` within the project group. Public note/search tool contracts drop +public `root_session_id` for note and search tools, while the plugin still +resolves canonical root session internally before runtime execution. + +**Tech Stack:** Deno, TypeScript, Zod, Redis/FalkorDB hot tier, +`@opencode-ai/plugin`. + +--- + +## File Map + +### Modify + +| File | Responsibility | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `src/services/session-notes.ts` | Dual-store note persistence, uniqueness checks, direct read by `id`, local/project note search, ownership-aware mutation | +| `src/services/session-notes.test.ts` | Unit tests for dual-store behavior, upsert/delete semantics, collision retry, and cross-session search | +| `src/services/session-mcp-types.ts` | Public request/response schema updates for `id`, singular note read response, and note search hit metadata | +| `src/services/session-mcp-runtime.ts` | Tool descriptions, public tool args, internal root-session resolution, search merge, direct note read routing | +| `src/services/session-mcp-runtime.test.ts` | Schema compatibility, runtime tool behavior, cross-session search ranking, and direct read-by-id | +| `src/index.ts` | Continue wiring note service/runtime/canonicalization with no public root parameter exposure | +| `src/index.test.ts` | Verify exposed tool args and description behavior still match the runtime contract | +| `docs/SmokeTests.md` | Update live note-search expectations and exact runtime contracts | + +### Keep unchanged in behavior + +| File | Why | +| ---------------------------- | ------------------------------------------------------- | +| `src/session.ts` | Compaction should still read only current-session notes | +| `src/handlers/compacting.ts` | Compaction remains current-session scoped | + +--- + +## Task 1: Lock The New Public Contracts In Tests First + +**Files:** + +- Modify: `src/services/session-mcp-types.ts` +- Modify: `src/services/session-mcp-runtime.test.ts` +- Modify: `src/index.test.ts` + +- [ ] **Step 1: Write failing schema tests for the new note/search requests and + responses** + + Add/replace schema assertions in `src/services/session-mcp-runtime.test.ts` so + the public contracts become: + + ```ts + Deno.test("note schema compatibility accepts approved note request and response contracts", () => { + const writeRequest = sessionMcpRequestSchemas.session_notes_write.safeParse( + { + text: "remember this", + replace: "note-1", + }, + ); + const deleteResponse = sessionMcpResponseSchemas.session_notes_write + .safeParse({ + action: "deleted", + id: "note-1", + }); + const readRequest = sessionMcpRequestSchemas.session_notes_read.safeParse({ + id: "note-1", + }); + const readResponse = sessionMcpResponseSchemas.session_notes_read.safeParse( + { + note: { + id: "note-1", + text: "remember this", + created_at: "2026-04-11T10:00:00.000Z", + updated_at: "2026-04-11T10:00:00.000Z", + }, + }, + ); + const readMiss = sessionMcpResponseSchemas.session_notes_read.safeParse({ + note: null, + }); + + assertEquals(writeRequest.success, true); + assertEquals(deleteResponse.success, true); + assertEquals(readRequest.success, true); + assertEquals(readResponse.success, true); + assertEquals(readMiss.success, true); + }); + + Deno.test("search schema compatibility accepts note hits with id, root_session_id, and scope", () => { + const accepted = sessionMcpResponseSchemas.session_search.safeParse({ + status: "ok", + results: [{ + corpus_ref: "session:root:corpus:1", + snippet: "remember this", + score: 0.9, + type: "note", + id: "note-1", + root_session_id: "root-123", + scope: "project", + }], + corpus_refs: ["session:root:corpus:1"], + truncated: false, + }); + + assertEquals(accepted.success, true); + }); + ``` + +- [ ] **Step 2: Write failing runtime-registration tests for rootless public + note/search args** + + Update the existing args assertions in + `src/services/session-mcp-runtime.test.ts` and `src/index.test.ts` so they + expect: + + ```ts + assertEquals(Object.keys(runtime.tools.session_notes_write.args), [ + "text", + "replace", + ]); + assertEquals(Object.keys(runtime.tools.session_notes_read.args), ["id"]); + assertEquals(Object.keys(runtime.tools.session_search.args), ["query"]); + ``` + +- [ ] **Step 3: Run the narrow schema/runtime test slice and confirm it fails + for the old contract** + + Run: + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts src/index.test.ts + ``` + + Expected: FAIL because the current runtime and schemas still require + `root_session_id`, still use `note_id`, and still return `{ notes: [...] }`. + +- [ ] **Step 4: Update `src/services/session-mcp-types.ts` to the new public + shapes** + + Make the request/response shape changes directly in + `src/services/session-mcp-types.ts`: + + ```ts + type SessionNotesWriteRequest = { + text: string; + replace?: string; + }; + + type SessionNotesReadRequest = { + id: string; + }; + + const searchResultSchema = z.object({ + corpus_ref: z.string().min(1), + snippet: z.string(), + score: z.number(), + type: z.enum(["memory", "note"]).optional(), + id: z.string().min(1).optional(), + root_session_id: z.string().min(1).optional(), + scope: z.enum(["local", "project"]).optional(), + }).strict(); + + const sessionNoteSchema = z.object({ + id: z.string().min(1), + text: z.string(), + created_at: z.string().min(1), + updated_at: z.string().min(1), + }).strict(); + + session_notes_write: z.object({ + text: z.string(), + replace: z.string().min(1).optional(), + }).strict(), + + session_notes_read: z.object({ + id: z.string().min(1), + }).strict(), + + session_search: z.object({ + query: z.string().min(1), + }).strict(), + + session_notes_write: z.object({ + action: z.enum(["created", "replaced", "deleted"]), + id: z.string().min(1).optional(), + cleared_count: z.number().int().nonnegative().optional(), + }).strict(), + + session_notes_read: z.object({ + note: sessionNoteSchema.nullable(), + }).strict(), + ``` + +- [ ] **Step 5: Re-run the same narrow slice and confirm the schema layer now + passes** + + Run: + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts src/index.test.ts + ``` + + Expected: still FAIL, but now deeper in runtime behavior rather than the old + public contract. + +--- + +## Task 2: Rebuild The Note Service Around Dual Stores And Global `id` + +**Files:** + +- Modify: `src/services/session-notes.ts` +- Modify: `src/services/session-notes.test.ts` + +- [ ] **Step 1: Write failing unit tests for project-scoped read/search and + ownership rules** + + Add tests in `src/services/session-notes.test.ts` covering: + + ```ts + it("reads one same-project note by id and returns null on miss", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + sessionTtlSeconds: 60, + groupId: "group-a", + createNoteId: createSequence(["note-1"]), + now: createClock("2026-04-19T10:00:00.000Z"), + }); + + await service.writeNote("root-a", "remember this"); + + assertEquals(await service.readNoteById("group-a", "note-1"), { + note: { + id: "note-1", + text: "remember this", + created_at: "2026-04-19T10:00:00.000Z", + updated_at: "2026-04-19T10:00:00.000Z", + }, + }); + assertEquals(await service.readNoteById("group-a", "missing"), { + note: null, + }); + }); + + it("searches local and same-project foreign notes with a project penalty", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + sessionTtlSeconds: 60, + groupId: "group-a", + createNoteId: createSequence(["note-1", "note-2"]), + now: createClock( + "2026-04-19T10:00:00.000Z", + "2026-04-19T10:00:01.000Z", + ), + }); + + await service.writeNote("root-local", "redis ttl drift note"); + await service.writeNote("root-other", "redis ttl drift note"); + + const hits = await service.searchProjectNotes( + "root-local", + "redis ttl drift note", + ); + assertEquals(hits.map((hit) => ({ id: hit.id, scope: hit.scope })), [ + { id: "note-1", scope: "local" }, + { id: "note-2", scope: "project" }, + ]); + assertEquals(hits[0]!.score > hits[1]!.score, true); + }); + + it("allows replace-on-miss, delete-on-miss, and blocks foreign ownership conflicts", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + sessionTtlSeconds: 60, + groupId: "group-a", + createNoteId: createSequence(["note-1"]), + now: createClock( + "2026-04-19T10:00:00.000Z", + "2026-04-19T10:00:01.000Z", + "2026-04-19T10:00:02.000Z", + ), + }); + + await service.writeNote("root-foreign", "foreign", { replace: "note-1" }); + assertEquals( + await service.writeNote("root-local", "local replacement", { + replace: "missing-local", + }), + { action: "replaced", id: "missing-local" }, + ); + assertEquals( + await service.writeNote("root-local", "", { + replace: "already-gone", + }), + { action: "deleted", id: "already-gone" }, + ); + await assertRejects( + () => + service.writeNote("root-local", "cannot steal", { replace: "note-1" }), + Error, + "owned by another session", + ); + }); + ``` + +- [ ] **Step 2: Add a failing collision-retry test for project-wide `id` + uniqueness** + + Add: + + ```ts + it("retries note id generation until the project id is unique", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + sessionTtlSeconds: 60, + groupId: "group-a", + createNoteId: createSequence(["collision", "collision", "note-unique"]), + now: createClock( + "2026-04-19T10:00:00.000Z", + "2026-04-19T10:00:01.000Z", + ), + }); + + await service.writeNote("root-a", "existing", { replace: "collision" }); + assertEquals(await service.writeNote("root-b", "new note"), { + action: "created", + id: "note-unique", + }); + }); + ``` + +- [ ] **Step 3: Run the note-service unit tests and confirm they fail** + + Run: + + ```bash + deno test -A src/services/session-notes.test.ts + ``` + + Expected: FAIL because the service is still single-store, `note_id`-based, and + root-session-only. + +- [ ] **Step 4: Implement the dual-store service with normalized `id` shapes** + + Update `src/services/session-notes.ts` to add the second store and the new + read/search API. The central service shape should look like: + + ```ts + export type SessionNote = { + id: string; + text: string; + created_at: string; + updated_at: string; + }; + + export type SessionNoteSearchHit = { + id: string; + root_session_id: string; + scope: "local" | "project"; + snippet: string; + score: number; + }; + + export type WriteNoteResult = + | { action: "created"; id: string } + | { action: "replaced"; id: string } + | { action: "deleted"; id: string } + | { action: "replaced"; id: string; cleared_count: number } + | { action: "replaced"; cleared_count: number }; + + export const sessionNotesKey = (rootSessionId: string): string => + `session:${rootSessionId}:notes`; + + export const projectNotesKey = (groupId: string): string => + `project:${groupId}:notes`; + ``` + + Implement the core methods with these signatures: + + ```ts + async writeNote( + rootSessionId: string, + text: string, + options?: { replace?: string }, + ): Promise + + async readNoteById( + groupId: string, + id: string, + ): Promise<{ note: SessionNote | null }> + + async searchProjectNotes( + rootSessionId: string, + query: string, + ): Promise + ``` + + Required implementation rules: + + ```ts + const projectHitPenalty = 0.85; + + if (replace && text !== "") { + if (!projectNote) { + // upsert by exact id into current session + } else if (projectNote.root_session_id !== rootSessionId) { + throw new Error(`Note ${replace} is owned by another session`); + } + } + + if (replace && text === "") { + if (!projectNote) { + return { action: "deleted", id: replace }; + } + if (projectNote.root_session_id !== rootSessionId) { + throw new Error(`Note ${replace} is owned by another session`); + } + } + ``` + +- [ ] **Step 5: Re-run the note-service tests and confirm they pass** + + Run: + + ```bash + deno test -A src/services/session-notes.test.ts + ``` + + Expected: PASS. + +--- + +## Task 3: Rewire The Runtime To Use Internal Root Resolution And Direct Read By `id` + +**Files:** + +- Modify: `src/services/session-mcp-runtime.ts` +- Modify: `src/services/session-mcp-runtime.test.ts` + +- [ ] **Step 1: Add failing runtime tests for rootless execution and direct + same-project read** + + Replace the old note runtime tests in + `src/services/session-mcp-runtime.test.ts` with assertions shaped like: + + ```ts + it("executes the updated note action contract through the runtime", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + groupId: "group-runtime", + } as never); + + const localContext = createToolContext({ sessionID: "child-local" }); + const foreignContext = createToolContext({ sessionID: "child-foreign" }); + + runtime.setSessionCanonicalizer({ + getCachedCanonicalSessionId(sessionId: string) { + return sessionId === "child-local" ? "root-local" : "root-foreign"; + }, + async resolveCanonicalSessionId(sessionId: string) { + return sessionId === "child-local" ? "root-local" : "root-foreign"; + }, + async validateRuntimeRootSessionId() {}, + } as never); + + const created = JSON.parse( + await runtime.tools.session_notes_write.execute( + { text: "first note" }, + localContext, + ), + ); + const read = JSON.parse( + await runtime.tools.session_notes_read.execute( + { id: created.id }, + foreignContext, + ), + ); + + assertEquals(read.note.id, created.id); + assertEquals(read.note.text, "first note"); + }); + ``` + +- [ ] **Step 2: Add a failing runtime test for `session_search` local/project + note ranking** + + Add: + + ```ts + it("returns local note hits above same-project foreign note hits", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + groupId: "group-note-search", + } as never); + + runtime.setSessionCanonicalizer({ + getCachedCanonicalSessionId() { + return "root-local"; + }, + async resolveCanonicalSessionId() { + return "root-local"; + }, + async validateRuntimeRootSessionId() {}, + } as never); + + await runtime.tools.session_notes_write.execute({ + text: "redis ttl drift note", + }, createToolContext({ sessionID: "local-child" })); + runtime.setSessionCanonicalizer({ + getCachedCanonicalSessionId(sessionId: string) { + return sessionId === "local-child" ? "root-local" : "root-other"; + }, + async resolveCanonicalSessionId(sessionId: string) { + return sessionId === "local-child" ? "root-local" : "root-other"; + }, + async validateRuntimeRootSessionId() {}, + } as never); + await runtime.tools.session_notes_write.execute({ + text: "redis ttl drift note", + }, createToolContext({ sessionID: "other-child" })); + + runtime.setSessionCanonicalizer({ + getCachedCanonicalSessionId() { + return "root-local"; + }, + async resolveCanonicalSessionId() { + return "root-local"; + }, + async validateRuntimeRootSessionId() {}, + } as never); + + const parsed = JSON.parse( + await runtime.tools.session_search.execute( + { query: "redis ttl drift note" }, + createToolContext({ sessionID: "local-child" }), + ), + ); + + const noteHits = parsed.results.filter((result: { type?: string }) => + result.type === "note" + ); + assertEquals(noteHits[0].scope, "local"); + assertEquals(noteHits[1].scope, "project"); + assertEquals(noteHits[0].score > noteHits[1].score, true); + }); + ``` + +- [ ] **Step 3: Run the runtime test slice and confirm it fails** + + Run: + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts + ``` + + Expected: FAIL because the runtime still expects public `root_session_id`, + still reads notes by current root, and does not merge same-project foreign + note hits. + +- [ ] **Step 4: Update `src/services/session-mcp-runtime.ts` to resolve root + internally for note/search tools** + + Add a helper near the runtime setup: + + ```ts + const resolveCanonicalRuntimeRootSessionId = async ( + context: ToolContext, + validator: RuntimeRootSessionValidator | undefined, + ): Promise => { + const sessionId = context.sessionID; + if (!sessionId) { + throw new Error("session_search requires a session context"); + } + return await validator?.resolveCanonicalSessionId(sessionId) ?? sessionId; + }; + ``` + + Then update the handlers: + + ```ts + session_search: async (request, context) => { + const rootSessionId = await resolveCanonicalRuntimeRootSessionId( + context, + sessionCanonicalizer, + ); + return await searchLocalCorpus(rootSessionId, request.query); + }, + + session_notes_write: async (request, context) => { + const rootSessionId = await resolveCanonicalRuntimeRootSessionId( + context, + sessionCanonicalizer, + ); + return await notes.writeNote(rootSessionId, request.text, { + replace: request.replace, + }); + }, + + session_notes_read: async (request) => { + return await notes.readNoteById(groupId, request.id); + }, + ``` + + Also remove public `root_session_id` from the registered `args` for these + tools. + +- [ ] **Step 5: Re-run the runtime test slice and confirm it passes** + + Run: + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts + ``` + + Expected: PASS. + +--- + +## Task 4: Update Tool Descriptions And Search-Hit Metadata + +**Files:** + +- Modify: `src/services/session-mcp-runtime.ts` +- Modify: `src/services/session-mcp-runtime.test.ts` +- Modify: `src/index.test.ts` + +- [ ] **Step 1: Write failing description assertions for delete semantics and + new read/search contracts** + + Replace the old description-string checks in + `src/services/session-mcp-runtime.test.ts` with assertions like: + + ```ts + assertStringIncludes( + runtime.tools.session_notes_write.description, + "If the `id` does not exist, deletion is a no-op and still returns `deleted`.", + ); + assertStringIncludes( + runtime.tools.session_notes_write.description, + "If the `id` exists but is owned by another session in the same project, the delete is rejected.", + ); + assertStringIncludes( + runtime.tools.session_notes_read.description, + '{ "note": null }', + ); + assertStringIncludes( + runtime.tools.session_search.description, + 'scope: "local" | "project"', + ); + ``` + +- [ ] **Step 2: Run the description-focused test slice and confirm it fails** + + Run: + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts src/index.test.ts + ``` + + Expected: FAIL because descriptions still mention `note_id`, root-session-only + reads, and the old `{ notes: [...] }` shape. + +- [ ] **Step 3: Replace the shipped tool-description strings in + `src/services/session-mcp-runtime.ts`** + + Replace the note tool descriptions with the new contract language. The key + wording that must ship is: + + ```ts + export const SESSION_NOTES_WRITE_DESCRIPTION = [ + "Pin working context as a session note so it survives topic switches, long tool", + "loops, and compaction.", + "", + 'Accepts `text` (markdown body) and optional `replace` (`id` for one note, or `"*"` to replace all notes for the current session).', + "", + "Mutation semantics:", + "- No `replace`: create a new note with a fresh `id`.", + '- `replace: ""` with non-empty `text`: upsert that note into the current session.', + '- `replace: ""` with empty `text`: delete that note from the current session.', + "- If the `id` does not exist, deletion is a no-op and still returns `deleted`.", + "- If the `id` exists but is owned by another session in the same project, the write or delete is rejected.", + '- `replace: "*"` with non-empty `text`: replace all notes for the current session with one new note.', + '- `replace: "*"` with empty `text`: clear all notes for the current session.', + ].join("\n"); + ``` + + And update the read/search descriptions to mention: + + ```ts + "Returns `{ note: { id, text, created_at, updated_at } }` when found and `{ note: null }` when the id is unknown.", + "Note hits include `id`, `root_session_id`, and `scope: \"local\" | \"project\"`.", + ``` + +- [ ] **Step 4: Re-run the description tests and confirm they pass** + + Run: + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts src/index.test.ts + ``` + + Expected: PASS. + +--- + +## Task 5: Update Docs And End-To-End Verification + +**Files:** + +- Modify: `docs/SmokeTests.md` + +- [ ] **Step 1: Update the smoke-test docs for the new runtime contract** + + In `docs/SmokeTests.md`, replace old note expectations so the live evidence + now requires: + + ```md + - `session_search({ query })` may return note hits with `id`, `root_session_id`, + and `scope: "local" | "project"`. + - `session_notes_read({ id })` reopens one note by id and returns + `{ note: null }` on miss. + - Same-project foreign note hits should rank below equivalent local note hits. + - Delete-on-miss remains a successful `{ action: "deleted", id }` no-op. + ``` + +- [ ] **Step 2: Run the targeted test suite for the modified files** + + Run: + + ```bash + deno test -A src/services/session-notes.test.ts src/services/session-mcp-runtime.test.ts src/index.test.ts + ``` + + Expected: PASS. + +- [ ] **Step 3: Run the full verification suite** + + Run: + + ```bash + deno test -A + deno task check + deno task lint + deno task fmt + ``` + + Expected: all PASS. + +- [ ] **Step 4: Perform the final spec-to-plan coverage check before + implementation handoff** + + Confirm each spec requirement maps to a task: + + ```md + - dual store: Task 2 + - project-unique id: Task 2 + - rootless public note/search contracts: Tasks 1 and 3 + - delete semantics in tool descriptions: Task 4 + - cross-session note search ranking: Tasks 2 and 3 + - current-session-only compaction behavior: preserved by architecture; no code + change required, but covered by regression awareness during full test run + ``` + + Expected: no uncovered spec requirements remain. + +--- + +## Notes For The Implementer + +- Do not add routing nudges, bootstrap prompt logic, or subagent logic here. + That work is intentionally out of scope for this plan. +- Do not run git commands unless explicitly requested by the user. +- Keep compaction behavior current-session-only even though note search becomes + same-project aware. +- Preserve legacy note reads/searches by normalizing legacy values on read and + rewriting touched entries in the new shape on write. diff --git a/docs/superpowers/specs/2026-04-07-package-relative-runtime-resolution-design.md b/docs/superpowers/specs/2026-04-07-package-relative-runtime-resolution-design.md new file mode 100644 index 0000000..b3a931a --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-package-relative-runtime-resolution-design.md @@ -0,0 +1,237 @@ +# Package-Relative Runtime Resolution Design + +## Goal + +Make the published `opencode-graphiti` package initialize correctly no matter +which directory launches OpenCode, by resolving runtime dependencies relative to +the plugin package rather than `process.cwd()`. + +The hard requirement is the installed package contract: + +```jsonc +{ + "plugin": ["opencode-graphiti"] +} +``` + +This change should make a package-shaped local regression the primary test flow +for this bug, while keeping any still-documented built-file coverage as +secondary compatibility coverage. + +## Why This Change + +The current DNT-generated runtime still relies on a `process.cwd()`-derived +`createRequire(...)` anchor in: + +- `src/services/connection-manager.ts` + +The same bug existed in `src/config.ts` and has already been corrected locally; +the remaining design work is to carry the same package-relative rule through the +rest of the runtime and generated package metadata. + +This is incorrect for an installed plugin package because the plugin's runtime +dependencies (`cosmiconfig` and `@modelcontextprotocol/sdk`) belong to the +plugin package, not to the directory from which OpenCode was launched. + +As a result, the plugin can fail to initialize when OpenCode is started from a +directory whose package tree does not provide those dependencies, even though +the plugin itself is properly installed. + +`src/config.ts` now derives runtime package resolution from `import.meta.url`. +`src/services/connection-manager.ts` still needs the same package-relative +treatment. The key requirement is that the generated ESM package resolves +runtime dependencies correctly from the plugin package. + +## Required Behavior + +### Installed Package Mode + +- When OpenCode loads `opencode-graphiti` by package name, the plugin must + resolve `cosmiconfig` and `@modelcontextprotocol/sdk` from the plugin package + itself. +- Launch directory must not affect whether those dependencies resolve. +- The plugin must initialize successfully from directories other than the user's + home directory, assuming its own package dependencies are present. + +### Runtime Resolution Strategy + +- Remove the dependency on `process.cwd()` for Node-side runtime dependency + resolution in generated package code. +- Anchor `createRequire(...)` to the plugin package location derived from the + current module, not from the caller's working directory. +- Standardize both runtime loaders on the same `createRequire(...)` input shape + so they do not diverge across files. +- `src/config.ts` should use this package-relative require for `cosmiconfig`. +- `src/services/connection-manager.ts` should use the same package-relative + anchor to resolve and dynamically import `@modelcontextprotocol/sdk` runtime + modules. + +### Packaging Metadata + +- The generated npm package must declare both required runtime dependencies: + - `cosmiconfig` + - `@modelcontextprotocol/sdk` +- `@types/node` remains a generated development-only dependency for package + type-checking. + +### Local Development Validation + +- Direct `file:///.../dist/esm/mod.js` loading is no longer treated as the + primary local-dev compatibility target for this issue. +- Local regression coverage should instead simulate a real installed package + shape by placing `dist/` under an isolated `node_modules/opencode-graphiti` + path. +- The OpenCode regression should launch from a directory different from the + isolated home/config root so the test explicitly covers the cwd-sensitive bug. + +## Recommended Approach + +### Option A: Package-Relative Runtime Resolution + +Recommended. + +- Compute a stable runtime anchor from `import.meta.url` so the generated code + can locate the plugin package it lives in. +- Create a `require` instance from a synthetic file path inside that package. +- Use that `require` for CommonJS/Node package resolution. +- Keep MCP SDK loading lazy at runtime so initialization behavior remains close + to the existing shape. +- Standardize on a `file://` URL-based anchor for `createRequire(...)` in both + modules so the implementation does not mix bare filesystem paths and URL + strings. +- Accept DNT's emitted `import.meta` helper wiring if needed, as long as the + generated ESM package still resolves runtime dependencies relative to the + plugin package instead of the caller's cwd. + +This approach fixes the actual bug at the correct boundary: module resolution +should follow the plugin package, not the caller's cwd. + +### Option B: Bundle Runtime Dependencies + +Not recommended. + +- Remove runtime package resolution by bundling dependency code into the build. + +This is a larger and less stable change than necessary. It increases build +complexity without improving the supported package contract. + +### Option C: Preserve Raw `file://dist` Loading as First-Class + +Not recommended. + +- Continue optimizing the runtime specifically for bare built-file loading. + +That mode is useful for ad hoc debugging, but it should not drive the package +runtime design when the supported contract is package-name installation. + +## Implementation Shape + +### `src/config.ts` + +- Preserve the local fix that replaced the old `process.cwd()`-based + `createRequire(...)` anchor. +- Derive a package-relative anchor from the current module location. +- Ensure the resulting Node resolution path works after DNT emission inside + `dist/esm/...`. +- `import.meta.url` is now part of the implementation in this file. +- Continue using `nodeRequire("cosmiconfig")` for the actual runtime load. + +### `src/services/connection-manager.ts` + +- Replace the current `process.cwd()`-based `createRequire(...)` anchor. +- Reuse the same package-relative anchoring strategy as `src/config.ts`. +- Standardize the `createRequire(...)` input form with `src/config.ts` instead + of preserving the current mismatch between bare path and `file://` URL styles. +- Keep lazy runtime resolution of: + - `@modelcontextprotocol/sdk/client/index.js` + - `@modelcontextprotocol/sdk/client/streamableHttp.js` +- Continue resolving first, then dynamic-importing the resolved file URL, so the + runtime stays compatible with the current Node/Bun packaging behavior. + +### `dnt.ts` + +- Add `@modelcontextprotocol/sdk` to generated package `dependencies`, mirroring + the version range already declared in `deno.json` unless there is a deliberate + documented reason to diverge. +- Retain `cosmiconfig` in generated package `dependencies`. +- Retain `@types/node` in `devDependencies`. + +### `packaging.test.ts` + +- Keep `deno task build` as the packaging prerequisite. +- Assert that generated `dist/package.json` contains: + - `cosmiconfig` + - `@modelcontextprotocol/sdk` +- Add a package-name regression that specifically exercises the installed + package contract under Node from a cwd that does not provide the plugin's + dependencies: + - create a temp directory + - create `temp/node_modules/opencode-graphiti` pointing at `dist/` + - configure isolated OpenCode home/config to load `opencode-graphiti` by + package name + - run OpenCode from a separate arbitrary cwd + - assert initialization does not fail due to missing plugin dependency + resolution +- Keep any existing direct `file:///.../dist/esm/mod.js` coverage only as + compatibility coverage while README continues to document local built-file + installation. +- Do not fail the regression solely because DNT emitted + `import-meta-ponyfill-esmodule`; only fail when emitted package behavior is + wrong. + +## Testing Strategy + +Follow TDD for the behavior change. + +### Red + +- Add or adjust packaging coverage so the installed-package simulation fails + with the current cwd-anchored runtime resolution. +- Add metadata assertions that fail until `@modelcontextprotocol/sdk` is present + in generated package dependencies. +- Ensure the failing regression runs from a cwd outside the repository tree; + running from the workspace can mask the bug because the workspace already has + matching `node_modules` entries. + +### Green + +- Implement only the package-relative resolution and package metadata changes + needed to satisfy the new failing test coverage. + +### Refactor + +- If the package-relative anchor logic is duplicated between modules, extract + the smallest clear helper only if it keeps the emitted/runtime behavior + obvious. + +## Validation Plan + +At minimum, verify: + +- `deno test -A packaging.test.ts` +- `deno test -A` +- `deno task check` +- `deno task lint` +- `deno task fmt` + +The critical regression evidence is that the packaged plugin loads by package +name from a non-home, non-package cwd without missing `cosmiconfig` or MCP SDK +resolution failures. + +The published package entrypoint remains the generated ESM output +(`./esm/mod.js` in `dnt.ts`). This design targets that supported ESM package +path; it does not expand support guarantees for DNT's separate CommonJS/script +output. + +The design does not require DNT to preserve native source-level +`import.meta.url` syntax verbatim in emitted ESM. It only requires the emitted +package to resolve runtime dependencies correctly relative to the plugin +package. + +## Non-Goals + +- Do not make direct `file:///.../dist/esm/mod.js` loading the primary contract + that drives this fix. +- Do not redesign the plugin's public package surface. +- Do not change the plugin's config semantics beyond the runtime dependency + resolution boundary. diff --git a/docs/superpowers/specs/2026-04-11-session-notes-anti-drift-design.md b/docs/superpowers/specs/2026-04-11-session-notes-anti-drift-design.md new file mode 100644 index 0000000..abec203 --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-session-notes-anti-drift-design.md @@ -0,0 +1,454 @@ +# Session Notes Cross-Session Recall Design + +## Goal + +Extend session notes so `session_search` can surface matching notes from other +sessions in the same project while still preferring the current session, and +make exact note reopen work by a single globally meaningful `id` within one +project. + +The design keeps notes on the Redis/FalkorDB hot tier, keeps compaction +injection local-first, and avoids Graphiti on the hot path. + +## Why This Change + +The current note design is root-session scoped. That is good for compaction and +same-lineage continuity, but it is too narrow for the real recall workflow: an +agent often resumes similar work in a different root session within the same +project and should be able to discover intentionally pinned notes from those +earlier sessions. + +The desired behavior is: + +- `session_search` remains the default recall tool. +- It can find note hits from the current session and from other sessions in the + same project. +- Current-session note hits rank above equivalent same-project foreign-session + note hits. +- `session_notes_read` can reopen any same-project note directly by `id`. +- Mutation stays session-owned: one session cannot overwrite or delete another + session's note. + +## Required Behavior + +### Storage Model + +Use two Redis hashes: + +1. `session:{rootSessionId}:notes` + +- session-local authoritative note store +- field: `id` +- value: + - `text` + - `created_at` + - `updated_at` + +2. `project:{groupId}:notes` + +- same-project cross-session note store +- field: `id` +- value: + - `root_session_id` + - `text` + - `created_at` + - `updated_at` + +The session store remains authoritative for: + +- compaction note injection +- current-session note enumeration and ordering +- current-session ownership semantics + +The project store exists for: + +- same-project cross-session note search +- direct note reopen by `id` +- project-scoped uniqueness checks + +There must not be an unscoped global `session:notes` key. Redis/FalkorDB may be +shared across multiple projects, so the shared note store must remain project +scoped. + +### Note Identity + +Public note identity is `id`, not `note_id`. + +`id` must be unique within `project:{groupId}:notes`. + +On note creation: + +1. Generate a UUID. +2. Check whether `project:{groupId}:notes` already contains that `id`. +3. If yes, generate a new UUID and retry until unique. +4. Persist the new note to both stores. + +This makes one `id` sufficient for: + +- `session_search` note hits +- `session_notes_read({ id })` +- owned-session mutation via `replace: id` + +### MCP Tool Surface + +Expose exactly two note tools: + +- `session_notes_write(text: string, replace?: string)` +- `session_notes_read(id: string)` + +Do not add a dedicated note-search tool. `session_search` remains the primary +recall entrypoint. + +### Public Tool Contracts + +#### `session_notes_write` + +Request: + +```json +{ + "text": "...", + "replace": "optional id or *" +} +``` + +Response: + +```json +{ "action": "created", "id": "uuid" } +``` + +```json +{ "action": "replaced", "id": "uuid" } +``` + +```json +{ "action": "deleted", "id": "uuid" } +``` + +```json +{ "action": "replaced", "id": "uuid", "cleared_count": 3 } +``` + +```json +{ "action": "replaced", "cleared_count": 3 } +``` + +Mutation semantics: + +- No `replace`: create a new note with a fresh unique `id`. +- `replace: ""` with non-empty `text`: upsert into the current session. + - If the `id` does not exist, create a new note with that exact `id` in the + current session. + - If the `id` exists and is owned by the current session, update it in place. + - If the `id` exists but is owned by another session in the same project, + reject the write. +- `replace: ""` with empty `text`: delete from the current session. + - If the `id` does not exist, deletion is a no-op and still returns + `{ action: "deleted", id }`. + - If the `id` exists and is owned by the current session, delete it from both + stores. + - If the `id` exists but is owned by another session in the same project, + reject the delete. +- `replace: "*"` with non-empty `text`: replace all notes for the current + session with one new note. +- `replace: "*"` with empty `text`: clear all notes for the current session. + +Only ownership conflicts are exceptional. Missing targets are normal control +flow and must not throw for upsert or delete. + +#### `session_notes_read` + +Request: + +```json +{ "id": "uuid" } +``` + +Response when found: + +```json +{ + "note": { + "id": "uuid", + "text": "...", + "created_at": "...", + "updated_at": "..." + } +} +``` + +Response when missing: + +```json +{ "note": null } +``` + +Behavior: + +- `session_notes_read` does not require `root_session_id`. +- It reopens one note by `id` from the current project. +- A specified `id` returns exactly one note or `null`, never multiple results. +- Not-found is a normal miss, not an error. +- The tool must preserve exact note text rather than paraphrasing or + transforming it. + +### `session_search` + +Public request: + +```json +{ "query": "..." } +``` + +The plugin resolves the canonical current `root_session_id` internally. The +agent should not need to pass it. + +`session_search` remains the primary recall tool. It must merge: + +1. current-session local corpus hits +2. current-session note hits +3. same-project foreign-session note hits + +Note hits must use this shape: + +```json +{ + "type": "note", + "id": "uuid", + "root_session_id": "ses_...", + "scope": "local", + "snippet": "...", + "score": 0.91 +} +``` + +or + +```json +{ + "type": "note", + "id": "uuid", + "root_session_id": "ses_other...", + "scope": "project", + "snippet": "...", + "score": 0.77 +} +``` + +Rules: + +- `scope: "local"` means the note belongs to the current root session. +- `scope: "project"` means the note belongs to another session in the same + project. +- Current-session note hits should rank above equivalent same-project foreign + note hits. +- Unrelated-project notes must not appear. +- If the same note is encountered through both local and project passes, keep a + single hit and prefer the local version. + +Recommended ranking rule: + +- local note hit: `final_score = raw_score` +- project note hit: `final_score = raw_score * 0.85` + +### Compaction Behavior + +Compaction remains current-session scoped. + +- The compaction hook injects complete current-session note bodies from + `session:{rootSessionId}:notes`. +- The plugin must not inject same-project foreign-session notes into compaction. +- The `` compaction envelope should preserve note boundaries and + `id` values. +- The compaction path remains local-first and must not require Graphiti. + +## Agent Usage Bias + +### `session_search` Is The Default Recall Tool + +`session_search` should explicitly describe itself as the first tool to use: + +- at the start of a new session +- after compaction +- when resuming a topic worked on earlier +- before re-solving a problem that may already have prior context +- when checking whether pinned notes already contain the needed information + +The description should explain that note hits may come from: + +- the current session (`scope: "local"`) +- another session in the same project (`scope: "project"`) + +### `session_notes_read` Is The Exact Reopen Tool + +`session_notes_read` should describe itself as the way to reopen exact pinned +note text by `id` instead of reconstructing it from memory. + +The description should explicitly say: + +- it reads one note by `id` +- it does not require `root_session_id` +- unknown ids return `{ note: null }` + +### `session_notes_write` Must Document Delete Semantics + +The write tool description must document mutation semantics precisely, +especially deletion behavior. + +It must explain: + +- `replace: id` is an upsert when `text` is non-empty +- empty `text` plus `replace: id` is a delete +- delete on a missing `id` is a no-op that still returns `deleted` +- mutation is rejected only when the target `id` exists but is owned by another + session in the same project +- `replace: "*"` replaces or clears the entire current-session note set + +This is required because consumer agents need to know whether delete-on-miss is +safe and whether an ownership conflict is the only exceptional mutation case. + +## Legacy Compatibility + +Do not run a migration. + +Instead: + +- reads must tolerate legacy stored note shapes +- search must tolerate legacy stored note shapes +- any touched note must be rewritten in the new shape on write + +This keeps rollout simple while allowing gradual cleanup through ordinary note +operations. + +## Implementation Approach + +- Keep the current session-scoped note store for compaction and local ownership. +- Add one project-scoped shared note hash for same-project cross-session recall. +- Keep the public identity model simple by using one project-unique `id`. +- Keep `session_search` as the unified recall entrypoint. + +This is the smallest design that satisfies: + +- same-project cross-session note search +- direct reopen by `id` +- current-session ranking preference +- compaction isolation +- no extra note locator type + +## Implementation Shape + +### `src/services/session-notes.ts` + +Extend the note service to own: + +- session-scoped note storage +- project-scoped note storage +- project-unique `id` generation with collision retry +- local and project note search +- ownership-aware mutation +- legacy-shape tolerant reads +- root-session migration for session-scoped note state if canonical roots change + +### `src/services/session-mcp-types.ts` + +- Remove public `root_session_id` from: + - `session_search` + - `session_notes_write` + - `session_notes_read` +- Update public note response shapes from `note_id` to `id`. +- Change `session_notes_read` response to singular `{ note: ... | null }`. +- Extend `session_search` note hit schema with: + - `type: "note"` + - `id` + - `root_session_id` + - `scope: "local" | "project"` + +### `src/services/session-mcp-runtime.ts` + +- Register the updated note tools. +- Resolve current root session internally for `session_search` and + `session_notes_write`. +- Route direct note reads by `id` through the project-scoped shared note store. +- Merge local and same-project foreign note hits into `session_search`. +- Rewrite tool descriptions for: + - `session_notes_write` + - `session_notes_read` + - `session_search` + +### `src/handlers/tool-before.ts` + +- Keep internal canonical root-session resolution available for session tools. +- Publicly removed parameters do not remove the need for internal canonical + session resolution. + +### `src/session.ts` + +- Continue to load current-session notes only for compaction injection. +- Preserve note boundaries and ids inside ``. + +### `src/handlers/compacting.ts` + +- Continue to inject full current-session notes into compaction context. +- Do not widen compaction note injection to same-project foreign sessions. + +## Testing Strategy + +Follow TDD. + +### Red + +Add failing tests for: + +- schema changes removing public `root_session_id` from note/search tools +- `session_notes_read({ id }) -> { note: ... | null }` +- cross-session same-project note hits in `session_search` +- local-vs-project note ranking +- ownership-blocked replace/delete +- replace-on-miss upsert +- delete-on-miss no-op success +- UUID collision retry within the project store +- legacy-shape tolerant read/search behavior + +### Green + +Implement only the smallest set of storage, schema, runtime, and search changes +required to satisfy those tests. + +### Refactor + +- Extract helpers only where note-shape normalization or result merging would + otherwise become unclear. +- Do not introduce a third note identity type. +- Do not broaden compaction scope to project-wide note injection. + +## Validation Plan + +At minimum, verify: + +- `deno test -A src/services/session-mcp-runtime.test.ts` +- `deno test -A src/services/session-notes.test.ts` +- `deno test -A src/session.test.ts` +- `deno test -A` +- `deno task check` +- `deno task lint` +- `deno task fmt` + +Critical evidence: + +- `session_search` returns both local and same-project foreign note hits +- local note hits outrank equivalent project note hits +- `session_notes_read({ id })` reopens a foreign-session same-project note +- `session_notes_read({ id })` returns `{ note: null }` on miss +- delete semantics are explicit in the tool description and runtime behavior +- ownership conflicts are the only exceptional mutation path +- compaction still injects only current-session note bodies + +## Out Of Scope + +- Graphiti-backed cross-session note recall on the hot path +- unrelated-project note visibility +- a dedicated note-search tool +- note injection into normal chat turns +- structured note payloads or typed note state +- subagent-specific note stores or note UI surfaces diff --git a/packaging.test.ts b/packaging.test.ts index 21b1669..82e02b8 100644 --- a/packaging.test.ts +++ b/packaging.test.ts @@ -2,9 +2,13 @@ import { assertEquals } from "jsr:@std/assert@^1.0.0"; import { fromFileUrl } from "jsr:@std/path@^1.0.0/from-file-url"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import manifest from "./deno.json" with { type: "json" }; const workspaceRoot = new URL(".", import.meta.url); const workspacePath = fromFileUrl(workspaceRoot); +const expectedSdkVersionFromDenoJson = manifest.imports[ + "@modelcontextprotocol/sdk" +].replace("npm:@modelcontextprotocol/sdk@", ""); const packagingRunPermissions = await Promise.all([ Deno.permissions.query({ name: "run", command: "deno" }), Deno.permissions.query({ name: "run", command: "node" }), @@ -67,25 +71,21 @@ Deno.test({ dependencies?: Record; devDependencies?: Record; }; - const builtConfig = await Deno.readTextFile( - join(workspacePath, "dist/esm/src/config.js"), - ); assertEquals( builtPackage.dependencies?.cosmiconfig, "^9.0.0", "generated npm package must declare cosmiconfig for runtime config loading", ); + assertEquals( + builtPackage.dependencies?.["@modelcontextprotocol/sdk"], + expectedSdkVersionFromDenoJson, + "generated npm package must declare the MCP SDK for runtime loading", + ); assertEquals( typeof builtPackage.devDependencies?.["@types/node"], "string", "generated npm package must declare Node typings for dnt typecheck", ); - assertEquals( - builtConfig.includes("import-meta-ponyfill-esmodule"), - false, - "generated config loader should not depend on DNT import-meta ponyfill", - ); - const tempDir = await Deno.makeTempDir(); try { let optionalOpenCodePath: string | undefined; @@ -95,19 +95,54 @@ Deno.test({ optionalOpenCodePath = undefined; } - const esmRunnerPath = join(tempDir, "load-esm.mjs"); - const bunRunnerPath = join(tempDir, "load-bun.mjs"); + const installRoot = join(tempDir, "package-install"); + const isolatedCwd = join(tempDir, "bare-cwd"); + const installNodeModules = join(installRoot, "node_modules"); + const packageDir = join(installNodeModules, "opencode-graphiti"); + const esmRunnerPath = join(installRoot, "load-esm.mjs"); + const configRunnerPath = join(installRoot, "load-config.mjs"); + const nodePackageRunnerPath = join(installRoot, "load-node-package.mjs"); + const bunRunnerPath = join(installRoot, "load-bun-package.mjs"); const esmEntrypoint = pathToFileURL(join(workspacePath, "dist/esm/mod.js")).href; - const packageDir = join(tempDir, "node_modules", "opencode-graphiti"); const isolatedHome = join(tempDir, "home"); const isolatedConfig = join(isolatedHome, ".config", "opencode"); + const isolatedConfigPackageDir = join( + isolatedConfig, + "node_modules", + "opencode-graphiti", + ); - await Deno.mkdir(join(tempDir, "node_modules"), { recursive: true }); + await Deno.mkdir(installNodeModules, { recursive: true }); + await Deno.mkdir(isolatedCwd, { recursive: true }); await Deno.mkdir(isolatedConfig, { recursive: true }); + await Deno.writeTextFile( + join(isolatedCwd, ".graphitirc"), + `${ + JSON.stringify( + { graphiti: { endpoint: "http://127.0.0.1:8899/mcp" } }, + null, + 2, + ) + }\n`, + ); await Deno.symlink(join(workspacePath, "dist"), packageDir, { type: "dir", }); + await Deno.mkdir(join(isolatedConfig, "node_modules"), { + recursive: true, + }); + await Deno.symlink( + join(workspacePath, "dist"), + isolatedConfigPackageDir, + { + type: "dir", + }, + ); + await Deno.writeTextFile( + join(isolatedConfig, "opencode.json"), + `${JSON.stringify({ plugin: ["opencode-graphiti"] }, null, 2)}\n`, + ); await Deno.writeTextFile( esmRunnerPath, @@ -115,18 +150,84 @@ Deno.test({ JSON.stringify(esmEntrypoint) };\nconsole.log(JSON.stringify(Object.keys(plugin).sort()));\n`, ); + await Deno.writeTextFile( + configRunnerPath, + 'import "opencode-graphiti";\n' + + `const { loadConfig } = await import(${ + JSON.stringify( + pathToFileURL(join(packageDir, "esm/src/config.js")).href, + ) + });\n` + + "const config = loadConfig(process.cwd());\n" + + "console.log(JSON.stringify({ endpoint: config.endpoint, graphiti: config.graphiti.endpoint, redis: config.redis.endpoint }));\n", + ); + await Deno.writeTextFile( + nodePackageRunnerPath, + 'import * as plugin from "opencode-graphiti";\n' + + "console.log(JSON.stringify(Object.keys(plugin).sort()));\n" + + "plugin.graphiti({ client: {}, directory: process.cwd() }).then(async () => {\n" + + " await new Promise((resolve) => setTimeout(resolve, 1000));\n" + + ' console.log("initialized");\n' + + " process.exit(0);\n" + + "}, (error) => {\n" + + " console.error(error);\n" + + " process.exit(1);\n" + + "});\n", + ); await Deno.writeTextFile( bunRunnerPath, 'import * as plugin from "opencode-graphiti";\n' + "console.log(JSON.stringify(Object.keys(plugin).sort()));\n", ); - const esmLoad = await run("node", [esmRunnerPath]); + const esmLoad = await run("node", [esmRunnerPath], isolatedCwd); assertEquals(esmLoad.code, 0, esmLoad.stderr || esmLoad.stdout); assertEquals(esmLoad.stdout.trim(), '["graphiti"]'); + const configLoad = await run("node", [configRunnerPath], isolatedCwd); + assertEquals( + configLoad.code, + 0, + [ + "config loader should resolve cosmiconfig from the plugin package instead of process.cwd()", + configLoad.stderr || configLoad.stdout, + ].filter(Boolean).join("\n\n"), + ); + assertEquals( + configLoad.stdout.trim(), + '{"endpoint":"http://127.0.0.1:8899/mcp","graphiti":"http://127.0.0.1:8899/mcp","redis":"redis://127.0.0.1:6379"}', + ); + + const nodePackageLoad = await run( + "node", + [nodePackageRunnerPath], + isolatedCwd, + ); + assertEquals( + nodePackageLoad.code, + 0, + [ + "node package-name import from a bare cwd should succeed; this is the primary regression for cwd-sensitive runtime resolution", + nodePackageLoad.stderr || nodePackageLoad.stdout, + ].filter(Boolean).join("\n\n"), + ); + assertEquals( + nodePackageLoad.stdout.trim(), + '["graphiti"]\ninitialized', + ); + assertEquals( + nodePackageLoad.stderr.includes( + "Cannot find module '@modelcontextprotocol/sdk/client/index.js'", + ), + false, + [ + "node package-name import from a bare cwd should not resolve runtime dependencies through process.cwd()", + nodePackageLoad.stderr || nodePackageLoad.stdout, + ].filter(Boolean).join("\n\n"), + ); + if (bunRunPermissionGranted && await commandExists("bun")) { - const bunLoad = await run("bun", [bunRunnerPath], tempDir); + const bunLoad = await run("bun", [bunRunnerPath], isolatedCwd); assertEquals(bunLoad.code, 0, bunLoad.stderr || bunLoad.stdout); assertEquals(bunLoad.stdout.trim(), '["graphiti"]'); } @@ -139,7 +240,7 @@ Deno.test({ optionalOpenCodePath, { args: ["--print-logs", "stats"], - cwd: workspacePath, + cwd: isolatedCwd, env: { HOME: isolatedHome, XDG_CONFIG_HOME: join(isolatedHome, ".config"), @@ -150,11 +251,23 @@ Deno.test({ ).output(); const isolatedOpenCodeOutput = decodeText(isolatedOpenCode.stdout) + decodeText(isolatedOpenCode.stderr); + assertEquals( + isolatedOpenCode.code, + 0, + isolatedOpenCodeOutput, + ); assertEquals( isolatedOpenCodeOutput.includes("Missing 'default' export"), false, isolatedOpenCodeOutput, ); + assertEquals( + isolatedOpenCodeOutput.includes( + "Cannot find module '@modelcontextprotocol/sdk/client/index.js'", + ), + false, + isolatedOpenCodeOutput, + ); } } catch { // OPENCODE_BIN is optional; keep the portable package checks above. diff --git a/src/config.ts b/src/config.ts index c6a3865..86c1fa7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,6 @@ import os from "node:os"; import { createRequire } from "node:module"; import { join } from "node:path"; -import process from "node:process"; import { redactEndpointUserInfo } from "./services/endpoint-redaction.ts"; import { notifyPluginWarning } from "./services/opencode-warning.ts"; import type { GraphitiConfig, RawGraphitiConfig } from "./types/index.ts"; @@ -61,9 +60,7 @@ export interface ConfigExplorerAdapter { type ConfigExplorerFactory = () => ConfigExplorerAdapter; -const nodeRequire = createRequire( - join(process.cwd(), "graphiti.config.runtime.cjs"), -); +const nodeRequire = createRequire(import.meta.url); const isRecord = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); diff --git a/src/handlers/chat.test.ts b/src/handlers/chat.test.ts index ca6af25..8516c03 100644 --- a/src/handlers/chat.test.ts +++ b/src/handlers/chat.test.ts @@ -34,8 +34,11 @@ class MockSessionManager { threshold: 0.5, cachedQuery: null, }; - prepareInjectionCalls: Array<{ sessionId: string; lastRequest?: string }> = - []; + prepareInjectionCalls: Array<{ + sessionId: string; + lastRequest?: string; + options?: { forCompaction?: boolean }; + }> = []; state = { groupId: "group-1", userGroupId: "user-1", @@ -77,10 +80,15 @@ class MockSessionManager { }; } - prepareInjection(_sessionId: string, lastRequest?: string) { + prepareInjection( + _sessionId: string, + lastRequest?: string, + options?: { forCompaction?: boolean }, + ) { this.prepareInjectionCalls.push({ sessionId: _sessionId, lastRequest, + options, }); const prepared = this.prepareInjectionResult === undefined ? { @@ -182,6 +190,7 @@ describe("chat handler", () => { assertEquals(sessionManager.prepareInjectionCalls, [{ sessionId: "session-1", lastRequest: "Continue the migration", + options: undefined, }]); assertEquals(graphitiAsync.drainCalls, []); }); @@ -322,6 +331,7 @@ describe("chat handler", () => { assertEquals(sessionManager.prepareInjectionCalls, [{ sessionId: "parent-session", lastRequest: "Continue the child task", + options: undefined, }]); }); @@ -431,6 +441,7 @@ describe("chat handler", () => { assertEquals(sessionManager.prepareInjectionCalls, [{ sessionId: "session-1", lastRequest: "Race the refresh", + options: undefined, }]); assertEquals(sessionManager.state.injectedMemories, false); assertEquals(sessionManager.state.pendingInjection, undefined); diff --git a/src/handlers/compacting.test.ts b/src/handlers/compacting.test.ts index cf4eaf6..88e2829 100644 --- a/src/handlers/compacting.test.ts +++ b/src/handlers/compacting.test.ts @@ -10,7 +10,11 @@ class MockSessionManager { hotTierReady: true, pendingInjection: undefined as unknown, }; - prepareInjectionCalls: string[] = []; + prepareInjectionCalls: Array<{ + sessionId: string; + lastRequest?: string; + options?: { forCompaction?: boolean }; + }> = []; clearPendingInjectionCalls = 0; activeCalls: Array<{ sessionId: string; canonicalSessionId?: string }> = []; @@ -22,8 +26,12 @@ class MockSessionManager { }; } - prepareInjection(sessionId: string) { - this.prepareInjectionCalls.push(sessionId); + prepareInjection( + sessionId: string, + lastRequest?: string, + options?: { forCompaction?: boolean }, + ) { + this.prepareInjectionCalls.push({ sessionId, lastRequest, options }); const prepared = { envelope: '', @@ -64,7 +72,11 @@ describe("compacting handler", () => { assertEquals(output.context.length, 2); assertStringIncludes(output.context[1], " { it("preserves local-first session memory shape during compaction with cached persistent memory optional", async () => { const sessionManager = new MockSessionManager(); - sessionManager.prepareInjection = ((sessionId: string) => { - sessionManager.prepareInjectionCalls.push(sessionId); + sessionManager.prepareInjection = (( + sessionId: string, + lastRequest?: string, + options?: { forCompaction?: boolean }, + ) => { + sessionManager.prepareInjectionCalls.push({ + sessionId, + lastRequest, + options, + }); const prepared = { envelope: 'continuecached recall', @@ -117,7 +137,11 @@ describe("compacting handler", () => { assertEquals(output.context.length, 2); assertStringIncludes(output.context[1], " unknown; + prepareInjectionImpl?: ( + sessionId: string, + lastRequest?: string, + options?: { forCompaction?: boolean }, + ) => unknown; activeCalls: Array<{ sessionId: string; canonicalSessionId?: string }> = []; clearPendingInjection(state: typeof this.state, prepared?: unknown) { if (state.pendingInjection === prepared) { @@ -37,9 +41,13 @@ class MockSessionManager { }; } - prepareInjection(sessionId: string, lastRequest?: string) { + prepareInjection( + sessionId: string, + lastRequest?: string, + options?: { forCompaction?: boolean }, + ) { if (this.prepareInjectionImpl) { - return this.prepareInjectionImpl(sessionId, lastRequest); + return this.prepareInjectionImpl(sessionId, lastRequest, options); } return this.state.pendingInjection; } diff --git a/src/index.test.ts b/src/index.test.ts index ac573b7..01cec16 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -2,6 +2,7 @@ import { assertEquals, assertRejects, assertStrictEquals, + assertStringIncludes, } from "jsr:@std/assert@^1.0.0"; import { afterEach, describe, it } from "jsr:@std/testing@^1.0.0/bdd"; import { @@ -11,6 +12,11 @@ import { } from "./index.ts"; import { logger } from "./services/logger.ts"; import { registerRuntimeTeardown } from "./services/runtime-teardown.ts"; +import { + SESSION_SEARCH_BASELINE_DESCRIPTION, + SESSION_SEARCH_STRENGTHENED_DESCRIPTION, +} from "./services/session-mcp-runtime.ts"; +import { createSessionMcpRuntime } from "./services/session-mcp-runtime.ts"; import { setOpenCodeClient, setWarningTaskScheduler, @@ -30,6 +36,7 @@ function createEntrypointHarnessWithOptions(options: { connected?: boolean; readyError?: Error; redisConnectError?: Error; + priorEventsBySessionId?: Record; teardownRun?: () => Promise; teardownDispose?: () => void; createSessionMcpRuntimeError?: Error; @@ -58,8 +65,12 @@ function createEntrypointHarnessWithOptions(options: { }; const hooks = { event: { kind: "event" }, - chat: { kind: "chat" }, - compacting: { kind: "compacting" }, + chat: (input: unknown, output: unknown) => { + records.chatHookCalls.push({ input, output }); + }, + compacting: (input: unknown, output: unknown) => { + records.compactingHookCalls.push({ input, output }); + }, messages: { kind: "messages" }, tool: { session_execute: { kind: "session_execute" }, @@ -102,6 +113,11 @@ function createEntrypointHarnessWithOptions(options: { graphitiMcpInstances: [] as unknown[], redisEventsArgs: [] as Array<[unknown, { sessionTtlSeconds: number }]>, redisEventsInstances: [] as unknown[], + redisEventsRecentCalls: [] as Array<{ + sessionId: string; + limit: number; + chronological: boolean; + }>, redisSnapshotArgs: [] as Array<[unknown, { ttlSeconds: number }]>, redisSnapshotInstances: [] as unknown[], redisCacheArgs: [] as Array<[ @@ -109,6 +125,11 @@ function createEntrypointHarnessWithOptions(options: { { ttlSeconds: number; driftThreshold: number }, ]>, redisCacheInstances: [] as unknown[], + sessionNotesArgs: [] as Array<[ + unknown, + { groupId: string; sessionTtlSeconds: number }, + ]>, + sessionNotesInstances: [] as unknown[], batchDrainArgs: [] as Array<[ unknown, unknown, @@ -126,7 +147,11 @@ function createEntrypointHarnessWithOptions(options: { unknown, unknown, unknown, - { idleRetentionMs: number; runtimeStateMigrator: unknown }, + { + idleRetentionMs: number; + runtimeStateMigrator: unknown; + notesService?: unknown; + }, ]>, sessionManagerInstances: [] as unknown[], createEventHandlerArgs: [] as Array>, @@ -135,6 +160,8 @@ function createEntrypointHarnessWithOptions(options: { createMessagesHandlerArgs: [] as Array>, createToolBeforeHandlerArgs: [] as Array>, createToolAfterHandlerArgs: [] as Array>, + chatHookCalls: [] as Array<{ input: unknown; output: unknown }>, + compactingHookCalls: [] as Array<{ input: unknown; output: unknown }>, toolGuidanceCacheInstances: [] as unknown[], toolRoutingOutcomeCacheInstances: [] as unknown[], teardownDisposeCalls: 0, @@ -197,6 +224,15 @@ function createEntrypointHarnessWithOptions(options: { records.redisEventsArgs.push([redisClient, options]); records.redisEventsInstances.push(this); } + + getRecentSessionEvents( + sessionId: string, + limit = 40, + chronological = true, + ) { + records.redisEventsRecentCalls.push({ sessionId, limit, chronological }); + return Promise.resolve(options.priorEventsBySessionId?.[sessionId] ?? []); + } } class MockRedisSnapshotService { @@ -216,6 +252,16 @@ function createEntrypointHarnessWithOptions(options: { } } + class MockSessionNotesService { + constructor( + redisClient: unknown, + options: { groupId: string; sessionTtlSeconds: number }, + ) { + records.sessionNotesArgs.push([redisClient, options]); + records.sessionNotesInstances.push(this); + } + } + class MockBatchDrainService { constructor( redisClient: unknown, @@ -367,6 +413,7 @@ function createEntrypointHarnessWithOptions(options: { RedisEventsService: MockRedisEventsService, RedisSnapshotService: MockRedisSnapshotService, RedisCacheService: MockRedisCacheService, + SessionNotesService: MockSessionNotesService, BatchDrainService: MockBatchDrainService, GraphitiAsyncService: MockGraphitiAsyncService, createSessionExecutor: (args?: Record) => @@ -852,6 +899,41 @@ describe("index", () => { }); describe("graphiti entrypoint", () => { + it("exposes public note/search tool args without root_session_id", () => { + const runtime = createSessionMcpRuntime(); + + try { + assertStringIncludes( + runtime.tools.session_notes_write.description, + "delete on missing id is a no-op success returning deleted", + ); + assertStringIncludes( + runtime.tools.session_notes_write.description, + "only ownership conflicts reject mutation", + ); + assertStringIncludes( + runtime.tools.session_notes_read.description, + "returns `{ note: null }`", + ); + assertStringIncludes( + runtime.tools.session_search.description, + '`id`, `root_session_id`, and `scope: "local" | "project"`', + ); + assertEquals(Object.keys(runtime.tools.session_notes_write.args), [ + "text", + "replace", + ]); + assertEquals(Object.keys(runtime.tools.session_notes_read.args), [ + "id", + ]); + assertEquals(Object.keys(runtime.tools.session_search.args), [ + "query", + ]); + } finally { + void runtime.dispose(); + } + }); + it("exports graphiti as the plugin entrypoint", () => { assertEquals(typeof graphiti, "function"); }); @@ -902,6 +984,7 @@ describe("index", () => { assertEquals(records.sessionMcpRuntimeArgs, [{ redisClient: records.redisClientInstances[0], graphitiCache: records.redisCacheInstances[0], + notesService: records.sessionNotesInstances[0], sessionTtlSeconds: config.redis.sessionTtlSeconds, groupId: "group-id", sessionExecutor: records.sessionExecutorInstances[0], @@ -934,6 +1017,14 @@ describe("index", () => { ttlSeconds: config.redis.cacheTtlSeconds, driftThreshold: config.graphiti.driftThreshold, }); + assertStrictEquals( + records.sessionNotesArgs[0][0], + records.redisClientInstances[0], + ); + assertEquals(records.sessionNotesArgs[0][1], { + groupId: "group-id", + sessionTtlSeconds: config.redis.sessionTtlSeconds, + }); assertStrictEquals( records.batchDrainArgs[0][0], records.redisClientInstances[0], @@ -985,6 +1076,7 @@ describe("index", () => { assertEquals(records.sessionManagerArgs[0][6], { idleRetentionMs: config.redis.sessionTtlSeconds * 1000, runtimeStateMigrator: records.sessionMcpRuntimeInstances[0], + notesService: records.sessionNotesInstances[0], }); assertStrictEquals( records.sessionMcpRuntimeCanonicalizerCalls[0], @@ -1077,10 +1169,10 @@ describe("index", () => { ); assertStrictEquals(plugin.event, hooks.event); - assertStrictEquals(plugin["chat.message"], hooks.chat); - assertStrictEquals( - plugin["experimental.session.compacting"], - hooks.compacting, + assertEquals(typeof plugin["chat.message"], "function"); + assertEquals( + typeof plugin["experimental.session.compacting"], + "function", ); assertStrictEquals( plugin["experimental.chat.messages.transform"], @@ -1089,6 +1181,7 @@ describe("index", () => { assertStrictEquals(plugin.tool, hooks.tool); assertStrictEquals(plugin["tool.execute.before"], hooks.toolBefore); assertStrictEquals(plugin["tool.execute.after"], hooks.toolAfter); + assertEquals(typeof plugin["tool.definition"], "function"); }); it("warns on degraded startup without blocking plugin initialization", async () => { @@ -1106,7 +1199,7 @@ describe("index", () => { assertEquals(records.connectionStartCalls, 1); assertEquals(records.redisConnectCalls, 1); assertStrictEquals(plugin.event, hooks.event); - assertStrictEquals(plugin["chat.message"], hooks.chat); + assertEquals(typeof plugin["chat.message"], "function"); }); it("degrades cleanly when Graphiti readiness rejects", async () => { @@ -1128,7 +1221,7 @@ describe("index", () => { }]); assertEquals(records.redisWarnCalls, []); assertStrictEquals(plugin.event, hooks.event); - assertStrictEquals(plugin["chat.message"], hooks.chat); + assertEquals(typeof plugin["chat.message"], "function"); }); it("degrades cleanly when Redis startup rejects", async () => { @@ -1150,7 +1243,168 @@ describe("index", () => { endpoint: config.redis.endpoint, }]); assertStrictEquals(plugin.event, hooks.event); - assertStrictEquals(plugin["chat.message"], hooks.chat); + assertEquals(typeof plugin["chat.message"], "function"); + }); + + it("strengthens session_search once for new-session bias and leaves other tools unchanged", async () => { + const { input, records, dependencies } = createEntrypointHarness(true); + + const plugin = await invokeGraphiti(input, dependencies) as Record< + string, + unknown + >; + const chatHook = plugin["chat.message"] as ( + input: { sessionID: string }, + output: { parts: unknown[] }, + ) => Promise; + const toolDefinitionHook = plugin["tool.definition"] as ( + input: { toolID: string }, + output: { description: string; parameters: unknown }, + ) => Promise; + + await chatHook( + { sessionID: "session-a" }, + { parts: [] }, + ); + + assertEquals(records.chatHookCalls.length, 1); + + const nonSearchOutput = { + description: "Execute a bounded session command.", + parameters: { type: "object" }, + }; + await toolDefinitionHook( + { toolID: "session_execute" }, + nonSearchOutput, + ); + assertEquals( + nonSearchOutput.description, + "Execute a bounded session command.", + ); + + const strengthenedOutput = { + description: SESSION_SEARCH_BASELINE_DESCRIPTION, + parameters: { type: "object" }, + }; + await toolDefinitionHook( + { toolID: "session_search" }, + strengthenedOutput, + ); + assertEquals( + strengthenedOutput.description, + SESSION_SEARCH_STRENGTHENED_DESCRIPTION, + ); + + const baselineOutput = { + description: SESSION_SEARCH_BASELINE_DESCRIPTION, + parameters: { type: "object" }, + }; + await toolDefinitionHook( + { toolID: "session_search" }, + baselineOutput, + ); + assertEquals( + baselineOutput.description, + SESSION_SEARCH_BASELINE_DESCRIPTION, + ); + }); + + it("does not set new-session bias when prior session events already exist", async () => { + const { input, records, dependencies } = + createEntrypointHarnessWithOptions({ + connected: true, + priorEventsBySessionId: { + "session-a": [{ id: "evt-1" }], + }, + }); + + const plugin = await invokeGraphiti(input, dependencies) as Record< + string, + unknown + >; + const chatHook = plugin["chat.message"] as ( + input: { sessionID: string }, + output: { parts: unknown[] }, + ) => Promise; + const toolDefinitionHook = plugin["tool.definition"] as ( + input: { toolID: string }, + output: { description: string; parameters: unknown }, + ) => Promise; + + await chatHook( + { sessionID: "session-a" }, + { parts: [] }, + ); + + assertEquals(records.redisEventsRecentCalls, [{ + sessionId: "session-a", + limit: 1, + chronological: false, + }]); + + const output = { + description: SESSION_SEARCH_BASELINE_DESCRIPTION, + parameters: { type: "object" }, + }; + await toolDefinitionHook( + { toolID: "session_search" }, + output, + ); + + assertEquals(output.description, SESSION_SEARCH_BASELINE_DESCRIPTION); + }); + + it("sets post-compaction bias and consumes multiple biased sessions together", async () => { + const { input, records, dependencies } = createEntrypointHarness(true); + + const plugin = await invokeGraphiti(input, dependencies) as Record< + string, + unknown + >; + const chatHook = plugin["chat.message"] as ( + input: { sessionID: string }, + output: { parts: unknown[] }, + ) => Promise; + const compactingHook = plugin["experimental.session.compacting"] as ( + input: { sessionID: string }, + output: { context: string[] }, + ) => Promise; + const toolDefinitionHook = plugin["tool.definition"] as ( + input: { toolID: string }, + output: { description: string; parameters: unknown }, + ) => Promise; + + await chatHook({ sessionID: "session-a" }, { parts: [] }); + await compactingHook({ sessionID: "session-b" }, { context: [] }); + + assertEquals(records.chatHookCalls.length, 1); + assertEquals(records.compactingHookCalls.length, 1); + + const firstOutput = { + description: SESSION_SEARCH_BASELINE_DESCRIPTION, + parameters: { type: "object" }, + }; + await toolDefinitionHook( + { toolID: "session_search" }, + firstOutput, + ); + assertEquals( + firstOutput.description, + SESSION_SEARCH_STRENGTHENED_DESCRIPTION, + ); + + const secondOutput = { + description: SESSION_SEARCH_BASELINE_DESCRIPTION, + parameters: { type: "object" }, + }; + await toolDefinitionHook( + { toolID: "session_search" }, + secondOutput, + ); + assertEquals( + secondOutput.description, + SESSION_SEARCH_BASELINE_DESCRIPTION, + ); }); it("passes live redis client, ttl, and groupId into session MCP runtime", async () => { @@ -1163,6 +1417,7 @@ describe("index", () => { assertEquals(records.sessionMcpRuntimeArgs, [{ redisClient: records.redisClientInstances[0], graphitiCache: records.redisCacheInstances[0], + notesService: records.sessionNotesInstances[0], sessionTtlSeconds: config.redis.sessionTtlSeconds, groupId: "group-id", sessionExecutor: records.sessionExecutorInstances[0], @@ -1181,6 +1436,23 @@ describe("index", () => { ); }); + it("creates one shared notes service and passes it to runtime and session manager", async () => { + const { input, records, dependencies } = createEntrypointHarness(true); + + await invokeGraphiti(input, dependencies); + + assertEquals(records.sessionNotesInstances.length, 1); + const runtimeArgs = records.sessionMcpRuntimeArgs[0] ?? {}; + assertStrictEquals( + runtimeArgs.notesService, + records.sessionNotesInstances[0], + ); + assertStrictEquals( + records.sessionManagerArgs[0][6].notesService, + records.sessionNotesInstances[0], + ); + }); + it("wires the session manager into the runtime root validator explicitly after construction", async () => { const { input, records, dependencies } = createEntrypointHarness(true); @@ -1200,6 +1472,7 @@ describe("index", () => { const args = records.sessionMcpRuntimeArgs[0] ?? {}; assertStrictEquals(args.redisClient, records.redisClientInstances[0]); + assertStrictEquals(args.notesService, records.sessionNotesInstances[0]); assertEquals(args.sessionTtlSeconds, 60); assertEquals(args.groupId, "group-id"); }); diff --git a/src/index.ts b/src/index.ts index 483c48f..bd3de95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import type { Plugin, PluginInput } from "@opencode-ai/plugin"; +import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"; import { loadConfig } from "./config.ts"; import { createChatHandler } from "./handlers/chat.ts"; import { createCompactingHandler } from "./handlers/compacting.ts"; @@ -20,14 +20,30 @@ import { RedisCacheService } from "./services/redis-cache.ts"; import { RedisClient } from "./services/redis-client.ts"; import { RedisEventsService } from "./services/redis-events.ts"; import { logger } from "./services/logger.ts"; +import { SessionNotesService } from "./services/session-notes.ts"; import { RedisSnapshotService } from "./services/redis-snapshot.ts"; import { registerRuntimeTeardown } from "./services/runtime-teardown.ts"; import { createSessionExecutor } from "./services/session-executor.ts"; -import { createSessionMcpRuntime } from "./services/session-mcp-runtime.ts"; +import { + createSessionMcpRuntime, + SESSION_SEARCH_STRENGTHENED_DESCRIPTION, +} from "./services/session-mcp-runtime.ts"; import { ToolGuidanceCache } from "./services/tool-guidance-cache.ts"; import { ToolRoutingOutcomeCache } from "./services/tool-routing-outcome-cache.ts"; import { makeGroupId, makeUserGroupId } from "./utils.ts"; +type BiasState = "normal" | "new-session" | "post-compaction"; + +type ChatMessageHook = NonNullable; +type ChatMessageInput = Parameters[0]; +type ChatMessageOutput = Parameters[1]; +type CompactingHook = NonNullable; +type CompactingInput = Parameters[0]; +type CompactingOutput = Parameters[1]; +type ToolDefinitionHook = NonNullable; +type ToolDefinitionInput = Parameters[0]; +type ToolDefinitionOutput = Parameters[1]; + type GraphitiDependencies = { loadConfig: typeof loadConfig; setOpenCodeClient: typeof setOpenCodeClient; @@ -46,6 +62,7 @@ type GraphitiDependencies = { RedisEventsService: typeof RedisEventsService; RedisSnapshotService: typeof RedisSnapshotService; RedisCacheService: typeof RedisCacheService; + SessionNotesService: typeof SessionNotesService; BatchDrainService: typeof BatchDrainService; GraphitiAsyncService: typeof GraphitiAsyncService; createSessionExecutor: typeof createSessionExecutor; @@ -104,6 +121,7 @@ const defaultGraphitiDependencies: GraphitiDependencies = { RedisEventsService, RedisSnapshotService, RedisCacheService, + SessionNotesService, BatchDrainService, GraphitiAsyncService, createSessionExecutor, @@ -206,6 +224,18 @@ export const graphiti: Plugin = ( ttlSeconds: config.redis.cacheTtlSeconds, driftThreshold: config.graphiti.driftThreshold, }); + const defaultGroupId = dependencies.makeGroupId( + config.graphiti.groupIdPrefix, + input.directory, + ); + const defaultUserGroupId = dependencies.makeUserGroupId( + config.graphiti.groupIdPrefix, + input.directory, + ); + const notesService = new dependencies.SessionNotesService(redisClient, { + groupId: defaultGroupId, + sessionTtlSeconds: config.redis.sessionTtlSeconds, + }); const batchDrain = new dependencies.BatchDrainService( redisClient, redisEvents, @@ -215,15 +245,6 @@ export const graphiti: Plugin = ( drainRetryMax: config.redis.drainRetryMax, }, ); - const defaultGroupId = dependencies.makeGroupId( - config.graphiti.groupIdPrefix, - input.directory, - ); - const defaultUserGroupId = dependencies.makeUserGroupId( - config.graphiti.groupIdPrefix, - input.directory, - ); - const graphitiAsync = new dependencies.GraphitiAsyncService( graphitiClient, redisCache, @@ -237,6 +258,7 @@ export const graphiti: Plugin = ( const sessionMcpRuntime = dependencies.createSessionMcpRuntime({ redisClient, graphitiCache: redisCache, + notesService, sessionTtlSeconds: config.redis.sessionTtlSeconds, groupId: defaultGroupId, sessionExecutor, @@ -256,12 +278,24 @@ export const graphiti: Plugin = ( redisCache, { idleRetentionMs: config.redis.sessionTtlSeconds * 1000, + notesService, runtimeStateMigrator: sessionMcpRuntime, }, ); sessionMcpRuntime.setSessionCanonicalizer(sessionManager); const toolGuidanceCache = new dependencies.ToolGuidanceCache(); const toolRoutingOutcomes = new dependencies.ToolRoutingOutcomeCache(); + const sessionBiasState = new Map(); + const chatHandler = dependencies.createChatHandler({ + sessionManager, + redisEvents, + graphitiAsync, + drainTriggerSize: config.redis.batchSize, + }); + const compactingHandler = dependencies + .createCompactingHandler({ + sessionManager, + }); startupTeardown = dependencies.registerRuntimeTeardown([ { @@ -302,21 +336,62 @@ export const graphiti: Plugin = ( sdkClient: input.client, directory: input.directory, }), - "chat.message": dependencies.createChatHandler({ - sessionManager, - redisEvents, - graphitiAsync, - drainTriggerSize: config.redis.batchSize, - }), - "experimental.session.compacting": dependencies - .createCompactingHandler({ - sessionManager, - }), + "chat.message": async ( + hookInput: ChatMessageInput, + output: ChatMessageOutput, + ) => { + const canonicalSessionId = sessionManager.getCachedCanonicalSessionId( + hookInput.sessionID, + ) ?? + await sessionManager.resolveCanonicalSessionId(hookInput.sessionID); + if (canonicalSessionId && !sessionBiasState.has(canonicalSessionId)) { + const priorEvents = await redisEvents.getRecentSessionEvents( + canonicalSessionId, + 1, + false, + ); + if (priorEvents.length === 0) { + sessionBiasState.set(canonicalSessionId, "new-session"); + } + } + await chatHandler(hookInput, output); + }, + "experimental.session.compacting": async ( + hookInput: CompactingInput, + output: CompactingOutput, + ) => { + const canonicalSessionId = sessionManager.getCachedCanonicalSessionId( + hookInput.sessionID, + ) ?? + await sessionManager.resolveCanonicalSessionId(hookInput.sessionID); + if (canonicalSessionId) { + sessionBiasState.set(canonicalSessionId, "post-compaction"); + } + await compactingHandler(hookInput, output); + }, "experimental.chat.messages.transform": dependencies .createMessagesHandler({ sessionManager, }), tool: sessionMcpRuntime.tools, + "tool.definition": ( + hookInput: ToolDefinitionInput, + output: ToolDefinitionOutput, + ) => { + if (hookInput.toolID !== "session_search") return Promise.resolve(); + + let anyBiased = false; + for (const [sessionId, state] of sessionBiasState) { + if (state === "normal") continue; + anyBiased = true; + sessionBiasState.delete(sessionId); + } + + if (anyBiased) { + output.description = SESSION_SEARCH_STRENGTHENED_DESCRIPTION; + } + return Promise.resolve(); + }, "tool.execute.before": dependencies.createToolBeforeHandler({ sessionCanonicalizer: sessionManager, guidanceThrottle: toolGuidanceCache, diff --git a/src/services/connection-manager.ts b/src/services/connection-manager.ts index dcc647d..87aae36 100644 --- a/src/services/connection-manager.ts +++ b/src/services/connection-manager.ts @@ -1,7 +1,5 @@ import { createRequire } from "node:module"; -import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import process from "node:process"; import manifest from "../../deno.json" with { type: "json" }; import { isAbortError } from "../utils.ts"; import { redactEndpointUserInfo } from "./endpoint-redaction.ts"; @@ -28,9 +26,7 @@ type McpRuntimeModules = { StreamableHTTPClientTransport: McpTransportConstructor; }; -const nodeRequire = createRequire( - pathToFileURL(join(process.cwd(), "graphiti.runtime.cjs")).href, -); +const nodeRequire = createRequire(import.meta.url); let mcpRuntimeModulesPromise: Promise | null = null; const importResolvedModule = async (specifier: string): Promise => { diff --git a/src/services/session-mcp-runtime.test.ts b/src/services/session-mcp-runtime.test.ts index 7fb1f47..c91e8f0 100644 --- a/src/services/session-mcp-runtime.test.ts +++ b/src/services/session-mcp-runtime.test.ts @@ -9,6 +9,9 @@ import { describe, it } from "jsr:@std/testing@^1.0.0/bdd"; import { createSessionMcpRuntime, SESSION_MCP_RESPONSE_BUDGET_BYTES, + SESSION_NOTES_READ_DESCRIPTION, + SESSION_NOTES_WRITE_DESCRIPTION, + SESSION_SEARCH_BASELINE_DESCRIPTION, } from "./session-mcp-runtime.ts"; import type { SessionExecutor } from "./session-executor.ts"; import { @@ -166,6 +169,15 @@ const createToolContext = (overrides: Partial = {}) => ({ ...overrides, }); +const createRootToolContext = ( + rootSessionId: string, + overrides: Partial = {}, +) => + createToolContext({ + sessionID: rootSessionId, + ...overrides, + }); + const validRequests: Record> = { session_execute: { root_session_id: "root-123", @@ -184,7 +196,6 @@ const validRequests: Record> = { content: "hello world", }, session_search: { - root_session_id: "root-123", query: "hello", }, session_fetch_and_index: { @@ -197,8 +208,124 @@ const validRequests: Record> = { session_doctor: { root_session_id: "root-123", }, + session_notes_write: { + text: "remember this", + }, + session_notes_read: { + id: "note-1", + }, }; +Deno.test("note schema compatibility accepts approved note request and response contracts", () => { + const writeRequest = sessionMcpRequestSchemas.session_notes_write.safeParse({ + text: "remember this", + replace: "note-1", + }); + const rejectedWriteRequest = sessionMcpRequestSchemas.session_notes_write + .safeParse({ + root_session_id: "root-123", + text: "remember this", + }); + const deleteResponse = sessionMcpResponseSchemas.session_notes_write + .safeParse({ + action: "deleted", + id: "note-1", + }); + const clearedResponse = sessionMcpResponseSchemas.session_notes_write + .safeParse({ + action: "replaced", + cleared_count: 2, + }); + const readRequest = sessionMcpRequestSchemas.session_notes_read.safeParse({ + id: "note-1", + }); + const missingReadRequest = sessionMcpRequestSchemas.session_notes_read + .safeParse({}); + const rejectedReadRequest = sessionMcpRequestSchemas.session_notes_read + .safeParse({ + root_session_id: "root-123", + id: "note-1", + }); + const readResponse = sessionMcpResponseSchemas.session_notes_read.safeParse({ + note: { + id: "note-1", + text: "remember this", + created_at: "2026-04-11T10:00:00.000Z", + updated_at: "2026-04-11T10:00:00.000Z", + }, + }); + const missingReadResponse = sessionMcpResponseSchemas.session_notes_read + .safeParse({ note: null }); + + assertEquals(writeRequest.success, true); + assertEquals(rejectedWriteRequest.success, false); + assertEquals(deleteResponse.success, true); + assertEquals(clearedResponse.success, true); + assertEquals(readRequest.success, true); + assertEquals(missingReadRequest.success, false); + assertEquals(rejectedReadRequest.success, false); + assertEquals(readResponse.success, true); + assertEquals(missingReadResponse.success, true); +}); + +Deno.test("search schema compatibility accepts note-flavored results and remains strict", () => { + const request = sessionMcpRequestSchemas.session_search.safeParse({ + query: "remember this", + }); + const rejectedRequest = sessionMcpRequestSchemas.session_search.safeParse({ + root_session_id: "root-123", + query: "remember this", + }); + const accepted = sessionMcpResponseSchemas.session_search.safeParse({ + status: "ok", + results: [{ + corpus_ref: "session:root:corpus:1", + snippet: "remember this", + score: 0.9, + type: "note", + id: "note-1", + root_session_id: "root-123", + scope: "local", + }], + corpus_refs: ["session:root:corpus:1"], + truncated: false, + }); + const rejected = sessionMcpResponseSchemas.session_search.safeParse({ + status: "ok", + results: [{ + corpus_ref: "session:root:corpus:1", + snippet: "remember this", + score: 0.9, + type: "note", + id: "note-1", + root_session_id: "root-123", + scope: "local", + extra: true, + }], + corpus_refs: ["session:root:corpus:1"], + truncated: false, + }); + const rejectedLegacyIdentity = sessionMcpResponseSchemas.session_search + .safeParse({ + status: "ok", + results: [{ + corpus_ref: "session:root:corpus:1", + snippet: "remember this", + score: 0.9, + type: "note", + note_id: "note-1", + }], + corpus_refs: ["session:root:corpus:1"], + truncated: false, + }); + + assertEquals(request.success, true); + assertEquals(rejectedRequest.success, false); + assertEquals(accepted.success, true); + assertEquals(rejected.success, false); + assertEquals(rejectedLegacyIdentity.success, false); +}); + Deno.test("mixed|batch schema compatibility", () => { const request = sessionMcpRequestSchemas.session_batch_execute.safeParse({ root_session_id: "root-123", @@ -290,7 +417,7 @@ Deno.test("index schema compatibility rejects requests without content or path", }); describe("session-mcp-runtime", () => { - it("registers exactly the 8 session tools", () => { + it("registers exactly the session tools in the declared order", () => { const runtime = createSessionMcpRuntime(); try { @@ -300,6 +427,514 @@ describe("session-mcp-runtime", () => { } }); + it("registers note tools with the shipped descriptions and expected args", () => { + const runtime = createSessionMcpRuntime(); + + try { + assertExists(runtime.tools.session_notes_write); + assertExists(runtime.tools.session_notes_read); + assertStringIncludes( + SESSION_NOTES_WRITE_DESCRIPTION, + "replace id + non-empty text is upsert", + ); + assertStringIncludes( + SESSION_NOTES_WRITE_DESCRIPTION, + 'replace "*" + non-empty text replaces all notes', + ); + assertStringIncludes( + SESSION_NOTES_WRITE_DESCRIPTION, + 'replace "*" + empty text clears all notes', + ); + assertStringIncludes( + SESSION_NOTES_READ_DESCRIPTION, + "returns that single note as", + ); + assertStringIncludes( + SESSION_NOTES_READ_DESCRIPTION, + "returns `{ note: null }`", + ); + assertStringIncludes( + SESSION_SEARCH_BASELINE_DESCRIPTION, + '`id`, `root_session_id`, and `scope: "local" | "project"`', + ); + assertEquals( + runtime.tools.session_notes_write.description, + SESSION_NOTES_WRITE_DESCRIPTION, + ); + assertEquals( + runtime.tools.session_notes_read.description, + SESSION_NOTES_READ_DESCRIPTION, + ); + assertEquals( + runtime.tools.session_search.description, + SESSION_SEARCH_BASELINE_DESCRIPTION, + ); + assertEquals(Object.keys(runtime.tools.session_notes_write.args), [ + "text", + "replace", + ]); + assertEquals(Object.keys(runtime.tools.session_notes_read.args), [ + "id", + ]); + assertEquals(Object.keys(runtime.tools.session_search.args), [ + "query", + ]); + } finally { + void runtime.dispose(); + } + }); + + it("executes the full note action contract through the runtime", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + } as never); + + try { + const created = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + text: "first note", + }, + toolContext, + ), + ); + assertEquals(created.action, "created"); + assertExists(created.id); + + const readCreated = JSON.parse( + await runtime.tools.session_notes_read.execute( + { + id: created.id, + }, + toolContext, + ), + ); + assertEquals(readCreated.note.text, "first note"); + assertEquals( + sessionMcpResponseSchemas.session_notes_read.safeParse(readCreated) + .success, + true, + ); + + const replaced = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + text: "updated note", + replace: created.id, + }, + toolContext, + ), + ); + assertEquals(replaced, { + action: "replaced", + id: created.id, + }); + assertEquals( + sessionMcpResponseSchemas.session_notes_write.safeParse(replaced) + .success, + true, + ); + + const createdSecond = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + text: "second note", + }, + toolContext, + ), + ); + assertEquals(createdSecond.action, "created"); + assertExists(createdSecond.id); + + const replacedAll = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + text: "replacement note", + replace: "*", + }, + toolContext, + ), + ); + assertEquals(replacedAll.action, "replaced"); + assertExists(replacedAll.id); + assertEquals(replacedAll.cleared_count, 2); + assertEquals( + sessionMcpResponseSchemas.session_notes_write.safeParse(replacedAll) + .success, + true, + ); + + const readSingle = JSON.parse( + await runtime.tools.session_notes_read.execute( + { + id: replacedAll.id, + }, + toolContext, + ), + ); + assertEquals(readSingle.note.text, "replacement note"); + + const deleted = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + text: "", + replace: replacedAll.id, + }, + toolContext, + ), + ); + assertEquals(deleted, { + action: "deleted", + id: replacedAll.id, + }); + assertEquals( + sessionMcpResponseSchemas.session_notes_write.safeParse(deleted) + .success, + true, + ); + + const cleared = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + text: "", + replace: "*", + }, + toolContext, + ), + ); + assertEquals(cleared, { + action: "replaced", + cleared_count: 0, + }); + assertEquals( + sessionMcpResponseSchemas.session_notes_write.safeParse(cleared) + .success, + true, + ); + + const readDeleted = JSON.parse( + await runtime.tools.session_notes_read.execute( + { + id: replacedAll.id, + }, + toolContext, + ), + ); + assertEquals(readDeleted, { note: null }); + assertEquals( + sessionMcpResponseSchemas.session_notes_read.safeParse(readDeleted) + .success, + true, + ); + } finally { + await runtime.dispose(); + } + }); + + it("resolves rootless search and note writes from the canonical tool context session", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const manager = new SessionManager( + "group-runtime-rootless", + "user-runtime-rootless", + { + session: { + get() { + throw new Error("unexpected session lookup"); + }, + }, + } as never, + {} as never, + {} as never, + {} as never, + ); + manager.setParentId("root-session", null); + manager.setParentId("child-session", "root-session"); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + sessionCanonicalizer: manager, + groupId: "group-runtime-rootless", + } as never); + + try { + await runtime.tools.session_index.execute( + { + root_session_id: "root-session", + content: "canonical root search corpus", + }, + createRootToolContext("root-session"), + ); + + const created = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + text: "canonical root pinned note", + }, + { + ...toolContext, + sessionID: "child-session", + }, + ), + ); + const search = JSON.parse( + await runtime.tools.session_search.execute( + { + query: "canonical root pinned note", + }, + { + ...toolContext, + sessionID: "child-session", + }, + ), + ); + + assertEquals(created.action, "created"); + assertExists(created.id); + assertEquals(search.status, "ok"); + assertEquals( + search.results.some((result: { id?: string }) => + result.id === created.id + ), + true, + ); + assertEquals( + search.results.some((result: { root_session_id?: string }) => + result.root_session_id === "root-session" + ), + true, + ); + } finally { + await runtime.dispose(); + } + }); + + it("reads a note directly by id across same-project sessions", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + groupId: "group-runtime-direct-read", + } as never); + + try { + const created = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + text: "same project note body", + }, + { + ...toolContext, + sessionID: "session-a", + }, + ), + ); + const read = JSON.parse( + await runtime.tools.session_notes_read.execute( + { + id: created.id, + }, + { + ...toolContext, + sessionID: "session-b", + }, + ), + ); + + assertEquals(read, { + note: { + id: created.id, + text: "same project note body", + created_at: read.note.created_at, + updated_at: read.note.updated_at, + }, + }); + } finally { + await runtime.dispose(); + } + }); + + it("ranks local note hits ahead of project note hits for the same query", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + groupId: "group-runtime-note-ranking", + } as never); + + try { + const project = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + text: "redis ttl ranking note exact phrase", + }, + { + ...toolContext, + sessionID: "project-session", + }, + ), + ); + const local = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + text: "redis ttl ranking note exact phrase", + }, + { + ...toolContext, + sessionID: "local-session", + }, + ), + ); + const search = JSON.parse( + await runtime.tools.session_search.execute( + { + query: "redis ttl ranking note exact phrase", + }, + { + ...toolContext, + sessionID: "local-session", + }, + ), + ); + const noteHits = search.results.filter((result: { type?: string }) => + result.type === "note" + ); + + assertEquals(noteHits.length >= 2, true); + assertEquals(noteHits[0].id, local.id); + assertEquals(noteHits[0].scope, "local"); + assertEquals(noteHits[0].root_session_id, "local-session"); + assertEquals(noteHits[1].id, project.id); + assertEquals(noteHits[1].scope, "project"); + assertEquals(noteHits[1].root_session_id, "project-session"); + assertEquals(noteHits[0].score >= noteHits[1].score, true); + } finally { + await runtime.dispose(); + } + }); + + it("merges note and memory hits in session_search with typed results sorted by score", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + groupId: "group-note-search", + } as never); + + try { + await runtime.tools.session_index.execute( + { + root_session_id: "root-note-search", + content: + "Redis TTL memory entry mentions the active bug and prior mitigation.", + }, + createRootToolContext("root-note-search"), + ); + const created = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + text: "Redis TTL bug active bug mitigation note for follow-up.", + }, + createRootToolContext("root-note-search"), + ), + ); + + const serialized = await runtime.tools.session_search.execute( + { + query: "Redis TTL bug active bug mitigation note for follow-up.", + }, + createRootToolContext("root-note-search"), + ); + const parsed = JSON.parse(serialized); + const noteHit = parsed.results.find((result: { type?: string }) => + result.type === "note" + ); + const memoryHit = parsed.results.find((result: { type?: string }) => + result.type === "memory" + ); + + assertEquals( + sessionMcpResponseSchemas.session_search.safeParse(parsed).success, + true, + ); + assertExists(noteHit); + assertExists(memoryHit); + assertEquals(noteHit.id, created.id); + assertEquals(noteHit.root_session_id, "root-note-search"); + assertEquals(noteHit.scope, "local"); + assertStringIncludes(noteHit.corpus_ref, created.id); + assertStringIncludes( + noteHit.snippet, + "Redis TTL bug active bug mitigation", + ); + assertStringIncludes( + runtime.tools.session_search.description, + "session_notes_read", + ); + assertEquals(memoryHit.type, "memory"); + assertEquals(parsed.results[0].score >= parsed.results[1].score, true); + assertEquals( + parsed.results.some((result: { type?: string }) => + result.type === "note" + ), + true, + ); + assertEquals( + parsed.results.some((result: { type?: string }) => + result.type === "memory" + ), + true, + ); + } finally { + await runtime.dispose(); + } + }); + + it("returns only memory hits when no notes match or exist", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + } as never); + + try { + await runtime.tools.session_index.execute( + { + root_session_id: "root-no-notes", + content: "Local memory result without pinned note entries.", + }, + createRootToolContext("root-no-notes"), + ); + + const parsed = JSON.parse( + await runtime.tools.session_search.execute( + { + query: "Local memory result", + }, + createRootToolContext("root-no-notes"), + ), + ); + + assertEquals(parsed.status, "ok"); + assertEquals(parsed.results.length > 0, true); + assertEquals( + parsed.results.every((result: { type?: string }) => + result.type !== "note" + ), + true, + ); + assertEquals( + parsed.results.every((result: { id?: string }) => + result.id === undefined + ), + true, + ); + } finally { + await runtime.dispose(); + } + }); + it("delegates execution tools to the injected shared executor when configured", async () => { const calls: Array<{ tool: string; payload: unknown }> = []; type ExecutorRequestMap = { @@ -378,13 +1013,23 @@ describe("session-mcp-runtime", () => { } }); - it("rejects requests without root_session_id for every tool schema", () => { + it("keeps root_session_id private only for note/search public request schemas", () => { + const toolsWithPrivateRoot = new Set([ + "session_search", + "session_notes_write", + "session_notes_read", + ]); + for (const toolName of SESSION_MCP_TOOL_NAMES) { const request = { ...validRequests[toolName] }; delete request.root_session_id; const parsed = sessionMcpRequestSchemas[toolName].safeParse(request); - assertEquals(parsed.success, false, toolName); + assertEquals( + parsed.success, + toolsWithPrivateRoot.has(toolName), + toolName, + ); } }); @@ -608,7 +1253,6 @@ describe("session-mcp-runtime", () => { try { const serialized = await runtime.tools.session_search.execute( { - root_session_id: "root-session", query: "indexed", }, { @@ -951,33 +1595,34 @@ describe("session-mcp-runtime", () => { } as never); try { + const rootContext = createRootToolContext("root-123"); await runtime.tools.session_execute.execute( validRequests.session_execute, - toolContext, + rootContext, ); await runtime.tools.session_execute_file.execute( validRequests.session_execute_file, - toolContext, + rootContext, ).catch(() => undefined); await runtime.tools.session_batch_execute.execute( validRequests.session_batch_execute, - toolContext, + rootContext, ); await runtime.tools.session_index.execute( validRequests.session_index, - toolContext, + rootContext, ); await runtime.tools.session_search.execute( validRequests.session_search, - toolContext, + rootContext, ); await runtime.tools.session_fetch_and_index.execute( validRequests.session_fetch_and_index, - toolContext, + rootContext, ); const statsSerialized = await runtime.tools.session_stats.execute( validRequests.session_stats, - toolContext, + rootContext, ); const stats = JSON.parse(statsSerialized); @@ -1216,14 +1861,13 @@ describe("session-mcp-runtime", () => { content: "# Redis Session TTLs\n\nSession TTL refreshes the local session corpus.", }, - toolContext, + createRootToolContext("root-123"), ); const serialized = await runtime.tools.session_search.execute( { - root_session_id: "root-123", query: "session ttl", }, - toolContext, + createRootToolContext("root-123"), ); const parsed = JSON.parse(serialized); @@ -1257,14 +1901,13 @@ describe("session-mcp-runtime", () => { try { const executeSerialized = await runtime.tools.session_execute.execute( validRequests.session_execute, - toolContext, + createRootToolContext("root-123"), ); const searchSerialized = await runtime.tools.session_search.execute( { - root_session_id: "root-123", query: "session ttl", }, - toolContext, + createRootToolContext("root-123"), ); const executed = JSON.parse(executeSerialized); const search = JSON.parse(searchSerialized); @@ -1346,15 +1989,14 @@ describe("session-mcp-runtime", () => { try { const executeSerialized = await runtime.tools.session_execute.execute( validRequests.session_execute, - toolContext, + createRootToolContext("root-123"), ); const execute = JSON.parse(executeSerialized); const searchSerialized = await runtime.tools.session_search.execute( { - root_session_id: "root-123", query: "searchable hidden marker", }, - toolContext, + createRootToolContext("root-123"), ); const search = JSON.parse(searchSerialized); @@ -1385,14 +2027,13 @@ describe("session-mcp-runtime", () => { content: "# Runtime Search\n\nSession TTL remains available through the live corpus.", }, - toolContext, + createRootToolContext("root-runtime"), ); const searchSerialized = await runtime.tools.session_search.execute( { - root_session_id: "root-runtime", query: "session ttl", }, - toolContext, + createRootToolContext("root-runtime"), ); const indexed = JSON.parse(indexedSerialized); @@ -1436,6 +2077,7 @@ describe("session-mcp-runtime", () => { path: localFile, }, createToolContext({ + sessionID: "root-path-index", worktree: worktreeDir, directory: worktreeDir, ask: (input) => { @@ -1447,10 +2089,10 @@ describe("session-mcp-runtime", () => { const searchSerialized = await runtime.tools.session_search.execute( { - root_session_id: "root-path-index", query: "Index local content for the current root session", }, createToolContext({ + sessionID: "root-path-index", worktree: worktreeDir, directory: worktreeDir, }), @@ -1502,6 +2144,7 @@ describe("session-mcp-runtime", () => { path: externalFile, }, createToolContext({ + sessionID: "root-path-index-external", worktree: worktreeDir, directory: worktreeDir, ask: (input) => { @@ -1513,10 +2156,10 @@ describe("session-mcp-runtime", () => { const searchSerialized = await runtime.tools.session_search.execute( { - root_session_id: "root-path-index-external", query: "Graphiti is never on the hot path", }, createToolContext({ + sessionID: "root-path-index-external", worktree: worktreeDir, directory: worktreeDir, }), @@ -1595,7 +2238,7 @@ describe("session-mcp-runtime", () => { source: "build-log", label: "latest", }, - toolContext, + createRootToolContext("root-runtime-replacement"), ); await runtime.tools.session_index.execute( { @@ -1604,25 +2247,23 @@ describe("session-mcp-runtime", () => { source: "build-log", label: "latest", }, - toolContext, + createRootToolContext("root-runtime-replacement"), ); const oldSearch = JSON.parse( await runtime.tools.session_search.execute( { - root_session_id: "root-runtime-replacement", query: "alpha", }, - toolContext, + createRootToolContext("root-runtime-replacement"), ), ); const newSearch = JSON.parse( await runtime.tools.session_search.execute( { - root_session_id: "root-runtime-replacement", query: "beta", }, - toolContext, + createRootToolContext("root-runtime-replacement"), ), ); @@ -1988,4 +2629,76 @@ describe("session-mcp-runtime", () => { assertEquals(disposeCalls, 1); }); + + it("migrates notes alongside corpus state when canonical roots change", async () => { + const migratedCorpusRoots: Array<[string, string]> = []; + const migratedNoteRoots: Array<[string, string]> = []; + + const runtime = createSessionMcpRuntime({ + redisClient: new RedisClient({ endpoint: "redis://unused" }), + sessionTtlSeconds: 60, + createSessionCorpusService: () => ({ + index: () => + Promise.resolve({ + status: "ok", + corpusRef: "ref", + chunkCount: 0, + queryHints: [], + }), + search: () => + Promise.resolve({ + status: "ok", + results: [], + corpusRefs: [], + truncated: false, + }), + fetchAndIndex: () => + Promise.resolve({ + status: "ok", + corpusRef: "ref", + summary: "ok", + queryHints: [], + fetchedUrl: "url", + contentType: "text/plain", + truncated: false, + }), + getStats: () => + Promise.resolve({ + counters: {}, + corpusCount: 0, + artifactCount: 0, + bytesSavedEstimate: 0, + }), + storeArtifact: () => + Promise.resolve({ + status: "ok", + artifactRef: "local://session_execute/1", + corpusRef: "ref", + summary: "ok", + }), + migrateRootSessionState: ( + sourceRootSessionId: string, + targetRootSessionId: string, + ) => { + migratedCorpusRoots.push([sourceRootSessionId, targetRootSessionId]); + return Promise.resolve(); + }, + dispose: () => Promise.resolve(), + }), + notesService: { + migrateRootSessionState: ( + sourceRootSessionId: string, + targetRootSessionId: string, + ) => { + migratedNoteRoots.push([sourceRootSessionId, targetRootSessionId]); + return Promise.resolve(); + }, + } as never, + } as never); + + await runtime.migrateRootSessionState("temp-root", "canonical-root"); + + assertEquals(migratedCorpusRoots, [["temp-root", "canonical-root"]]); + assertEquals(migratedNoteRoots, [["temp-root", "canonical-root"]]); + }); }); diff --git a/src/services/session-mcp-runtime.ts b/src/services/session-mcp-runtime.ts index 0dd379c..6f36055 100644 --- a/src/services/session-mcp-runtime.ts +++ b/src/services/session-mcp-runtime.ts @@ -3,7 +3,7 @@ import { type ToolContext, type ToolDefinition, } from "@opencode-ai/plugin"; -import type { RedisClient } from "./redis-client.ts"; +import { RedisClient } from "./redis-client.ts"; import type { RedisCacheService } from "./redis-cache.ts"; import { createSessionCorpusService, @@ -24,11 +24,109 @@ import { sessionMcpResponseSchemas, type SessionMcpToolName, } from "./session-mcp-types.ts"; +import { SessionNotesService } from "./session-notes.ts"; import type { RuntimeRootSessionValidator } from "../session.ts"; import { readFile as readFileNode } from "node:fs/promises"; import path from "node:path"; export const SESSION_MCP_RESPONSE_BUDGET_BYTES = 8 * 1024; +const SESSION_SEARCH_RESULT_LIMIT = 5; + +export const SESSION_NOTES_WRITE_DESCRIPTION = [ + "Pin working context as a session note so it survives topic switches, long tool", + "loops, and compaction. Use this BEFORE drifting away from important context:", + "", + "- Before switching to a different topic or task", + "- After a user correction changes your assumptions", + "- When a small task stalls and work shifts elsewhere", + "- During long tool-calling sequences where key state lives only in your context", + "- Before compaction is likely (many messages into a session)", + "", + "Do NOT use this for ephemeral state that will be irrelevant within a few turns", + "(e.g., intermediate variable values, transient build errors you are about to", + "fix, or scratchpad reasoning). Notes are for context you need to survive", + "across topic switches or compaction — not for every observation.", + "", + "Accepts `text` (markdown body) and optional `replace`:", + "", + "- replace id + non-empty text is upsert", + "- replace id + empty text is delete", + "- delete on missing id is a no-op success returning deleted", + "- only ownership conflicts reject mutation", + '- replace "*" + non-empty text replaces all notes and returns `{ action: "replaced", id, cleared_count }`', + '- replace "*" + empty text clears all notes and returns `{ action: "replaced", cleared_count }`', + '- omit `replace` to create a new note and return `{ action: "created", id }`', + "", + "Always rely on the returned `action` instead of inferring the outcome from the", + "inputs alone.", + "", + "Prefer concise markdown with headings, bullets, and short code snippets:", + "", + " ## Current Task: Fix Redis TTL bug", + " - **File:** `src/services/redis-client.ts`", + " - **Root cause:** TTL not refreshed on read", + " - **Next step:** Add EXPIRE call after GET in `refreshEntry()`", + " - **User correction:** Use seconds not milliseconds for TTL", +].join("\n"); + +export const SESSION_NOTES_READ_DESCRIPTION = [ + "Reopen exact pinned note text instead of reconstructing it from memory. Use this", + "when you resume an interrupted topic, need the exact wording of a pinned user", + "instruction, or want to verify what you previously noted before acting on it.", + "", + "If `id` is provided, returns that single note as", + "`{ note: { id, text, created_at, updated_at } }`; when the id does not exist,", + "returns `{ note: null }`.", + "", + "Always prefer reading a pinned note over reciting its contents from recall —", + "notes are the source of truth for intentionally preserved context.", +].join("\n"); + +export const SESSION_SEARCH_BASELINE_DESCRIPTION = [ + "Search local indexed content for the current root session. This is the default", + "recall path — use it FIRST when you need prior context, especially:", + "", + "- At the start of a new session or after compaction", + "- When resuming a topic you worked on earlier", + "- Before re-solving a problem that may already have a solution in session history", + "- To check whether pinned session notes already contain the context you need", + "", + 'Results may include indexed memory content (type: "memory") and, when pinned', + 'session notes exist, matching notes (type: "note"). Note results include', + '`id`, `root_session_id`, and `scope: "local" | "project"` — use', + "`session_notes_read` with the note `id` to reopen the full note text. Not every", + "query will return note results; notes only appear when they match the search", + "query and the session has pinned notes.", + "", + "Prefer session_search over reconstructing context from scratch. If search", + "returns relevant note hits, read the note before duplicating its contents.", +].join("\n"); + +export const SESSION_SEARCH_STRENGTHENED_DESCRIPTION = [ + "Search local indexed content for the current root session. This is the default", + "recall path — use it FIRST when you need prior context, especially:", + "", + "- At the start of a new session or after compaction", + "- When resuming a topic you worked on earlier", + "- Before re-solving a problem that may already have a solution in session history", + "- To check whether pinned session notes already contain the context you need", + "", + 'Results may include indexed memory content (type: "memory") and, when pinned', + 'session notes exist, matching notes (type: "note"). Note results include', + '`id`, `root_session_id`, and `scope: "local" | "project"` — use', + "`session_notes_read` with the note `id` to reopen the full note text. Not every", + "query will return note results; notes only appear when they match the search", + "query and the session has pinned notes.", + "", + "Prefer session_search over reconstructing context from scratch. If search", + "returns relevant note hits, read the note before duplicating its contents.", + "", + "⚠️ This is a new session or a post-compaction turn. Prior context may have been", + "summarized or is not yet in your working memory. STRONGLY RECOMMENDED: run a", + "session_search query before starting work to recover earlier decisions, pinned", + "notes, and task state. This avoids re-solving problems or contradicting earlier", + "decisions that survived compaction.", +].join("\n"); type PluginToolArgs = Parameters[0]["args"]; @@ -74,7 +172,6 @@ const sessionMcpToolArgs: Record = { label: pluginSchema.string().min(1).optional(), }, session_search: { - ...pluginRootSessionIdArgs, query: pluginSchema.string().min(1), }, session_fetch_and_index: { @@ -88,6 +185,13 @@ const sessionMcpToolArgs: Record = { session_doctor: { ...pluginRootSessionIdArgs, }, + session_notes_write: { + text: pluginSchema.string(), + replace: pluginSchema.string().min(1).optional(), + }, + session_notes_read: { + id: pluginSchema.string().min(1).optional(), + }, }; type SessionMcpHandler = ( @@ -103,6 +207,7 @@ type SessionMcpRuntimeOptions = { handlers?: Partial; redisClient?: RedisClient; graphitiCache?: RedisCacheService | object; + notesService?: SessionNotesService; sessionTtlSeconds?: number; groupId?: string; createSessionCorpusService?: typeof createSessionCorpusService; @@ -359,6 +464,20 @@ export const createSessionMcpRuntime = ( let sessionCanonicalizer = options.sessionCanonicalizer; const createExecutor = options.createSessionExecutor ?? createSessionExecutor; const readSessionIndexFile = options.readSessionIndexFile ?? readTextFile; + const notes = options.notesService ?? new SessionNotesService( + options.redisClient ?? new RedisClient({ endpoint: "redis://unused" }), + { groupId, sessionTtlSeconds: options.sessionTtlSeconds ?? 60 }, + ); + + const resolveCanonicalRootSessionId = async ( + context: ToolContext, + fallbackRootSessionId?: string, + ): Promise => { + const sessionId = context.sessionID; + if (!sessionId) return fallbackRootSessionId ?? ""; + return await sessionCanonicalizer?.resolveCanonicalSessionId(sessionId) ?? + (fallbackRootSessionId || sessionId); + }; const writeArtifact = ( toolName: SessionMcpToolName, @@ -448,25 +567,60 @@ export const createSessionMcpRuntime = ( rootSessionId: string, query: string, ): Promise => { - if (!corpus) { + const noteResults = (await notes.searchNotes(rootSessionId, query)).map( + (note) => ({ + corpus_ref: + `session:${groupId}:${note.root_session_id}:note:${note.id}`, + snippet: note.snippet, + score: note.score, + type: "note" as const, + id: note.id, + root_session_id: note.root_session_id, + scope: note.scope, + }), + ); + + const mergeResults = ( + memoryResults: SessionSearchResponse["results"], + memoryCorpusRefs: string[], + truncated: boolean, + status: SessionSearchResponse["status"], + ): SessionSearchResponse => { + const typedMemoryResults = memoryResults.map((result) => ({ + ...result, + type: result.type ?? "memory" as const, + })); + const mergedResults = [...typedMemoryResults, ...noteResults] + .sort((left, right) => right.score - left.score) + .slice(0, SESSION_SEARCH_RESULT_LIMIT); + const corpusRefs = [ + ...new Set(mergedResults.map((result) => result.corpus_ref)), + ]; + return { - status: "ok", - results: [], - corpus_refs: [], - truncated: false, + status, + results: mergedResults, + corpus_refs: corpusRefs.length > 0 ? corpusRefs : memoryCorpusRefs, + truncated: truncated || + typedMemoryResults.length + noteResults.length > + SESSION_SEARCH_RESULT_LIMIT, }; + }; + + if (!corpus) { + return mergeResults([], [], false, "ok"); } const result = await corpus.search({ rootSessionId, query, }); - return { - status: result.status, - results: result.results, - corpus_refs: result.corpusRefs, - truncated: result.truncated, - }; + return mergeResults( + result.results, + result.corpusRefs, + result.truncated, + result.status, + ); }; const defaultHandlers: SessionMcpHandlerMap = { @@ -551,8 +705,9 @@ export const createSessionMcpRuntime = ( query_hints: result.queryHints, }; }, - session_search: async (request) => { - return await searchLocalCorpus(request.root_session_id, request.query); + session_search: async (request, context) => { + const rootSessionId = await resolveCanonicalRootSessionId(context); + return await searchLocalCorpus(rootSessionId, request.query); }, session_fetch_and_index: async (request) => { if (!corpus) { @@ -636,6 +791,15 @@ export const createSessionMcpRuntime = ( }, }; }, + session_notes_write: async (request, context) => { + const rootSessionId = await resolveCanonicalRootSessionId(context); + return await notes.writeNote(rootSessionId, request.text, { + replace: request.replace, + }); + }, + session_notes_read: async (request) => { + return await notes.readNote(request.id); + }, }; const handlerMap: SessionMcpHandlerMap = { @@ -830,13 +994,21 @@ export const createSessionMcpRuntime = ( context: ToolContext, ): Promise => { const request = parseRequest(toolName, rawRequest); + const effectiveRootSessionId = toolName === "session_search" || + toolName === "session_notes_write" || + toolName === "session_notes_read" + ? await resolveCanonicalRootSessionId(context) + : request.root_session_id; await validateRuntimeRootSessionContract( toolName, - request, + { + ...request, + root_session_id: effectiveRootSessionId, + } as SessionMcpRequestMap[TToolName], context, sessionCanonicalizer, ); - await recordToolCall(request.root_session_id, toolName); + await recordToolCall(effectiveRootSessionId, toolName); let response = validateResponsePreservingBatchShape( toolName, await (handlerMap[toolName] as ( @@ -851,7 +1023,7 @@ export const createSessionMcpRuntime = ( await persistInlineArtifactIfPresent( toolName, response as SessionMcpResponseMap["session_execute"], - request.root_session_id, + effectiveRootSessionId, ), ); } @@ -862,7 +1034,7 @@ export const createSessionMcpRuntime = ( await persistInlineArtifactIfPresent( toolName, response as SessionMcpResponseMap["session_execute_file"], - request.root_session_id, + effectiveRootSessionId, ), ); } @@ -875,7 +1047,7 @@ export const createSessionMcpRuntime = ( await coerceOversizedResponse( toolName, response, - request.root_session_id, + effectiveRootSessionId, ), ); serialized = serialize(response); @@ -891,7 +1063,7 @@ export const createSessionMcpRuntime = ( await persistCanonicalLocalArtifactIfNeeded( toolName, response as SessionMcpResponseMap["session_execute"], - request.root_session_id, + effectiveRootSessionId, ); } @@ -899,11 +1071,11 @@ export const createSessionMcpRuntime = ( await persistCanonicalLocalArtifactIfNeeded( toolName, response as SessionMcpResponseMap["session_execute_file"], - request.root_session_id, + effectiveRootSessionId, ); } - await recordReturnedBytes(request.root_session_id, serialized); + await recordReturnedBytes(effectiveRootSessionId, serialized); return serialized; }; @@ -913,12 +1085,13 @@ export const createSessionMcpRuntime = ( session_execute_file: "Read local files through the session runtime.", session_batch_execute: "Execute bounded session commands sequentially.", session_index: "Index local content for the current root session.", - session_search: - "Search local indexed content for the current root session.", + session_search: SESSION_SEARCH_BASELINE_DESCRIPTION, session_fetch_and_index: "Fetch content and index it for the current root session.", session_stats: "Return local session MCP stats.", session_doctor: "Return local session MCP health checks.", + session_notes_write: SESSION_NOTES_WRITE_DESCRIPTION, + session_notes_read: SESSION_NOTES_READ_DESCRIPTION, }; const tools = Object.fromEntries( @@ -955,6 +1128,10 @@ export const createSessionMcpRuntime = ( sourceRootSessionId, targetRootSessionId, ); + await notes.migrateRootSessionState?.( + sourceRootSessionId, + targetRootSessionId, + ); }; return { diff --git a/src/services/session-mcp-types.ts b/src/services/session-mcp-types.ts index 22eb45b..7310658 100644 --- a/src/services/session-mcp-types.ts +++ b/src/services/session-mcp-types.ts @@ -13,6 +13,8 @@ export const SESSION_MCP_TOOL_NAMES = [ "session_fetch_and_index", "session_stats", "session_doctor", + "session_notes_write", + "session_notes_read", ] as const; export type SessionMcpToolName = (typeof SESSION_MCP_TOOL_NAMES)[number]; @@ -75,10 +77,37 @@ type SessionIndexRequest = { label?: string; }; +type SessionSearchRequest = { + root_session_id: string; + query: string; +}; + +type SessionNotesWriteRequest = { + root_session_id: string; + text: string; + replace?: string; +}; + +type SessionNotesReadRequest = { + root_session_id: string; + id: string; +}; + const searchResultSchema = z.object({ corpus_ref: z.string().min(1), snippet: z.string(), score: z.number(), + type: z.enum(["memory", "note"]).optional(), + id: z.string().min(1).optional(), + root_session_id: z.string().min(1).optional(), + scope: z.enum(["local", "project"]).optional(), +}).strict(); + +const sessionNoteSchema = z.object({ + id: z.string().min(1), + text: z.string(), + created_at: z.string().min(1), + updated_at: z.string().min(1), }).strict(); const doctorCheckSchema = z.object({ @@ -161,9 +190,11 @@ export const sessionMcpRequestSchemas = { session_batch_execute: sessionBatchExecuteRequestSchema, session_index: sessionIndexRequestSchema, session_search: z.object({ - ...rootSessionIdShape, query: z.string().min(1), - }).strict(), + }).strict().transform((request) => ({ + root_session_id: "", + query: request.query, + } satisfies SessionSearchRequest)), session_fetch_and_index: z.object({ ...rootSessionIdShape, url: z.string().url(), @@ -175,6 +206,20 @@ export const sessionMcpRequestSchemas = { session_doctor: z.object({ ...rootSessionIdShape, }).strict(), + session_notes_write: z.object({ + text: z.string(), + replace: z.string().min(1).optional(), + }).strict().transform((request) => ({ + root_session_id: "", + text: request.text, + replace: request.replace, + } satisfies SessionNotesWriteRequest)), + session_notes_read: z.object({ + id: z.string().min(1), + }).strict().transform((request) => ({ + root_session_id: "", + id: request.id, + } satisfies SessionNotesReadRequest)), }; export const sessionExecuteResponseSchema = z.object({ @@ -263,6 +308,14 @@ export const sessionMcpResponseSchemas = { graphiti_cache: doctorSubsystemSchema, runtime: doctorSubsystemSchema, }).strict(), + session_notes_write: z.object({ + action: z.enum(["created", "replaced", "deleted"]), + id: z.string().min(1).optional(), + cleared_count: z.number().int().nonnegative().optional(), + }).strict(), + session_notes_read: z.object({ + note: sessionNoteSchema.nullable(), + }).strict(), }; type SessionMcpInferredRequestMap = { @@ -276,13 +329,18 @@ export type SessionMcpRequestMap = [ K in Exclude< SessionMcpToolName, - "session_batch_execute" | "session_index" + | "session_batch_execute" + | "session_index" + | "session_notes_write" + | "session_notes_read" > ]: SessionMcpInferredRequestMap[K]; } & { session_batch_execute: SessionBatchExecuteRequest; session_index: SessionIndexRequest; + session_notes_write: SessionNotesWriteRequest; + session_notes_read: SessionNotesReadRequest; }; type SessionExecuteResponse = z.infer; diff --git a/src/services/session-notes.test.ts b/src/services/session-notes.test.ts new file mode 100644 index 0000000..c977778 --- /dev/null +++ b/src/services/session-notes.test.ts @@ -0,0 +1,383 @@ +import { assert, assertEquals, assertRejects } from "jsr:@std/assert@^1.0.0"; +import { describe, it } from "jsr:@std/testing@^1.0.0/bdd"; +import { RedisClient } from "./redis-client.ts"; +import { sessionNotesKey, SessionNotesService } from "./session-notes.ts"; + +const createRedis = () => new RedisClient({ endpoint: "redis://unused" }); + +const createSequence = (values: string[]) => { + let index = 0; + return () => values[index++] ?? `generated-${index}`; +}; + +const createClock = (...timestamps: string[]) => { + let index = 0; + return () => + new Date(timestamps[index++] ?? timestamps[timestamps.length - 1]!); +}; + +describe("session notes", () => { + it("appends and reads notes while refreshing the session TTL", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + sessionTtlSeconds: 60, + createNoteId: createSequence(["note-1", "note-2"]), + now: createClock( + "2026-04-11T10:00:00.000Z", + "2026-04-11T10:00:01.000Z", + ), + }); + + const first = await service.writeNote("root-1", "## First note"); + const second = await service.writeNote("root-1", "## Second note"); + + assertEquals(first, { action: "created", id: "note-1" }); + assertEquals(second, { action: "created", id: "note-2" }); + + const key = sessionNotesKey("root-1"); + const writtenSnapshot = await redis.snapshot(key); + assertEquals(writtenSnapshot.kind, "hash"); + if (writtenSnapshot.kind === "hash") { + assertEquals(writtenSnapshot.ttlSeconds, 60); + assertEquals(Object.keys(writtenSnapshot.values).sort(), [ + "note-1", + "note-2", + ]); + } + + await redis.touch(key, 5); + const touchedSnapshot = await redis.snapshot(key); + assertEquals(touchedSnapshot.kind, "hash"); + if (touchedSnapshot.kind === "hash") { + assertEquals(touchedSnapshot.ttlSeconds, 5); + } + + assertEquals(await service.readNotes("root-2"), { notes: [] }); + assertEquals(await service.readNote("missing"), { note: null }); + + const all = await service.readNotes("root-1"); + assertEquals(all, { + notes: [ + { + id: "note-1", + text: "## First note", + created_at: "2026-04-11T10:00:00.000Z", + updated_at: "2026-04-11T10:00:00.000Z", + }, + { + id: "note-2", + text: "## Second note", + created_at: "2026-04-11T10:00:01.000Z", + updated_at: "2026-04-11T10:00:01.000Z", + }, + ], + }); + assertEquals(await service.readNote("note-2"), { + note: all.notes[1], + }); + + const refreshedSnapshot = await redis.snapshot(key); + assertEquals(refreshedSnapshot.kind, "hash"); + if (refreshedSnapshot.kind === "hash") { + assertEquals(refreshedSnapshot.ttlSeconds, 60); + } + }); + + it("supports replace and clear semantics within a single root session", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + sessionTtlSeconds: 120, + createNoteId: createSequence(["note-1", "note-2", "note-3", "note-4"]), + now: createClock( + "2026-04-11T11:00:00.000Z", + "2026-04-11T11:00:01.000Z", + "2026-04-11T11:00:02.000Z", + "2026-04-11T11:00:03.000Z", + "2026-04-11T11:00:04.000Z", + "2026-04-11T11:00:05.000Z", + "2026-04-11T11:00:06.000Z", + ), + }); + + await service.writeNote("root-a", "alpha"); + await service.writeNote("root-a", "beta"); + await service.writeNote("root-b", "other session"); + + const replacedOne = await service.writeNote("root-a", "alpha updated", { + replace: "note-1", + }); + assertEquals(replacedOne, { action: "replaced", id: "note-1" }); + assertEquals(await service.readNote("note-1"), { + note: { + id: "note-1", + text: "alpha updated", + created_at: "2026-04-11T11:00:00.000Z", + updated_at: "2026-04-11T11:00:03.000Z", + }, + }); + + await assertRejects( + () => + service.writeNote("root-a", "foreign overwrite", { replace: "note-3" }), + Error, + "owned by another session", + ); + + await assertRejects( + () => service.writeNote("root-a", "", { replace: "note-3" }), + Error, + "owned by another session", + ); + + const replacedAll = await service.writeNote("root-a", "replacement", { + replace: "*", + }); + assertEquals(replacedAll, { + action: "replaced", + id: "note-4", + cleared_count: 2, + }); + assertEquals(await service.readNotes("root-a"), { + notes: [{ + id: "note-4", + text: "replacement", + created_at: "2026-04-11T11:00:04.000Z", + updated_at: "2026-04-11T11:00:04.000Z", + }], + }); + assertEquals(await service.readNotes("root-b"), { + notes: [{ + id: "note-3", + text: "other session", + created_at: "2026-04-11T11:00:02.000Z", + updated_at: "2026-04-11T11:00:02.000Z", + }], + }); + + const deletedOne = await service.writeNote("root-b", "", { + replace: "note-3", + }); + assertEquals(deletedOne, { action: "deleted", id: "note-3" }); + assertEquals(await service.readNotes("root-b"), { notes: [] }); + + const deletedMissing = await service.writeNote("root-b", "", { + replace: "missing-note", + }); + assertEquals(deletedMissing, { action: "deleted", id: "missing-note" }); + + const createdByReplace = await service.writeNote("root-b", "created late", { + replace: "missing-note", + }); + assertEquals(createdByReplace, { + action: "replaced", + id: "missing-note", + }); + assertEquals(await service.readNotes("root-b"), { + notes: [{ + id: "missing-note", + text: "created late", + created_at: "2026-04-11T11:00:05.000Z", + updated_at: "2026-04-11T11:00:05.000Z", + }], + }); + + const cleared = await service.writeNote("root-a", "", { replace: "*" }); + assertEquals(cleared, { action: "replaced", cleared_count: 1 }); + assertEquals(await service.readNotes("root-a"), { notes: [] }); + }); + + it("returns deterministic normalized note search results with snippets", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + sessionTtlSeconds: 90, + createNoteId: createSequence(["note-1", "note-2", "note-3"]), + now: createClock( + "2026-04-11T12:00:00.000Z", + "2026-04-11T12:00:01.000Z", + "2026-04-11T12:00:02.000Z", + ), + }); + + await service.writeNote( + "root-search", + "## Redis TTL refresh\nEnsure session ttl refresh happens on note reads.", + ); + await service.writeNote( + "root-search", + "## Search scoring\nToken overlap should stay deterministic and normalized.", + ); + await service.writeNote( + "root-other", + "## Redis TTL refresh\nEnsure session ttl refresh happens on note reads.", + ); + + const exact = await service.searchNotes( + "root-search", + "## Redis TTL refresh\nEnsure session ttl refresh happens on note reads.", + ); + assertEquals(exact[0], { + id: "note-1", + root_session_id: "root-search", + scope: "local", + snippet: + "## Redis TTL refresh Ensure session ttl refresh happens on note reads.", + score: 1, + }); + + const firstPass = await service.searchNotes( + "root-search", + "redis ttl refresh", + ); + const secondPass = await service.searchNotes( + "root-search", + "redis ttl refresh", + ); + + assertEquals(firstPass, secondPass); + assertEquals(firstPass.length, 2); + assertEquals(firstPass[0]?.id, "note-1"); + assertEquals(firstPass[0]?.root_session_id, "root-search"); + assertEquals(firstPass[0]?.scope, "local"); + assertEquals(firstPass[1]?.id, "note-3"); + assertEquals(firstPass[1]?.root_session_id, "root-other"); + assertEquals(firstPass[1]?.scope, "project"); + assert(firstPass[0]!.score > 0); + assert(firstPass[0]!.score <= 1); + assert(firstPass[1]!.score > 0); + assert(firstPass[0]!.score > firstPass[1]!.score); + assertEquals( + firstPass[0]?.snippet.includes("session ttl refresh"), + true, + ); + + const multi = await service.searchNotes( + "root-search", + "deterministic normalized", + ); + assertEquals(multi, [{ + id: "note-2", + root_session_id: "root-search", + scope: "local", + snippet: + "## Search scoring Token overlap should stay deterministic and normalized.", + score: multi[0]!.score, + }]); + assert(multi[0]!.score > 0); + assert(multi[0]!.score < 1); + + assertEquals(await service.searchNotes("root-search", "foreign"), []); + assertEquals(await service.searchNotes("root-search", " "), []); + }); + + it("anchors and truncates snippets around late matches in long notes", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + sessionTtlSeconds: 90, + createNoteId: createSequence(["note-1"]), + now: createClock("2026-04-11T13:00:00.000Z"), + }); + + const longPrefix = "prefix text ".repeat(30); + const longSuffix = " suffix text".repeat(20); + await service.writeNote( + "root-long", + `${longPrefix}target anchor phrase${longSuffix}`, + ); + + const [hit] = await service.searchNotes( + "root-long", + "target anchor phrase", + ); + + assert(hit); + assert(hit.snippet.length <= 160); + assert(hit.snippet.includes("target anchor phrase")); + assertEquals( + hit.snippet.startsWith("prefix text prefix text prefix text"), + false, + ); + }); + + it("ignores malformed stored note payloads safely", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + sessionTtlSeconds: 45, + createNoteId: createSequence(["note-1"]), + now: createClock("2026-04-11T14:00:00.000Z"), + }); + + await redis.setHashFields(sessionNotesKey("root-malformed"), { + broken_json: "{not-json", + wrong_shape: JSON.stringify({ + text: 123, + created_at: "x", + updated_at: "y", + }), + valid_note: JSON.stringify({ + text: "valid searchable note", + created_at: "2026-04-11T14:00:00.000Z", + updated_at: "2026-04-11T14:00:00.000Z", + }), + }, 45); + + assertEquals(await service.readNotes("root-malformed"), { + notes: [{ + id: "valid_note", + text: "valid searchable note", + created_at: "2026-04-11T14:00:00.000Z", + updated_at: "2026-04-11T14:00:00.000Z", + }], + }); + const [hit] = await service.searchNotes("root-malformed", "searchable"); + assert(hit); + assertEquals(hit.id, "valid_note"); + assertEquals(hit.root_session_id, "root-malformed"); + assertEquals(hit.scope, "local"); + assertEquals(hit.snippet, "valid searchable note"); + assert(hit.score > 0); + assert(hit.score < 1); + }); + + it("retries note id generation until the project-scoped id is unique", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + sessionTtlSeconds: 45, + createNoteId: createSequence(["dup", "dup", "unique"]), + now: createClock( + "2026-04-11T15:00:00.000Z", + "2026-04-11T15:00:01.000Z", + ), + }); + + assertEquals(await service.writeNote("root-a", "first"), { + action: "created", + id: "dup", + }); + assertEquals(await service.writeNote("root-b", "second"), { + action: "created", + id: "unique", + }); + assertEquals(await service.readNote("dup"), { + note: { + id: "dup", + text: "first", + created_at: "2026-04-11T15:00:00.000Z", + updated_at: "2026-04-11T15:00:00.000Z", + }, + }); + assertEquals(await service.readNote("unique"), { + note: { + id: "unique", + text: "second", + created_at: "2026-04-11T15:00:01.000Z", + updated_at: "2026-04-11T15:00:01.000Z", + }, + }); + }); +}); diff --git a/src/services/session-notes.ts b/src/services/session-notes.ts new file mode 100644 index 0000000..f220383 --- /dev/null +++ b/src/services/session-notes.ts @@ -0,0 +1,515 @@ +import type { RedisClient } from "./redis-client.ts"; +import type { RedisKeySnapshot } from "./redis-client.ts"; + +type StoredNote = { + text: string; + created_at: string; + updated_at: string; +}; + +type StoredProjectNote = StoredNote & { + root_session_id: string; +}; + +export type SessionNote = StoredNote & { + id: string; +}; + +export type SessionNoteSearchHit = { + id: string; + root_session_id: string; + scope: "local" | "project"; + snippet: string; + score: number; +}; + +export type WriteNoteResult = + | { action: "created"; id: string } + | { action: "replaced"; id: string } + | { action: "deleted"; id: string } + | { action: "replaced"; id: string; cleared_count: number } + | { action: "replaced"; cleared_count: number }; + +export const sessionNotesKey = (rootSessionId: string): string => + `session:${rootSessionId}:notes`; + +const projectNotesKey = (groupId: string): string => `session:notes:${groupId}`; + +type SessionNotesServiceOptions = { + groupId: string; + sessionTtlSeconds: number; + now?: () => Date; + createNoteId?: () => string; +}; + +const TOKEN_PATTERN = /[a-z0-9]{2,}/g; +const SNIPPET_LIMIT = 160; + +const normalizeText = (value: string): string => + value.replace(/\s+/g, " ").trim(); + +const tokenize = (value: string): string[] => + normalizeText(value).toLowerCase().match(TOKEN_PATTERN) ?? []; + +const clampScore = (value: number): number => + Math.max(0, Math.min(1, Number(value.toFixed(6)))); + +const parseStoredNote = (value: string): StoredNote | null => { + try { + const parsed = JSON.parse(value) as Partial; + if ( + typeof parsed.text !== "string" || + typeof parsed.created_at !== "string" || + typeof parsed.updated_at !== "string" + ) { + return null; + } + return { + text: parsed.text, + created_at: parsed.created_at, + updated_at: parsed.updated_at, + }; + } catch { + return null; + } +}; + +const parseStoredProjectNote = (value: string): StoredProjectNote | null => { + try { + const parsed = JSON.parse(value) as Partial & { + rootSessionId?: string; + }; + if ( + typeof parsed.text !== "string" || + typeof parsed.created_at !== "string" || + typeof parsed.updated_at !== "string" + ) { + return null; + } + + const rootSessionId = typeof parsed.root_session_id === "string" + ? parsed.root_session_id + : typeof parsed.rootSessionId === "string" + ? parsed.rootSessionId + : null; + if (!rootSessionId) return null; + + return { + text: parsed.text, + created_at: parsed.created_at, + updated_at: parsed.updated_at, + root_session_id: rootSessionId, + }; + } catch { + return null; + } +}; + +const compareNotes = (left: SessionNote, right: SessionNote): number => { + if (left.created_at !== right.created_at) { + return left.created_at.localeCompare(right.created_at); + } + return left.id.localeCompare(right.id); +}; + +const compareSearchHits = ( + left: SessionNoteSearchHit & { created_at: string; updated_at: string }, + right: SessionNoteSearchHit & { created_at: string; updated_at: string }, +): number => { + if (right.score !== left.score) return right.score - left.score; + if (right.updated_at !== left.updated_at) { + return right.updated_at.localeCompare(left.updated_at); + } + if (right.created_at !== left.created_at) { + return right.created_at.localeCompare(left.created_at); + } + return left.id.localeCompare(right.id); +}; + +const buildSnippet = (text: string, query: string): string => { + const normalizedText = normalizeText(text); + if (normalizedText.length <= SNIPPET_LIMIT) return normalizedText; + + const lowerText = normalizedText.toLowerCase(); + const lowerQuery = normalizeText(query).toLowerCase(); + const queryIndex = lowerQuery ? lowerText.indexOf(lowerQuery) : -1; + const tokenIndex = tokenize(query) + .map((token) => lowerText.indexOf(token)) + .filter((index) => index >= 0) + .sort((left, right) => left - right)[0] ?? -1; + const anchor = queryIndex >= 0 ? queryIndex : Math.max(tokenIndex, 0); + const start = Math.max(anchor - 40, 0); + return normalizedText.slice(start, start + SNIPPET_LIMIT).trim(); +}; + +const scoreNote = (text: string, query: string): number => { + const normalizedText = normalizeText(text).toLowerCase(); + const normalizedQuery = normalizeText(query).toLowerCase(); + if (!normalizedQuery) return 0; + if (normalizedText === normalizedQuery) return 1; + + const queryTokens = [...new Set(tokenize(normalizedQuery))]; + if (queryTokens.length === 0) { + if (!normalizedText.includes(normalizedQuery)) return 0; + return clampScore( + Math.min(0.99, 0.8 + normalizedQuery.length / normalizedText.length / 5), + ); + } + + const matchedTokens = queryTokens.filter((token) => + normalizedText.includes(token) + ); + if (matchedTokens.length === 0) return 0; + + const coverage = matchedTokens.length / queryTokens.length; + const contiguousBonus = normalizedText.includes(normalizedQuery) ? 0.2 : 0; + const lengthRatio = Math.min( + normalizedQuery.length / Math.max(normalizedText.length, 1), + 1, + ); + return clampScore( + Math.min( + 0.99, + 0.15 + coverage * 0.55 + contiguousBonus + lengthRatio * 0.1, + ), + ); +}; + +export class SessionNotesService { + private readonly now: () => Date; + private readonly createNoteId: () => string; + private readonly groupId: string; + + constructor( + private readonly redis: RedisClient, + private readonly options: SessionNotesServiceOptions, + ) { + this.groupId = options.groupId; + this.now = options.now ?? (() => new Date()); + this.createNoteId = options.createNoteId ?? (() => crypto.randomUUID()); + } + + private async loadNotes( + rootSessionId: string, + ): Promise> { + const raw = await this.redis.getHashAll(sessionNotesKey(rootSessionId)); + return new Map( + Object.entries(raw).flatMap(([noteId, value]) => { + const parsed = parseStoredNote(value); + return parsed ? [[noteId, parsed] as const] : []; + }), + ); + } + + private async loadProjectNotes(): Promise> { + const raw = await this.redis.getHashAll(projectNotesKey(this.groupId)); + return new Map( + Object.entries(raw).flatMap(([noteId, value]) => { + const parsed = parseStoredProjectNote(value); + return parsed ? [[noteId, parsed] as const] : []; + }), + ); + } + + private async writeNotesHash( + rootSessionId: string, + notes: ReadonlyMap, + ): Promise { + const key = sessionNotesKey(rootSessionId); + if (notes.size === 0) { + await this.redis.deleteKey(key); + return; + } + + await this.redis.deleteKey(key); + await this.redis.setHashFields( + key, + Object.fromEntries( + [...notes.entries()].map(( + [noteId, note], + ) => [noteId, JSON.stringify(note)]), + ), + this.options.sessionTtlSeconds, + ); + } + + private async writeSingleNote( + rootSessionId: string, + noteId: string, + note: StoredNote, + ): Promise { + await this.redis.setHashFields( + sessionNotesKey(rootSessionId), + { [noteId]: JSON.stringify(note) }, + this.options.sessionTtlSeconds, + ); + } + + private async writeProjectNotesHash( + notes: ReadonlyMap, + ): Promise { + const key = projectNotesKey(this.groupId); + if (notes.size === 0) { + await this.redis.deleteKey(key); + return; + } + + await this.redis.deleteKey(key); + await this.redis.setHashFields( + key, + Object.fromEntries( + [...notes.entries()].map(( + [noteId, note], + ) => [noteId, JSON.stringify(note)]), + ), + ); + } + + private async writeSingleProjectNote( + noteId: string, + note: StoredProjectNote, + ): Promise { + await this.redis.setHashFields(projectNotesKey(this.groupId), { + [noteId]: JSON.stringify(note), + }); + } + + private createUniqueNoteId( + projectNotes: ReadonlyMap, + ): string { + while (true) { + const noteId = this.createNoteId(); + if (!projectNotes.has(noteId)) return noteId; + } + } + + private async deleteOwnedNote( + rootSessionId: string, + noteId: string, + sessionNotes: Map, + projectNotes: Map, + ): Promise { + sessionNotes.delete(noteId); + projectNotes.delete(noteId); + await this.writeNotesHash(rootSessionId, sessionNotes); + await this.writeProjectNotesHash(projectNotes); + } + + async writeNote( + rootSessionId: string, + text: string, + options?: { replace?: string }, + ): Promise { + const replace = options?.replace; + const notes = await this.loadNotes(rootSessionId); + const projectNotes = await this.loadProjectNotes(); + + if (replace === "*") { + const clearedCount = notes.size; + const remainingProjectNotes = new Map(projectNotes); + for (const noteId of notes.keys()) { + const projectNote = remainingProjectNotes.get(noteId); + if (projectNote?.root_session_id === rootSessionId) { + remainingProjectNotes.delete(noteId); + } + } + + if (text === "") { + await this.redis.deleteKey(sessionNotesKey(rootSessionId)); + await this.writeProjectNotesHash(remainingProjectNotes); + return { action: "replaced", cleared_count: clearedCount }; + } + + const timestamp = this.now().toISOString(); + const noteId = this.createUniqueNoteId(remainingProjectNotes); + const note = { + text, + created_at: timestamp, + updated_at: timestamp, + }; + await this.writeNotesHash( + rootSessionId, + new Map([[noteId, note]]), + ); + remainingProjectNotes.set(noteId, { + ...note, + root_session_id: rootSessionId, + }); + await this.writeProjectNotesHash(remainingProjectNotes); + return { + action: "replaced", + id: noteId, + cleared_count: clearedCount, + }; + } + + if (replace) { + const projectNote = projectNotes.get(replace); + if (projectNote && projectNote.root_session_id !== rootSessionId) { + throw new Error(`Note ${replace} is owned by another session`); + } + + if (text === "") { + if (!projectNote) { + notes.delete(replace); + await this.writeNotesHash(rootSessionId, notes); + return { action: "deleted", id: replace }; + } + + await this.deleteOwnedNote(rootSessionId, replace, notes, projectNotes); + return { action: "deleted", id: replace }; + } + + const timestamp = this.now().toISOString(); + const current = notes.get(replace) ?? projectNote; + const note = { + text, + created_at: current?.created_at ?? timestamp, + updated_at: timestamp, + }; + await this.writeSingleNote(rootSessionId, replace, note); + await this.writeSingleProjectNote(replace, { + ...note, + root_session_id: rootSessionId, + }); + return { action: "replaced", id: replace }; + } + + const timestamp = this.now().toISOString(); + const noteId = this.createUniqueNoteId(projectNotes); + const note = { + text, + created_at: timestamp, + updated_at: timestamp, + }; + await this.writeSingleNote(rootSessionId, noteId, note); + await this.writeSingleProjectNote(noteId, { + ...note, + root_session_id: rootSessionId, + }); + return { action: "created", id: noteId }; + } + + async readNotes( + rootSessionId: string, + noteId?: string, + ): Promise<{ notes: SessionNote[] }> { + const key = sessionNotesKey(rootSessionId); + const notes = [...(await this.loadNotes(rootSessionId)).entries()] + .map(([id, note]) => ({ id, ...note })) + .sort(compareNotes); + + if (notes.length > 0) { + await this.redis.touch(key, this.options.sessionTtlSeconds); + } + + if (!noteId) return { notes }; + return { notes: notes.filter((note) => note.id === noteId) }; + } + + async readNote(noteId: string): Promise<{ note: SessionNote | null }> { + const note = (await this.loadProjectNotes()).get(noteId); + if (!note) return { note: null }; + return { + note: { + id: noteId, + text: note.text, + created_at: note.created_at, + updated_at: note.updated_at, + }, + }; + } + + async searchNotes( + rootSessionId: string, + query: string, + ): Promise { + const normalizedQuery = normalizeText(query); + if (!normalizedQuery) return []; + + const localNotes = (await this.readNotes(rootSessionId)).notes; + const projectNotes = [...(await this.loadProjectNotes()).entries()] + .filter(([, note]) => note.root_session_id !== rootSessionId) + .map(([id, note]) => ({ + id, + root_session_id: note.root_session_id, + scope: "project" as const, + snippet: buildSnippet(note.text, normalizedQuery), + score: clampScore(scoreNote(note.text, normalizedQuery) * 0.85), + created_at: note.created_at, + updated_at: note.updated_at, + })); + + return [ + ...localNotes.map((note) => ({ + id: note.id, + root_session_id: rootSessionId, + scope: "local" as const, + snippet: buildSnippet(note.text, normalizedQuery), + score: scoreNote(note.text, normalizedQuery), + created_at: note.created_at, + updated_at: note.updated_at, + })), + ...projectNotes, + ] + .filter((note) => note.score > 0) + .sort(compareSearchHits) + .map(({ created_at: _createdAt, updated_at: _updatedAt, ...hit }) => hit); + } + + async migrateRootSessionState( + sourceRootSessionId: string, + targetRootSessionId: string, + ): Promise { + if (sourceRootSessionId === targetRootSessionId) return; + + const sourceKey = sessionNotesKey(sourceRootSessionId); + const targetKey = sessionNotesKey(targetRootSessionId); + const sourceSnapshot = await this.redis.snapshot(sourceKey); + if (sourceSnapshot.kind === "missing") return; + + const targetSnapshot = await this.redis.snapshot(targetKey); + const mergedSnapshot = mergeNoteSnapshots(targetSnapshot, sourceSnapshot); + await this.redis.restoreSnapshot(targetKey, mergedSnapshot); + await this.redis.deleteKey(sourceKey); + + const projectNotes = await this.loadProjectNotes(); + let changed = false; + for (const [noteId, note] of projectNotes.entries()) { + if (note.root_session_id !== sourceRootSessionId) continue; + projectNotes.set(noteId, { + ...note, + root_session_id: targetRootSessionId, + }); + changed = true; + } + if (changed) { + await this.writeProjectNotesHash(projectNotes); + } + } +} + +const mergeNoteSnapshots = ( + target: RedisKeySnapshot, + source: RedisKeySnapshot, +): RedisKeySnapshot => { + if (source.kind === "missing") return target; + if (source.kind !== "hash") { + throw new Error("Expected hash snapshot for source session notes"); + } + if (target.kind !== "missing" && target.kind !== "hash") { + throw new Error("Expected hash snapshot for target session notes"); + } + + return { + kind: "hash", + values: { + ...(target.kind === "hash" ? target.values : {}), + ...source.values, + }, + ttlSeconds: Math.max( + target.kind === "hash" ? target.ttlSeconds ?? 0 : 0, + source.ttlSeconds ?? 0, + ) || undefined, + }; +}; diff --git a/src/session.test.ts b/src/session.test.ts index 738b6d2..cec3d60 100644 --- a/src/session.test.ts +++ b/src/session.test.ts @@ -1,4 +1,8 @@ -import { assertEquals, assertRejects } from "jsr:@std/assert@^1.0.0"; +import { + assertEquals, + assertRejects, + assertStringIncludes, +} from "jsr:@std/assert@^1.0.0"; import { describe, it } from "jsr:@std/testing@^1.0.0/bdd"; import * as sessionModule from "./session.ts"; import { setSuppressConsoleWarningsDuringTestsOverride } from "./services/opencode-warning.ts"; @@ -11,6 +15,80 @@ const createExplicitSessionNotFoundError = ( details: Record = { status: 404 }, ): Error => Object.assign(new Error("Session not found"), details); +const emptyCache = { + get() { + return null; + }, + getMeta() { + return null; + }, + renderPersistentMemory() { + return { body: "", nodeRefs: [] }; + }, + classifyRefresh() { + return { + classification: "miss", + shouldRefresh: true, + similarity: 0, + threshold: 0.5, + cachedQuery: null, + }; + }, +}; + +const createSessionManagerForInjection = ( + notes: Array<{ + id: string; + text: string; + created_at: string; + updated_at: string; + }> = [], +) => { + const readNotesCalls: Array<{ sessionId: string; noteId?: string }> = []; + const manager = new SessionManager( + "group-notes", + "user-notes", + { session: {} } as never, + { + getRecentSessionEvents() { + return [{ + id: "evt-1", + ts: Date.now(), + category: "intent", + priority: 0, + role: "user", + summary: "Continue compaction work", + }]; + }, + recallSessionEvents() { + return []; + }, + } as never, + { + getSnapshot() { + return "Current snapshot"; + }, + } as never, + emptyCache as never, + { + notesService: { + readNotes(sessionId: string, noteId?: string) { + readNotesCalls.push({ sessionId, noteId }); + return { notes }; + }, + } as never, + }, + ); + + manager.setParentId("session-1", null); + manager.setState( + "session-1", + manager.createDefaultState("group-notes", "user-notes"), + ); + + return { manager, readNotesCalls }; +}; + describe("SessionManager Task 6 runtime migration", () => { it("resolves child sessions to the canonical parent root session id", async () => { const manager = new SessionManager( @@ -347,3 +425,105 @@ describe("SessionManager Task 6 runtime migration", () => { ); }); }); + +describe("SessionManager compaction notes injection", () => { + it("includes full session_notes with note ids and timestamps for compaction", async () => { + const { manager, readNotesCalls } = createSessionManagerForInjection([ + { + id: "note-1", + text: "First full note body", + created_at: "2026-04-10T10:00:00.000Z", + updated_at: "2026-04-10T10:05:00.000Z", + }, + { + id: "note-2", + text: "Second full note body", + created_at: "2026-04-10T11:00:00.000Z", + updated_at: "2026-04-10T11:05:00.000Z", + }, + ]); + + const prepared = await manager.prepareInjection( + "session-1", + undefined, + { forCompaction: true }, + ); + + assertEquals(readNotesCalls, [{ + sessionId: "session-1", + noteId: undefined, + }]); + assertStringIncludes( + prepared?.envelope ?? "", + '', + ); + assertStringIncludes( + prepared?.envelope ?? "", + 'First full note body', + ); + assertStringIncludes( + prepared?.envelope ?? "", + 'Second full note body', + ); + }); + + it("escapes XML special characters in rendered compaction notes", async () => { + const { manager } = createSessionManagerForInjection([ + { + id: `note-&<>'"`, + text: `Keep & "quotes" and 'apostrophes' safe`, + created_at: `2026-04-10T10:00:00&<>'"Z`, + updated_at: `2026-04-10T10:05:00&<>'"Z`, + }, + ]); + + const prepared = await manager.prepareInjection( + "session-1", + undefined, + { forCompaction: true }, + ); + + assertStringIncludes( + prepared?.envelope ?? "", + 'Keep <tag> & "quotes" and 'apostrophes' safe', + ); + }); + + it("omits session_notes during compaction when no notes exist", async () => { + const { manager, readNotesCalls } = createSessionManagerForInjection([]); + + const prepared = await manager.prepareInjection( + "session-1", + undefined, + { forCompaction: true }, + ); + + assertEquals(readNotesCalls, [{ + sessionId: "session-1", + noteId: undefined, + }]); + assertEquals( + (prepared?.envelope ?? "").includes(" { + const { manager, readNotesCalls } = createSessionManagerForInjection([ + { + id: "note-1", + text: "Should stay out of normal injection", + created_at: "2026-04-10T10:00:00.000Z", + updated_at: "2026-04-10T10:05:00.000Z", + }, + ]); + + const prepared = await manager.prepareInjection("session-1", "continue"); + + assertEquals(readNotesCalls, []); + assertEquals( + (prepared?.envelope ?? "").includes(" void, delayMs: number) => TimerHandle; clearTimer?: (timer: TimerHandle) => void; + notesService?: SessionNotesService; runtimeStateMigrator?: SessionRuntimeStateMigrator; } @@ -242,9 +247,14 @@ type PreparedInjectionData = { cacheMeta: PersistentMemoryCacheMeta | null; events: SessionEvent[]; latestRequest: string; + notes: SessionNote[] | null; snapshot: string | null; }; +export interface PrepareInjectionOptions { + forCompaction?: boolean; +} + class AssistantMessageBuffer { private pendingMessages = new Map< string, @@ -489,6 +499,7 @@ const buildPreparedInjectionEnvelope = ( events: SessionEvent[], snapshot: string | null, latestRequest: string, + notes: SessionNote[] | null, persistent: { body: string; nodeRefs: string[] }, ): string => { const occupiedNormalized = new Set(); @@ -561,6 +572,17 @@ const buildPreparedInjectionEnvelope = ( snapshot, occupiedNormalized, ); + const renderedNotes = notes && notes.length > 0 + ? `${ + notes.map((note) => + `${ + escapeXml(note.text) + }` + ).join("") + }` + : ""; const sections = [ `${escapeXml(latestRequest)}`, @@ -597,6 +619,7 @@ const buildPreparedInjectionEnvelope = ( filteredSnapshot ? `${filteredSnapshot}` : "", + renderedNotes, persistent.body ? ` void, delayMs: number, @@ -647,6 +671,7 @@ export class SessionManager { this.setTimerImpl, this.clearTimerImpl, ); + this.notesService = options.notesService; this.runtimeStateMigrator = options.runtimeStateMigrator; } @@ -1080,6 +1105,7 @@ export class SessionManager { async prepareInjection( sessionId: string, lastRequest?: string, + options: PrepareInjectionOptions = {}, ): Promise { const state = this.sessions.get(sessionId); if (!state?.isMain) return null; @@ -1090,8 +1116,9 @@ export class SessionManager { sessionId, state, lastRequest, + options, ); - const prepared = this.buildPreparedInjection(state, data); + const prepared = this.buildPreparedInjection(state, data, options); if (!prepared) return null; const currentState = this.sessions.get(sessionId); @@ -1111,17 +1138,23 @@ export class SessionManager { sessionId: string, state: SessionState, lastRequest?: string, + options: PrepareInjectionOptions = {}, ): Promise { - const [recentEvents, snapshot, cache, cacheMeta] = await Promise.all([ - this.redisEvents.getRecentSessionEvents( - sessionId, - RECENT_BASELINE_LIMIT, - true, - ), - this.redisSnapshot.getSnapshot(sessionId), - this.redisCache.get(state.groupId), - this.redisCache.getMeta(state.groupId), - ]); + const [recentEvents, snapshot, cache, cacheMeta, notesResult] = + await Promise + .all([ + this.redisEvents.getRecentSessionEvents( + sessionId, + RECENT_BASELINE_LIMIT, + true, + ), + this.redisSnapshot.getSnapshot(sessionId), + this.redisCache.get(state.groupId), + this.redisCache.getMeta(state.groupId), + options.forCompaction && this.notesService + ? this.notesService.readNotes(sessionId) + : Promise.resolve(null), + ]); const canonicalLatestRequest = sanitizeMemoryInput( state.latestUserRequest ?? "", @@ -1143,6 +1176,7 @@ export class SessionManager { cacheMeta, events: mergeSessionEvents(recentEvents, recalledEvents), latestRequest, + notes: notesResult?.notes ?? null, snapshot, }; } @@ -1150,6 +1184,7 @@ export class SessionManager { private buildPreparedInjection( _state: SessionState, data: PreparedInjectionData, + _options: PrepareInjectionOptions = {}, ): PreparedSessionMemory { const persistent = this.redisCache.renderPersistentMemory( data.cache, @@ -1165,6 +1200,7 @@ export class SessionManager { data.events, data.snapshot, data.latestRequest, + data.notes, persistent, ), nodeRefs: persistent.nodeRefs,