diff --git a/deno.lock b/deno.lock index 10350f1..0a01523 100644 --- a/deno.lock +++ b/deno.lock @@ -5,6 +5,7 @@ "jsr:@deno/dnt@~0.42.3": "0.42.3", "jsr:@std/assert@1": "1.0.18", "jsr:@std/assert@^1.0.17": "1.0.18", + "jsr:@std/async@1": "1.3.0", "jsr:@std/fmt@1": "1.0.9", "jsr:@std/fs@1": "1.0.22", "jsr:@std/internal@^1.0.12": "1.0.12", @@ -44,6 +45,9 @@ "jsr:@std/internal" ] }, + "@std/async@1.3.0": { + "integrity": "80485538a4f7baaa46bfe2246168069e02ed142b9f9079cd164f43bb060ad9e9" + }, "@std/fmt@1.0.9": { "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" }, diff --git a/docs/SmokeTests.md b/docs/SmokeTests.md index 50ef56a..61a3255 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-05-10 - Replaces: historical native-hook-first test plan This file, `docs/SmokeTests.md`, replaces the retiring @@ -109,7 +109,7 @@ automated suite and live runtime scenario. - **Delegation continuity assumption:** child sessions resolve to the canonical root session via the `parentID` chain, child events are recorded in the root event log, and parent/child activity must appear in the same - `` continuity model. + `` continuity model. - **Degraded variants allowed by design:** - Graphiti may be unavailable for local-only or degraded-mode coverage; the plugin is still expected to operate with Redis/FalkorDB-backed session @@ -146,8 +146,8 @@ automated suite and live runtime scenario. - **Operator-managed evidence bundles** must be recorded for each run in a single run-scoped location chosen before execution and named in the scenario notes. That bundle is where copied command output, `session_*` tool responses, - relevant logs, and copied `` or optional `` - excerpts are retained. + relevant logs, and copied `` or optional + `` excerpts are retained. - Later sections may define per-scenario filenames, but every scenario must say both where the operator-kept evidence bundle lives and which runtime store is the source of truth for any claimed state observation. @@ -176,9 +176,10 @@ automated suite and live runtime scenario. the repository test harness without requiring a live delegated operator session. - **Live-runtime-only coverage**: any proof that depends on real OpenCode - delegation, a root agent with child agents, emitted live `` - continuity, compaction survival across delegated work, runtime - routing/enforcement behavior, or human-observed degraded-service recovery. + delegation, a root agent with child agents, emitted live + `` continuity, compaction survival across delegated work, + runtime routing/enforcement behavior, or human-observed degraded-service + recovery. - **Hard boundary:** CI and mocked/unit coverage support the release argument but do not replace the mandatory live multi-agent runtime scenarios required by this plan. @@ -196,17 +197,17 @@ what state changed, and what the next-turn continuity surface emitted. ### 4.1 Mandatory evidence classes -| Evidence class | Mandatory when | Minimum proof required | -| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Scripted prompts and operator actions | Any live scenario | Exact root prompt, child prompts or delegated instructions, and any manual operator action that changes service availability, compaction timing, or restart state. | -| Command output | Any suite or scenario that claims shell/runtime behavior | The exact bounded command output or saved artifact ref for each command whose result is part of the claim. | -| `session_*` tool responses | Any suite or scenario that claims MCP-first runtime behavior | Raw bounded responses for each relevant `session_*` call, including typed batch results, refs, metadata, warnings, and failure shapes where applicable. | -| Emitted `` envelopes | Any claim about continuity, compaction survival, delegated roll-up, or omission/presence behavior | The next-turn injected envelope or equivalent prompt/log export showing the continuity surface actually emitted by the runtime. | -| Emitted `` section with surrounding `` context | Any claim about Graphiti-backed recall, presence/omission, bounded formatting, stale-cache handling, or cross-session recall | The full surrounding `` block, not an isolated excerpt, so operators can verify presence, omission, bounded structure, and additive behavior. | -| Redis/FalkorDB state observations | Any claim about root-session sharing, local corpus state, compaction persistence, restart recovery, or hot-tier degradation | Direct store observations, exported state snapshots, or repo-supported inspection output tied to the same run and root session. | -| Graphiti cache/drain observations | Any claim about async drain, cache refresh, stale-cache handling, later recall, or Graphiti degradation | Cache metadata, drain logs, warning output, doctor/status results, or equivalent runtime observations showing Graphiti activity stayed off the hot path. | -| Logs and warnings | Any degraded, restarted, denied, or policy-enforced path | Relevant warnings, denial text, routing guidance, reconnect logs, or health output that explain what degraded or was enforced. | -| Screenshots or copied transcript excerpts | Only when another class cannot capture the UI/runtime surface cleanly | Supplemental only; include them only alongside the underlying tool/log/state evidence they illustrate. | +| Evidence class | Mandatory when | Minimum proof required | +| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Scripted prompts and operator actions | Any live scenario | Exact root prompt, child prompts or delegated instructions, and any manual operator action that changes service availability, compaction timing, or restart state. | +| Command output | Any suite or scenario that claims shell/runtime behavior | The exact bounded command output or saved artifact ref for each command whose result is part of the claim. | +| `session_*` tool responses | Any suite or scenario that claims MCP-first runtime behavior | Raw bounded responses for each relevant `session_*` call, including typed batch results, refs, metadata, warnings, and failure shapes where applicable. | +| Emitted `` envelopes | Any claim about continuity, compaction survival, delegated roll-up, or omission/presence behavior | The next-turn injected envelope or equivalent prompt/log export showing the continuity surface actually emitted by the runtime. | +| Emitted `` section with surrounding `` context | Any claim about Graphiti-backed recall, presence/omission, bounded formatting, stale-cache handling, or cross-session recall | The full surrounding `` block, not an isolated excerpt, so operators can verify presence, omission, bounded structure, and additive behavior. | +| Redis/FalkorDB state observations | Any claim about root-session sharing, local corpus state, compaction persistence, restart recovery, or hot-tier degradation | Direct store observations, exported state snapshots, or repo-supported inspection output tied to the same run and root session. | +| Graphiti cache/drain observations | Any claim about async drain, cache refresh, stale-cache handling, later recall, or Graphiti degradation | Cache metadata, drain logs, warning output, doctor/status results, or equivalent runtime observations showing Graphiti activity stayed off the hot path. | +| Logs and warnings | Any degraded, restarted, denied, or policy-enforced path | Relevant warnings, denial text, routing guidance, reconnect logs, or health output that explain what degraded or was enforced. | +| Screenshots or copied transcript excerpts | Only when another class cannot capture the UI/runtime surface cleanly | Supplemental only; include them only alongside the underlying tool/log/state evidence they illustrate. | ### 4.2 Anti-evidence rules @@ -262,20 +263,30 @@ 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. Non-empty `session_notes_write` calls that would + make the eventual `session_notes_read` JSON exceed the shared 32 KB serialized + response budget must be rejected before storage, while delete operations with + empty text remain valid. - **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`. - **Common failure signatures:** Missing tool registration; schema drift; - acceptance of missing `root_session_id`; placeholder or shape-invalid + acceptance of caller-supplied `root_session_id`; placeholder or shape-invalid responses; type drift between schemas and runtime handlers. - **Release-gate severity:** Critical. @@ -327,28 +338,49 @@ 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, freshness-aware + ranking, snippet boundedness, and durable note persistence without TTL expiry. - **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, and `session_search` can merge matching pinned-note + hits with `type: "note"` plus `id`, `root_session_id`, and + `scope: "local" | "project"`. Each note hit includes `created_at` and + `updated_at` timestamps. `session_notes_read` can reopen exact note text from + a note `id` and records a `last_read_at` timestamp for freshness scoring. + Same-project foreign note hits rank by freshness rather than a flat locality + penalty (local tie-break only applies when scores are effectively equal). + Session notes persist in Redis without a TTL until explicitly deleted. + + Required note-specific evidence: + - Session notes persist without TTL expiry until explicitly deleted. + - `session_search` note hits include `created_at` and `updated_at`. + - Same-project sessions can delete obsolete note ids from earlier sessions + without being blocked by ownership checks on the delete path. + - Reopening a note through `session_notes_read` contributes to read freshness, + which can keep an older but useful note competitive in later searches. - **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 including timestamps, exact note-read assertions, + and freshness ranking evidence. - **Common failure signatures:** Wrong top-ranked corpus for the baseline query; - flat unstructured retrieval; snippet overflow; corpus lookup exceptions after - expiry; search behavior depending on Graphiti availability. + flat unstructured retrieval; missing `type: "note"` / `id` / `root_session_id` + / `scope` metadata for pinned-note hits; missing `created_at` or `updated_at` + on note hits; TTL set on session-local note hash; foreign-session delete + rejected on the delete path; read freshness not updating `last_read_at`; + project-scoped note hits outranking equivalent local hits when scores are + genuinely equal; snippet overflow; search behavior depending on Graphiti + availability. - **Release-gate severity:** Critical. ### 5.5 Suite E — Explicit `session_index` replacement semantics for the same `(rootSessionId, source, label)` logical document @@ -382,8 +414,8 @@ the change that introduces it; do not assume one here, and do not invent a new correctness. - **Prerequisites:** Same as Suite A. Where tests exercise cached Graphiti-backed data, they must still prove that cold-cache or stale-cache - cases degrade to local-first `` rather than failing the hot - path. + cases degrade to local-first `` continuity rather than + failing the hot path. - **Exact commands:** ```bash @@ -396,7 +428,7 @@ the change that introduces it; do not assume one here, and do not invent a new the current turn still injects the best local/cached envelope while refresh is deferred. - **Artifacts/evidence to save:** Full test output; representative emitted - `` envelopes with and without ``; cache + `` envelopes with and without ``; cache metadata observations; refresh scheduling assertions. - **Common failure signatures:** `` required on cold start; synchronous Graphiti dependency introduced on the hot path; stale memory not @@ -420,13 +452,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 @@ -442,9 +476,10 @@ the change that introduces it; do not assume one here, and do not invent a new deno task check ``` -- **Expected result:** PASS. `session_*` calls receive canonical - `root_session_id`; risky native tools such as `WebFetch` are denied or guided - toward the correct `session_*` replacement; `Task` guidance remains MCP-first; +- **Expected result:** PASS. `session_*` calls rely on canonical root-session + resolution from runtime context rather than caller-supplied `root_session_id`; + risky native tools such as `WebFetch` are denied or guided toward the correct + `session_*` replacement; `Task` guidance remains MCP-first; `tool.execute.after` stays attribution-only. - **Artifacts/evidence to save:** Full test output; routing outcome assertions; denial/guidance messages; attribution metadata assertions. @@ -457,26 +492,32 @@ the change that introduces it; do not assume one here, and do not invent a new ### 5.9 Suite I — Continuity assembly and compaction survival - **Objective:** Prove that `session_*` activity folds into local continuity, - `` assembly stays deterministic, and continuity survives + `` assembly stays deterministic, and continuity survives compaction. - **Prerequisites:** Same as Suite A. - **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 - continuity for both direct and delegated work. + additive only, stale envelopes are scrubbed, normal chat-turn injection omits + ``, compaction-only injection includes complete pinned note + bodies inside `` from the current root + session only (same-project foreign-session note bodies are excluded), 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. -- **Common failure signatures:** Missing or duplicated `` + `` 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; foreign same-project note bodies being + injected or promoted into compaction; Graphiti moved onto the synchronous + path. - **Release-gate severity:** Critical. ### 5.10 Suite J — Async Graphiti drain and cache refresh @@ -549,11 +590,13 @@ the change that introduces it; do not assume one here, and do not invent a new ``` - **Expected result:** PASS. At minimum, automated coverage must continue to - enforce the locked bounded-response budget, artifact spillover rules, bytes - saved/accounting expectations, and no-unbounded-growth invariants already - owned by the runtime and corpus tests. Any future latency or storage-growth - numeric threshold added to the suite must be asserted in these existing test - surfaces unless a separately justified harness is approved. + enforce the locked 32 KB bounded-response budget, artifact spillover rules, + bytes saved/accounting expectations, and no-unbounded-growth invariants + already owned by the runtime and corpus tests. `session_notes_read` remains + under the normal runtime guard, so accepted notes must stay readable within + that shared limit. Any future latency or storage-growth numeric threshold + added to the suite must be asserted in these existing test surfaces unless a + separately justified harness is approved. - **Artifacts/evidence to save:** Full test output; any serialized payload-size assertions; corpus/artifact/stats counters relevant to storage growth; any threshold-failure logs added in future colocated tests. @@ -578,7 +621,7 @@ Unless a scenario explicitly declares an exception, use this default topology: - `child agent A` and `child agent B`: execute delegated work inside the same canonical root session. - `observer/evidence collector`: copies prompts, `session_*` responses, - warnings, and emitted `` / optional `` + warnings, and emitted `` / optional `` evidence into one run-scoped bundle. For every scenario below, save evidence in one operator-chosen run bundle such @@ -626,8 +669,8 @@ envelope or equivalent prompt-body/log export when the runtime exposes it. 5. Immediately ask the root agent a follow-up prompt: `What sentinel did the delegated work add, and which child proved cross-child visibility?` - 6. Save the next root-turn `` envelope or equivalent injected - prompt/log export. + 6. Save the next root-turn `` envelope or equivalent + injected prompt/log export. - **Expected runtime observations:** - child A indexes `ALPHA-ROOT-17` under the canonical root session; - child B's search step can see `ALPHA-ROOT-17` without re-indexing it; @@ -735,7 +778,7 @@ envelope or equivalent prompt-body/log export when the runtime exposes it. the current turn to function. - **Evidence to collect:** phase-A delegated transcript; idle/compaction timing note; Graphiti drain or cache-refresh observations if exposed; phase-B root - prompt; phase-B `` with ``; final root + prompt; phase-B `` with ``; final root answer. - **Pass interpretation:** PASS only if the later recall is bounded and cache- backed, while the original delegated work completed without any synchronous @@ -780,33 +823,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 @@ -860,8 +917,8 @@ envelope or equivalent prompt-body/log export when the runtime exposes it. - delegated indexing, search, and batch execution still succeed from local hot-tier state; - `session_doctor` or warning output reports Graphiti degradation; - - the captured `` envelope omits `` rather - than blocking the turn. + - the captured `` envelope omits `` + rather than blocking the turn. - **Evidence to collect:** Graphiti-down confirmation note; child tool results; warnings or doctor output; captured root-turn memory envelope; root summary. - **Pass interpretation:** PASS only if local-first continuity stays correct and @@ -974,28 +1031,81 @@ envelope or equivalent prompt-body/log export when the runtime exposes it. artifact refs; `session_stats` shows no accounting change; local search cannot retrieve the stored large-output sentinel lines. +### 6.12 Graceful-shutdown host-lifecycle proof and dreaming wait requirement + +- **Objective:** Prove the currently supported graceful-shutdown behavior per + host lifecycle before relying on it for dreaming handoff decisions. +- **Scope note:** Detached shutdown continuation is not a supported release + behavior yet. The earlier proof attempt established that a generic plugin + export plus unsupported plugin `dispose` handling is not enough. The current + proof setup instead exposes separate TUI and server host proof tools so each + host lifecycle can be validated directly. +- **Proof plugin wiring:** a proof config should load three plugins: + - the main runtime plugin at `dist/esm/mod.js` + - `.opencode/plugins/detached-dream-proof-tui.js` with `tui` export and tool + `detached_dream_proof_tui` + - `.opencode/plugins/detached-dream-proof-server.js` with `server` export and + tool `detached_dream_proof_server` +- **Expected proof artifacts:** + - TUI host writes `.opencode-detached-dream-proof-tui.json` + - server/web/serve host writes `.opencode-detached-dream-proof-server.json` +- **Manual validation flow:** + 1. Start the target host with the proof config from the previous step loaded. + 2. In the TUI, invoke `detached_dream_proof_tui` once. + 3. In `opencode web` or `opencode serve`, invoke `detached_dream_proof_server` + once. + 4. Confirm the immediate warning toast says the matching host proof is armed. + 5. Trigger each required graceful-exit path separately: + - TUI: `CTRL+D` + - TUI: `CTRL+C` + - TUI: `CTRL+P`, then choose Exit + - `opencode web`: `CTRL+C` + - `opencode serve`: `CTRL+C` + 6. For each path, verify whether the host exits immediately or remains open + long enough for the proof wait to complete. + 7. If the host stays open, wait about 10-15 seconds and verify the matching + proof artifact now exists. + 8. Open the proof artifact and verify it contains + `mode: + "runtime_teardown_wait"`, the matching `host`, and a completion + timestamp. + 9. Treat detached continuation as non-viable for that host if the process + exits immediately with no later artifact, or if the artifact appears only + while the foreground host is still clearly alive. + 10. Until every required host path is proven, keep the product behavior and + operator guidance on the conservative path: graceful shutdown may require + waiting for dreaming completion on the foreground path. + +- **Operator handoff text:** Host-lifecycle proof is ready. Run + `detached_dream_proof_tui` in the TUI and `detached_dream_proof_server` in + `opencode web` and `opencode serve`, then verify the required exit paths + above. Each passing path should either wait long enough to produce its proof + artifact or prove conclusively that foreground waiting is required for that + host. + ## 7. Coverage Map 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 with `created_at` and `updated_at`, compaction envelopes with `` | Required explicit row. Proof must show: (1) exact note reads plus compaction-only injection of complete note bodies, not note summaries on ordinary chat turns; (2) session notes persist without TTL until explicitly deleted; (3) `session_search` note hits include `created_at` and `updated_at`; (4) same-project sessions can delete obsolete note ids from earlier sessions; (5) `session_notes_read` updates `last_read_at`, keeping an older but useful note competitive in freshness-aware ranking; (6) compaction injects only current-session notes. | +| `` 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 @@ -1081,7 +1191,7 @@ Any one of the conditions below immediately fails release readiness: plan requires bounded responses or artifact spillover; - emitted evidence shows `` required for hot-path success, fabricated during Graphiti outage, or emitted without the surrounding bounded - `` context; + `` context; - degraded states are silent, misreported as healthy, or overclaimed as equivalent to the default persisted Redis/FalkorDB path; - the release packet omits the run bundle, omits the L10 exception note when diff --git a/docs/superpowers/plans/2026-03-20-context-mode-mcp-first-implementation.md b/docs/superpowers/plans/2026-03-20-context-mode-mcp-first-implementation.md index aa95648..c3bcc30 100644 --- a/docs/superpowers/plans/2026-03-20-context-mode-mcp-first-implementation.md +++ b/docs/superpowers/plans/2026-03-20-context-mode-mcp-first-implementation.md @@ -101,7 +101,7 @@ execution. - `session_execute`, `session_execute_file`, and `session_batch_execute` return a bounded human-readable summary plus references, never an unbounded raw payload. -- Tool response body budget: 8 KB maximum serialized response payload per +- Tool response body budget: 32 KB maximum serialized response payload per `session_*` call. - Large execution/fetch/file artifacts are stored locally and referenced by artifact or corpus ID. @@ -238,7 +238,9 @@ enforcement-hook rewrite. - `session_fetch_and_index` - `session_stats` - `session_doctor` -2. Every request schema must require `root_session_id`. +2. Superseded by later runtime contract changes: public request schemas no + longer accept `root_session_id`; canonical root-session identity is resolved + implicitly from runtime context. 3. Every response schema must include `status` and enough metadata to attribute results later in hooks. 4. Add a runtime module in `src/services/session-mcp-runtime.ts` that: @@ -278,11 +280,11 @@ Write failing tests first in `src/services/session-mcp-runtime.test.ts` and `src/index.test.ts` covering: - runtime registers exactly the 8 `session_*` tools -- each tool schema rejects calls without `root_session_id` +- each tool schema rejects caller-supplied `root_session_id` - initial stub handlers return minimal valid responses for all 8 registered tools -- response payloads are capped to the exact 8 KB response budget -- at least one large-output case crossing the 8 KB boundary falls back to local +- response payloads are capped to the exact 32 KB response budget +- at least one large-output case crossing the 32 KB boundary falls back to local artifact storage/reference instead of returning an oversized inline payload - `session_batch_execute` executes sequentially in request order - `src/index.ts` wires runtime initialization and teardown in-process @@ -481,12 +483,13 @@ shared across parent/child sessions. ### 7.3 Implementation requirements 1. Reuse `SessionManager` as the only canonical lineage authority. -2. `tool.execute.before` must inject `root_session_id` into every `session_*` - call using canonical resolution from `src/session.ts`. -3. The `session_*` runtime must reject mismatched or missing `root_session_id` - after schema validation; it must not invent a second lineage model. -4. All corpus/artifact/stats writes must use `root_session_id`, never the raw - child session ID. +2. `tool.execute.before` must preserve canonical root-session context for every + `session_*` call using canonical resolution from `src/session.ts`. +3. The `session_*` runtime must resolve canonical root-session identity from + runtime context; callers must not supply `root_session_id`, and the runtime + must not invent a second lineage model. +4. All corpus/artifact/stats writes must use the canonical root session ID, + never the raw child session ID. 5. Parent and child sessions must read from the same root corpus namespace. 6. Temporary-root sessions must remain supported until later migration work in Task 6. @@ -498,10 +501,11 @@ Write failing tests first in `src/session.test.ts`, covering: - parent and child `session_*` calls share one root corpus namespace -- `tool.execute.before` injects `root_session_id` on `session_*` calls +- `tool.execute.before` keeps `session_*` calls rooted in canonical session + context without mutating public args - native tool calls do not receive `root_session_id` -- the runtime rejects `session_*` calls when `root_session_id` is absent or - mismatched +- the runtime rejects caller-supplied `root_session_id` and resolves canonical + root identity from context ### 7.5 Verification commands @@ -623,7 +627,8 @@ model toward `session_*` tools and attributes outcomes cleanly. ### 9.3 Implementation requirements 1. Keep `session_*` calls simple in `tool.execute.before`: - - inject canonical `root_session_id` + - preserve canonical root-session context without injecting public + `root_session_id` args - allow the call to proceed 2. Rewrite native-tool policy so it is explicitly secondary: - `WebFetch` -> deny with direct guidance to `session_fetch_and_index` @@ -643,7 +648,8 @@ model toward `session_*` tools and attributes outcomes cleanly. Write failing tests first in the existing hook/routing test files covering: -- `session_*` calls are allowed with injected `root_session_id` +- `session_*` calls are allowed with canonical root-session context and without + caller-supplied `root_session_id` - `WebFetch` is denied toward `session_fetch_and_index` - data-heavy `Bash` is routed toward `session_execute` - `Task` prompt rewriting adds MCP-first routing guidance @@ -839,7 +845,7 @@ This cleanup is mandatory and not optional follow-up polish. enforcement layer only. 2. Retain `src/handlers/tool-before.ts` and `src/handlers/tool-after.ts`, but narrow them to: - - `root_session_id` injection for `session_*` + - canonical root-session context handling for `session_*` - native fallback enforcement - routing attribution metadata 3. Retain `src/session.ts` as lineage authority and extend it for corpus/state diff --git a/docs/superpowers/plans/2026-03-20-context-mode-mcp-first.md b/docs/superpowers/plans/2026-03-20-context-mode-mcp-first.md index 9a8830c..49e8b64 100644 --- a/docs/superpowers/plans/2026-03-20-context-mode-mcp-first.md +++ b/docs/superpowers/plans/2026-03-20-context-mode-mcp-first.md @@ -209,16 +209,16 @@ in this repository. ### 5.1 Tool suite and exact role -| Tool | Role | Primary inputs | Primary outputs | Notes | -| ------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------------------------------- | -| `session_execute` | Run one bounded sandbox execution task | command/script, runtime, intent, timeout, `root_session_id` | bounded result, summary, optional artifact/index handle | primary replacement for raw data-heavy Bash workflows | -| `session_execute_file` | Run one bounded sandbox file-processing task | path(s), processing intent, runtime/handler, `root_session_id` | findings, summary, optional artifact/index handle | primary replacement for raw file-dump analysis | -| `session_batch_execute` | Combine multiple execute/search sub-operations into one call | list of execute/search/file subrequests, `root_session_id` | bounded multi-result response + handles | sequential in v1; no hidden parallelism | -| `session_index` | Normalize and locally index supplied content into the hot-tier corpus | content or pre-normalized text, source metadata, `root_session_id` | corpus id, chunk count, query hints | local-only indexing; no Graphiti involvement | -| `session_search` | Query the local indexed corpus for the canonical root session | query or query list, optional corpus filters, `root_session_id` | ranked bounded snippets + corpus/chunk refs | searches only local session-scoped indexed data | -| `session_fetch_and_index` | Fetch a URL in sandbox, normalize it, then index it locally | url, fetch options, content-type hint, `root_session_id` | corpus id, summary, query hints | primary replacement for native `WebFetch` | -| `session_stats` | Show local context-savings and tool/index activity for the root session | optional scope, `root_session_id` | counters, byte ratios, corpus counts, queue depth | in scope | -| `session_doctor` | Diagnose MCP/plugin/hot-tier health | optional checks, `root_session_id` | health report for Redis, hooks, cache, Graphiti connectivity | in scope | +| Tool | Role | Primary inputs | Primary outputs | Notes | +| ------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------- | +| `session_execute` | Run one bounded sandbox execution task | command/script, runtime, intent, timeout | bounded result, summary, optional artifact/index handle | canonical root session resolves implicitly from runtime context | +| `session_execute_file` | Run one bounded sandbox file-processing task | path(s), processing intent, runtime/handler | findings, summary, optional artifact/index handle | canonical root session resolves implicitly from runtime context | +| `session_batch_execute` | Combine multiple execute/search sub-operations into one call | list of execute/search/file subrequests | bounded multi-result response + handles | sequential in v1; no hidden parallelism; canonical root resolves implicitly | +| `session_index` | Normalize and locally index supplied content into the hot-tier corpus | content or pre-normalized text, source metadata | corpus id, chunk count, query hints | local-only indexing; no Graphiti involvement | +| `session_search` | Query the local indexed corpus for the canonical root session | query or query list, optional corpus filters | ranked bounded snippets + corpus/chunk refs | searches only local session-scoped indexed data | +| `session_fetch_and_index` | Fetch a URL in sandbox, normalize it, then index it locally | url, fetch options, content-type hint | corpus id, summary, query hints | primary replacement for native `WebFetch` | +| `session_stats` | Show local context-savings and tool/index activity for the root session | optional scope | counters, byte ratios, corpus counts, queue depth | in scope | +| `session_doctor` | Diagnose MCP/plugin/hot-tier health | optional checks | health report for Redis, hooks, cache, Graphiti connectivity | in scope | ### 5.2 Scope decision for `session_upgrade` @@ -240,10 +240,12 @@ replacement milestone, the validation bar, or the migration work. The following defaults are mandatory unless later superseded by a narrower implementation plan: -1. Every `session_*` tool must accept `root_session_id`. -2. In OpenCode, the plugin must populate `root_session_id` in - `tool.execute.before` for every `session_*` call using canonical root-session - resolution from `src/session.ts`. +1. Every public `session_*` tool request resolves the canonical root session + implicitly from runtime context; callers must not pass `root_session_id`. +2. In OpenCode, the plugin/runtime must preserve canonical root-session context + for every `session_*` call using canonical root-session resolution from + `src/session.ts`, without mutating the public request contract to require + `root_session_id`. 3. `session_*` tools are session-scoped by default; they do not create indefinite project-wide local corpora. 4. If a full result exceeds the bounded response budget, the tool must @@ -523,14 +525,14 @@ architecture” to “enforcement + continuity around the MCP-first runtime.” ### 7.1 Hook responsibilities -| Hook | Required role in the new model | -| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `tool.execute.before` | populate canonical `root_session_id` on `session_*` calls; enforce fallback from risky native tools toward `session_*`; never become the main execution engine | -| `tool.execute.after` | capture bounded tool events, context-savings stats, artifact refs, and routing outcomes; never rewrite large raw output after the fact as the primary mechanism | -| `chat.message` | assemble local `` from events, snapshot, and cached persistent memory; schedule async refresh decisions only | -| `experimental.chat.messages.transform` | prepend the prepared `` envelope to the last user message | -| `experimental.session.compacting` | inject the same prepared local continuity envelope into compaction | -| `event` | capture user/assistant/session lifecycle events, maintain canonical root-session lineage state, schedule snapshot rebuilds and async Graphiti drain | +| Hook | Required role in the new model | +| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tool.execute.before` | preserve canonical root-session context for `session_*` calls and enforce fallback from risky native tools toward `session_*`; never become the main execution engine | +| `tool.execute.after` | capture bounded tool events, context-savings stats, artifact refs, and routing outcomes; never rewrite large raw output after the fact as the primary mechanism | +| `chat.message` | assemble local `` from events, snapshot, and cached persistent memory; schedule async refresh decisions only | +| `experimental.chat.messages.transform` | prepend the prepared `` envelope to the last user message | +| `experimental.session.compacting` | inject the same prepared local continuity envelope into compaction | +| `event` | capture user/assistant/session lifecycle events, maintain canonical root-session lineage state, schedule snapshot rebuilds and async Graphiti drain | ### 7.2 Hook interaction sequence @@ -544,7 +546,7 @@ architecture” to “enforcement + continuity around the MCP-first runtime.” 3. tool call selected by the model a. tool.execute.before - - if tool is session_*: inject canonical root_session_id and allow + - if tool is session_*: route using canonical session context and allow - if tool is risky native fallback: redirect/deny toward session_* - if tool is safe bounded native fallback: allow b. tool runs @@ -582,7 +584,9 @@ continuity concept. The new architecture must reuse that logic. Rule: - the plugin is authoritative for canonical root-session identity in OpenCode -- `tool.execute.before` must add `root_session_id` to all `session_*` tool calls +- `tool.execute.before` / runtime wiring must preserve canonical root-session + context for all `session_*` tool calls without exposing `root_session_id` as a + required public request field - `tool.execute.after` and `event` must attribute all resulting continuity events, stats, corpora, and artifacts to that same canonical root session @@ -929,8 +933,8 @@ The implementation/tasks must explicitly prevent these drift modes. 3. **Child-session split brain**\ Symptom: child `session_*` calls create separate corpora or stats outside the root session.\ - Prevention: plugin-injected `root_session_id` is mandatory for all - `session_*` calls; no alternative local-session namespace is allowed. + Prevention: canonical root resolution from runtime context is mandatory for + all `session_*` calls; no alternative local-session namespace is allowed. 4. **Temporary-root orphaning**\ Symptom: artifacts indexed before lineage resolution remain under obsolete 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..9094476 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-session-notes-anti-drift.md @@ -0,0 +1,957 @@ +# 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. + + Use this after `session_search` returns a matching note hit. `session_notes_read` + requires `id` and returns `{ note: { id, text, created_at, updated_at } }`. + When the note does not exist, it 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. + + + +**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 exact indexed hits (type: "entry"), summaries + (type: "summary"), and, when pinned session notes exist, matching notes + (type: "note"). Note results include an `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 exact indexed hits (type: "entry"), summaries + (type: "summary"), and, when pinned session notes exist, matching notes + (type: "note"). Note results include an `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` with id → returns a single note + - `session_notes_read` with a missing note → returns `{ note: null }` + - 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 `id` + - Existing indexed hits use `type: "entry"`; summary-only hits use + `type: "summary"` + - 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 entry/summary 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: + - Indexed hits: `type: "entry"`; summary hits: `type: "summary"` + - Note hits: `type: "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/plans/2026-04-21-search-first-unified-memory-implementation.md b/docs/superpowers/plans/2026-04-21-search-first-unified-memory-implementation.md new file mode 100644 index 0000000..8e86c83 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-search-first-unified-memory-implementation.md @@ -0,0 +1,1107 @@ +# Search-First Unified Memory 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:** Replace the current event/cache-based memory surfaces with a +search-first architecture where `session_search()` becomes the canonical +exact-memory API, injected XML is rendered from normalized `note` and `summary` +results under one `` wrapper, the legacy corpus subsystem is removed, +and dream summaries become a local durable hint layer. + +**Architecture:** Add a normalized memory-read layer with three result kinds: +`entry`, `note`, and `summary`. `entry` comes from an exact-history adapter over +`opencode db`; `note` comes from session notes; `summary` comes from session +snapshots, dream snapshots, and one-off Graphiti normalization. Rebuild +injection so it renders only `note` and `summary` results into one top-level +`` wrapper with nested ``, and remove the legacy +corpus subsystem and Graphiti cache from the memory path. + +**Tech Stack:** Deno, TypeScript, Zod, `@opencode-ai/plugin`, +`@opencode-ai/sdk`, Redis/FalkorDB, Node compatibility APIs, OpenCode runtime +hooks. + +--- + +## File Map + +### Create + +| File | Responsibility | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `src/services/exact-history.ts` | Read exact user turns, assistant turns, and tool calls from `opencode db` / SQLite and expose normalized `entry` search results | +| `src/services/memory-results.ts` | Shared normalized memory result types, ranking helpers, and XML-safe result rendering contracts | +| `src/services/memory-search.ts` | Canonical search orchestration for `entry`, `note`, and `summary` adapters, including query mode and reflection mode | +| `src/services/dream-store.ts` | Persist and retrieve dream summaries and summary watermarks with no expiry | +| `src/services/dream-runner.ts` | Build daily and higher-granularity summaries from durable local memory and notes | +| `src/services/dream-jobs.ts` | Persist bounded dream job descriptors and coordinate detached/shutdown catch-up work | +| `src/services/detached-dream-worker.ts` | Entry point for the detached headless dream catch-up worker | +| `src/services/exact-history.test.ts` | Unit tests for exact-history adapter behavior and noise reduction for tool-heavy sessions | +| `src/services/memory-search.test.ts` | Unit tests for mixed search ordering, empty-query reflection, `when`, and summary symmetry | +| `src/services/dream-runner.test.ts` | Unit tests for summary generation across granularities | +| `src/services/dream-jobs.test.ts` | Unit tests for job persistence, locking, and startup/shutdown handoff | + +### Modify + +| File | Responsibility | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/services/session-mcp-types.ts` | Expand `session_search` request/response schemas to support `query`, `when`, and normalized result kinds | +| `src/services/session-mcp-runtime.ts` | Replace legacy search/runtime wiring with the canonical memory search service and remove corpus-shaped contracts | +| `src/services/session-notes.ts` | Add helpers needed by normalized note search and injection limits | +| `src/services/redis-snapshot.ts` | Expose snapshot material through normalized `summary` result helpers rather than bespoke XML ownership | +| `src/services/graphiti-mcp.ts` | Add one-off Graphiti normalization helpers for summary hints | +| `src/services/opencode-warning.ts` | Expose a dedicated toast helper for dream shutdown fallback messaging | +| `src/services/runtime-teardown.ts` | Add hook points for dream job handoff during graceful shutdown without blocking foreground exit when detached spawning succeeds | +| `src/session.ts` | Replace bespoke event-derived envelope assembly with normalized `` rendering; remove exact-event-driven `last_request`/`active_tasks`/`key_decisions` shaping | +| `src/handlers/chat.ts` | Stop ordinary-turn injection preparation from relying on exact event recall as the memory authority | +| `src/handlers/messages.ts` | Update injection scrubbing and rendering to one top-level `` wrapper with nested `` | +| `src/handlers/compacting.ts` | Use normalized injection assembly for compaction | +| `src/index.ts` | Wire exact-history, memory-search, dream store/runner/jobs, detached-worker handoff, and remove corpus/Graphiti-cache memory dependency | +| `src/testing/detached-dream-proof.ts` | Temporary proof plugin that shows a toast, sleeps, and writes a verifiable artifact from detached shutdown work | +| `src/types/index.ts` | Add normalized memory result types and dream job / summary types | +| `src/handlers/messages.test.ts` | Cover `` wrapper rendering and scrubbing | +| `src/handlers/chat.test.ts` | Cover ordinary-turn injection behavior and startup/compaction boundaries | +| `src/handlers/compacting.test.ts` | Cover compaction injection with normalized `summary` and `note` sections | +| `src/services/session-mcp-runtime.test.ts` | Cover `session_search(query, when)` and empty-query reflection behavior | +| `src/session.test.ts` | Cover normalized memory assembly, note limits, and no exact-entry injection | +| `src/index.test.ts` | Cover new runtime wiring, detached dream handoff, toast fallback, and tool schema exposure | +| `docs/SmokeTests.md` | Update runtime validation instructions for the new search-first memory architecture | + +### Delete + +| File | Change | +| ------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `src/services/session-corpus.ts` | Delete the legacy corpus subsystem entirely; the approved design no longer contains this concept | +| `src/services/session-corpus.test.ts` | Delete corpus-specific tests along with the subsystem | + +### Remove From The Memory Path + +| File | Change | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `src/services/redis-cache.ts` | Remove ordinary-turn persistent-memory cache ownership and Graphiti cached prompt rendering from the memory path | +| `src/services/redis-events.ts` | Remove exact-memory authority responsibilities from injection/search | + +--- + +## Task 1: Lock The New Search And Injection Contracts In Tests First + +**Files:** + +- Modify: `src/services/session-mcp-types.ts` +- Modify: `src/services/session-mcp-runtime.test.ts` +- Modify: `src/handlers/messages.test.ts` +- Modify: `src/session.test.ts` + +- [ ] **Step 1: Write failing schema tests for the new `session_search` request + and mixed result shapes** + + Add test coverage in `src/services/session-mcp-runtime.test.ts` for the new + search contracts: + + ```ts + it("session_search schema accepts query mode with optional when", () => { + const queryRequest = sessionMcpRequestSchemas.session_search.safeParse({ + query: "memory redesign", + when: "2026-04-21T12:00:00.000Z", + }); + const reflectionRequest = sessionMcpRequestSchemas.session_search.safeParse( + { + query: "", + when: "2026-04-21T12:00:00.000Z", + }, + ); + const response = sessionMcpResponseSchemas.session_search.safeParse({ + status: "ok", + results: [ + { + ref: "session:root:entry:turn-1", + snippet: "Use opencode db as exact truth.", + score: 0.95, + type: "entry", + id: "turn-1", + created_at: "2026-04-21T11:00:00.000Z", + }, + { + ref: "session:root:summary:day:2026-04-21", + snippet: "Recent design work moved exact recall to session_search().", + score: 0.81, + type: "summary", + created_at: "2026-04-21T00:00:00.000Z", + granularity: "day", + }, + ], + refs: [ + "session:root:entry:turn-1", + "session:root:summary:day:2026-04-21", + ], + truncated: false, + }); + + assertEquals(queryRequest.success, true); + assertEquals(reflectionRequest.success, true); + assertEquals(response.success, true); + }); + ``` + +- [ ] **Step 2: Write failing XML rendering tests for the one-wrapper contract** + + Add test coverage in `src/handlers/messages.test.ts` and `src/session.test.ts` + expecting a single top-level `` wrapper and nested + ``: + + ```ts + assertStringIncludes(rendered, ''); + assertStringIncludes(rendered, ''); + assertStringIncludes(rendered, ""); + assertEquals(rendered.includes(" { + const prepared = await manager.prepareInjection("root-1", undefined, { + forCompaction: true, + }); + + assertExists(prepared); + assertEquals((prepared!.envelope.match(/`, still uses corpus-shaped fields, and + does not support `when` or normalized result kinds. + +- [ ] **Step 5: Update `src/services/session-mcp-types.ts` to the new public + request/response contracts** + + Replace the search request and response schema shapes with a normalized + contract: + + ```ts + type SessionSearchRequest = { + root_session_id: string; + query: string; + when?: string; + }; + + const searchResultSchema = z.object({ + ref: z.string().min(1), + snippet: z.string(), + score: z.number(), + type: z.enum(["entry", "note", "summary"]), + id: z.string().min(1).optional(), + root_session_id: z.string().min(1).optional(), + scope: z.enum(["session", "local", "project"]).optional(), + created_at: z.string().min(1), + updated_at: z.string().min(1).optional(), + granularity: z.string().min(1).optional(), + source: z.string().min(1).optional(), + }).strict(); + + session_search: z.object({ + query: z.string(), + when: z.string().datetime().optional(), + }).strict().transform((request) => ({ + root_session_id: "", + query: request.query, + when: request.when, + } satisfies SessionSearchRequest)), + ``` + +- [ ] **Step 6: Re-run the same focused slice and confirm failures have moved + into runtime and injection behavior** + + Run: + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts src/handlers/messages.test.ts src/session.test.ts + ``` + + Expected: schema assertions pass, but runtime behavior still fails because the + old event/cache architecture and legacy corpus-based runtime are still in + place. + +--- + +## Task 2: Build The Normalized Memory Result Layer + +**Files:** + +- Create: `src/services/memory-results.ts` +- Modify: `src/types/index.ts` +- Create: `src/services/memory-search.test.ts` + +- [ ] **Step 1: Write failing unit tests for normalized result ordering and + segmentation** + + Add tests in `src/services/memory-search.test.ts` covering query mode ordering + and reflection mode chronology: + + ```ts + it("query mode returns entries and notes before summaries", () => { + const results = orderMemoryResults([ + { type: "summary", score: 0.99, created_at: "2026-04-21T00:00:00.000Z" }, + { type: "entry", score: 0.70, created_at: "2026-04-21T12:00:00.000Z" }, + { type: "note", score: 0.68, created_at: "2026-04-21T11:00:00.000Z" }, + ] as NormalizedMemoryResult[], { mode: "query" }); + + assertEquals(results.map((result) => result.type), [ + "entry", + "note", + "summary", + ]); + }); + + it("reflection mode returns summaries only in chronological order", () => { + const results = orderMemoryResults([ + { type: "summary", created_at: "2026-04-22T00:00:00.000Z", score: 0.9 }, + { type: "summary", created_at: "2026-04-20T00:00:00.000Z", score: 0.8 }, + ] as NormalizedMemoryResult[], { mode: "reflection" }); + + assertEquals(results.map((result) => result.created_at), [ + "2026-04-20T00:00:00.000Z", + "2026-04-22T00:00:00.000Z", + ]); + }); + ``` + +- [ ] **Step 2: Implement normalized memory result types in + `src/types/index.ts`** + + Add explicit shared types: + + ```ts + export type MemoryResultType = "entry" | "note" | "summary"; + + export type NormalizedMemoryResult = { + type: MemoryResultType; + ref: string; + snippet: string; + score: number; + created_at: string; + updated_at?: string; + id?: string; + root_session_id?: string; + scope?: "session" | "local" | "project"; + granularity?: string; + source?: string; + }; + ``` + +- [ ] **Step 3: Implement ranking helpers in `src/services/memory-results.ts`** + + Add the first-pass helpers: + + ```ts + export function orderMemoryResults( + results: NormalizedMemoryResult[], + options: { mode: "query" | "reflection" }, + ): NormalizedMemoryResult[] { + if (options.mode === "reflection") { + return results + .filter((result) => result.type === "summary") + .sort((left, right) => left.created_at.localeCompare(right.created_at)); + } + + const entries = results + .filter((result) => result.type === "entry" || result.type === "note") + .sort(compareWeightedResults); + const summaries = results + .filter((result) => result.type === "summary") + .sort(compareWeightedResults); + return [...entries, ...summaries]; + } + + export function compareWeightedResults( + left: NormalizedMemoryResult, + right: NormalizedMemoryResult, + ): number { + if (right.score !== left.score) return right.score - left.score; + if (right.created_at !== left.created_at) { + return right.created_at.localeCompare(left.created_at); + } + return left.ref.localeCompare(right.ref); + } + ``` + +- [ ] **Step 4: Run the new focused test file and confirm it passes** + + Run: + + ```bash + deno test -A src/services/memory-search.test.ts + ``` + + Expected: PASS. + +--- + +## Task 3: Replace `session_search()` With A Canonical Memory Search Service + +**Files:** + +- Create: `src/services/exact-history.ts` +- Create: `src/services/memory-search.ts` +- Modify: `src/services/session-mcp-runtime.ts` +- Modify: `src/services/session-mcp-runtime.test.ts` +- Modify: `src/services/session-mcp-types.ts` + +- [ ] **Step 1: Write failing runtime tests for query mode, reflection mode, and + `when` support** + + Add tests in `src/services/session-mcp-runtime.test.ts` for: + + ```ts + it("returns entry and note hits before summaries in query mode", async () => { + const result = JSON.parse( + await runtime.tools.session_search.execute( + { query: "exact truth", when: "2026-04-21T12:00:00.000Z" }, + createRootToolContext("root-memory"), + ), + ); + + assertEquals(result.results[0].type, "entry"); + assertEquals( + result.results.some((item: { type: string }) => item.type === "summary"), + true, + ); + }); + + it("returns summaries only for empty-query reflection mode", async () => { + const result = JSON.parse( + await runtime.tools.session_search.execute( + { query: "", when: "2026-04-21T12:00:00.000Z" }, + createRootToolContext("root-memory"), + ), + ); + + assertEquals( + result.results.every((item: { type: string }) => item.type === "summary"), + true, + ); + }); + ``` + +- [ ] **Step 2: Create `src/services/exact-history.ts` with a minimal adapter + interface** + + Start with an injectable adapter instead of fully implementing `opencode db` + access immediately: + + ```ts + export type ExactHistoryAdapter = { + search(input: { + rootSessionId: string; + query: string; + when: string; + }): Promise; + }; + + export function createExactHistoryAdapter(): ExactHistoryAdapter { + return { + async search() { + return []; + }, + }; + } + ``` + +- [ ] **Step 3: Create `src/services/memory-search.ts` to orchestrate exact + entries, notes, and summaries** + + Implement a canonical read surface: + + ```ts + export function createMemorySearchService(deps: { + exactHistory: ExactHistoryAdapter; + notes: SessionNotesService; + summaries: SummaryReader; + }) { + return { + async search(input: { + rootSessionId: string; + query: string; + when: string; + }): Promise { + const [entries, notes, summaries] = await Promise.all([ + input.query ? deps.exactHistory.search(input) : Promise.resolve([]), + input.query + ? deps.notes.searchNotes(input.rootSessionId, input.query) + : Promise.resolve([]), + deps.summaries.search({ + rootSessionId: input.rootSessionId, + query: input.query, + when: input.when, + }), + ]); + + return buildSessionSearchResponse( + entries, + notes, + summaries, + input.query, + ); + }, + }; + } + ``` + +- [ ] **Step 4: Replace the legacy runtime search wiring in + `src/services/session-mcp-runtime.ts`** + + Remove `searchLocalCorpus(...)` as the search authority and route + `session_search` through the new service: + + ```ts + session_search: async (request, context) => { + const rootSessionId = await resolveCanonicalRootSessionId(context); + return await memorySearch.search({ + rootSessionId, + query: request.query, + when: request.when ?? new Date().toISOString(), + }); + }, + ``` + +- [ ] **Step 5: Run the runtime test slice and confirm the new search path works + without corpus dependency** + + Run: + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts + ``` + + Expected: PASS for the new search contracts, with no remaining dependency on + the corpus subsystem. + +--- + +## Task 4: Rebuild Injection Around Normalized `` Rendering + +**Files:** + +- Modify: `src/session.ts` +- Modify: `src/handlers/messages.ts` +- Modify: `src/handlers/chat.ts` +- Modify: `src/handlers/compacting.ts` +- Modify: `src/session.test.ts` +- Modify: `src/handlers/messages.test.ts` +- Modify: `src/handlers/compacting.test.ts` + +- [ ] **Step 1: Write failing tests for startup-only / compaction-only session + continuity injection** + + Add tests covering: + + ```ts + it("wraps injected continuity in one top-level wrapper", async () => { + const prepared = await manager.prepareInjection("root-1", "proceed", { + forCompaction: true, + }); + + assertExists(prepared); + assertEquals(prepared!.envelope.startsWith(" { + const prepared = await manager.prepareInjection( + "root-1", + "search-first memory", + ); + assertExists(prepared); + assertEquals(prepared!.envelope.includes(" { + const sessionBody = [ + ...sessionSummaries.map(renderSummaryXml), + ...notes.slice(0, 10).map(renderNoteXml), + ].join(""); + + const persistentBody = persistentSummaries.map(renderSummaryXml).join(""); + + return `${sessionBody}${persistentBody}`; + }; + ``` + + Use actual string assembly without the accidental `$` placeholder above. + +- [ ] **Step 3: Update `src/handlers/messages.ts` to scrub and inject `` + instead of ``** + + Replace the leading-block detection to recognize the new wrapper: + + ```ts + const LEADING_MEMORY_BLOCK = + /^]*>[\s\S]*?<\/memory>(?:\r?\n){0,2}/; + + const USER_MEMORY_ENVELOPE_TAG_PATTERN = + /<\/?(?:memory|persistent_memory)\b[^>]*>/gi; + ``` + +- [ ] **Step 4: Update `src/handlers/compacting.ts` and `src/handlers/chat.ts` + to use the new injection semantics** + + Keep compaction injection, keep startup/new-session injection, and stop + relying on exact-event recall as the canonical memory producer. + +- [ ] **Step 5: Run the injection-focused test slice and confirm the wrapper and + filtering behavior passes** + + Run: + + ```bash + deno test -A src/session.test.ts src/handlers/messages.test.ts src/handlers/compacting.test.ts src/handlers/chat.test.ts + ``` + + Expected: PASS. + +--- + +## Task 5: Implement Dream Storage, Summary Selection, And Reflection Symmetry + +**Files:** + +- Create: `src/services/dream-store.ts` +- Create: `src/services/dream-runner.ts` +- Create: `src/services/dream-runner.test.ts` +- Modify: `src/services/memory-search.ts` +- Modify: `src/services/redis-snapshot.ts` + +- [ ] **Step 1: Write failing tests for temporal summary selection around + `when`** + + Add tests in `src/services/dream-runner.test.ts` or + `src/services/memory-search.test.ts`: + + ```ts + it("reflection mode returns summaries before and after the reference time", async () => { + const results = await service.search({ + rootSessionId: "root-1", + query: "", + when: "2026-04-21T12:00:00.000Z", + }); + + assertEquals( + results.results.every((item) => item.type === "summary"), + true, + ); + assertEquals( + results.results.some((item) => + item.created_at < "2026-04-21T12:00:00.000Z" + ), + true, + ); + assertEquals( + results.results.some((item) => + item.created_at > "2026-04-21T12:00:00.000Z" + ), + true, + ); + }); + ``` + +- [ ] **Step 2: Implement durable dream summary storage with no expiry** + + Create `src/services/dream-store.ts` with keys and APIs for: + + ```ts + export type DreamSummaryRecord = { + rootSessionId: string; + granularity: string; + created_at: string; + body: string; + }; + + export class DreamStore { + async putSummary(record: DreamSummaryRecord): Promise {} + async getSummariesAround(input: { + rootSessionId: string; + when: string; + query?: string; + }): Promise {} + async getWatermark(rootSessionId: string): Promise {} + async setWatermark(rootSessionId: string, value: string): Promise {} + } + ``` + +- [ ] **Step 3: Implement `dream-runner.ts` to build daily-first and + higher-granularity summaries** + + Start with a deterministic summarizer interface rather than model inference: + + ```ts + export function createDreamRunner(deps: { + store: DreamStore; + summarize: (input: { granularity: string; snippets: string[] }) => string; + }) { + return { + async refresh( + rootSessionId: string, + fromWatermark: string | null, + ): Promise { + // 1. collect exact/note/session summary inputs + // 2. build day summaries + // 3. roll up week/month/year/... summaries + // 4. store records and advance watermark + }, + }; + } + ``` + +- [ ] **Step 4: Update `memory-search.ts` so query mode and reflection mode + share the same summary-selection machinery** + + Enforce the spec rule: + + ```ts + const summaries = await summariesAdapter.search({ + rootSessionId, + query, + when, + }); + ``` + + Use the same summary adapter for both empty and non-empty query paths. + +- [ ] **Step 5: Run the dream/search test slice and confirm temporal selection + works** + + Run: + + ```bash + deno test -A src/services/memory-search.test.ts src/services/dream-runner.test.ts + ``` + + Expected: PASS. + +--- + +## Task 6: Add Detached Dream Job Handoff And Shutdown Fallback + +**Files:** + +- Create: `src/services/dream-jobs.ts` +- Create: `src/services/detached-dream-worker.ts` +- Create: `src/services/dream-jobs.test.ts` +- Modify: `src/services/runtime-teardown.ts` +- Modify: `src/services/opencode-warning.ts` +- Modify: `src/index.ts` +- Modify: `src/index.test.ts` + +- [ ] **Step 1: Write failing tests for detached dream handoff and toast + fallback** + + Add tests in `src/index.test.ts` covering: + + ```ts + it("spawns a detached dream worker on graceful shutdown when there is a dream gap", async () => { + const spawnCalls: Array> = []; + // expect detached spawn with stdio ignored and immediate foreground exit + }); + + it("shows a warning toast when detached dreaming cannot be started safely", async () => { + const toastCalls: unknown[] = []; + // expect one warning toast with wait messaging + }); + ``` + +- [ ] **Step 2: Implement persisted dream job descriptors in + `src/services/dream-jobs.ts`** + + Add minimal APIs: + + ```ts + export type DreamJob = { + rootSessionId: string; + fromWatermark: string | null; + targetWatermark: string; + created_at: string; + }; + + export class DreamJobStore { + async writeJob(job: DreamJob): Promise {} + async readPendingJob(rootSessionId: string): Promise {} + async clearJob(rootSessionId: string): Promise {} + } + ``` + +- [ ] **Step 3: Add a dedicated toast helper for shutdown fallback in + `src/services/opencode-warning.ts`** + + Add: + + ```ts + export const notifyDreamShutdownDelay = (): void => { + notifyPluginWarning( + "Dreaming is still in progress; wait for completion before exiting.", + ); + }; + ``` + +- [ ] **Step 4: Wire detached handoff in `src/index.ts` teardown registration** + + Add a teardown task ahead of full shutdown that: + + ```ts + { + name: "dream-handoff", + run: async () => { + const job = await dreamJobs.preparePendingJobs(sessionManager.getTrackedRootSessionIds()); + if (!job) return; + const spawned = await spawnDetachedDreamWorker(job); + if (!spawned) notifyDreamShutdownDelay(); + }, + } + ``` + + The detached worker must bootstrap only from persisted job input and + watermarks. + +- [ ] **Step 5: Run the teardown/index test slice and confirm shutdown behavior + passes** + + Run: + + ```bash + deno test -A src/index.test.ts src/services/runtime-teardown.test.ts src/services/dream-jobs.test.ts + ``` + + Expected: PASS. + +--- + +## Task 7: Prove Detached Worker Behavior End-To-End With A Temporary Test Plugin + +**Files:** + +- Create: `src/testing/detached-dream-proof.ts` +- Modify: `docs/SmokeTests.md` + +- [ ] **Step 1: Write a temporary testing plugin that proves foreground exit + does not need to wait** + + Create `src/testing/detached-dream-proof.ts` with a minimal proof-only plugin + that: + + ```ts + import type { Plugin, PluginInput } from "@opencode-ai/plugin"; + import { showWarningToast } from "../services/opencode-warning.ts"; + import { spawn } from "node:child_process"; + + export const detachedDreamProof: Plugin = (input: PluginInput) => { + const proofFile = `${input.directory}/.opencode-detached-dream-proof.json`; + + return { + hooks: { + "tool.definition": () => ({ + name: "detached_dream_proof", + description: "Proof helper for detached shutdown worker.", + args: {}, + }), + "tool.execute": async () => { + showWarningToast( + "Detached dream proof armed. Gracefully exit after this session.", + ); + return { ok: true }; + }, + }, + dispose: async () => { + const child = spawn( + process.execPath, + [ + "-e", + `setTimeout(() => require('node:fs').writeFileSync(${ + JSON.stringify(proofFile) + }, JSON.stringify({ done: true, finished_at: new Date().toISOString() })), 10000)`, + ], + { + detached: true, + stdio: "ignore", + }, + ); + child.unref(); + }, + }; + }; + ``` + + Keep this plugin clearly marked as proof-only and temporary. + +- [ ] **Step 2: Add a manual proof flow to `docs/SmokeTests.md`** + + Add an explicit detached-worker validation procedure: + + ```md + 1. Load the temporary detached dream proof plugin. + 2. Start a new OpenCode session with the plugin enabled. + 3. Invoke the `detached_dream_proof` tool once. + 4. Confirm the toast appears immediately. + 5. Gracefully exit OpenCode. + 6. Verify the foreground process exits without waiting 10 seconds. + 7. Wait 10-15 seconds and verify `.opencode-detached-dream-proof.json` now + exists. + 8. Open the file and verify it contains a completion timestamp. + ``` + +- [ ] **Step 3: Ask the user to run the manual proof after implementation** + + Use this exact handoff text once the proof plugin is ready: + + ```md + Detached-worker proof is ready. Start a session with the temporary proof plugin + loaded, invoke `detached_dream_proof` once, then gracefully exit OpenCode. You + should see the toast immediately, OpenCode should exit without waiting 10 + seconds, and `.opencode-detached-dream-proof.json` should appear shortly + afterward as proof the detached process kept running. + ``` + +- [ ] **Step 4: Evaluate the proof result and pivot immediately if detached work + is non-viable** + + Use this decision rule: + + ```md + Treat detached dreaming as non-viable if any of these happen during proof: + + - OpenCode waits for the full 10 seconds before exiting. + - The proof artifact never appears. + - The proof artifact appears only while the foreground process is still alive. + - The detached worker setup is platform-fragile enough that the proof cannot be + relied on. + + If any condition above is true, stop pursuing detached shutdown work in this + branch and pivot the plan to require users to wait for dreaming to finish. + ``` + +- [ ] **Step 5: If proof fails, update the product behavior and docs to require + waiting** + + If detached work is non-viable, make these plan-level changes immediately: + + ```md + - Remove the detached worker path from runtime wiring. + - Keep the shutdown toast, but change it into an explicit waiting instruction. + - Update `docs/SmokeTests.md` to require users to wait for dreaming completion + on graceful shutdown. + - Update the final user-facing handoff text to say: "Gracefully exit OpenCode + and wait for the dreaming toast/work to finish before closing the process." + ``` + +- [ ] **Step 6: Remove or quarantine the temporary proof plugin after + validation** + + Once detached-worker behavior is verified, either delete the proof plugin or + move it under a test-only path that is not shipped in normal runtime wiring. + +--- + +## Task 8: Delete The Corpus Subsystem And Remove Graphiti Cache From The Memory Path + +**Files:** + +- Modify: `src/services/session-mcp-runtime.ts` +- Modify: `src/index.ts` +- Modify: `src/session.ts` +- Modify: `src/services/redis-cache.ts` +- Delete: `src/services/session-corpus.ts` +- Delete: `src/services/session-corpus.test.ts` +- Modify: `src/index.test.ts` + +- [ ] **Step 1: Write failing regression tests proving the runtime no longer + exposes corpus concepts** + + Add/replace runtime tests that assert: + + ```ts + assertEquals("session_index" in runtime.tools, false); + assertEquals("session_fetch_and_index" in runtime.tools, false); + assertEquals( + runtime.tools.session_search.description.includes("local corpus"), + false, + ); + ``` + +- [ ] **Step 2: Remove corpus-backed tools and Graphiti cache from + `src/index.ts` wiring** + + Keep Graphiti for async ingestion and one-off compaction/startup hint queries + only. Remove it from ordinary-turn persistent-memory assembly, and stop + registering any corpus-backed tool surfaces. + +- [ ] **Step 3: Delete the corpus subsystem and corpus-shaped response fields** + + Remove `src/services/session-corpus.ts`, + `src/services/session-corpus.test.ts`, and the `session_index` / + `session_fetch_and_index` tool contracts. Rename any remaining `corpus_ref` / + `corpus_refs` fields in memory search responses to `ref` / `refs`. + +- [ ] **Step 4: Remove Graphiti cache-based ordinary-turn rendering from + `src/session.ts`** + + Ordinary-turn `` should come from the summary adapter, not + `RedisCacheService.renderPersistentMemory(...)`. + +- [ ] **Step 5: Run the broader runtime suite and confirm no memory-path + regressions remain** + + Run: + + ```bash + deno test -A src/index.test.ts src/services/session-mcp-runtime.test.ts src/session.test.ts + ``` + + Expected: PASS, with no remaining corpus subsystem files or corpus-shaped + memory contracts. + +--- + +## Task 9: Update Smoke Tests And Run Full Verification + +**Files:** + +- Modify: `docs/SmokeTests.md` +- Modify: `src/index.test.ts` +- Modify: `src/session.test.ts` +- Modify: `src/services/session-mcp-runtime.test.ts` +- Modify: `src/handlers/messages.test.ts` + +- [ ] **Step 1: Update `docs/SmokeTests.md` to the new memory architecture** + + Document these expectations explicitly: + + ```md + - `session_search()` is the canonical exact-memory API. + - Exact turns/tool calls are discoverable through `session_search()` and never + injected. + - Injected memory is wrapped in one `` block. + - `` is nested inside ``. + - Dream summaries persist without expiry and are used for reflection and hint + injection. + - Detached dream handoff on shutdown is preferred; toast-backed waiting is + fallback. + - The legacy corpus subsystem and corpus-shaped memory contracts no longer + exist. + ``` + +- [ ] **Step 2: Run the targeted verification suite** + + Run: + + ```bash + deno test -A \ + src/services/memory-search.test.ts \ + src/services/exact-history.test.ts \ + src/services/dream-runner.test.ts \ + src/services/dream-jobs.test.ts \ + src/services/session-mcp-runtime.test.ts \ + src/session.test.ts \ + src/handlers/messages.test.ts \ + src/handlers/chat.test.ts \ + src/handlers/compacting.test.ts \ + src/index.test.ts + ``` + + Expected: PASS. + +- [ ] **Step 3: Run repository-wide verification** + + Run: + + ```bash + deno test -A + deno check src/index.ts + deno lint + ``` + + Expected: all commands PASS. + +--- + +## Spec Coverage Check + +Covered sections from +`docs/superpowers/specs/2026-04-21-search-first-unified-memory-design.md`: + +- authority split (`opencode db` exact truth, derived local artifacts, Graphiti + narrowing) +- keep/drop decisions +- normalized result model (`entry`, `note`, `summary`) +- shared code-path rule +- `session_search(query, when)` semantics +- empty-query reflection symmetry +- exact-hit noise reduction for tool-heavy sessions +- one top-level `` wrapper with nested `` +- no exact-entry injection +- up to 10 injected session notes +- dream summaries without expiry +- detached dream handoff plus toast fallback +- corpus removal from the resulting codebase + +No uncovered spec requirement remains. + +## Placeholder Scan + +No `TODO`, `TBD`, or deferred “implement later” placeholders remain in this +plan. New files are named explicitly, commands are concrete, and each task has a +bounded verification command. + +## Type Consistency Check + +The plan uses one normalized memory result vocabulary consistently: + +- `entry` +- `note` +- `summary` + +The XML vocabulary is also consistent: + +- top-level `` +- nested `` +- child `` and `` only + +--- + +Plan complete and saved to +`docs/superpowers/plans/2026-04-21-search-first-unified-memory-implementation.md`. +Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, +review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, +batch execution with checkpoints + +Which approach? diff --git a/docs/superpowers/plans/2026-05-10-session-notes-freshness-without-ttl.md b/docs/superpowers/plans/2026-05-10-session-notes-freshness-without-ttl.md new file mode 100644 index 0000000..0f3a9bc --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-session-notes-freshness-without-ttl.md @@ -0,0 +1,676 @@ +# Session Notes Freshness Without TTL 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 session notes durable without TTL, rank note hits by relevance +plus write/read freshness, allow same-project delete-by-id, and expose +`created_at` plus `updated_at` on `session_search` note results. + +**Architecture:** Keep the existing two-store note model, but remove TTL from +the session-local note hash, extend the project note record with `last_read_at`, +and move note ranking from the old local-vs-project multiplier to a +freshness-based score. Preserve the existing tool surface and compaction +boundary, while updating the note service and MCP response schema so freshness +metadata is observable and testable. + +**Tech Stack:** Deno, TypeScript, Zod schemas, in-memory Redis test double via +`RedisClient`, existing `session_*` MCP runtime and note service tests. + +--- + +## File Map + +- Modify: `src/services/session-notes.ts` Responsibility: note storage, project + note metadata, delete semantics, read freshness updates, note ranking, note + hit shape. +- Modify: `src/services/session-notes.test.ts` Responsibility: note + persistence/no-TTL behavior, same-project delete semantics, read freshness, + ranking expectations, returned timestamps. +- Modify: `src/services/session-mcp-types.ts` Responsibility: public + `session_search` response schema already supports note timestamps; verify and + align any note-result typing if needed. +- Modify: `src/services/session-mcp-runtime.ts` Responsibility: keep tool + descriptions aligned with search/read behavior and make sure runtime wiring + still constructs `SessionNotesService` correctly after option changes. +- Modify: `src/services/session-mcp-runtime.test.ts` Responsibility: assert + public `session_search` note hits include `created_at` and `updated_at`, and + that `session_notes_read` remains the exact reopen path. +- Modify: `src/index.ts` Responsibility: pass the updated note-service options + from config/runtime setup. +- Modify: `src/index.test.ts` Responsibility: assert entrypoint wiring matches + the updated `SessionNotesService` constructor options. +- Modify: `docs/SmokeTests.md` Responsibility: update manual validation guidance + for durable notes, same-project deletion, and freshness-aware ranking. + +### Task 1: Lock The Public Search Contract First + +**Files:** + +- Modify: `src/services/session-mcp-runtime.test.ts` +- Modify: `src/services/session-mcp-types.ts` + +- [ ] **Step 1: Add a failing runtime test for timestamped note hits** + +Add a focused test near the existing note-tool/runtime coverage in +`src/services/session-mcp-runtime.test.ts`: + +```ts +it("returns note hits with created_at and updated_at in session_search", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + groupId: "group-note-search-shape", + sessionTtlSeconds: 60, + } as never); + + try { + await runtime.tools.session_notes_write.execute( + { text: "## Redis freshness\nTrack note ranking with timestamps." }, + createToolContext({ + sessionID: "root-note-shape", + worktree: Deno.cwd(), + directory: Deno.cwd(), + }), + ); + + const search = JSON.parse( + await runtime.tools.session_search.execute( + { query: "redis freshness" }, + createToolContext({ + sessionID: "root-note-shape", + worktree: Deno.cwd(), + directory: Deno.cwd(), + }), + ), + ); + + const noteHit = search.results.find((result: { type: string }) => + result.type === "note" + ); + assertExists(noteHit); + assertEquals(typeof noteHit.created_at, "string"); + assertEquals(typeof noteHit.updated_at, "string"); + } finally { + await runtime.dispose(); + } +}); +``` + +- [ ] **Step 2: Run the focused runtime test and confirm it fails for the right + reason** + +Run: +`deno test -A src/services/session-mcp-runtime.test.ts --filter "returns note hits with created_at and updated_at in session_search"` + +Expected: FAIL because note hits currently omit one or both timestamps. + +- [ ] **Step 3: Align the search result schema if needed** + +Make the note-hit timestamp fields explicit in +`src/services/session-mcp-types.ts` if the new test shows a schema mismatch. The +relevant shape should stay equivalent to: + +```ts +const searchResultSchema = z.object({ + ref: z.string().min(1), + snippet: z.string(), + score: z.number(), + type: z.enum(["entry", "note", "summary"]), + id: z.string().min(1).optional(), + root_session_id: z.string().min(1).optional(), + scope: z.enum(["session", "local", "project"]).optional(), + created_at: z.string().min(1), + updated_at: z.string().min(1).optional(), + granularity: z.string().min(1).optional(), + source: z.string().min(1).optional(), +}).strict(); +``` + +If the schema already matches, leave this file unchanged. + +- [ ] **Step 4: Re-run the focused runtime test** + +Run: +`deno test -A src/services/session-mcp-runtime.test.ts --filter "returns note hits with created_at and updated_at in session_search"` + +Expected: PASS. + +### Task 2: Remove Session-Note TTL And Add Read Metadata + +**Files:** + +- Modify: `src/services/session-notes.test.ts` +- Modify: `src/services/session-notes.ts` + +- [ ] **Step 1: Replace the TTL-refresh test with a durable-note test** + +Update the first note-service test in `src/services/session-notes.test.ts` so it +proves the local session hash has no TTL and reads do not reintroduce one: + +```ts +it("appends and reads durable notes without setting a session TTL", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + createNoteId: createSequence(["note-1", "note-2"]), + now: createClock( + "2026-04-11T10:00:00.000Z", + "2026-04-11T10:00:01.000Z", + "2026-04-11T10:00:02.000Z", + ), + }); + + await service.writeNote("root-1", "## First note"); + await service.writeNote("root-1", "## Second note"); + + const key = sessionNotesKey("root-1"); + const writtenSnapshot = await redis.snapshot(key); + assertEquals(writtenSnapshot.kind, "hash"); + if (writtenSnapshot.kind === "hash") { + assertEquals(writtenSnapshot.ttlSeconds, null); + } + + await service.readNotes("root-1"); + + const readSnapshot = await redis.snapshot(key); + assertEquals(readSnapshot.kind, "hash"); + if (readSnapshot.kind === "hash") { + assertEquals(readSnapshot.ttlSeconds, null); + } +}); +``` + +- [ ] **Step 2: Add a failing read-freshness test** + +Add a new test in `src/services/session-notes.test.ts`: + +```ts +it("updates last_read_at when reopening a note", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + createNoteId: createSequence(["note-1"]), + now: createClock( + "2026-04-11T16:00:00.000Z", + "2026-04-11T16:05:00.000Z", + ), + }); + + await service.writeNote("root-a", "useful note body"); + await service.readNote("note-1"); + + const projectSnapshot = await redis.snapshot("session:notes:project-1"); + assertEquals(projectSnapshot.kind, "hash"); + if (projectSnapshot.kind === "hash") { + const stored = JSON.parse(projectSnapshot.values["note-1"]!); + assertEquals(stored.last_read_at, "2026-04-11T16:05:00.000Z"); + } +}); +``` + +- [ ] **Step 3: Run the note-service tests to verify the red state** + +Run: `deno test -A src/services/session-notes.test.ts` + +Expected: FAIL because the service still sets and refreshes TTL, and +`readNote()` does not record `last_read_at`. + +- [ ] **Step 4: Remove TTL from the note-service option surface and storage + writes** + +In `src/services/session-notes.ts`, make the constructor options and write paths +stop requiring or using `sessionTtlSeconds`: + +```ts +type SessionNotesServiceOptions = { + groupId: string; + now?: () => Date; + createNoteId?: () => string; +}; +``` + +Update the write helpers to stop passing TTL values: + +```ts +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)]), + ), + ); +} + +private async writeSingleNote( + rootSessionId: string, + noteId: string, + note: StoredNote, +): Promise { + await this.redis.setHashFields(sessionNotesKey(rootSessionId), { + [noteId]: JSON.stringify(note), + }); +} +``` + +Remove the read-time TTL refresh from `readNotes()` entirely. + +- [ ] **Step 5: Extend project-note parsing and persistence for `last_read_at`** + +Update `StoredProjectNote`, the parser, and the project-note writers in +`src/services/session-notes.ts`: + +```ts +type StoredProjectNote = StoredNote & { + root_session_id: string; + last_read_at?: string | null; +}; +``` + +```ts +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, + last_read_at: typeof parsed.last_read_at === "string" + ? parsed.last_read_at + : null, + }; + } catch { + return null; + } +}; +``` + +- [ ] **Step 6: Update `readNote()` to record `last_read_at` on successful + reads** + +In `src/services/session-notes.ts`, update `readNote()` along these lines: + +```ts +async readNote(noteId: string): Promise<{ note: SessionNote | null }> { + const projectNotes = await this.loadProjectNotes(); + const note = projectNotes.get(noteId); + if (!note) return { note: null }; + + const lastReadAt = this.now().toISOString(); + await this.writeSingleProjectNote(noteId, { + ...note, + last_read_at: lastReadAt, + }); + + return { + note: { + id: noteId, + text: note.text, + created_at: note.created_at, + updated_at: note.updated_at, + }, + }; +} +``` + +- [ ] **Step 7: Re-run the note-service tests** + +Run: `deno test -A src/services/session-notes.test.ts` + +Expected: PASS for the durable-note and read-freshness tests. + +### Task 3: Change Delete Semantics To Same-Project Scope + +**Files:** + +- Modify: `src/services/session-notes.test.ts` +- Modify: `src/services/session-notes.ts` + +- [ ] **Step 1: Turn the foreign-session delete rejection into a failing + cross-session delete success test** + +Replace the current delete rejection assertion in +`src/services/session-notes.test.ts` with: + +```ts +const crossSessionDelete = await service.writeNote("root-a", "", { + replace: "note-3", +}); +assertEquals(crossSessionDelete, { action: "deleted", id: "note-3" }); +assertEquals(await service.readNotes("root-b"), { notes: [] }); +assertEquals(await service.readNote("note-3"), { note: null }); +``` + +- [ ] **Step 2: Run the focused replace/clear test and confirm the red state** + +Run: +`deno test -A src/services/session-notes.test.ts --filter "supports replace and clear semantics within a single root session"` + +Expected: FAIL because delete still throws on foreign-session ownership. + +- [ ] **Step 3: Narrow ownership checks to non-empty replace writes only** + +Adjust the `replace` branch in `src/services/session-notes.ts` so only non-empty +writes reject foreign ownership: + +```ts +if (replace) { + const projectNote = projectNotes.get(replace); + + if (text === "") { + if (!projectNote) { + notes.delete(replace); + await this.writeNotesHash(rootSessionId, notes); + return { action: "deleted", id: replace }; + } + + const ownerNotes = await this.loadNotes(projectNote.root_session_id); + await this.deleteOwnedNote( + projectNote.root_session_id, + replace, + ownerNotes, + projectNotes, + ); + return { action: "deleted", id: replace }; + } + + if (projectNote && projectNote.root_session_id !== rootSessionId) { + throw new Error(`Note ${replace} is owned by another session`); + } + + // existing upsert logic continues here +} +``` + +- [ ] **Step 4: Re-run the focused replace/clear test** + +Run: +`deno test -A src/services/session-notes.test.ts --filter "supports replace and clear semantics within a single root session"` + +Expected: PASS. + +### Task 4: Replace The Hard-Coded Locality Penalty With Freshness Ranking + +**Files:** + +- Modify: `src/services/session-notes.test.ts` +- Modify: `src/services/session-notes.ts` + +- [ ] **Step 1: Replace the old ranking expectations with failing + freshness-driven tests** + +Add or rewrite tests in `src/services/session-notes.test.ts` to cover: + +```ts +it("includes created_at and updated_at on note search hits", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + createNoteId: createSequence(["note-1"]), + now: createClock("2026-04-11T12:00:00.000Z"), + }); + + await service.writeNote("root-search", "timestamped note body"); + const [hit] = await service.searchNotes("root-search", "timestamped"); + + assert(hit); + assertEquals(hit.created_at, "2026-04-11T12:00:00.000Z"); + assertEquals(hit.updated_at, "2026-04-11T12:00:00.000Z"); +}); +``` + +```ts +it("ranks an old recently read note above a newer weaker match", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + createNoteId: createSequence(["note-1", "note-2"]), + now: createClock( + "2026-01-01T00:00:00.000Z", + "2026-04-01T00:00:00.000Z", + "2026-04-11T00:00:00.000Z", + "2026-04-11T00:10:00.000Z", + ), + }); + + await service.writeNote( + "root-old", + "graphiti async drain retry and dead-letter recovery", + ); + await service.writeNote( + "root-new", + "graphiti retry", + ); + await service.readNote("note-1"); + + const [first, second] = await service.searchNotes( + "root-new", + "graphiti async drain retry dead-letter", + ); + + assertEquals(first?.id, "note-1"); + assertEquals(second?.id, "note-2"); + assert(first!.score > second!.score); +}); +``` + +- [ ] **Step 2: Run the targeted freshness-ranking tests and confirm they fail** + +Run: +`deno test -A src/services/session-notes.test.ts --filter "note search hits|recently read note"` + +Expected: FAIL because search hits do not return timestamps and still use the +old `* 0.85` project penalty. + +- [ ] **Step 3: Add timestamp fields to `SessionNoteSearchHit` and implement + freshness helpers** + +In `src/services/session-notes.ts`, extend the hit type and add the smallest +helper set needed: + +```ts +export type SessionNoteSearchHit = { + id: string; + root_session_id: string; + scope: "local" | "project"; + snippet: string; + score: number; + created_at: string; + updated_at: string; +}; +``` + +```ts +const WRITE_FRESHNESS_HALF_LIFE_DAYS = 30; +const READ_FRESHNESS_HALF_LIFE_DAYS = 14; +const READ_FRESHNESS_ALPHA = 0.35; +const SCORE_EPSILON = 1e-6; + +const ageInDays = (now: Date, iso: string): number => + Math.max(0, (now.getTime() - new Date(iso).getTime()) / 86_400_000); + +const exponentialFreshness = (ageDays: number, halfLifeDays: number): number => + Math.exp(-Math.log(2) * ageDays / halfLifeDays); +``` + +- [ ] **Step 4: Implement freshness-based note scoring and local tie-breaks** + +In `src/services/session-notes.ts`, replace the old project penalty logic in +`searchNotes()` with score composition equivalent to: + +```ts +const toSearchHit = ( + note: { + id: string; + text: string; + created_at: string; + updated_at: string; + root_session_id: string; + last_read_at?: string | null; + }, + scope: "local" | "project", + currentRootSessionId: string, +): SessionNoteSearchHit & { locality_rank: number } => { + const relevance = scoreNote(note.text, normalizedQuery); + const write_freshness = exponentialFreshness( + ageInDays(now, note.updated_at), + WRITE_FRESHNESS_HALF_LIFE_DAYS, + ); + const read_freshness = 1 + READ_FRESHNESS_ALPHA * exponentialFreshness( + note.last_read_at + ? ageInDays(now, note.last_read_at) + : Number.POSITIVE_INFINITY, + READ_FRESHNESS_HALF_LIFE_DAYS, + ); + + return { + id: note.id, + root_session_id: note.root_session_id ?? currentRootSessionId, + scope, + snippet: buildSnippet(note.text, normalizedQuery), + score: clampScore(relevance * write_freshness * read_freshness), + created_at: note.created_at, + updated_at: note.updated_at, + locality_rank: scope === "local" ? 0 : 1, + }; +}; +``` + +Update sort behavior so it prefers higher score, then local scope only for +effectively equal scores, then newer `updated_at`, then `id`. + +- [ ] **Step 5: Re-run the full note-service test file** + +Run: `deno test -A src/services/session-notes.test.ts` + +Expected: PASS. + +### Task 5: Update Entrypoint Wiring And Tool Descriptions + +**Files:** + +- Modify: `src/index.ts` +- Modify: `src/index.test.ts` +- Modify: `src/services/session-mcp-runtime.ts` + +- [ ] **Step 1: Add a failing entrypoint wiring assertion for the new + note-service options** + +Update the `MockSessionNotesService` constructor shape in `src/index.test.ts`: + +```ts +class MockSessionNotesService { + constructor( + redisClient: unknown, + options: { groupId: string }, + ) { + records.sessionNotesArgs.push([redisClient, options]); + records.sessionNotesInstances.push(this); + } +} +``` + +Update the corresponding assertions so they expect only `groupId`. + +- [ ] **Step 2: Run the focused entrypoint test and confirm it fails** + +Run: `deno test -A src/index.test.ts --filter "SessionNotesService"` + +Expected: FAIL because `src/index.ts` still passes `sessionTtlSeconds`. + +- [ ] **Step 3: Remove `sessionTtlSeconds` from note-service construction and + refresh the note-tool wording** + +Update `src/index.ts` to construct the note service like this: + +```ts +const notesService = new dependencies.SessionNotesService(redisClient, { + groupId: defaultGroupId, +}); +``` + +In `src/services/session-mcp-runtime.ts`, keep the search/read descriptions +aligned with the new behavior. The descriptions should continue to say search +returns note hits and `session_notes_read` reopens the full note text, while +avoiding wording that implies TTL-based note retention. + +- [ ] **Step 4: Re-run the focused entrypoint test** + +Run: `deno test -A src/index.test.ts --filter "SessionNotesService"` + +Expected: PASS. + +### Task 6: Update Smoke-Test Documentation And Run Full Verification + +**Files:** + +- Modify: `docs/SmokeTests.md` + +- [ ] **Step 1: Update the smoke-test manual for durable notes and + freshness-aware ranking** + +Add or revise the relevant note sections in `docs/SmokeTests.md` so they +explicitly check: + +```md +- Session notes persist without TTL expiry until explicitly deleted. +- `session_search` note hits include `created_at` and `updated_at`. +- Same-project sessions can delete obsolete note ids from earlier sessions. +- Reopening a note through `session_notes_read` contributes to read freshness, + which can keep an older but useful note competitive in later searches. +``` + +- [ ] **Step 2: Run the targeted verification commands** + +Run: + +```bash +deno test -A src/services/session-notes.test.ts +deno test -A src/services/session-mcp-runtime.test.ts --filter "note" +deno test -A src/index.test.ts --filter "SessionNotesService" +``` + +Expected: PASS. + +- [ ] **Step 3: Run full project verification** + +Run: + +```bash +deno test -A +deno task check +deno task lint +deno task fmt --check +``` + +Expected: PASS with no new failures. diff --git a/docs/superpowers/plans/2026-05-26-root-session-id-implicit-resolution.md b/docs/superpowers/plans/2026-05-26-root-session-id-implicit-resolution.md new file mode 100644 index 0000000..b13e9ac --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-root-session-id-implicit-resolution.md @@ -0,0 +1,255 @@ +# Root Session ID Implicit 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:** Remove legacy `root_session_id` request plumbing from public +`session_*` tools and resolve the canonical root session implicitly everywhere. + +**Architecture:** Public MCP request schemas become context-only and no longer +accept `root_session_id`. The runtime resolves the canonical root session once +from `context.sessionID` for every tool execution and threads that value +internally to services that need it. Tool descriptions, tests, and design docs +are updated to match the implicit-resolution contract. + +**Tech Stack:** Deno, TypeScript, Zod, in-process MCP runtime, OpenCode plugin +hooks. + +--- + +### Task 1: Remove Public `root_session_id` Request Inputs + +**Files:** + +- Modify: `src/services/session-mcp-types.ts` +- Modify: `src/services/session-mcp-runtime.ts` +- Test: `src/services/session-mcp-runtime.test.ts` + +- [ ] **Step 1: Write the failing schema/runtime assertions** + +Add or update tests in `src/services/session-mcp-runtime.test.ts` so every +public `session_*` request rejects `root_session_id`, and valid requests omit +it: + +```ts +const validRequests: Record> = { + session_execute: { command: "pwd" }, + session_execute_file: { paths: ["README.md"] }, + session_batch_execute: { commands: [{ command: "first" }] }, + session_index: { content: "hello world" }, + session_search: { query: "hello" }, + session_fetch_and_index: { url: "https://example.com" }, + session_stats: {}, + session_doctor: {}, + session_notes_write: { text: "remember this" }, + session_notes_read: { id: "note-1" }, +}; +``` + +- [ ] **Step 2: Run targeted tests to verify they fail first** + +Run: `deno test src/services/session-mcp-runtime.test.ts` Expected: failures +around request schemas or runtime assumptions that still require +`root_session_id`. + +- [ ] **Step 3: Remove `root_session_id` from public request schemas and request + types** + +Update `src/services/session-mcp-types.ts` so request types no longer include +`root_session_id`, and all public schemas are strict without that field. Keep +`root_session_id` in response metadata where already documented: + +```ts +type SessionExecuteRequest = { + command: string; + timeout_seconds?: number; +}; + +type SessionBatchExecuteRequest = { + commands: SessionExecuteStep[]; + steps?: SessionBatchStep[]; +}; + +export const sessionMcpRequestSchemas = { + session_execute: z.object({ + command: z.string().min(1), + timeout_seconds: z.number().int().positive().max(120).optional(), + }).strict(), + // same pattern for execute_file, batch_execute, index, + // fetch_and_index, stats, doctor, notes_write, notes_read, search +}; +``` + +- [ ] **Step 4: Move canonical root-session resolution fully into runtime + execution** + +Update `src/services/session-mcp-runtime.ts` so every tool resolves the +canonical root session from `context` inside `executeTool`, then passes that +resolved value into handlers or helper calls without relying on request +payloads: + +```ts +const executeTool = async ( + toolName: TToolName, + rawRequest: unknown, + context: ToolContext, +): Promise => { + const request = parseRequest(toolName, rawRequest); + const rootSessionId = await resolveCanonicalRootSessionId(context); + + await validateRuntimeRootSessionContract( + toolName, + rootSessionId, + context, + sessionCanonicalizer, + ); + + const response = await handlerMap[toolName](request, { + ...context, + rootSessionId, + }); + // ... +}; +``` + +Use the smallest internal-context change that keeps handlers readable; avoid +spreading synthetic `root_session_id` back into parsed public requests. + +- [ ] **Step 5: Run targeted tests to verify the contract passes** + +Run: `deno test src/services/session-mcp-runtime.test.ts` Expected: PASS. + +### Task 2: Remove Hook-Level Injection And Legacy Plumbing + +**Files:** + +- Modify: `src/handlers/tool-before.ts` +- Modify: `src/handlers/tool-before.test.ts` +- Modify: `src/services/session-executor.ts` (only if types or internal helper + signatures require cleanup) +- Modify: `src/index.test.ts` + +- [ ] **Step 1: Write/update failing hook tests** + +Update `src/handlers/tool-before.test.ts` so session tools no longer gain a +`root_session_id` field during `tool.execute.before`, while routing still +receives the canonical session context it needs: + +```ts +assertEquals(output.args, { query: "indexed" }); +assertEquals(routedArgs, { query: "original" }); +``` + +- [ ] **Step 2: Run hook-focused tests to verify they fail first** + +Run: `deno test src/handlers/tool-before.test.ts src/index.test.ts` Expected: +failures because the hook still injects `root_session_id` and tests still expect +it. + +- [ ] **Step 3: Remove root-session argument injection from the before hook** + +Simplify `src/handlers/tool-before.ts` so it resolves the canonical session only +for routing decisions, not for mutating session-tool args: + +```ts +const canonicalSessionId = await resolveCanonicalSessionId( + deps.sessionCanonicalizer, + sessionID, +); +const args = toRecord(output.args); + +const decision = route({ + canonicalSessionId, + toolName: tool, + args, + guidanceThrottle: deps.guidanceThrottle, +}); +``` + +If the modify path rewrites args, preserve only the rewritten public args. + +- [ ] **Step 4: Remove now-dead root-session plumbing helpers or request + assumptions** + +Delete or simplify helper code that exists only to inject, normalize, or +preserve `root_session_id` in public tool args. Keep internal artifact/corpus +storage keyed by the canonical root session where needed. + +- [ ] **Step 5: Run hook/integration tests to verify behavior** + +Run: `deno test src/handlers/tool-before.test.ts src/index.test.ts` Expected: +PASS. + +### Task 3: Align Tool Descriptions, Specs, And Historical Docs + +**Files:** + +- Modify: `src/services/session-mcp-runtime.ts` +- Modify: `docs/SmokeTests.md` +- Modify: `docs/superpowers/plans/2026-03-20-context-mode-mcp-first.md` +- Modify: + `docs/superpowers/plans/2026-03-20-context-mode-mcp-first-implementation.md` +- Test: `src/services/session-mcp-runtime.test.ts` +- Test: `src/index.test.ts` + +- [ ] **Step 1: Update description assertions first** + +Adjust tests so shipped tool descriptions explicitly state that canonical root +session resolution is automatic and callers must not provide `root_session_id`: + +```ts +assertStringIncludes( + runtime.tools.session_search.description, + "Do not pass `root_session_id`; the runtime resolves the current canonical root session automatically.", +); +``` + +- [ ] **Step 2: Run description tests to verify they fail first** + +Run: `deno test src/services/session-mcp-runtime.test.ts src/index.test.ts` +Expected: failures because descriptions/docs still reflect the old contract. + +- [ ] **Step 3: Update shipped tool descriptions** + +Edit the `SESSION_*_DESCRIPTION` strings in +`src/services/session-mcp-runtime.ts` so every public session tool describes +implicit root resolution and no longer suggests caller-supplied root targeting. + +- [ ] **Step 4: Update docs that still claim all session tools require + `root_session_id`** + +Revise old plan/spec text so it records the current contract instead of the +obsolete one. Keep historical context where useful, but mark the old requirement +as superseded rather than leaving contradictory guidance in place. + +- [ ] **Step 5: Run the targeted tests again** + +Run: `deno test src/services/session-mcp-runtime.test.ts src/index.test.ts` +Expected: PASS. + +### Task 4: Verify Repo-Wide Behavior + +**Files:** + +- No new source files expected + +- [ ] **Step 1: Run the focused affected suites** + +Run: +`deno test src/services/session-mcp-runtime.test.ts src/handlers/tool-before.test.ts src/index.test.ts` +Expected: PASS. + +- [ ] **Step 2: Run repo type-check** + +Run: `deno task check` Expected: PASS. + +- [ ] **Step 3: Run lint** + +Run: `deno lint` Expected: PASS. + +- [ ] **Step 4: Run format** + +Run: `deno fmt` Expected: files are already formatted or formatting is applied +cleanly. 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..8a925d2 --- /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. `session:notes:${groupId}` + +- 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 `session:notes:${groupId}`. + +On note creation: + +1. Generate a UUID. +2. Check whether `session:notes:${groupId}` 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/docs/superpowers/specs/2026-04-21-search-first-unified-memory-design.md b/docs/superpowers/specs/2026-04-21-search-first-unified-memory-design.md new file mode 100644 index 0000000..1c7f5e9 --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-search-first-unified-memory-design.md @@ -0,0 +1,504 @@ +# Search-First Unified Memory Design + +## Goal + +Redesign memory around one search-first contract that keeps exact history in +`opencode db` SQLite, limits injected memory to lossy hints, and makes injected +XML and `session_search()` results come from a common normalized subset of code +paths. + +This design replaces the current split between event-derived injected sections, +local corpus search, cached Graphiti renderers, and ad hoc continuity shaping. + +## Overarching Design Concept + +The system becomes one memory architecture with separated authority layers: + +1. `opencode db` is the only exact chronological truth. +2. `session_search()` is the primary memory API. +3. Injected XML is a bounded render of a subset of normalized search-style + results. +4. Exact entries are never injected. +5. Session summaries, notes, dream snapshots, and Graphiti hints are derived + artifacts, not transcript truth. + +The practical meaning is: + +- SQLite stores exact user turns, assistant turns, and tool calls. +- Local plugin storage keeps only derived or promoted artifacts and references + back to SQLite. +- Normal turns rely on lossy injected hints for the common case. +- Exact recall happens only through `session_search()`. + +## Goals + +1. Make project memory a durable asset across months and years. +2. Preserve one authoritative source for exact chronology. +3. Stop injecting exact records into routine prompts. +4. Make startup and compaction continuity deterministic and concise. +5. Let `session_search()` reconnect the current turn to exact history when + precision matters. +6. Keep retrieval predictable and bounded with O(n) scoring over candidate + records. +7. Remove short TTL assumptions from durable memory while keeping operational + state bounded. +8. Keep Graphiti useful but non-authoritative. +9. Ensure injected XML tags are derived from the same normalized result model as + search results. + +## Critique Of The Current Design + +The current memory system mixes too many producers with incompatible semantics. + +The failure sample from this design session shows the concrete problems: + +- `` promoted `yes, write into a spec now`, which is an approval, + not durable memory. +- `` also later promoted `yes, fold it into spec.`, showing that + ordinary approvals still leak straight into injected memory. +- `` promoted raw user fragments like `keep it` and a long + keep/drop list, which are transcript slices rather than stable memory types. +- `` also promoted `fix the stale wording`, which is a transient + editing step rather than a stable memory object. +- `` duplicated content that also appeared in the snapshot. +- `` repeated decisions already surfaced elsewhere. +- `` rendered unrelated Graphiti cache material that had no + bearing on the active design discussion. + +These are not isolated ranking bugs. They are architectural symptoms: + +1. Injection is built from different producers than search. +2. Redis/FalkorDB currently acts as partial exact-memory storage instead of a + derived-memory store. +3. The local corpus and Graphiti cache create separate retrieval semantics that + do not line up with injected XML. +4. The system promotes lightly cleaned transcript fragments into invented XML + sections instead of using stable memory result types. + +## Authority Model + +### Exact Truth + +`opencode db` SQLite is the only exact ground truth for chronology. + +It owns: + +- user turns +- assistant turns +- tool calls +- their timestamps and identities + +Exact transcript-like records must not be duplicated into FalkorDB/Redis as a +second authoritative store. + +### Derived Local Artifacts + +FalkorDB/Redis stores only derived or promoted artifacts and references back to +exact SQLite records. + +It owns: + +- session notes +- session summaries +- dream snapshots +- Graphiti-related storage and references +- operational state needed for hooks and background work + +### Graphiti + +Graphiti remains an asynchronous enrichment layer. + +It consumes promoted local memory and exact-memory references. It does not own +authoritative exact history. It is read only as a one-off hint source on new +sessions and compaction. There is no Graphiti cache layer for injected memory. + +## Keep / Drop Decisions + +### Keep + +1. Session snapshots. +2. Session notes. +3. One-off Graphiti queries on new sessions and compaction. +4. A new exact-entry adapter over `opencode db` for `session_search()`. +5. A shared normalization and ranking layer that feeds both search results and + injected XML. + +### Drop + +1. The Redis exact event stream as a memory authority. +2. Event-derived injected section builders like `last_request`, `active_tasks`, + `key_decisions`, `files_in_play`, `project_rules`, `unresolved_errors`, + `git_state`, and `subagent_work`. +3. The local corpus as a memory substrate for `session_search()`. +4. The Graphiti cache render path used to build ordinary-turn + ``. + +## Normalized Result Model + +All memory adapters normalize into one shared result model. + +### `entry` + +- Source: `opencode db` SQLite. +- Meaning: exact user turns, assistant turns, and tool calls. +- Visibility: `session_search()` only. +- Injection: never. + +### `note` + +- Source: session notes storage. +- Meaning: explicit durable notes. +- Visibility: searchable and injectable where allowed. + +### `summary` + +- Source: session snapshot adapter, dream snapshot adapter, and one-off Graphiti + normalization. +- Meaning: lossy summaries and hint layers. +- Visibility: searchable and injectable where allowed. + +No other top-level memory result kinds are part of this design. + +## Shared Code-Path Rule + +Injected XML sections and `session_search()` result sections must come from the +same normalized subset of code paths. + +The rule is: + +1. Adapters read from sources and emit `entry`, `note`, or `summary` items. +2. Retrieval ranks and filters those normalized items. +3. `session_search()` returns normalized results directly. +4. XML renderers render only the allowed subset of those same normalized + results. + +This is the core anti-drift mechanism for the new design. XML tags must emerge +from normalized result types instead of hand-built parallel summarizers. + +## `session_search()` Contract + +`session_search()` becomes the canonical memory read API. + +### Query Mode + +When `query` is non-empty: + +- accept `when`, defaulting to the current timestamp +- search exact SQLite-backed entries +- search notes +- search the same summary set used by empty-query reflection mode +- limit exact entry and note hits to records at or before `when` +- return exact results first, then summaries + +The exact-results segment contains `entry` and `note` results. The summary +segment contains `summary` results. + +Within each segment, order by: + +1. `weight` descending +2. `created_at` descending +3. stable tie-break + +This keeps exact evidence ahead of summaries while still preserving a single +normalization layer. + +The summary segment must be produced by the same reflection machinery used by +empty-query search. Query mode does not introduce a second summary-selection +algorithm. + +Tool-heavy sessions can produce too many exact SQLite hits. That is a real +concern rather than overthinking. The noise-reduction rule is: + +- exact entry adapters may collapse contiguous low-signal tool activity into one + bounded exact result when the underlying raw sequence is mechanically related + and has no intervening user or assistant turn +- this compaction must preserve a reference back to the underlying exact records + in `opencode db` +- the compaction rule applies only to exact search results and must not create a + new injected-memory type + +### Reflection Mode + +When `query` is empty or null: + +- return summaries only +- accept `when`, defaulting to the current timestamp +- resolve granularity with decreasing resolution the farther away from `when` +- include snapshots from both before and after the reference time +- order returned summaries chronologically + +The temporal ladder is numeric rather than bespoke. Examples include: + +- day +- week +- month +- year +- decade +- century +- millennia + +Every summary snapshot is retained indefinitely. Larger timeframes are access +points, not replacements. + +Query mode and reflection mode therefore share the same summary-selection +mechanism. The difference is only that query mode also returns exact entries and +notes ahead of those summaries. + +### Exact Recall Boundary + +`session_search()` is the only bridge from hints back to exact history. + +If an injected summary looks relevant, the agent uses `session_search()` to +recover exact entries. Exact entries never appear in injected XML. + +## Injection Contract + +### Top-Level `` Wrapper + +Injected memory must be wrapped in one top-level `` element. Multiple +top-level XML nodes are not allowed because they render poorly in the user view +and introduce meaningless line breaks. + +`` remains nested inside ``. + +### `` + +`` is the session-start and compaction continuity wrapper. + +It is injected only on: + +1. new sessions, including subagents +2. compaction + +It is not the general ordinary-turn memory surface. + +It may contain: + +- session-scoped summaries +- notes where explicitly allowed + +It may not contain: + +- exact entries +- raw turns +- raw tool calls +- hand-built transcript projections outside the normalized result model + +For new sessions, it should primarily contain session-scoped summary material. + +For new sessions and compaction, up to the last 10 session notes may be injected +when they are relevant to the active continuity surface. + +For compaction, it may also include notes because compaction benefits from a +slightly richer continuity surface. + +### `` + +`` remains available on ordinary turns. + +The common 80% autopilot criterion applies to the whole injected-memory surface, +not only ``. The injected blocks together should provide a +bounded hint layer that is sufficient for most routine work without replacing +explicit search. + +It may contain: + +- dream summaries +- other local summary artifacts + +On new sessions and compaction, it may also include one-off Graphiti-derived +summaries normalized into the same `summary` result shape. + +It may not contain: + +- exact entries +- exact notes +- literal Graphiti nodes, facts, or episodes rendered verbatim + +### Summary-Only Rule + +The entire injected `` surface is hint-only. Exact memory remains +search-only. + +## XML Shape + +Use one top-level wrapper: + +- `` + +Inside ``, only render tags derived from normalized result kinds. + +`` remains a nested child section inside ``. + +### Allowed Child Tags + +1. `` +2. `` + +There is no injected `` tag. + +### Session Summary Attributes + +Session-local summaries use `scope`, not `granularity`. + +This is valid: + +```xml +... +``` + +This is invalid: + +```xml +... +``` + +`granularity` is reserved for temporal buckets like `day`, `week`, `month`, +`year`, `decade`, `century`, and `millennia`. + +### Example Shape + +```xml + + ... + + ... + ... + + + + ... + ... + ... + + +``` + +The exact tags shown above are illustrative. The invariant is that rendered tags +must come directly from normalized `note` and `summary` results. + +## Dream Pipeline + +Dream is a local asynchronous summarization pipeline inside the plugin. It is +not a server-side dependency. + +The pipeline works like this: + +1. consume promoted local memory and note material +2. produce daily summaries first +3. recursively compose higher timeframes from lower ones +4. store all generated summaries indefinitely + +Dream summaries are a permanent chronological access layer. They are a hint +surface for injection and a searchable summary layer for reflection mode. + +### Dream Triggers + +Because OpenCode usually runs as a CLI process rather than a persisted daemon, +dreaming cannot rely on a permanently resident worker. + +The trigger model is therefore opportunistic and local: + +1. run a bounded dream refresh during session startup if required summaries are + missing relative to the current exact-history watermark +2. run a bounded dream refresh during compaction +3. on orderly runtime shutdown, if there is dirty exact-history material not yet + incorporated into summaries, persist a bounded dream job descriptor and spawn + a detached headless dream worker to consume that job while letting the + foreground OpenCode process exit immediately +4. if detached dreaming cannot be started safely, show an explicit OpenCode + toast telling the user that dreaming is still in progress and they should + wait for completion before exiting +5. on the next process start, detect any remaining exact-history gap and resume + bounded catch-up dreaming before serving reflection-style summary reads + +This gives the system durable dream progress without requiring a resident +service. + +Detached dreaming is viable only as an independent bounded catch-up worker. It +must bootstrap from persisted job input and persisted exact-history watermarks; +it must not depend on the parent process's in-memory runtime state. + +## Graphiti Role + +Graphiti stays in the architecture, but with a narrower contract. + +It is: + +- an asynchronous consumer of promoted local memory and note material +- a semantic enrichment layer +- a one-off hint source on new sessions and compaction + +It is not: + +- authoritative transcript storage +- a cached ordinary-turn injection substrate +- a replacement for `session_search()` + +Graphiti summaries are hints only. If they matter, the agent must still use +`session_search()` to reconnect to exact records. + +## Storage And Retention + +Durable memory has no TTL. + +This applies to: + +- notes +- summaries +- Graphiti-related durable references + +Operational state remains bounded. + +This applies to: + +- transient hook state +- background job coordination +- any remaining short-lived runtime caches + +The system relies only on relevancy and weighting to suppress low-value recall. +Durable artifacts are not expired by time. + +## Migration Direction + +Implementation should proceed by moving toward the normalized read model first. + +The sequence is: + +1. add the SQLite-backed exact-entry adapter for `session_search()` +2. add normalized `note` and `summary` result types around existing notes and + snapshot material +3. teach XML renderers to consume normalized results instead of bespoke event + section builders +4. remove local corpus memory search from the memory path +5. remove Graphiti cache-based ordinary-turn rendering +6. remove Redis exact-event memory authority from injected continuity assembly + +This preserves a working system while shifting all memory surfaces toward one +shared normalization layer. + +## Validation Expectations + +The redesigned memory system is correct when: + +1. exact user turns, assistant turns, and tool calls are discoverable through + `session_search()` and not injected. +2. new-session and compaction injection are wrapped in one top-level `` + block and contain only normalized `summary`, `note`, and nested + `` sections. +3. ordinary-turn `` contains only summary and note hints, with exact + entries excluded. +4. the bad promotion pattern from the current failure sample is impossible + because `last_request`, `active_tasks`, and `key_decisions` no longer exist + as independent injected-memory producers. +5. Graphiti absence does not break local dream summaries or exact recall via + `session_search()`. + +## Non-Goals + +This design does not try to: + +- inject exact transcript fragments directly into model prompts +- keep the local corpus as a memory search surface +- make Graphiti the authoritative memory reader +- preserve the current bespoke injected section taxonomy diff --git a/docs/superpowers/specs/2026-05-10-session-notes-freshness-without-ttl-design.md b/docs/superpowers/specs/2026-05-10-session-notes-freshness-without-ttl-design.md new file mode 100644 index 0000000..aefcdaf --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-session-notes-freshness-without-ttl-design.md @@ -0,0 +1,274 @@ +# Session Notes Freshness Without TTL Design + +## Goal + +Make session notes durable instead of expiring on TTL, reduce the visibility of +old noisy notes through freshness-aware ranking rather than deletion-by-time, +and let same-project sessions explicitly delete obsolete notes by `id`. + +The design keeps the existing small tool surface: + +- `session_search` remains the default recall entrypoint +- `session_notes_read(id)` remains the exact reopen path +- `session_notes_write(text, replace?)` remains the mutation path + +## Why This Change + +The current design uses `sessionTtlSeconds` for the session-scoped note store, +and `session_search` applies a hard-coded non-local penalty to same-project +notes from other sessions. + +That creates two problems: + +1. useful notes can disappear just because they are old +2. old incorrect or noisy notes are hard to remove from a later session, while + still sometimes surfacing because search relevance alone does not reflect + whether a note has stayed useful over time + +The desired behavior is: + +- notes persist until explicitly deleted +- stale notes become less likely to surface naturally +- same-project notes are not penalized only for being non-local +- exact note reopen through `session_notes_read(id)` becomes a meaningful + usefulness signal + +## Current Relevant Behavior + +### Note Storage + +- `session:{rootSessionId}:notes` stores current-session note bodies and is used + for compaction note injection +- `session:notes:${groupId}` stores same-project notes for cross-session search + and direct reopen by `id` + +### Search Ranking + +Current note ranking rule: + +- local note hit: `final_score = raw_score` +- project note hit: `final_score = raw_score * 0.85` + +### Read Path + +- `session_search` returns note hits with excerpt `snippet`, not full note text +- `session_notes_read(id)` returns the exact full note text +- reads do not currently update note metadata + +## Required Behavior + +### Persistence + +- Session notes must no longer expire because of TTL. +- The session-local note store must stop being written with TTL. +- Read operations must stop refreshing note TTL. +- Notes remain present until explicitly deleted. + +This change applies to session notes only. It does not change unrelated TTL use +for other hot-tier data. + +### Deletion Semantics + +- Any session in the same project may delete a note by `id`. +- Same-project delete must remove the note from both the session-local store and + the project-scoped store. +- Delete-on-miss remains a successful no-op returning a deleted result. +- Cross-project deletion must remain impossible. + +Recommended scope rule: + +- create and non-empty replace stay session-scoped +- empty-text delete becomes same-project scoped + +This keeps ordinary authorship conservative while allowing later cleanup of old +incorrect or noisy notes. + +### Search Result Shape + +`session_search` note hits must include: + +- `id` +- `root_session_id` +- `scope` +- `snippet` +- `score` +- `created_at` +- `updated_at` + +Example: + +```json +{ + "type": "note", + "id": "uuid", + "root_session_id": "ses_...", + "scope": "local", + "snippet": "...", + "score": 0.91, + "created_at": "2026-05-10T12:00:00.000Z", + "updated_at": "2026-05-10T12:30:00.000Z" +} +``` + +`last_read_at` should not be returned in search results initially. + +### Read Freshness + +To better measure note usefulness, project note metadata must add +`last_read_at`. + +Rules: + +- `session_notes_read(id)` updates `last_read_at` when the note exists +- missing-note reads remain normal misses and must not create or modify data +- `last_read_at` is project-scoped metadata because usefulness is shared across + same-project sessions + +`updated_at` remains write freshness only. It must not be overloaded to mean +read freshness. + +## Ranking Model + +### Terminology + +- `relevance`: how well the note matches the query +- `write_freshness`: freshness derived from `updated_at` +- `read_freshness`: usefulness derived from `last_read_at` + +### Scoring + +Use note ranking based on: + +```text +final_score = relevance * write_freshness * read_freshness +``` + +Recommended shape: + +- `write_freshness = exp(-lambda_write * age_since_updated_at)` +- `read_freshness = 1 + alpha * exp(-lambda_read * age_since_last_read_at)` + +Properties: + +- `relevance` remains the primary semantic match measure +- `write_freshness` causes old untouched notes to fade smoothly +- `read_freshness` partially rescues notes that agents repeatedly find useful +- `read_freshness` must be capped and bounded +- `read_freshness` must not fully reset or overwhelm `write_freshness` + +This means: + +- a new note can rank highly without reads +- an old unread note fades naturally +- an old but recently reopened note can remain competitive +- a very strong semantic match can still beat a weaker newer note + +### Locality + +Remove the hard-coded same-project non-local penalty. + +Do not broadly multiply project-note scores down only because they come from a +different root session. + +Instead: + +- apply the same freshness model to local and project note hits +- use locality only as a tie-break when scores are effectively equal + +Tie-break order: + +1. higher `score` +2. prefer `scope: "local"` +3. newer `updated_at` +4. stable deterministic fallback such as `id` + +## Search And Read Roles + +### `session_search` + +- remains the default recall tool +- returns only a relevance-centered excerpt/snippet, not full note text +- lets agents judge whether a note is promising enough to reopen + +The snippet should remain informative enough to support triage. It should not be +degraded into an opaque summary that hides likely relevance. + +### `session_notes_read` + +- remains the only exact reopen path for full note text +- acts as the explicit signal that a note was useful enough to inspect in full + +This aligns the tool workflow with ranking: search discovers, read confirms, and +read activity feeds note usefulness over time. + +## Storage Model + +### Session-Local Store + +`session:{rootSessionId}:notes` + +- remains the authoritative store for current-session note enumeration and + compaction injection +- stores current-session note bodies keyed by `id` +- no longer uses TTL + +Stored fields remain: + +- `text` +- `created_at` +- `updated_at` + +### Project Store + +`session:notes:${groupId}` + +- remains the cross-session source of truth for same-project search and direct + reopen by `id` + +Stored fields become: + +- `root_session_id` +- `text` +- `created_at` +- `updated_at` +- `last_read_at` optional or nullable + +## Compaction Behavior + +Compaction remains current-session scoped. + +- only current-session notes are injected into compaction context +- same-project foreign-session notes must not be injected into compaction +- note freshness ranking has no effect on compaction note inclusion + +## Migration + +- Existing notes must survive this change. +- Existing `updated_at` values become the initial write-freshness clock. +- Existing notes may begin with missing `last_read_at` and be treated as never + read. +- No destructive backfill is required. + +## Validation + +At minimum, verify: + +- notes no longer disappear because of session note TTL +- search results include `created_at` and `updated_at` +- old unread notes rank below newer comparable notes +- old recently read notes can outrank newer weaker matches +- same-project delete-by-id succeeds for foreign-session notes +- delete-on-miss remains a no-op success +- cross-project note isolation remains intact +- compaction still injects only current-session notes + +## Risks + +- If `write_freshness` decays too aggressively, useful old notes become too hard + to discover. +- If `read_freshness` is too strong, agents can accidentally keep junk alive by + reopening it. +- If snippets become too weak, agents may fail to call `session_notes_read` on + the right note. + +The ranking constants should therefore be conservative and test-driven. diff --git a/opencode.json b/opencode.json deleted file mode 100644 index 021f297..0000000 --- a/opencode.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "plugin": [ - "file:///Users/vicary/Documents/Projects/vicary/opencode-graphiti/dist/esm/mod.js" - ] -} 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/chat.ts b/src/handlers/chat.ts index f0172b3..6a04618 100644 --- a/src/handlers/chat.ts +++ b/src/handlers/chat.ts @@ -61,7 +61,7 @@ export function createChatHandler(deps: ChatHandlerDeps): ChatMessageHook { if (prepared) { state.injectedMemories = true; } - logger.info("Prepared local session memory for chat transform", { + logger.info("Prepared local memory for chat transform", { sessionID: canonicalSessionId, sourceSessionID: sessionID, hotTierReady: state.hotTierReady, @@ -75,7 +75,7 @@ export function createChatHandler(deps: ChatHandlerDeps): ChatMessageHook { graphitiAsync.scheduleDrain(state.groupId); } } catch (error) { - logger.warn("Unable to prepare local session memory for chat transform", { + logger.warn("Unable to prepare local memory for chat transform", { sessionID, error, }); diff --git a/src/handlers/compacting.test.ts b/src/handlers/compacting.test.ts index cf4eaf6..bac3f5e 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,11 +26,15 @@ 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: - '', + '', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -53,7 +61,7 @@ class MockSessionManager { describe("compacting handler", () => { setSuppressConsoleWarningsDuringTestsOverride(true); - it("injects locally prepared session_memory without Graphiti reads", async () => { + it("injects locally prepared memory without Graphiti reads", async () => { const sessionManager = new MockSessionManager(); const handler = createCompactingHandler({ sessionManager: sessionManager as never, @@ -63,8 +71,14 @@ describe("compacting handler", () => { await handler({ sessionID: "session-1" }, output as never); assertEquals(output.context.length, 2); - assertStringIncludes(output.context[1], "'); + assertEquals(output.context[1].includes(" { }]); }); - it("preserves local-first session memory shape during compaction with cached persistent memory optional", async () => { + it("preserves normalized 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', + 'continuecached recall', nodeRefs: ["node-1"], refreshDecision: { classification: "aligned", @@ -100,9 +122,11 @@ describe("compacting handler", () => { await handler({ sessionID: "session-1" }, output as never); assertEquals(output.context.length, 1); + assertStringIncludes(output.context[0], ''); assertStringIncludes(output.context[0], ""); assertStringIncludes(output.context[0], " { @@ -116,8 +140,12 @@ describe("compacting handler", () => { await handler({ sessionID: "child-session" }, output as never); assertEquals(output.context.length, 2); - assertStringIncludes(output.context[1], "'); + assertEquals(sessionManager.prepareInjectionCalls, [{ + sessionId: "parent-session", + lastRequest: undefined, + options: { forCompaction: true }, + }]); assertEquals(sessionManager.activeCalls, [{ sessionId: "child-session", canonicalSessionId: "parent-session", diff --git a/src/handlers/compacting.ts b/src/handlers/compacting.ts index db813a1..05cb1d4 100644 --- a/src/handlers/compacting.ts +++ b/src/handlers/compacting.ts @@ -31,17 +31,19 @@ export function createCompactingHandler( const prepared = await sessionManager.prepareInjection( canonicalSessionId, + undefined, + { forCompaction: true }, ); if (!prepared?.envelope) return; output.context.push(prepared.envelope); sessionManager.clearPendingInjection(state, prepared); - logger.info("Injected local session_memory into compaction context", { + logger.info("Injected local memory into compaction context", { sessionID: canonicalSessionId, sourceSessionID: sessionID, hotTierReady: state.hotTierReady, }); } catch (error) { - logger.warn("Unable to prepare local session memory for compaction", { + logger.warn("Unable to prepare local memory for compaction", { sessionID, error, }); diff --git a/src/handlers/messages.test.ts b/src/handlers/messages.test.ts index d64374c..f3e33b3 100644 --- a/src/handlers/messages.test.ts +++ b/src/handlers/messages.test.ts @@ -21,7 +21,11 @@ class MockSessionManager { }; } | undefined, }; - prepareInjectionImpl?: (sessionId: string, lastRequest?: string) => 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; } @@ -54,7 +62,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'fresh', + 'fresh', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -76,7 +84,14 @@ describe("messages handler", () => { }; await handler({}, output as never); - assertStringIncludes(output.messages[0].parts[0].text, "', + ); + assertEquals( + output.messages[0].parts[0].text.includes(" { }]); }); + it("expects one top-level memory wrapper with nested persistent_memory", async () => { + const sessionManager = new MockSessionManager(); + sessionManager.state.pendingInjection = { + envelope: + 'Current snapshotCached summary', + nodeRefs: [], + refreshDecision: { + classification: "aligned", + shouldRefresh: false, + similarity: 1, + threshold: 0.5, + cachedQuery: "fresh", + }, + }; + const handler = createMessagesHandler({ + sessionManager: sessionManager as never, + }); + + const output = { + messages: [{ + info: { role: "user", sessionID: "session-1" }, + parts: [{ type: "text", text: "Continue work" }], + }], + }; + await handler({}, output as never); + + const rendered = output.messages[0].parts[0].text; + assertStringIncludes(rendered, ''); + assertStringIncludes(rendered, ""); + assertEquals(rendered.includes(" { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'freshcached recall', + 'freshcached recall', nodeRefs: ["node-1"], refreshDecision: { classification: "aligned", @@ -132,7 +181,7 @@ describe("messages handler", () => { assertEquals(lastRequest, "fallback request"); return { envelope: - 'fallback request', + 'fallback request', nodeRefs: [], refreshDecision: { classification: "miss", @@ -155,7 +204,10 @@ describe("messages handler", () => { }; await handler({ message: "fallback request" } as never, output as never); - assertStringIncludes(output.messages[0].parts[0].text, "', + ); }); it("falls back to latest user text when transform fallback message is non-string", async () => { @@ -168,7 +220,8 @@ describe("messages handler", () => { assertEquals(sessionId, "session-1"); assertEquals(lastRequest, "fallback request"); return { - envelope: '', + envelope: + '', nodeRefs: [], refreshDecision: { classification: "miss", @@ -194,7 +247,10 @@ describe("messages handler", () => { output as never, ); - assertStringIncludes(output.messages[0].parts[0].text, "', + ); }); it("falls back to the latest user text as the recall query", async () => { @@ -208,7 +264,7 @@ describe("messages handler", () => { assertEquals(lastRequest, "message body query"); return { envelope: - 'message body query', + 'message body query', nodeRefs: [], refreshDecision: { classification: "miss", @@ -231,13 +287,17 @@ describe("messages handler", () => { }; await handler({} as never, output as never); - assertStringIncludes(output.messages[0].parts[0].text, "', + ); }); it("does not mutate assistant history text while reinjecting the latest user prompt", async () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { - envelope: '', + envelope: + '', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -269,7 +329,10 @@ describe("messages handler", () => { }; await handler({}, output as never); - assertStringIncludes(output.messages[1].parts[0].text, "', + ); assertEquals( output.messages[0].parts[0].text, '', @@ -280,7 +343,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'next', + 'next', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -306,7 +369,10 @@ describe("messages handler", () => { await handler({}, output as never); - assertStringIncludes(output.messages[0].parts[0].text, "', + ); assertEquals( output.messages[0].parts[0].text.includes( '', @@ -326,7 +392,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'next', + 'next', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -348,8 +414,14 @@ describe("messages handler", () => { await handler({}, output as never); - assertStringIncludes(output.messages[0].parts[0].text, "', + ); + assertEquals( + output.messages[0].parts[0].text.includes(text.split("\n\n")[0]), + false, + ); assertStringIncludes(output.messages[0].parts[0].text, "next"); } }); @@ -358,7 +430,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'next', + 'next', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -385,7 +457,10 @@ describe("messages handler", () => { await handler({}, output as never); - assertStringIncludes(output.messages[0].parts[0].text, "', + ); assertStringIncludes( output.messages[0].parts[0].text, "<persistent_memory fact_uuids="fact-standalone-1,fact-standalone-2">stale memory</persistent_memory>", @@ -396,7 +471,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'inspect example', + 'inspect example', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -423,7 +498,10 @@ describe("messages handler", () => { await handler({} as never, output as never); - assertStringIncludes(output.messages[0].parts[0].text, "', + ); assertStringIncludes( output.messages[0].parts[0].text, "<session_memory version="1">example</session_memory>", @@ -434,7 +512,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'inspect example', + 'inspect example', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -485,7 +563,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'inspect example', + 'inspect example', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -522,7 +600,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'inspect example', + 'inspect example', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -559,7 +637,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'inspect example', + 'inspect example', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -587,7 +665,7 @@ describe("messages handler", () => { await handler({} as never, output as never); assertEquals( - output.messages[0].parts[0].text.match(/ { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'next', + 'next', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -629,7 +707,7 @@ describe("messages handler", () => { await handler({}, output as never); const call = infoSpy.calls.find((entry) => - entry.args[0] === "Injected canonical session_memory block" + entry.args[0] === "Injected canonical memory block" ); assertEquals(Boolean(call), true); assertEquals( @@ -646,7 +724,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'continue', + 'continue', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -695,9 +773,12 @@ describe("messages handler", () => { output.messages[1].parts[0].text, 'before legacy old memory after legacy', ); - assertStringIncludes(output.messages[2].parts[0].text, "', + ); assertEquals( - output.messages[2].parts[0].text.match(/ { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'continue', + 'continue', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -743,9 +824,12 @@ describe("messages handler", () => { output.messages[0].parts[0].text, 'before standalone stale memory after standalone', ); - assertStringIncludes(output.messages[1].parts[0].text, "', + ); assertEquals( - output.messages[1].parts[0].text.match(/ { it("does not clear a newer pending injection after awaiting prepareInjection", async () => { const newerPrepared = { envelope: - 'newer', + 'newer', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -769,7 +853,7 @@ describe("messages handler", () => { sessionManager.state.pendingInjection = newerPrepared; return { envelope: - 'older', + 'older', nodeRefs: [], refreshDecision: { classification: "miss", @@ -830,7 +914,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'continue', + 'continue', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -862,14 +946,17 @@ describe("messages handler", () => { await handler({} as never, output as never); assertEquals(output.messages[0].parts[0].text, assistantText); - assertStringIncludes(output.messages[1].parts[0].text, "', + ); }); it("scrubs only the leading injected block from the latest user prompt", async () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'continue', + 'continue', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -891,7 +978,7 @@ describe("messages handler", () => { parts: [{ type: "text", text: - `stale\n\n${trailingExample}`, + `stale\n\n${trailingExample}`, }], }], }; @@ -900,15 +987,15 @@ describe("messages handler", () => { assertEquals( output.messages[0].parts[0].text, - 'continue\n\nkeep transcript\n\n<session_memory version="1">example</session_memory>', + 'continue\n\nkeep transcript\n\n<session_memory version="1">example</session_memory>', ); }); - it("scrubs leading local-first session_memory envelopes regardless of source/version values", async () => { + it("scrubs leading normalized memory envelopes regardless of source/version values", async () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'continue', + 'continue', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -928,7 +1015,7 @@ describe("messages handler", () => { parts: [{ type: "text", text: - 'stale\n\ncontinue', + 'stale\n\ncontinue', }], }], }; @@ -937,15 +1024,15 @@ describe("messages handler", () => { assertEquals( output.messages[0].parts[0].text, - 'continue\n\ncontinue', + 'continue\n\ncontinue', ); }); - it("scrubs multiple sequential leading session_memory envelopes even when later blocks omit attrs", async () => { + it("scrubs multiple sequential leading memory envelopes even when later blocks omit attrs", async () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'continue', + 'continue', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -965,7 +1052,7 @@ describe("messages handler", () => { parts: [{ type: "text", text: - 'stale\n\nolder stale\n\ncontinue', + 'stale\n\nolder stale\n\ncontinue', }], }], }; @@ -974,7 +1061,7 @@ describe("messages handler", () => { assertEquals( output.messages[0].parts[0].text, - 'continue\n\ncontinue', + 'continue\n\ncontinue', ); }); @@ -982,7 +1069,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'continue', + 'continue', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -1011,13 +1098,14 @@ describe("messages handler", () => { assertEquals( output.messages[0].parts[0].text, - 'continue\n\ncontinue', + 'continue\n\ncontinue', ); }); it("remains compatible with extended prepareInjection results", async () => { const prepared = { - envelope: '', + envelope: + '', nodeRefs: ["node-1"], refreshDecision: { classification: "drifted", @@ -1041,7 +1129,10 @@ describe("messages handler", () => { }; await handler({}, output as never); - assertStringIncludes(output.messages[0].parts[0].text, "', + ); assertEquals(sessionManager.state.pendingInjection, undefined); }); @@ -1056,7 +1147,7 @@ describe("messages handler", () => { assertEquals(lastRequest, "follow up from child"); return { envelope: - 'follow up from child', + 'follow up from child', nodeRefs: [], refreshDecision: { classification: "miss", @@ -1080,7 +1171,10 @@ describe("messages handler", () => { await handler({}, output as never); - assertStringIncludes(output.messages[0].parts[0].text, "', + ); assertStringIncludes( output.messages[0].parts[0].text, "follow up from child", diff --git a/src/handlers/messages.ts b/src/handlers/messages.ts index cf08c81..b30ca4d 100644 --- a/src/handlers/messages.ts +++ b/src/handlers/messages.ts @@ -31,16 +31,19 @@ const getTransformMessage = (input: unknown): string | undefined => { const LEADING_SESSION_MEMORY_BLOCK = /^]*>[\s\S]*?<\/session_memory>(?:\r?\n){0,2}/; -const LEADING_INJECTED_LEGACY_MEMORY_BLOCK_WITH_UUIDS = - /^]*\bdata-uuids=(["'])(?:[^"']*)\1)[^>]*>[\s\S]*?<\/memory>(?:\r?\n){0,2}/; -const LEADING_INJECTED_EMPTY_LEGACY_MEMORY_BLOCK = - /^]*\bdata-uuids=)[^>]*>\s*<\/memory>(?:\r?\n){0,2}/; +const LEADING_MEMORY_BLOCK = /^]*>[\s\S]*?<\/memory>(?:\r?\n){0,2}/; const LEADING_PERSISTENT_MEMORY_BLOCK = /^]*>[\s\S]*?<\/persistent_memory>(?:\r?\n){0,2}/; const SESSION_MEMORY_SOURCE_ATTR_PATTERN = /]*\bsource=(['"])[^'"]+\1/i; const SESSION_MEMORY_GENERATED_SECTION_PATTERN = /<(?:session_snapshot|persistent_memory)\b/i; +const MEMORY_VERSION_ATTR_PATTERN = /]*\bversion=(['"])2\1/i; +const LEGACY_MEMORY_UUID_ATTR_PATTERN = + /]*\bdata-uuids=(['"])(?:[^'"]*)\1/i; +const EMPTY_MEMORY_BLOCK_PATTERN = /^]*>\s*<\/memory>$/i; +const MEMORY_GENERATED_SECTION_PATTERN = + /<(?:session_snapshot|persistent_memory)\b/i; const PERSISTENT_MEMORY_GENERATED_CONTENT_PATTERN = /<(?:node|fact|episode)\b/i; const USER_MEMORY_ENVELOPE_TAG_PATTERN = /<\/?(?:session_memory|memory|persistent_memory)\b[^>]*>/gi; @@ -53,6 +56,16 @@ const looksLikeInjectedSessionMemoryBlock = ( SESSION_MEMORY_GENERATED_SECTION_PATTERN.test(block) || allowAttrlessFollowup; +const looksLikeInjectedMemoryBlock = ( + block: string, + allowAttrlessFollowup: boolean, +): boolean => + MEMORY_VERSION_ATTR_PATTERN.test(block) || + LEGACY_MEMORY_UUID_ATTR_PATTERN.test(block) || + EMPTY_MEMORY_BLOCK_PATTERN.test(block) || + MEMORY_GENERATED_SECTION_PATTERN.test(block) || + allowAttrlessFollowup; + const looksLikeInjectedPersistentMemoryBlock = (block: string): boolean => PERSISTENT_MEMORY_GENERATED_CONTENT_PATTERN.test(block); @@ -77,11 +90,12 @@ const scrubPromptMemoryText = (text: string): string => { continue; } - const next = scrubbed - .replace(LEADING_INJECTED_LEGACY_MEMORY_BLOCK_WITH_UUIDS, "") - .replace(LEADING_INJECTED_EMPTY_LEGACY_MEMORY_BLOCK, ""); - if (next !== scrubbed) { - scrubbed = next; + const leadingMemory = scrubbed.match(LEADING_MEMORY_BLOCK)?.[0]; + if ( + leadingMemory && + looksLikeInjectedMemoryBlock(leadingMemory, scrubbedInjectedPrefix) + ) { + scrubbed = scrubbed.slice(leadingMemory.length); scrubbedInjectedPrefix = true; continue; } @@ -159,7 +173,7 @@ export function createMessagesHandler( return; } textPart.text = `${prepared.envelope}\n\n${effectiveUserText}`; - logger.info("Injected canonical session_memory block", { + logger.info("Injected canonical memory block", { sessionID: canonicalSessionId, sourceSessionID, rewroteExistingMemory: scrubbedUserText !== latestUserText, diff --git a/src/handlers/tool-before.test.ts b/src/handlers/tool-before.test.ts index b59773e..080da9f 100644 --- a/src/handlers/tool-before.test.ts +++ b/src/handlers/tool-before.test.ts @@ -34,7 +34,7 @@ describe("tool execute before handler", () => { routingOutcomes.clearAll(); }); - it("throws on denied WebFetch calls", async () => { + it("throws guidance on denied WebFetch calls", async () => { const canonicalizer = new MockSessionCanonicalizer(); canonicalizer.cached.set("root-session", "root-session"); const handler = createToolBeforeHandler({ @@ -55,7 +55,7 @@ describe("tool execute before handler", () => { { args: { url: "https://example.com" } } as never, ), Error, - "Tool denied (WebFetch)", + "Use session_fetch_and_index", ); assertEquals(routingOutcomes.take("call-1"), { @@ -65,7 +65,7 @@ describe("tool execute before handler", () => { }); }); - it("throws on denied WebFetch calls from a child session after first-call canonical lookup", async () => { + it("throws guidance on denied WebFetch calls from a child session after first-call canonical lookup", async () => { const canonicalizer = new MockSessionCanonicalizer(); canonicalizer.resolved.set("child-session", "root-session"); const handler = createToolBeforeHandler({ @@ -86,7 +86,7 @@ describe("tool execute before handler", () => { { args: { url: "https://example.com" } } as never, ), Error, - "Tool denied (WebFetch)", + "Use session_fetch_and_index", ); assertEquals(canonicalizer.cachedCalls, ["child-session"]); @@ -98,7 +98,7 @@ describe("tool execute before handler", () => { }); }); - it("throws a stable denial message without embedding guidance text", async () => { + it("throws the deny guidance text when provided", async () => { const canonicalizer = new MockSessionCanonicalizer(); canonicalizer.cached.set("root-session", "root-session"); const handler = createToolBeforeHandler({ @@ -124,16 +124,54 @@ describe("tool execute before handler", () => { { args: { command: "curl https://example.com" } } as never, ), Error, - "Tool denied (Bash)", + "Dynamic guidance details that should stay out of the thrown error.", ); - assertEquals(error.message, "Tool denied (Bash)"); + assertEquals( + error.message, + "Dynamic guidance details that should stay out of the thrown error.", + ); assertStringIncludes( String(routingOutcomes.take("call-stable-deny")?.reason), "test-deny", ); }); + it("falls back to the generic denial message when guidance is absent", async () => { + const canonicalizer = new MockSessionCanonicalizer(); + canonicalizer.cached.set("root-session", "root-session"); + const handler = createToolBeforeHandler({ + sessionCanonicalizer: canonicalizer as never, + guidanceThrottle: new ToolGuidanceCache(), + routingOutcomes, + routeToolCall: () => ({ + action: "deny", + reason: "test-deny-no-guidance", + guidance: "", + }), + }); + + const error = await assertRejects( + () => + handler( + { + tool: "Bash", + sessionID: "root-session", + callID: "call-generic-deny", + } as never, + { args: { command: "curl https://example.com" } } as never, + ), + Error, + "Tool denied (Bash)", + ); + + assertEquals(error.message, "Tool denied (Bash)"); + assertStringIncludes( + String(routingOutcomes.take("call-generic-deny")?.reason), + "test-deny-no-guidance", + ); + }); + it("mutates args for Bash rewrite cases", async () => { const canonicalizer = new MockSessionCanonicalizer(); canonicalizer.cached.set("root-session", "root-session"); @@ -224,7 +262,7 @@ describe("tool execute before handler", () => { assertEquals(routingOutcomes.take("call-6"), undefined); }); - it("injects canonical root_session_id into every session tool call", async () => { + it("leaves public args unchanged for every session tool call", async () => { const canonicalizer = new MockSessionCanonicalizer(); canonicalizer.cached.set("root-session", "root-session"); const handler = createToolBeforeHandler({ @@ -257,11 +295,11 @@ describe("tool execute before handler", () => { output as never, ); - assertEquals(output.args.root_session_id, "root-session", tool); + assertEquals(output.args, args, tool); } }); - it("injects the canonical parent root_session_id for child session tools", async () => { + it("resolves the canonical parent for routing without mutating child session tool args", async () => { const canonicalizer = new MockSessionCanonicalizer(); canonicalizer.resolved.set("child-session", "root-session"); const handler = createToolBeforeHandler({ @@ -283,13 +321,13 @@ describe("tool execute before handler", () => { output as never, ); - assertEquals(output.args.root_session_id, "root-session"); + assertEquals(output.args, { query: "indexed" }); assertEquals(canonicalizer.cachedCalls, ["child-session"]); assertEquals(canonicalizer.resolveCalls, ["child-session"]); assertEquals(routingOutcomes.take("call-8"), undefined); }); - it("normalizes an already-present mismatched root_session_id for session tools", async () => { + it("strips caller-supplied root_session_id from session tools before forwarding", async () => { const canonicalizer = new MockSessionCanonicalizer(); canonicalizer.cached.set("child-session", "root-session"); const handler = createToolBeforeHandler({ @@ -298,7 +336,7 @@ describe("tool execute before handler", () => { routingOutcomes, routeToolCall, }); - const output = { + const output: { args: Record } = { args: { root_session_id: "wrong-root", command: "pwd" }, }; @@ -311,11 +349,13 @@ describe("tool execute before handler", () => { output as never, ); - assertEquals(output.args.root_session_id, "root-session"); + assertEquals(output.args, { + command: "pwd", + }); assertEquals(routingOutcomes.take("call-9"), undefined); }); - it("preserves root_session_id when a session tool is modified by routing", async () => { + it("routes session tools without caller-supplied root_session_id and forwards rewritten public args only", async () => { const canonicalizer = new MockSessionCanonicalizer(); canonicalizer.cached.set("child-session", "root-session"); let routedArgs: Record | undefined; @@ -332,7 +372,7 @@ describe("tool execute before handler", () => { }; }, }); - const output = { + const output: { args: Record } = { args: { root_session_id: "wrong-root", query: "original" }, }; @@ -346,11 +386,9 @@ describe("tool execute before handler", () => { ); assertEquals(routedArgs, { - root_session_id: "root-session", query: "original", }); assertEquals(output.args, { - root_session_id: "root-session", query: "rewritten", }); assertEquals(routingOutcomes.take("call-10"), { diff --git a/src/handlers/tool-before.ts b/src/handlers/tool-before.ts index 6a82c16..b038306 100644 --- a/src/handlers/tool-before.ts +++ b/src/handlers/tool-before.ts @@ -32,13 +32,12 @@ const isSessionMcpTool = (toolName: string): boolean => toolName as typeof SESSION_MCP_TOOL_NAMES[number], ); -const injectRootSessionId = ( +const stripRootSessionId = ( args: Record, - canonicalSessionId: string, -): Record => ({ - ...args, - root_session_id: canonicalSessionId, -}); +): Record => { + const { root_session_id: _ignored, ...rest } = args; + return rest; +}; const resolveCanonicalSessionId = async ( sessionCanonicalizer: ToolRoutingSessionCanonicalizer, @@ -64,13 +63,13 @@ export function createToolBeforeHandler( { tool, sessionID, callID }: ToolBeforeInput, output: ToolBeforeOutput, ) => { + const sessionTool = isSessionMcpTool(tool); const canonicalSessionId = await resolveCanonicalSessionId( deps.sessionCanonicalizer, sessionID, ); - const sessionTool = isSessionMcpTool(tool); const args = sessionTool - ? injectRootSessionId(toRecord(output.args), canonicalSessionId) + ? stripRootSessionId(toRecord(output.args)) : toRecord(output.args); if (sessionTool) { output.args = args; @@ -87,7 +86,7 @@ export function createToolBeforeHandler( return; case "modify": output.args = sessionTool - ? injectRootSessionId(toRecord(decision.args), canonicalSessionId) + ? stripRootSessionId(toRecord(decision.args)) : decision.args; deps.routingOutcomes.set(callID, { source: "tool-routing", @@ -109,7 +108,7 @@ export function createToolBeforeHandler( action: "deny", reason: decision.reason, }); - throw new Error(`Tool denied (${tool})`); + throw new Error(decision.guidance || `Tool denied (${tool})`); } }; } diff --git a/src/index.test.ts b/src/index.test.ts index ac573b7..e17a8e3 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 { @@ -12,9 +13,16 @@ import { 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 { + notifyDreamShutdownDelay, setOpenCodeClient, setWarningTaskScheduler, } from "./services/opencode-warning.ts"; +import type { DreamJob } from "./services/dream-jobs.ts"; import { makeGroupId, makeUserGroupId } from "./utils.ts"; const invokeGraphiti = graphiti as unknown as ( @@ -30,6 +38,11 @@ function createEntrypointHarnessWithOptions(options: { connected?: boolean; readyError?: Error; redisConnectError?: Error; + priorEventsBySessionId?: Record; + dreamWatermarksBySessionId?: Record; + trackedRootSessionIds?: string[]; + spawnDetachedDreamWorkerResult?: boolean; + nowValues?: string[]; teardownRun?: () => Promise; teardownDispose?: () => void; createSessionMcpRuntimeError?: Error; @@ -58,8 +71,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" }, @@ -83,6 +100,15 @@ function createEntrypointHarnessWithOptions(options: { redisCloseCalls: 0, graphitiAsyncDisposeCalls: 0, graphitiAsyncFlushCalls: [] as string[][], + dreamStoreArgs: [] as unknown[], + dreamStoreInstances: [] as unknown[], + dreamStoreGetWatermarkCalls: [] as string[], + spawnDetachedDreamWorkerCalls: [] as Array<{ + directory: string; + job: DreamJob; + }>, + notifyDreamShutdownDelayCalls: 0, + nowCalls: 0, createSessionExecutorCalls: [] as Array< Record | undefined >, @@ -102,6 +128,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 +140,11 @@ function createEntrypointHarnessWithOptions(options: { { ttlSeconds: number; driftThreshold: number }, ]>, redisCacheInstances: [] as unknown[], + sessionNotesArgs: [] as Array<[ + unknown, + { groupId: string }, + ]>, + sessionNotesInstances: [] as unknown[], batchDrainArgs: [] as Array<[ unknown, unknown, @@ -126,7 +162,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 +175,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 +239,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 +267,16 @@ function createEntrypointHarnessWithOptions(options: { } } + class MockSessionNotesService { + constructor( + redisClient: unknown, + options: { groupId: string }, + ) { + records.sessionNotesArgs.push([redisClient, options]); + records.sessionNotesInstances.push(this); + } + } + class MockBatchDrainService { constructor( redisClient: unknown, @@ -231,6 +292,20 @@ function createEntrypointHarnessWithOptions(options: { } } + class MockDreamStore { + constructor(redisClient: unknown) { + records.dreamStoreArgs.push(redisClient); + records.dreamStoreInstances.push(this); + } + + getWatermark(rootSessionId: string) { + records.dreamStoreGetWatermarkCalls.push(rootSessionId); + return Promise.resolve( + options.dreamWatermarksBySessionId?.[rootSessionId] ?? null, + ); + } + } + class MockGraphitiAsyncService { constructor( graphitiClient: unknown, @@ -267,6 +342,10 @@ function createEntrypointHarnessWithOptions(options: { return ["group-id"]; } + getTrackedRootSessionIds() { + return options.trackedRootSessionIds ?? ["root-session"]; + } + constructor( defaultGroupId: string, defaultUserGroupId: string, @@ -367,8 +446,25 @@ function createEntrypointHarnessWithOptions(options: { RedisEventsService: MockRedisEventsService, RedisSnapshotService: MockRedisSnapshotService, RedisCacheService: MockRedisCacheService, + SessionNotesService: MockSessionNotesService, BatchDrainService: MockBatchDrainService, + DreamStore: MockDreamStore, GraphitiAsyncService: MockGraphitiAsyncService, + spawnDetachedDreamWorker: (input: { + directory: string; + job: DreamJob; + }) => { + records.spawnDetachedDreamWorkerCalls.push(input); + return Promise.resolve(options.spawnDetachedDreamWorkerResult ?? true); + }, + notifyDreamShutdownDelay: () => { + records.notifyDreamShutdownDelayCalls += 1; + }, + now: () => { + const value = options.nowValues?.shift() ?? "2026-04-21T12:00:00.000Z"; + records.nowCalls += 1; + return value; + }, createSessionExecutor: (args?: Record) => new MockSessionExecutor(args), createSessionMcpRuntime: (args?: Record) => @@ -851,7 +947,95 @@ describe("index", () => { }); }); + describe("notifyDreamShutdownDelay", () => { + it("shows a dedicated warning toast for dream shutdown delay", () => { + const appLogCalls: unknown[] = []; + const toastCalls: unknown[] = []; + const scheduledTasks: Array<() => void> = []; + setWarningTaskScheduler((callback) => { + scheduledTasks.push(callback); + }); + setOpenCodeClient({ + app: { + log: (input: unknown) => { + appLogCalls.push(input); + }, + }, + tui: { + showToast: (input: unknown) => { + toastCalls.push(input); + }, + }, + }); + + notifyDreamShutdownDelay(); + + assertEquals(scheduledTasks.length, 2); + for (const task of scheduledTasks) task(); + + assertEquals(appLogCalls, [{ + body: { + service: "graphiti", + level: "warn", + message: + "Dreaming is still in progress; keep OpenCode open and wait for dreaming to complete before exiting.", + }, + }]); + assertEquals(toastCalls, [{ + body: { + message: + "Dreaming is still in progress; keep OpenCode open and wait for dreaming to complete before exiting.", + variant: "warning", + }, + }]); + }); + }); + 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, + "any same-project session may delete a note by id", + ); + assertStringIncludes( + runtime.tools.session_notes_read.description, + "returns `{ note: null }`", + ); + assertStringIncludes( + runtime.tools.session_search.description, + '`id`, `root_session_id`, `scope: "local" | "project"`, `created_at`, and', + ); + assertStringIncludes( + runtime.tools.session_execute.description, + "Do not pass `root_session_id`; the runtime resolves the current canonical", + ); + assertStringIncludes( + runtime.tools.session_notes_write.description, + "Do not pass `root_session_id`; the runtime resolves the current canonical", + ); + 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", + "when", + ]); + } finally { + void runtime.dispose(); + } + }); + it("exports graphiti as the plugin entrypoint", () => { assertEquals(typeof graphiti, "function"); }); @@ -881,6 +1065,7 @@ describe("index", () => { assertEquals( records.teardownRegistrations[0].tasks.map((task) => task.name), [ + "dream-shutdown-warning", "graphiti-drain-flush", "graphiti-async", "session-mcp-runtime", @@ -894,6 +1079,7 @@ describe("index", () => { records.teardownRegistrations[0].tasks[2].run(); records.teardownRegistrations[0].tasks[3].run(); records.teardownRegistrations[0].tasks[4].run(); + records.teardownRegistrations[0].tasks[5].run(); assertEquals(records.graphitiAsyncFlushCalls, [["group-id"]]); assertEquals(records.graphitiAsyncDisposeCalls, 1); assertEquals(records.sessionMcpRuntimeDisposeCalls, 1); @@ -902,6 +1088,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 +1121,13 @@ 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", + }); assertStrictEquals( records.batchDrainArgs[0][0], records.redisClientInstances[0], @@ -985,6 +1179,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 +1272,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 +1284,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 +1302,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 +1324,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 +1346,169 @@ 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 for the current canonical root session. Do not pass `root_session_id`; the runtime resolves the current canonical root session automatically.", + parameters: { type: "object" }, + }; + await toolDefinitionHook( + { toolID: "session_execute" }, + nonSearchOutput, + ); + assertEquals( + nonSearchOutput.description, + "Execute a bounded session command for the current canonical root session. Do not pass `root_session_id`; the runtime resolves the current canonical root session automatically.", + ); + + 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 +1521,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 +1540,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 +1576,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"); }); @@ -1430,5 +1807,50 @@ describe("index", () => { assertEquals(signalHandlers.size, 0); assertEquals(processEventHandlers.size, 0); }); + + it("does not attempt detached spawn on graceful shutdown when there is a dream gap", async () => { + const { input, records, dependencies } = + createEntrypointHarnessWithOptions({ + connected: true, + trackedRootSessionIds: ["root-a"], + dreamWatermarksBySessionId: { + "root-a": null, + }, + nowValues: ["2026-04-21T15:30:00.000Z"], + }); + + await invokeGraphiti(input, dependencies); + + const teardownTask = records.teardownRegistrations[0].tasks.find((task) => + task.name === "dream-shutdown-warning" + ); + await teardownTask?.run(); + + assertEquals(records.dreamStoreGetWatermarkCalls, ["root-a"]); + assertEquals(records.spawnDetachedDreamWorkerCalls, []); + assertEquals(records.notifyDreamShutdownDelayCalls, 1); + }); + + it("does not show the shutdown waiting instruction when there is no dream gap", async () => { + const { input, records, dependencies } = + createEntrypointHarnessWithOptions({ + connected: true, + trackedRootSessionIds: ["root-a"], + dreamWatermarksBySessionId: { + "root-a": "2026-04-21T15:30:00.000Z", + }, + nowValues: ["2026-04-21T15:30:00.000Z"], + }); + + await invokeGraphiti(input, dependencies); + + const teardownTask = records.teardownRegistrations[0].tasks.find((task) => + task.name === "dream-shutdown-warning" + ); + await teardownTask?.run(); + + assertEquals(records.spawnDetachedDreamWorkerCalls, []); + assertEquals(records.notifyDreamShutdownDelayCalls, 0); + }); }); }); diff --git a/src/index.ts b/src/index.ts index 483c48f..a733989 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"; @@ -13,24 +13,60 @@ import { GraphitiAsyncService } from "./services/graphiti-async.ts"; import { GraphitiMcpClient } from "./services/graphiti-mcp.ts"; import { redactEndpointUserInfo } from "./services/endpoint-redaction.ts"; import { + notifyDreamShutdownDelay, notifyGraphitiAvailabilityIssue, setOpenCodeClient, } from "./services/opencode-warning.ts"; import { RedisCacheService } from "./services/redis-cache.ts"; import { RedisClient } from "./services/redis-client.ts"; import { RedisEventsService } from "./services/redis-events.ts"; +import { DreamStore } from "./services/dream-store.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 TrackedRootSessionManager = { + getTrackedRootSessionIds?: () => string[]; + sessions?: Map; +}; + +const getTrackedRootSessionIds = (sessionManager: unknown): string[] => { + const manager = sessionManager as TrackedRootSessionManager; + const tracked = manager.getTrackedRootSessionIds; + if (typeof tracked === "function") { + return tracked.call(sessionManager); + } + if (!(manager.sessions instanceof Map)) return []; + return [...manager.sessions.entries()] + .filter(([, state]) => state?.isMain) + .map(([sessionId]) => sessionId); +}; + type GraphitiDependencies = { loadConfig: typeof loadConfig; setOpenCodeClient: typeof setOpenCodeClient; + notifyDreamShutdownDelay: typeof notifyDreamShutdownDelay; warnOnGraphitiStartupUnavailable: ( connected: boolean, endpoint: string, @@ -46,6 +82,8 @@ type GraphitiDependencies = { RedisEventsService: typeof RedisEventsService; RedisSnapshotService: typeof RedisSnapshotService; RedisCacheService: typeof RedisCacheService; + DreamStore: typeof DreamStore; + SessionNotesService: typeof SessionNotesService; BatchDrainService: typeof BatchDrainService; GraphitiAsyncService: typeof GraphitiAsyncService; createSessionExecutor: typeof createSessionExecutor; @@ -61,6 +99,7 @@ type GraphitiDependencies = { ToolRoutingOutcomeCache: typeof ToolRoutingOutcomeCache; makeGroupId: typeof makeGroupId; makeUserGroupId: typeof makeUserGroupId; + now: () => string; }; let activeRuntimeTeardown: @@ -95,6 +134,7 @@ export const warnOnRedisStartupUnavailable = ( const defaultGraphitiDependencies: GraphitiDependencies = { loadConfig, setOpenCodeClient, + notifyDreamShutdownDelay, warnOnGraphitiStartupUnavailable, warnOnRedisStartupUnavailable, GraphitiConnectionManager, @@ -104,6 +144,8 @@ const defaultGraphitiDependencies: GraphitiDependencies = { RedisEventsService, RedisSnapshotService, RedisCacheService, + DreamStore, + SessionNotesService, BatchDrainService, GraphitiAsyncService, createSessionExecutor, @@ -119,6 +161,7 @@ const defaultGraphitiDependencies: GraphitiDependencies = { ToolRoutingOutcomeCache, makeGroupId, makeUserGroupId, + now: () => new Date().toISOString(), }; export const graphiti: Plugin = ( @@ -206,6 +249,18 @@ export const graphiti: Plugin = ( ttlSeconds: config.redis.cacheTtlSeconds, driftThreshold: config.graphiti.driftThreshold, }); + const dreamStore = new dependencies.DreamStore(redisClient); + 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, + }); const batchDrain = new dependencies.BatchDrainService( redisClient, redisEvents, @@ -215,15 +270,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 +283,7 @@ export const graphiti: Plugin = ( const sessionMcpRuntime = dependencies.createSessionMcpRuntime({ redisClient, graphitiCache: redisCache, + notesService, sessionTtlSeconds: config.redis.sessionTtlSeconds, groupId: defaultGroupId, sessionExecutor, @@ -256,14 +303,41 @@ 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([ + { + name: "dream-shutdown-warning", + run: async () => { + const targetWatermark = dependencies.now(); + for ( + const rootSessionId of getTrackedRootSessionIds(sessionManager) + ) { + const watermark = await dreamStore.getWatermark(rootSessionId); + if (watermark === null || watermark < targetWatermark) { + dependencies.notifyDreamShutdownDelay(); + return; + } + } + }, + }, { name: "graphiti-drain-flush", run: () => @@ -302,21 +376,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 87aae36..4668f9d 100644 --- a/src/services/connection-manager.ts +++ b/src/services/connection-manager.ts @@ -212,8 +212,8 @@ type GraphitiConnectionManagerOptions = { connectionFactory?: ConnectionFactory; random?: () => number; now?: () => number; - setTimer?: (callback: () => void, delayMs: number) => TimerHandle; - clearTimer?: (timer: TimerHandle) => void; + setTimer?(callback: () => void, delayMs: number): TimerHandle; + clearTimer?(timer: TimerHandle): void; }; function createMcpConnection(endpoint: string): GraphitiConnection { diff --git a/src/services/detached-dream-worker.ts b/src/services/detached-dream-worker.ts new file mode 100644 index 0000000..4f8e48d --- /dev/null +++ b/src/services/detached-dream-worker.ts @@ -0,0 +1,12 @@ +import type { DreamJob } from "./dream-jobs.ts"; + +export type DetachedDreamSpawnInput = { + directory: string; + job: DreamJob; +}; + +export const spawnDetachedDreamWorker = ( + _input: DetachedDreamSpawnInput, +): Promise => { + return Promise.resolve(false); +}; diff --git a/src/services/dream-jobs.test.ts b/src/services/dream-jobs.test.ts new file mode 100644 index 0000000..0afc1bc --- /dev/null +++ b/src/services/dream-jobs.test.ts @@ -0,0 +1,60 @@ +import { assertEquals } 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 { type DreamJob, DreamJobStore } from "./dream-jobs.ts"; + +describe("DreamJobStore", () => { + it("writes, reads, and clears a pending dream job", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const store = new DreamJobStore(redis); + const job: DreamJob = { + rootSessionId: "root-1", + fromWatermark: "2026-04-20T00:00:00.000Z", + targetWatermark: "2026-04-21T00:00:00.000Z", + created_at: "2026-04-21T01:00:00.000Z", + }; + + assertEquals(await store.readPendingJob("root-1"), null); + + await store.writeJob(job); + + assertEquals(await store.readPendingJob("root-1"), job); + + await store.clearJob("root-1"); + + assertEquals(await store.readPendingJob("root-1"), null); + }); + + it("prepares a pending job only when the target watermark is ahead", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const store = new DreamJobStore(redis, { + readWatermark: (rootSessionId: string) => + Promise.resolve( + rootSessionId === "root-gap" + ? "2026-04-20T00:00:00.000Z" + : "2026-04-21T00:00:00.000Z", + ), + now: () => "2026-04-21T12:00:00.000Z", + }); + + const job = await store.preparePendingJobs([ + { + rootSessionId: "root-caught-up", + targetWatermark: "2026-04-21T00:00:00.000Z", + }, + { + rootSessionId: "root-gap", + targetWatermark: "2026-04-21T08:00:00.000Z", + }, + ]); + + assertEquals(job, { + rootSessionId: "root-gap", + fromWatermark: "2026-04-20T00:00:00.000Z", + targetWatermark: "2026-04-21T08:00:00.000Z", + created_at: "2026-04-21T12:00:00.000Z", + }); + assertEquals(await store.readPendingJob("root-gap"), job); + }); +}); diff --git a/src/services/dream-jobs.ts b/src/services/dream-jobs.ts new file mode 100644 index 0000000..f6d7694 --- /dev/null +++ b/src/services/dream-jobs.ts @@ -0,0 +1,102 @@ +import type { RedisClient } from "./redis-client.ts"; + +export type DreamJob = { + rootSessionId: string; + fromWatermark: string | null; + targetWatermark: string; + created_at: string; +}; + +type PendingDreamCandidate = { + rootSessionId: string; + targetWatermark: string; + created_at?: string; +}; + +type DreamJobStoreOptions = { + readWatermark?: (rootSessionId: string) => Promise; + now?: () => string; +}; + +const dreamJobKey = (rootSessionId: string): string => + `session:${rootSessionId}:dream:job:pending`; + +const isDreamJob = (value: unknown): value is DreamJob => { + if (!value || typeof value !== "object") return false; + const candidate = value as Record; + return typeof candidate.rootSessionId === "string" && + (typeof candidate.fromWatermark === "string" || + candidate.fromWatermark === null) && + typeof candidate.targetWatermark === "string" && + typeof candidate.created_at === "string"; +}; + +const parseDreamJob = (value: string | null): DreamJob | null => { + if (value === null) return null; + try { + const parsed = JSON.parse(value); + return isDreamJob(parsed) ? parsed : null; + } catch { + return null; + } +}; + +export class DreamJobStore { + private readonly readWatermark: ( + rootSessionId: string, + ) => Promise; + private readonly now: () => string; + + constructor( + private readonly redis: RedisClient, + options: DreamJobStoreOptions = {}, + ) { + this.readWatermark = options.readWatermark ?? (() => Promise.resolve(null)); + this.now = options.now ?? (() => new Date().toISOString()); + } + + async writeJob(job: DreamJob): Promise { + await this.redis.setString( + dreamJobKey(job.rootSessionId), + JSON.stringify(job), + ); + } + + async readPendingJob(rootSessionId: string): Promise { + return parseDreamJob( + await this.redis.getString(dreamJobKey(rootSessionId)), + ); + } + + async clearJob(rootSessionId: string): Promise { + await this.redis.deleteKey(dreamJobKey(rootSessionId)); + } + + async preparePendingJobs( + candidates: Iterable, + ): Promise { + for (const candidate of candidates) { + const existing = await this.readPendingJob(candidate.rootSessionId); + if (existing) return existing; + + const fromWatermark = await this.readWatermark(candidate.rootSessionId); + if ( + fromWatermark !== null && + fromWatermark >= candidate.targetWatermark + ) { + continue; + } + + const job = { + rootSessionId: candidate.rootSessionId, + fromWatermark, + targetWatermark: candidate.targetWatermark, + created_at: candidate.created_at ?? this.now(), + } satisfies DreamJob; + await this.writeJob(job); + return job; + } + + return null; + } +} diff --git a/src/services/dream-runner.test.ts b/src/services/dream-runner.test.ts new file mode 100644 index 0000000..5c581cd --- /dev/null +++ b/src/services/dream-runner.test.ts @@ -0,0 +1,122 @@ +import { assertEquals } 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 { DreamStore } from "./dream-store.ts"; +import { createDreamRunner, type DreamSummarizer } from "./dream-runner.ts"; +import type { NormalizedMemoryResult } from "../types/index.ts"; + +describe("DreamStore", () => { + it("returns summaries before and after the reference time in chronological order", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const store = new DreamStore(redis); + + await store.putSummary({ + rootSessionId: "root-1", + granularity: "day", + created_at: "2026-04-19T00:00:00.000Z", + body: "far before", + }); + await store.putSummary({ + rootSessionId: "root-1", + granularity: "day", + created_at: "2026-04-20T00:00:00.000Z", + body: "before", + }); + await store.putSummary({ + rootSessionId: "root-1", + granularity: "day", + created_at: "2026-04-22T00:00:00.000Z", + body: "after", + }); + await store.putSummary({ + rootSessionId: "root-1", + granularity: "day", + created_at: "2026-04-23T00:00:00.000Z", + body: "far after", + }); + + const results = await store.getSummariesAround({ + rootSessionId: "root-1", + when: "2026-04-21T12:00:00.000Z", + }); + + assertEquals(results.map((item: NormalizedMemoryResult) => item.type), [ + "summary", + "summary", + ]); + assertEquals( + results.map((item: NormalizedMemoryResult) => item.created_at), + [ + "2026-04-20T00:00:00.000Z", + "2026-04-22T00:00:00.000Z", + ], + ); + }); + + it("stores watermark without expiry semantics in the API", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const store = new DreamStore(redis); + + assertEquals(await store.getWatermark("root-1"), null); + await store.setWatermark("root-1", "2026-04-21T12:00:00.000Z"); + assertEquals( + await store.getWatermark("root-1"), + "2026-04-21T12:00:00.000Z", + ); + }); +}); + +describe("createDreamRunner", () => { + it("stores deterministic summaries and advances the watermark", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const store = new DreamStore(redis); + const summarizeCalls: Array<{ granularity: string; snippets: string[] }> = + []; + + const runner = createDreamRunner({ + store, + summarize(input: Parameters[0]) { + summarizeCalls.push(input); + return `${input.granularity}:${input.snippets.join(" | ")}`; + }, + }); + + await runner.refresh("root-1", null, [ + { + created_at: "2026-04-20T09:00:00.000Z", + snippet: "alpha", + }, + { + created_at: "2026-04-20T12:00:00.000Z", + snippet: "beta", + }, + { + created_at: "2026-04-21T08:00:00.000Z", + snippet: "gamma", + }, + ]); + + assertEquals(summarizeCalls, [ + { granularity: "day", snippets: ["alpha", "beta"] }, + { granularity: "day", snippets: ["gamma"] }, + ]); + + const summaries = await store.getSummariesAround({ + rootSessionId: "root-1", + when: "2026-04-20T18:00:00.000Z", + }); + + assertEquals( + summaries.map((item: NormalizedMemoryResult) => item.snippet), + [ + "day:alpha | beta", + "day:gamma", + ], + ); + assertEquals( + await store.getWatermark("root-1"), + "2026-04-21T08:00:00.000Z", + ); + }); +}); diff --git a/src/services/dream-runner.ts b/src/services/dream-runner.ts new file mode 100644 index 0000000..96c68a7 --- /dev/null +++ b/src/services/dream-runner.ts @@ -0,0 +1,53 @@ +import type { DreamStore } from "./dream-store.ts"; + +export type DreamRunnerInput = { + created_at: string; + snippet: string; +}; + +export type DreamSummarizer = (input: { + granularity: string; + snippets: string[]; +}) => string; + +const dayBucket = (timestamp: string): string => + `${timestamp.slice(0, 10)}T00:00:00.000Z`; + +export const createDreamRunner = (deps: { + store: DreamStore; + summarize: DreamSummarizer; +}) => ({ + async refresh( + rootSessionId: string, + fromWatermark: string | null, + inputs: DreamRunnerInput[] = [], + ): Promise { + const filtered = inputs + .filter((input) => + fromWatermark === null || input.created_at > fromWatermark + ) + .sort((left, right) => left.created_at.localeCompare(right.created_at)); + + if (filtered.length === 0) return; + + const grouped = new Map(); + for (const input of filtered) { + const bucket = dayBucket(input.created_at); + grouped.set(bucket, [...(grouped.get(bucket) ?? []), input.snippet]); + } + + for (const [created_at, snippets] of grouped.entries()) { + await deps.store.putSummary({ + rootSessionId, + granularity: "day", + created_at, + body: deps.summarize({ granularity: "day", snippets }), + }); + } + + await deps.store.setWatermark( + rootSessionId, + filtered[filtered.length - 1].created_at, + ); + }, +}); diff --git a/src/services/dream-store.ts b/src/services/dream-store.ts new file mode 100644 index 0000000..1e21875 --- /dev/null +++ b/src/services/dream-store.ts @@ -0,0 +1,145 @@ +import type { NormalizedMemoryResult } from "../types/index.ts"; +import type { RedisClient } from "./redis-client.ts"; + +export type DreamSummaryRecord = { + rootSessionId: string; + granularity: string; + created_at: string; + body: string; +}; + +type StoredDreamSummaryRecord = { + granularity: string; + created_at: string; + body: string; +}; + +const dreamSummariesKey = (rootSessionId: string): string => + `session:${rootSessionId}:dream:summaries`; + +const dreamSummaryField = (record: { + granularity: string; + created_at: string; +}): string => `${record.granularity}:${record.created_at}`; + +const dreamWatermarkKey = (rootSessionId: string): string => + `session:${rootSessionId}:dream:watermark`; + +const normalizeText = (value: string): string => + value.trim().replace(/\s+/g, " "); + +const tokenize = (value: string): string[] => + normalizeText(value).toLowerCase().match(/[a-z0-9]{2,}/g) ?? []; + +const parseStoredRecord = (value: string): StoredDreamSummaryRecord | null => { + try { + const parsed = JSON.parse(value) as Partial; + if ( + typeof parsed.granularity !== "string" || + typeof parsed.created_at !== "string" || + typeof parsed.body !== "string" + ) { + return null; + } + + return { + granularity: parsed.granularity, + created_at: parsed.created_at, + body: parsed.body, + }; + } catch { + return null; + } +}; + +const scoreSummary = ( + record: StoredDreamSummaryRecord, + query?: string, +): number => { + const normalizedQuery = normalizeText(query ?? "").toLowerCase(); + if (!normalizedQuery) return 1; + + const body = normalizeText(record.body).toLowerCase(); + if (body === normalizedQuery) return 1; + if (body.includes(normalizedQuery)) return 0.95; + + const queryTokens = [...new Set(tokenize(normalizedQuery))]; + if (queryTokens.length === 0) return 0; + const matched = queryTokens.filter((token) => body.includes(token)); + if (matched.length === 0) return 0; + return Number((matched.length / queryTokens.length).toFixed(6)); +}; + +export class DreamStore { + constructor(private readonly redis: RedisClient) {} + + async putSummary(record: DreamSummaryRecord): Promise { + await this.redis.setHashFields(dreamSummariesKey(record.rootSessionId), { + [dreamSummaryField(record)]: JSON.stringify( + { + granularity: record.granularity, + created_at: record.created_at, + body: record.body, + } satisfies StoredDreamSummaryRecord, + ), + }); + } + + async getSummariesAround(input: { + rootSessionId: string; + when: string; + query?: string; + }): Promise { + const summaries = Object.values( + await this.redis.getHashAll(dreamSummariesKey(input.rootSessionId)), + ) + .map(parseStoredRecord) + .filter((record): record is StoredDreamSummaryRecord => record !== null) + .map((record) => ({ + record, + score: scoreSummary(record, input.query), + })) + .filter(({ score }) => score > 0) + .sort((left, right) => + left.record.created_at.localeCompare(right.record.created_at) + ); + + const before = summaries.filter(({ record }) => + record.created_at < input.when + ); + const exact = summaries.filter(({ record }) => + record.created_at === input.when + ); + const after = summaries.filter(({ record }) => + record.created_at > input.when + ); + + const selected = [ + ...before.slice(-1), + ...exact, + ...after.slice(0, 1), + ]; + + return selected.map(({ record, score }) => ({ + type: "summary", + ref: + `session:${input.rootSessionId}:summary:dream:${record.granularity}:${record.created_at}`, + snippet: record.body, + score, + id: `${record.granularity}:${record.created_at}`, + root_session_id: input.rootSessionId, + scope: "session", + granularity: record.granularity, + created_at: record.created_at, + source: "dream", + } satisfies NormalizedMemoryResult)); + } + + async getWatermark(rootSessionId: string): Promise { + return await this.redis.getString(dreamWatermarkKey(rootSessionId)); + } + + async setWatermark(rootSessionId: string, value: string): Promise { + await this.redis.setString(dreamWatermarkKey(rootSessionId), value); + } +} diff --git a/src/services/exact-history.ts b/src/services/exact-history.ts new file mode 100644 index 0000000..9175bf2 --- /dev/null +++ b/src/services/exact-history.ts @@ -0,0 +1,13 @@ +import type { NormalizedMemoryResult } from "../types/index.ts"; + +export type ExactHistoryAdapter = { + search(input: { + rootSessionId: string; + query: string; + when: string; + }): Promise; +}; + +export const createExactHistoryAdapter = (): ExactHistoryAdapter => ({ + search: () => Promise.resolve([]), +}); diff --git a/src/services/graphiti-async.ts b/src/services/graphiti-async.ts index 05ba0b4..0909dc3 100644 --- a/src/services/graphiti-async.ts +++ b/src/services/graphiti-async.ts @@ -7,8 +7,8 @@ import type { RedisCacheService } from "./redis-cache.ts"; type TimerHandle = ReturnType | number; type GraphitiAsyncServiceOptions = { - setTimer?: (callback: () => void, delayMs: number) => TimerHandle; - clearTimer?: (timer: TimerHandle) => void; + setTimer?(callback: () => void, delayMs: number): TimerHandle; + clearTimer?(timer: TimerHandle): void; }; export class GraphitiAsyncService { diff --git a/src/services/hot-tier-slice.test.ts b/src/services/hot-tier-slice.test.ts index 3988183..f6af7f1 100644 --- a/src/services/hot-tier-slice.test.ts +++ b/src/services/hot-tier-slice.test.ts @@ -447,11 +447,11 @@ describe("hot-tier vertical slice", () => { assertStringIncludes( transformOutput.messages[0].parts[0].text, - "', ); - assertEquals( - transformOutput.messages[0].parts[0].text.includes("", ); const events = await redisEvents.getRecentSessionEvents( @@ -468,7 +468,7 @@ describe("hot-tier vertical slice", () => { compactOutput as never, ); assertEquals(compactOutput.context.length, 1); - assertStringIncludes(compactOutput.context[0], "'); }); it("keeps chat, transform, and compaction on the cache-only hook path while rendering cached long-term summaries", async () => { @@ -561,7 +561,7 @@ describe("hot-tier vertical slice", () => { assertStringIncludes( transformOutput.messages[0].parts[0].text, - "', ); assertEquals(compactOutput.context.length, 1); assertStringIncludes(compactOutput.context[0], " { "real query", ); assertEquals(primerPrepared?.refreshDecision.classification, "primer-only"); - assertStringIncludes(primerPrepared?.envelope ?? "", "', + ); await redisCache.set("group-1", { query: "older query", @@ -1814,7 +1817,7 @@ describe("hot-tier vertical slice", () => { "older query", ); assertEquals(stalePrepared?.refreshDecision.classification, "stale"); - assertStringIncludes(stalePrepared?.envelope ?? "", "'); assertEquals((stalePrepared?.envelope ?? "").includes("Stale fact"), false); }); @@ -1968,7 +1971,7 @@ describe("hot-tier vertical slice", () => { }]); assertStringIncludes( transformOutput.messages[0].parts[0].text, - "', ); assertEquals( transformOutput.messages[0].parts[0].text.includes( diff --git a/src/services/memory-results.ts b/src/services/memory-results.ts new file mode 100644 index 0000000..53f1175 --- /dev/null +++ b/src/services/memory-results.ts @@ -0,0 +1,34 @@ +import type { NormalizedMemoryResult } from "../types/index.ts"; + +export const compareWeightedResults = ( + left: NormalizedMemoryResult, + right: NormalizedMemoryResult, +): number => { + if (right.score !== left.score) { + return right.score - left.score; + } + if (right.created_at !== left.created_at) { + return right.created_at.localeCompare(left.created_at); + } + return left.ref.localeCompare(right.ref); +}; + +export const orderMemoryResults = ( + results: NormalizedMemoryResult[], + options: { mode: "query" | "reflection" }, +): NormalizedMemoryResult[] => { + if (options.mode === "reflection") { + return results + .filter((result) => result.type === "summary") + .sort((left, right) => left.created_at.localeCompare(right.created_at)); + } + + const primary = results + .filter((result) => result.type === "entry" || result.type === "note") + .sort(compareWeightedResults); + const summaries = results + .filter((result) => result.type === "summary") + .sort(compareWeightedResults); + + return [...primary, ...summaries]; +}; diff --git a/src/services/memory-search.test.ts b/src/services/memory-search.test.ts new file mode 100644 index 0000000..272e8cd --- /dev/null +++ b/src/services/memory-search.test.ts @@ -0,0 +1,300 @@ +import { assertEquals } from "jsr:@std/assert@^1.0.0"; +import { describe, it } from "jsr:@std/testing@^1.0.0/bdd"; + +import { orderMemoryResults } from "./memory-results.ts"; +import { createMemorySearchService } from "./memory-search.ts"; +import type { NormalizedMemoryResult } from "../types/index.ts"; + +const createResult = ( + overrides: Partial, +): NormalizedMemoryResult => ({ + type: "entry", + ref: "memory:default", + snippet: "default snippet", + score: 0.5, + created_at: "2026-04-21T00:00:00.000Z", + id: "memory-default", + root_session_id: "root-1", + scope: "session", + granularity: "turn", + source: "test", + ...overrides, +}); + +describe("memory result ordering", () => { + it("orders query-mode results with entries and notes before summaries", () => { + const results: NormalizedMemoryResult[] = [ + createResult({ + type: "summary", + ref: "memory:summary:top", + score: 1, + created_at: "2026-04-21T12:00:00.000Z", + id: "summary-top", + granularity: "day", + source: "snapshot", + }), + createResult({ + type: "entry", + ref: "memory:entry:newest", + score: 0.9, + created_at: "2026-04-21T11:00:00.000Z", + id: "entry-newest", + granularity: "turn", + source: "opencode-db", + }), + createResult({ + type: "note", + ref: "memory:note:zulu", + score: 0.9, + created_at: "2026-04-21T10:00:00.000Z", + id: "note-zulu", + scope: "local", + granularity: "note", + source: "session-notes", + }), + createResult({ + type: "entry", + ref: "memory:entry:alpha", + score: 0.9, + created_at: "2026-04-21T10:00:00.000Z", + id: "entry-alpha", + granularity: "turn", + source: "opencode-db", + }), + createResult({ + type: "summary", + ref: "memory:summary:older", + score: 0.8, + created_at: "2026-04-20T12:00:00.000Z", + id: "summary-older", + granularity: "day", + source: "snapshot", + }), + ]; + + assertEquals( + orderMemoryResults(results, { mode: "query" }).map((result) => + result.ref + ), + [ + "memory:entry:newest", + "memory:entry:alpha", + "memory:note:zulu", + "memory:summary:top", + "memory:summary:older", + ], + ); + }); + + it("orders reflection-mode summaries in chronological order", () => { + const results: NormalizedMemoryResult[] = [ + createResult({ + type: "summary", + ref: "memory:summary:latest", + created_at: "2026-04-21T12:00:00.000Z", + id: "summary-latest", + granularity: "day", + source: "snapshot", + }), + createResult({ + type: "entry", + ref: "memory:entry:ignored", + created_at: "2026-04-21T11:30:00.000Z", + id: "entry-ignored", + granularity: "turn", + source: "opencode-db", + }), + createResult({ + type: "summary", + ref: "memory:summary:earliest", + created_at: "2026-04-19T08:00:00.000Z", + id: "summary-earliest", + granularity: "day", + source: "snapshot", + }), + createResult({ + type: "note", + ref: "memory:note:ignored", + created_at: "2026-04-20T09:00:00.000Z", + id: "note-ignored", + scope: "project", + granularity: "note", + source: "session-notes", + }), + createResult({ + type: "summary", + ref: "memory:summary:middle", + created_at: "2026-04-20T08:00:00.000Z", + id: "summary-middle", + granularity: "day", + source: "snapshot", + }), + ]; + + assertEquals( + orderMemoryResults(results, { mode: "reflection" }).map((result) => + result.ref + ), + [ + "memory:summary:earliest", + "memory:summary:middle", + "memory:summary:latest", + ], + ); + }); +}); + +describe("createMemorySearchService", () => { + it("reflection mode returns summaries before and after the reference time", async () => { + let exactCalls = 0; + let noteCalls = 0; + let summaryCalls = 0; + + const service = createMemorySearchService({ + exactHistoryAdapter: { + search() { + exactCalls += 1; + return Promise.resolve([ + createResult({ + type: "entry", + ref: "memory:entry:ignored", + created_at: "2026-04-21T11:30:00.000Z", + source: "opencode-db", + }), + ]); + }, + }, + notesService: { + searchNotes() { + noteCalls += 1; + return Promise.resolve([ + { + id: "note-ignored", + root_session_id: "root-1", + scope: "local" as const, + snippet: "ignored note", + score: 0.7, + created_at: "2026-04-21T00:00:00.000Z", + updated_at: "2026-04-21T00:00:00.000Z", + }, + ]); + }, + }, + summarySearchAdapter: { + search() { + summaryCalls += 1; + return Promise.resolve([ + createResult({ + type: "summary", + ref: "memory:summary:before", + created_at: "2026-04-20T12:00:00.000Z", + id: "summary-before", + granularity: "day", + source: "dream", + }), + createResult({ + type: "summary", + ref: "memory:summary:after", + created_at: "2026-04-22T12:00:00.000Z", + id: "summary-after", + granularity: "day", + source: "dream", + }), + ]); + }, + }, + groupId: "group-1", + }); + + const response = await service.search({ + rootSessionId: "root-1", + query: "", + when: "2026-04-21T12:00:00.000Z", + }); + + assertEquals( + response.results.every((item) => item.type === "summary"), + true, + ); + assertEquals( + response.results.some((item) => + item.created_at < "2026-04-21T12:00:00.000Z" + ), + true, + ); + assertEquals( + response.results.some((item) => + item.created_at > "2026-04-21T12:00:00.000Z" + ), + true, + ); + assertEquals(response.results.map((item) => item.ref), [ + "memory:summary:before", + "memory:summary:after", + ]); + assertEquals(exactCalls, 0); + assertEquals(noteCalls, 0); + assertEquals(summaryCalls, 1); + }); + + it("query mode keeps exact hits ahead of shared summary hits", async () => { + const service = createMemorySearchService({ + exactHistoryAdapter: { + search() { + return Promise.resolve([ + createResult({ + type: "entry", + ref: "memory:entry:match", + score: 0.95, + created_at: "2026-04-21T11:00:00.000Z", + source: "opencode-db", + }), + ]); + }, + }, + notesService: { + searchNotes() { + return Promise.resolve([ + { + id: "note-match", + root_session_id: "root-1", + scope: "local" as const, + snippet: "matching note", + score: 0.9, + created_at: "2026-04-21T00:00:00.000Z", + updated_at: "2026-04-21T00:00:00.000Z", + }, + ]); + }, + }, + summarySearchAdapter: { + search() { + return Promise.resolve([ + createResult({ + type: "summary", + ref: "memory:summary:match", + score: 0.8, + created_at: "2026-04-21T12:00:00.000Z", + id: "summary-match", + granularity: "day", + source: "dream", + }), + ]); + }, + }, + groupId: "group-1", + }); + + const response = await service.search({ + rootSessionId: "root-1", + query: "matching", + when: "2026-04-21T12:00:00.000Z", + }); + + assertEquals(response.results.map((item) => item.type), [ + "entry", + "note", + "summary", + ]); + }); +}); diff --git a/src/services/memory-search.ts b/src/services/memory-search.ts new file mode 100644 index 0000000..ddeab83 --- /dev/null +++ b/src/services/memory-search.ts @@ -0,0 +1,94 @@ +import type { NormalizedMemoryResult } from "../types/index.ts"; +import { orderMemoryResults } from "./memory-results.ts"; +import type { ExactHistoryAdapter } from "./exact-history.ts"; +import type { SessionMcpResponseMap } from "./session-mcp-types.ts"; +import type { SessionNotesService } from "./session-notes.ts"; + +export type SummarySearchAdapter = { + search(input: { + rootSessionId: string; + query: string; + when: string; + }): Promise; +}; + +export type MemorySearchService = { + search(input: { + rootSessionId: string; + query: string; + when: string; + }): Promise; +}; + +type MemorySearchServiceOptions = { + exactHistoryAdapter: ExactHistoryAdapter; + notesService: Pick; + summarySearchAdapter: SummarySearchAdapter; + groupId: string; + resultLimit?: number; +}; + +export const createSummarySearchAdapter = (): SummarySearchAdapter => ({ + search: () => Promise.resolve([]), +}); + +const uniqueRefs = (results: NormalizedMemoryResult[]): string[] => [ + ...new Set(results.map((result) => result.ref)), +]; + +export const createMemorySearchService = ( + options: MemorySearchServiceOptions, +): MemorySearchService => { + const resultLimit = options.resultLimit ?? Number.POSITIVE_INFINITY; + + return { + async search(input) { + const summaries = await options.summarySearchAdapter.search(input); + + if (input.query === "") { + const ordered = orderMemoryResults(summaries, { mode: "reflection" }); + const results = ordered.slice(0, resultLimit); + + return { + status: "ok", + results, + refs: uniqueRefs(results), + truncated: ordered.length > results.length, + }; + } + + const [entries, notes] = await Promise.all([ + options.exactHistoryAdapter.search(input), + options.notesService.searchNotes(input.rootSessionId, input.query), + ]); + + const normalizedNotes: NormalizedMemoryResult[] = notes.map((note) => ({ + type: "note", + ref: + `session:${options.groupId}:${note.root_session_id}:note:${note.id}`, + snippet: note.snippet, + score: note.score, + id: note.id, + root_session_id: note.root_session_id, + scope: note.scope, + created_at: note.created_at, + updated_at: note.updated_at, + source: "session-notes", + })); + + const ordered = orderMemoryResults([ + ...entries, + ...normalizedNotes, + ...summaries, + ], { mode: "query" }); + const results = ordered.slice(0, resultLimit); + + return { + status: "ok", + results, + refs: uniqueRefs(results), + truncated: ordered.length > results.length, + }; + }, + }; +}; diff --git a/src/services/opencode-warning.ts b/src/services/opencode-warning.ts index f9a5250..51cb2ca 100644 --- a/src/services/opencode-warning.ts +++ b/src/services/opencode-warning.ts @@ -181,3 +181,9 @@ export const notifyGraphitiAvailabilityIssue = ( ): void => { notifyPluginWarning(message, extra); }; + +export const notifyDreamShutdownDelay = (): void => { + notifyPluginWarning( + "Dreaming is still in progress; keep OpenCode open and wait for dreaming to complete before exiting.", + ); +}; diff --git a/src/services/redis-client.test.ts b/src/services/redis-client.test.ts index 8981a57..1124727 100644 --- a/src/services/redis-client.test.ts +++ b/src/services/redis-client.test.ts @@ -272,15 +272,15 @@ class ObservableDeferredConnectRedisRuntime } async function waitFor( - condition: () => boolean, + condition: () => boolean | Promise, timeoutMs = 200, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - if (condition()) return; + if (await condition()) return; await new Promise((resolve) => setTimeout(resolve, 5)); } - assert(condition(), "condition not met before timeout"); + assert(await condition(), "condition not met before timeout"); } describe("redis client", () => { @@ -458,9 +458,11 @@ describe("redis client", () => { lastQuery: "Continue overhaul", }); - await new Promise((resolve) => setTimeout(resolve, 10)); - - assertEquals(await redis.getHashAll("memory-cache:group-1:meta"), {}); + await waitFor(async () => + Object.keys(await redis.getHashAll("memory-cache:group-1:meta")) + .length === + 0 + ); await redis.close(); }); diff --git a/src/services/redis-snapshot.test.ts b/src/services/redis-snapshot.test.ts new file mode 100644 index 0000000..f51d00a --- /dev/null +++ b/src/services/redis-snapshot.test.ts @@ -0,0 +1,14 @@ +import { assertEquals } from "jsr:@std/assert@^1.0.0"; + +import { createSnapshotSummaryResult } from "./redis-snapshot.ts"; + +Deno.test("createSnapshotSummaryResult keeps session scope separate from temporal granularity", () => { + const result = createSnapshotSummaryResult({ + rootSessionId: "root-1", + created_at: "2026-06-05T00:00:00.000Z", + snippet: "snapshot summary", + }); + + assertEquals(result.scope, "session"); + assertEquals(result.granularity, undefined); +}); diff --git a/src/services/redis-snapshot.ts b/src/services/redis-snapshot.ts index 5c991fa..8c4eb58 100644 --- a/src/services/redis-snapshot.ts +++ b/src/services/redis-snapshot.ts @@ -12,10 +12,32 @@ import { sanitizeMemoryInput, uniqueNormalizedValues, } from "./render-utils.ts"; +import type { NormalizedMemoryResult } from "../types/index.ts"; const SNAPSHOT_BUDGET = 3_000; const BLOCKER_PATTERN = /\b(blocker|blocked|blocking)\b/i; +export const createSnapshotSummaryResult = (input: { + rootSessionId: string; + created_at: string; + snippet: string; + id?: string; + granularity?: string; +}): NormalizedMemoryResult => ({ + type: "summary", + ref: `session:${input.rootSessionId}:summary:snapshot:${ + input.id ?? input.created_at + }`, + snippet: input.snippet, + score: 1, + created_at: input.created_at, + id: input.id ?? input.created_at, + root_session_id: input.rootSessionId, + scope: "session", + granularity: input.granularity, + source: "snapshot", +}); + const selectRecent = ( events: SessionEvent[], predicate: (event: SessionEvent) => boolean, diff --git a/src/services/runtime-teardown.test.ts b/src/services/runtime-teardown.test.ts index 2e33ea4..1d6d77b 100644 --- a/src/services/runtime-teardown.test.ts +++ b/src/services/runtime-teardown.test.ts @@ -1,8 +1,26 @@ -import { assertEquals, assertRejects } from "jsr:@std/assert@^1.0.0"; +import { assert, assertEquals, assertRejects } from "jsr:@std/assert@^1.0.0"; import { describe, it } from "jsr:@std/testing@^1.0.0/bdd"; import { logger } from "./logger.ts"; import { registerRuntimeTeardown } from "./runtime-teardown.ts"; +const runtimeTeardownModuleUrl = new URL( + "./runtime-teardown.ts", + import.meta.url, +).href; + +const writeRuntimeTeardownScript = async (source: string): Promise => { + const scriptPath = await Deno.makeTempFile({ + prefix: "runtime-teardown-", + suffix: ".ts", + }); + await Deno.writeTextFile(scriptPath, source); + return scriptPath; +}; + +const cleanupTempScript = async (scriptPath: string): Promise => { + await Deno.remove(scriptPath).catch(() => undefined); +}; + describe("runtime teardown", () => { it("runs teardown tasks only once even when invoked repeatedly", async () => { const calls: string[] = []; @@ -538,4 +556,162 @@ describe("runtime teardown", () => { logger.warn = originalWarn; } }); + + it("waits for registered teardown completion before exiting after SIGINT in a live runtime", async () => { + const teardownDelayMs = 150; + const scriptPath = await writeRuntimeTeardownScript(` +import { registerRuntimeTeardown } from ${ + JSON.stringify(runtimeTeardownModuleUrl) + }; + +registerRuntimeTeardown([ + { + name: "proof", + run: async () => { + await new Promise((resolve) => setTimeout(resolve, ${teardownDelayMs})); + console.log("teardown-run"); + }, + }, +]); + +console.log("ready"); +setInterval(() => {}, 1_000); +`); + + try { + const child = new Deno.Command(Deno.execPath(), { + args: ["run", "-A", scriptPath], + stdout: "piped", + stderr: "piped", + }).spawn(); + + // Wait for the child to signal it is ready before sending SIGINT so that + // the signal handler is guaranteed to be registered. + const decoder = new TextDecoder(); + const stdoutChunks: Uint8Array[] = []; + const stdoutReader = child.stdout.getReader(); + let accumulated = ""; + while (!accumulated.includes("ready")) { + const { value, done } = await stdoutReader.read(); + if (done) break; + stdoutChunks.push(value); + accumulated += decoder.decode(value, { stream: true }); + } + stdoutReader.releaseLock(); + + const shutdownStartedAt = Date.now(); + child.kill("SIGINT"); + + // Drain remaining stdout and stderr after the kill. + const [remainingStdout, stderrBytes] = await Promise.all([ + (async () => { + const remaining: Uint8Array[] = []; + const reader = child.stdout.getReader(); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + remaining.push(value); + } + } finally { + reader.releaseLock(); + } + return remaining; + })(), + (async () => { + const chunks: Uint8Array[] = []; + const reader = child.stderr.getReader(); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + return chunks; + })(), + ]); + + const { code } = await child.status; + const elapsedMs = Date.now() - shutdownStartedAt; + + const allStdout = [...stdoutChunks, ...remainingStdout]; + const totalLen = allStdout.reduce((n, c) => n + c.length, 0); + const merged = new Uint8Array(totalLen); + let off = 0; + for (const chunk of allStdout) { + merged.set(chunk, off); + off += chunk.length; + } + const output = decoder.decode(merged); + const stderrMergedLen = stderrBytes.reduce((n, c) => n + c.length, 0); + const stderrMerged = new Uint8Array(stderrMergedLen); + let sOff = 0; + for (const chunk of stderrBytes) { + stderrMerged.set(chunk, sOff); + sOff += chunk.length; + } + const errorOutput = decoder.decode(stderrMerged); + + assertEquals(code, 130); + assert(output.includes("ready")); + assert(output.includes("teardown-run")); + assert( + elapsedMs >= teardownDelayMs - 25, + `expected SIGINT shutdown to wait about ${teardownDelayMs}ms, got ${elapsedMs}ms\nstdout:\n${output}\nstderr:\n${errorOutput}`, + ); + } finally { + await cleanupTempScript(scriptPath); + } + }); + + it("waits for registered teardown completion on the beforeExit path in a live node-style runtime", async () => { + const teardownDelayMs = 150; + const scriptPath = await writeRuntimeTeardownScript(` +import process from "node:process"; +import { registerRuntimeTeardown } from ${ + JSON.stringify(runtimeTeardownModuleUrl) + }; + +registerRuntimeTeardown([ + { + name: "proof", + run: async () => { + await new Promise((resolve) => setTimeout(resolve, ${teardownDelayMs})); + console.log("teardown-run"); + }, + }, +], { process }); + +console.log("ready"); +`); + + try { + const startedAt = Date.now(); + const { code, signal, stdout, stderr } = await new Deno.Command( + Deno.execPath(), + { + args: ["run", "-A", scriptPath], + stdout: "piped", + stderr: "piped", + }, + ).output(); + const elapsedMs = Date.now() - startedAt; + const output = new TextDecoder().decode(stdout); + const errorOutput = new TextDecoder().decode(stderr); + + assertEquals(code, 0); + assertEquals(signal, null); + assert(output.includes("ready")); + assert(output.includes("teardown-run")); + assert( + elapsedMs >= teardownDelayMs - 25, + `expected beforeExit shutdown to wait about ${teardownDelayMs}ms, got ${elapsedMs}ms\nstdout:\n${output}\nstderr:\n${errorOutput}`, + ); + } finally { + await cleanupTempScript(scriptPath); + } + }); }); diff --git a/src/services/runtime-teardown.ts b/src/services/runtime-teardown.ts index 103e1ec..f339b7e 100644 --- a/src/services/runtime-teardown.ts +++ b/src/services/runtime-teardown.ts @@ -5,6 +5,11 @@ export type RuntimeTeardownTask = { run: () => void | Promise; }; +export const createRuntimeTeardownTask = ( + name: string, + run: () => void | Promise, +): RuntimeTeardownTask => ({ name, run }); + export interface RuntimeTeardownRegistration { run(): Promise; dispose(): void; @@ -108,8 +113,11 @@ export function registerRuntimeTeardown( if (signalListenersDisposed) return; signalListenersDisposed = true; for (const { signal, handler } of signalListeners) { - runtime.Deno?.removeSignalListener?.(signal, handler); - runtime.process?.off?.(signal, handler); + if (runtime.Deno?.removeSignalListener) { + runtime.Deno.removeSignalListener(signal, handler); + } else { + runtime.process?.off?.(signal, handler); + } } for (const { event, handler } of processEventListeners) { runtime.process?.off?.(event, handler); @@ -221,8 +229,11 @@ export function registerRuntimeTeardown( beginGracefulShutdown({ kind: "signal", signal }); }; - runtime.Deno?.addSignalListener?.(signal, handler); - runtime.process?.on?.(signal, handler); + if (runtime.Deno?.addSignalListener) { + runtime.Deno.addSignalListener(signal, handler); + } else { + runtime.process?.on?.(signal, handler); + } signalListeners.push({ signal, handler }); } diff --git a/src/services/session-executor.ts b/src/services/session-executor.ts index fcdee9e..c9d2957 100644 --- a/src/services/session-executor.ts +++ b/src/services/session-executor.ts @@ -19,9 +19,19 @@ type SessionExecuteResponse = SessionMcpResponseMap["session_execute"]; type SessionExecuteFileResponse = SessionMcpResponseMap["session_execute_file"]; type SessionBatchExecuteResponse = SessionMcpResponseMap["session_batch_execute"]; -type SessionExecuteRequest = SessionMcpRequestMap["session_execute"]; -type SessionExecuteFileRequest = SessionMcpRequestMap["session_execute_file"]; -type SessionBatchExecuteRequest = SessionMcpRequestMap["session_batch_execute"]; +type SessionExecuteRequest = SessionMcpRequestMap["session_execute"] & { + root_session_id: string; +}; +type SessionExecuteFileRequest = + & SessionMcpRequestMap["session_execute_file"] + & { + root_session_id: string; + }; +type SessionBatchExecuteRequest = + & SessionMcpRequestMap["session_batch_execute"] + & { + root_session_id: string; + }; export type SessionExecutorContext = { worktree?: string; diff --git a/src/services/session-mcp-runtime.test.ts b/src/services/session-mcp-runtime.test.ts index 7fb1f47..a26a970 100644 --- a/src/services/session-mcp-runtime.test.ts +++ b/src/services/session-mcp-runtime.test.ts @@ -9,6 +9,10 @@ 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, + SESSION_SEARCH_STRENGTHENED_DESCRIPTION, } from "./session-mcp-runtime.ts"; import type { SessionExecutor } from "./session-executor.ts"; import { @@ -21,6 +25,15 @@ import { RedisClient } from "./redis-client.ts"; import { SessionManager } from "../session.ts"; import type { RedisEvent } from "./test-helpers.ts"; +const createSearchResult = (overrides: Record) => ({ + ref: "session:root:summary:default", + snippet: "default snippet", + score: 0.5, + type: "summary", + created_at: "2026-04-21T00:00:00.000Z", + ...overrides, +}); + class DoctorRedisRuntime { private readonly hashes = new Map>(); private readonly listeners = new Map< @@ -145,6 +158,21 @@ class DoctorRedisRuntime { const textEncoder = new TextEncoder(); +const createOversizedSessionNoteText = (): string => { + const timestamp = "2026-04-11T10:00:00.000Z"; + const emptyPayloadBytes = textEncoder.encode(JSON.stringify({ + note: { + id: crypto.randomUUID(), + text: "", + created_at: timestamp, + updated_at: timestamp, + }, + })).byteLength; + return "x".repeat( + SESSION_MCP_RESPONSE_BUDGET_BYTES - emptyPayloadBytes + 1, + ); +}; + const toolContext = { sessionID: "session-123", messageID: "message-123", @@ -166,42 +194,200 @@ 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", command: "pwd", }, session_execute_file: { - root_session_id: "root-123", paths: ["README.md"], }, session_batch_execute: { - root_session_id: "root-123", commands: [{ command: "first" }, { command: "second" }], }, session_index: { - root_session_id: "root-123", content: "hello world", }, session_search: { - root_session_id: "root-123", query: "hello", }, session_fetch_and_index: { - root_session_id: "root-123", url: "https://example.com", }, - session_stats: { - root_session_id: "root-123", + session_stats: {}, + session_doctor: {}, + session_notes_write: { + text: "remember this", }, - session_doctor: { - root_session_id: "root-123", + session_notes_read: { + id: "note-1", }, }; -Deno.test("mixed|batch schema compatibility", () => { - const request = sessionMcpRequestSchemas.session_batch_execute.safeParse({ +it("rejects caller-supplied root_session_id for every public session request schema", () => { + for (const toolName of SESSION_MCP_TOOL_NAMES) { + const valid = sessionMcpRequestSchemas[toolName].safeParse( + validRequests[toolName], + ); + const rejected = sessionMcpRequestSchemas[toolName].safeParse({ + ...validRequests[toolName], + root_session_id: "root-123", + }); + + assertEquals( + valid.success, + true, + `${toolName} should accept rootless input`, + ); + assertEquals( + rejected.success, + false, + `${toolName} should reject caller-supplied root_session_id`, + ); + } +}); + +it("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); +}); + +it("session_search schema accepts query mode with optional when", () => { + const queryRequest = sessionMcpRequestSchemas.session_search.safeParse({ + query: "memory redesign", + when: "2026-04-21T12:00:00.000Z", + }); + const reflectionRequest = sessionMcpRequestSchemas.session_search.safeParse({ + query: "", + when: "2026-04-21T12:00:00.000Z", + }); + const rejectedRequest = sessionMcpRequestSchemas.session_search.safeParse({ root_session_id: "root-123", + query: "memory redesign", + }); + const accepted = sessionMcpResponseSchemas.session_search.safeParse({ + status: "ok", + results: [ + { + ref: "session:root:entry:turn-1", + snippet: "Use opencode db as exact truth.", + score: 0.95, + type: "entry", + id: "turn-1", + created_at: "2026-04-21T11:00:00.000Z", + updated_at: "2026-04-21T11:05:00.000Z", + root_session_id: "root-123", + scope: "session", + source: "opencode-db", + }, + { + ref: "session:root:note:note-1", + snippet: "Remember to keep summary injection lightweight.", + score: 0.87, + type: "note", + id: "note-1", + created_at: "2026-04-21T10:00:00.000Z", + updated_at: "2026-04-21T10:10:00.000Z", + root_session_id: "root-123", + scope: "local", + source: "session-notes", + }, + { + ref: "session:root:summary:day:2026-04-21", + snippet: "Recent design work moved exact recall to session_search().", + score: 0.81, + type: "summary", + created_at: "2026-04-21T00:00:00.000Z", + granularity: "day", + source: "snapshot", + scope: "session", + }, + ], + refs: [ + "session:root:entry:turn-1", + "session:root:note:note-1", + "session:root:summary:day:2026-04-21", + ], + truncated: false, + }); + const rejected = sessionMcpResponseSchemas.session_search.safeParse({ + status: "ok", + results: [{ + ref: "session:root:entry:turn-1", + snippet: "Use opencode db as exact truth.", + score: 0.95, + type: "entry", + created_at: "2026-04-21T11:00:00.000Z", + corpus_ref: "session:root:corpus:1", + }], + refs: ["session:root:entry:turn-1"], + truncated: false, + }); + + assertEquals(queryRequest.success, true); + assertEquals(reflectionRequest.success, true); + assertEquals(rejectedRequest.success, false); + assertEquals(accepted.success, true); + assertEquals(rejected.success, false); +}); + +it("mixed|batch schema compatibility", () => { + const request = sessionMcpRequestSchemas.session_batch_execute.safeParse({ steps: [ { kind: "command", command: "pwd" }, { kind: "search", query: "session continuity" }, @@ -228,12 +414,17 @@ Deno.test("mixed|batch schema compatibility", () => { status: "ok", results: [ { - corpus_ref: "session:root:corpus:1", + ref: "session:root:summary:day:2026-04-21", snippet: "session continuity", score: 0.9, + type: "summary", + created_at: "2026-04-21T00:00:00.000Z", + granularity: "day", + source: "snapshot", + scope: "session", }, ], - corpus_refs: ["session:root:corpus:1"], + refs: ["session:root:summary:day:2026-04-21"], truncated: false, }, }, @@ -241,62 +432,986 @@ Deno.test("mixed|batch schema compatibility", () => { truncated: false, }); - assertEquals(request.success, true); - if (request.success) { - assertEquals(request.data.commands.length, 1); - assertEquals(request.data.commands[0]?.command, "pwd"); - assertEquals(request.data.steps, [ - { kind: "command", command: "pwd" }, - { kind: "search", query: "session continuity" }, - ]); - } + assertEquals(request.success, true); + if (request.success) { + assertEquals(request.data.commands.length, 1); + assertEquals(request.data.commands[0]?.command, "pwd"); + assertEquals(request.data.steps, [ + { kind: "command", command: "pwd" }, + { kind: "search", query: "session continuity" }, + ]); + } + + assertEquals(response.success, true); +}); + +it("index schema compatibility accepts critical request fields", () => { + const inlineRequest = sessionMcpRequestSchemas.session_index.safeParse({ + content: "hello world", + }); + const pathRequest = sessionMcpRequestSchemas.session_index.safeParse({ + path: "docs/notes.md", + }); + const metadataRequest = sessionMcpRequestSchemas.session_index.safeParse({ + content: "hello world", + source: "local-file", + label: "notes", + }); + + assertEquals(inlineRequest.success, true); + assertEquals(pathRequest.success, true); + assertEquals(metadataRequest.success, true); + if (metadataRequest.success) { + assertEquals(metadataRequest.data.source, "local-file"); + assertEquals(metadataRequest.data.label, "notes"); + } +}); + +it("index schema compatibility rejects requests without content or path", () => { + const request = sessionMcpRequestSchemas.session_index.safeParse({ + source: "local-file", + label: "notes", + }); + + assertEquals(request.success, false); +}); + +describe("session-mcp-runtime", () => { + it("returns entry and note hits before summary hits in query mode", async () => { + const runtime = createSessionMcpRuntime({ + groupId: "group-memory-search-query", + notesService: { + searchNotes: () => + Promise.resolve([{ + id: "note-1", + root_session_id: "root-memory-search", + scope: "local", + snippet: "Pinned note hit", + score: 0.89, + created_at: "2026-04-21T00:00:00.000Z", + updated_at: "2026-04-21T00:00:00.000Z", + }]), + }, + exactHistoryAdapter: { + search: () => + Promise.resolve([ + createSearchResult({ + ref: "session:root:entry:turn-1", + snippet: "Exact entry hit", + score: 0.92, + type: "entry", + id: "turn-1", + root_session_id: "root-memory-search", + scope: "session", + source: "opencode-db", + updated_at: "2026-04-21T11:05:00.000Z", + created_at: "2026-04-21T11:00:00.000Z", + }), + ]), + }, + summarySearchAdapter: { + search: () => + Promise.resolve([ + createSearchResult({ + ref: "session:root:summary:day:2026-04-21", + snippet: "Recent summary hit", + score: 0.99, + type: "summary", + scope: "session", + source: "snapshot", + granularity: "day", + }), + ]), + }, + } as never); + + try { + const parsed = JSON.parse( + await runtime.tools.session_search.execute( + { query: "memory redesign" }, + createRootToolContext("root-memory-search"), + ), + ); + + assertEquals(parsed.status, "ok"); + assertEquals( + parsed.results.map((result: { type: string }) => result.type), + [ + "entry", + "note", + "summary", + ], + ); + } finally { + await runtime.dispose(); + } + }); + + it("returns summaries only for reflection mode", async () => { + const runtime = createSessionMcpRuntime({ + groupId: "group-memory-search-reflection", + notesService: { + searchNotes: () => + Promise.resolve([{ + id: "note-ignored", + root_session_id: "root-memory-search", + scope: "local", + snippet: "Ignored note hit", + score: 1, + created_at: "2026-04-21T00:00:00.000Z", + updated_at: "2026-04-21T00:00:00.000Z", + }]), + }, + exactHistoryAdapter: { + search: () => + Promise.resolve([ + createSearchResult({ + ref: "session:root:entry:turn-2", + snippet: "Ignored entry hit", + score: 1, + type: "entry", + id: "turn-2", + root_session_id: "root-memory-search", + scope: "session", + source: "opencode-db", + created_at: "2026-04-21T11:00:00.000Z", + }), + ]), + }, + summarySearchAdapter: { + search: () => + Promise.resolve([ + createSearchResult({ + ref: "session:root:summary:day:2026-04-20", + snippet: "Older summary", + score: 0.2, + type: "summary", + scope: "session", + source: "snapshot", + granularity: "day", + created_at: "2026-04-20T00:00:00.000Z", + }), + createSearchResult({ + ref: "session:root:summary:day:2026-04-21", + snippet: "Newer summary", + score: 0.9, + type: "summary", + scope: "session", + source: "snapshot", + granularity: "day", + created_at: "2026-04-21T00:00:00.000Z", + }), + ]), + }, + } as never); + + try { + const parsed = JSON.parse( + await runtime.tools.session_search.execute( + { query: "" }, + createRootToolContext("root-memory-search"), + ), + ); + + assertEquals(parsed.status, "ok"); + assertEquals( + parsed.results.map((result: { type: string }) => result.type), + [ + "summary", + "summary", + ], + ); + assertEquals( + parsed.results.map((result: { ref: string }) => result.ref), + [ + "session:root:summary:day:2026-04-20", + "session:root:summary:day:2026-04-21", + ], + ); + } finally { + await runtime.dispose(); + } + }); + + it("passes when through the canonical runtime search path", async () => { + const calls: Array<{ rootSessionId: string; query: string; when: string }> = + []; + const manager = new SessionManager( + "group-memory-search-when", + "user-memory-search-when", + { + 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({ + sessionCanonicalizer: manager, + notesService: { + searchNotes: () => Promise.resolve([]), + }, + exactHistoryAdapter: { + search: (input: { + rootSessionId: string; + query: string; + when: string; + }) => { + calls.push(input); + return Promise.resolve([]); + }, + }, + summarySearchAdapter: { + search: (input: { + rootSessionId: string; + query: string; + when: string; + }) => { + calls.push(input); + return Promise.resolve([]); + }, + }, + } as never); + + try { + await runtime.tools.session_search.execute( + { + query: "carry context forward", + when: "2026-04-21T12:00:00.000Z", + }, + { + ...toolContext, + sessionID: "child-session", + }, + ); + + assertEquals(calls, [ + { + rootSessionId: "root-session", + query: "carry context forward", + when: "2026-04-21T12:00:00.000Z", + }, + { + rootSessionId: "root-session", + query: "carry context forward", + when: "2026-04-21T12:00:00.000Z", + }, + ]); + } finally { + await runtime.dispose(); + } + }); + + it("registers exactly the session tools in the declared order", () => { + const runtime = createSessionMcpRuntime(); + + try { + assertEquals(Object.keys(runtime.tools), [...SESSION_MCP_TOOL_NAMES]); + } finally { + void runtime.dispose(); + } + }); + + 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`, `scope: "local" | "project"`, `created_at`, and', + ); + 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, + ); + for ( + const description of [ + runtime.tools.session_execute.description, + runtime.tools.session_execute_file.description, + runtime.tools.session_batch_execute.description, + runtime.tools.session_index.description, + runtime.tools.session_search.description, + runtime.tools.session_fetch_and_index.description, + runtime.tools.session_stats.description, + runtime.tools.session_doctor.description, + runtime.tools.session_notes_write.description, + runtime.tools.session_notes_read.description, + ] + ) { + assertStringIncludes( + description, + "Do not pass `root_session_id`; the runtime resolves the current canonical", + ); + assertStringIncludes( + description, + "root session automatically.", + ); + } + 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", + "when", + ]); + } finally { + void runtime.dispose(); + } + }); + + it("pins the cross-tool continuity protocol language in shipped descriptions", () => { + const search = SESSION_SEARCH_BASELINE_DESCRIPTION.toLowerCase(); + const strengthened = SESSION_SEARCH_STRENGTHENED_DESCRIPTION.toLowerCase(); + const read = SESSION_NOTES_READ_DESCRIPTION.toLowerCase(); + const write = SESSION_NOTES_WRITE_DESCRIPTION.toLowerCase(); + + // session_search should bias agents toward search-first recall, especially + // at the start of a new session or after compaction, and should explicitly + // chain to session_notes_read for note hits. + assertStringIncludes(search, "first"); + assertStringIncludes(search, "after compaction"); + assertStringIncludes(search, "session_notes_read"); + assertStringIncludes(search, "session_notes_write"); + + // The strengthened overlay (used on new sessions and post-compaction turns) + // must keep the strong recommendation and still chain to session_notes_read. + assertStringIncludes(strengthened, "new session"); + assertStringIncludes(strengthened, "post-compaction"); + assertStringIncludes(strengthened, "strongly recommended"); + assertStringIncludes(strengthened, "session_search"); + assertStringIncludes(strengthened, "session_notes_read"); + + // session_notes_read should reinforce that it is the second step after + // session_search and that new-session/post-compaction are recall moments. + // It must also tell agents that progress updates use non-empty text so + // they don't accidentally delete via empty-text replace. + assertStringIncludes(read, "session_search"); + assertStringIncludes(read, "after compaction"); + assertStringIncludes(read, "non-empty"); + // Pin the empty-text deletion footgun warning on the read description so + // future edits cannot silently drop it. Use small lowercase-normalized + // fragments rather than exact sentence/casing. + assertStringIncludes(read, "empty `text`"); + assertStringIncludes(read, "delete"); + assertStringIncludes(read, "fully"); + assertStringIncludes(read, "complete"); + + // session_notes_write should encode the full lifecycle protocol: + // start -> search/read existing then create with checklist; + // sub-task done -> upsert; stop mid-task -> update before reporting; + // ~75% context counts as stopping mid-task; complete -> clear (delete) + // only when fully done, before reporting back. + for ( + const phrase of [ + // Step 1: search before creating, then create checklist. + "session_search", + "session_notes_read", + "checklist", + // Step 2: upsert with id. + "replace: ", + "non-empty", + // Step 3: stop mid-task / approaching context limit. + "mid-task", + "before reporting back", + "75%", + // Step 4: clear only when fully complete; clearing == delete. + "fully", + "complete", + "empty `text`", + "trivial", + "evergreen", + "learnings", + "stale facts", + "prior sessions", + "same-project", + ] + ) { + assertStringIncludes(write, phrase.toLowerCase()); + } + }); + + 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("rejects oversized note writes before storage and suggests splitting notes", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + } as never); + const oversizedText = createOversizedSessionNoteText(); + + try { + await assertRejects( + () => + runtime.tools.session_notes_write.execute( + { + text: oversizedText, + }, + toolContext, + ), + Error, + "multiple cross-referencing session notes", + ); + } finally { + await runtime.dispose(); + } + }); + + it("applies the shared response budget guard to session_notes_read", async () => { + const oversizedText = "x".repeat(SESSION_MCP_RESPONSE_BUDGET_BYTES + 1_024); + const runtime = createSessionMcpRuntime({ + notesService: { + readNote: () => + Promise.resolve({ + note: { + id: "note-oversized", + text: oversizedText, + created_at: "2026-04-11T10:00:00.000Z", + updated_at: "2026-04-11T10:00:00.000Z", + }, + }), + } as never, + } as never); + + try { + await assertRejects( + () => + runtime.tools.session_notes_read.execute( + { id: "note-oversized" }, + toolContext, + ), + Error, + `session_notes_read response exceeded ${SESSION_MCP_RESPONSE_BUDGET_BYTES} bytes`, + ); + } 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( + { + 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); - assertEquals(response.success, true); -}); + try { + await runtime.tools.session_index.execute( + { + 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"), + ), + ); -Deno.test("index schema compatibility accepts critical request fields", () => { - const inlineRequest = sessionMcpRequestSchemas.session_index.safeParse({ - root_session_id: "root-123", - content: "hello world", - }); - const pathRequest = sessionMcpRequestSchemas.session_index.safeParse({ - root_session_id: "root-123", - path: "docs/notes.md", - }); - const metadataRequest = sessionMcpRequestSchemas.session_index.safeParse({ - root_session_id: "root-123", - content: "hello world", - source: "local-file", - label: "notes", + 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 summaryHit = parsed.results.find((result: { type?: string }) => + result.type === "summary" + ); + + assertEquals( + sessionMcpResponseSchemas.session_search.safeParse(parsed).success, + true, + ); + assertExists(noteHit); + assertExists(summaryHit); + assertEquals(noteHit.id, created.id); + assertEquals(noteHit.root_session_id, "root-note-search"); + assertEquals(noteHit.scope, "local"); + assertStringIncludes(noteHit.ref, created.id); + assertStringIncludes( + noteHit.snippet, + "Redis TTL bug active bug mitigation", + ); + assertStringIncludes( + runtime.tools.session_search.description, + "session_notes_read", + ); + assertEquals(summaryHit.type, "summary"); + assertEquals( + parsed.results.findIndex((result: { type?: string }) => + result.type === "note" + ) < parsed.results.findIndex((result: { type?: string }) => + result.type === "summary" + ), + true, + ); + assertEquals( + parsed.results.some((result: { type?: string }) => + result.type === "note" + ), + true, + ); + assertEquals( + parsed.results.some((result: { type?: string }) => + result.type === "summary" + ), + true, + ); + } finally { + await runtime.dispose(); + } }); - assertEquals(inlineRequest.success, true); - assertEquals(pathRequest.success, true); - assertEquals(metadataRequest.success, true); - if (metadataRequest.success) { - assertEquals(metadataRequest.data.source, "local-file"); - assertEquals(metadataRequest.data.label, "notes"); - } -}); + it("note hits from session_search include created_at and updated_at strings", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + groupId: "group-note-timestamps", + } as never); -Deno.test("index schema compatibility rejects requests without content or path", () => { - const request = sessionMcpRequestSchemas.session_index.safeParse({ - root_session_id: "root-123", - source: "local-file", - label: "notes", - }); + try { + await runtime.tools.session_notes_write.execute( + { text: "timestamp freshness contract note for search" }, + createRootToolContext("root-note-timestamps"), + ); - assertEquals(request.success, false); -}); + const serialized = await runtime.tools.session_search.execute( + { query: "timestamp freshness contract" }, + createRootToolContext("root-note-timestamps"), + ); + const parsed = JSON.parse(serialized); + const noteHit = parsed.results.find( + (result: { type?: string }) => result.type === "note", + ); -describe("session-mcp-runtime", () => { - it("registers exactly the 8 session tools", () => { - const runtime = createSessionMcpRuntime(); + assertExists(noteHit); + assertEquals(typeof noteHit.created_at, "string"); + assertEquals(typeof noteHit.updated_at, "string"); + assert(noteHit.created_at.length > 0); + assert(noteHit.updated_at.length > 0); + } 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 { - assertEquals(Object.keys(runtime.tools), [...SESSION_MCP_TOOL_NAMES]); + await runtime.tools.session_index.execute( + { + 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 { - void runtime.dispose(); + await runtime.dispose(); } }); @@ -378,19 +1493,18 @@ describe("session-mcp-runtime", () => { } }); - it("rejects requests without root_session_id for every tool schema", () => { + it("keeps root_session_id private for all public session request schemas", () => { for (const toolName of SESSION_MCP_TOOL_NAMES) { - const request = { ...validRequests[toolName] }; - delete request.root_session_id; + const parsed = sessionMcpRequestSchemas[toolName].safeParse( + validRequests[toolName], + ); - const parsed = sessionMcpRequestSchemas[toolName].safeParse(request); - assertEquals(parsed.success, false, toolName); + assertEquals(parsed.success, true, toolName); } }); it("accepts mixed batch step requests via steps and normalizes them internally", () => { const parsed = sessionMcpRequestSchemas.session_batch_execute.safeParse({ - root_session_id: "root-123", steps: [ { kind: "command", command: "pwd" }, { kind: "search", query: "session continuity" }, @@ -412,7 +1526,6 @@ describe("session-mcp-runtime", () => { it("accepts legacy batch commands input and normalizes it to mixed steps", () => { const parsed = sessionMcpRequestSchemas.session_batch_execute.safeParse({ - root_session_id: "root-123", commands: [ { command: "first" }, { command: "second", timeout_seconds: 5 }, @@ -435,13 +1548,11 @@ describe("session-mcp-runtime", () => { it("rejects empty batch requests", () => { const emptySteps = sessionMcpRequestSchemas.session_batch_execute.safeParse( { - root_session_id: "root-123", steps: [], }, ); const emptyCommands = sessionMcpRequestSchemas.session_batch_execute .safeParse({ - root_session_id: "root-123", commands: [], }); @@ -451,7 +1562,6 @@ describe("session-mcp-runtime", () => { it("rejects unknown mixed batch step kinds", () => { const parsed = sessionMcpRequestSchemas.session_batch_execute.safeParse({ - root_session_id: "root-123", steps: [ { kind: "command", command: "pwd" }, { kind: "unknown", query: "session continuity" }, @@ -483,12 +1593,15 @@ describe("session-mcp-runtime", () => { status: "ok", results: [ { - corpus_ref: "session:root:corpus:1", + ref: "session:root:summary:day:2026-04-21", snippet: "session continuity", score: 0.9, + type: "summary", + created_at: "2026-04-21T00:00:00.000Z", + granularity: "day", }, ], - corpus_refs: ["session:root:corpus:1"], + refs: ["session:root:summary:day:2026-04-21"], truncated: false, }, }, @@ -525,7 +1638,7 @@ describe("session-mcp-runtime", () => { } }); - it("rejects schema-valid caller/root mismatches before handler execution", async () => { + it("rejects caller-supplied root_session_id before handler execution", async () => { const manager = new SessionManager( "group-runtime-mismatch", "user-runtime-mismatch", @@ -575,7 +1688,7 @@ describe("session-mcp-runtime", () => { }, ), Error, - "root_session_id mismatch", + "root_session_id", ); assertEquals(handlerCalls, 0); } finally { @@ -608,7 +1721,6 @@ describe("session-mcp-runtime", () => { try { const serialized = await runtime.tools.session_search.execute( { - root_session_id: "root-session", query: "indexed", }, { @@ -660,9 +1772,7 @@ describe("session-mcp-runtime", () => { try { const provisionalSerialized = await runtime.tools.session_stats.execute( - { - root_session_id: "child-session", - }, + {}, { ...toolContext, sessionID: "child-session", @@ -672,9 +1782,7 @@ describe("session-mcp-runtime", () => { assertEquals(provisional.status, "ok"); const canonicalSerialized = await runtime.tools.session_stats.execute( - { - root_session_id: "parent-session", - }, + {}, { ...toolContext, sessionID: "child-session", @@ -686,16 +1794,14 @@ describe("session-mcp-runtime", () => { await assertRejects( () => runtime.tools.session_stats.execute( - { - root_session_id: "child-session", - }, + { root_session_id: "child-session" }, { ...toolContext, sessionID: "child-session", }, ), Error, - "root_session_id mismatch", + "root_session_id", ); } finally { await runtime.dispose(); @@ -724,9 +1830,7 @@ describe("session-mcp-runtime", () => { try { const serialized = await runtime.tools.session_stats.execute( - { - root_session_id: "session-123", - }, + {}, toolContext, ); const parsed = JSON.parse(serialized); @@ -759,9 +1863,7 @@ describe("session-mcp-runtime", () => { try { const uncheckedSerialized = await runtime.tools.session_stats.execute( - { - root_session_id: "wrong-root", - }, + {}, { ...toolContext, sessionID: "child-session", @@ -774,16 +1876,14 @@ describe("session-mcp-runtime", () => { await assertRejects( () => runtime.tools.session_stats.execute( - { - root_session_id: "wrong-root", - }, + { root_session_id: "wrong-root" }, { ...toolContext, sessionID: "child-session", }, ), Error, - "root_session_id mismatch", + "root_session_id", ); } finally { await runtime.dispose(); @@ -951,33 +2051,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); @@ -1030,7 +2131,7 @@ 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 artifactKeys = await redis.keysByPrefix( @@ -1072,12 +2173,12 @@ 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 statsSerialized = await runtime.tools.session_stats.execute( validRequests.session_stats, - toolContext, + createRootToolContext("root-123"), ); const stats = JSON.parse(statsSerialized); const artifactKeys = await redis.keysByPrefix( @@ -1096,7 +2197,7 @@ describe("session-mcp-runtime", () => { } }); - it("caps serialized responses to the exact 8 KB budget", async () => { + it("caps serialized responses to the exact 32 KB budget", async () => { const runtime = createSessionMcpRuntime(); try { @@ -1117,7 +2218,7 @@ describe("session-mcp-runtime", () => { } }); - it("falls back to a local artifact reference when inline output crosses 8 KB", async () => { + it("falls back to a local artifact reference when inline output crosses 32 KB", async () => { const runtime = createSessionMcpRuntime({ handlers: { session_execute: () => @@ -1135,7 +2236,7 @@ describe("session-mcp-runtime", () => { try { const serialized = await runtime.tools.session_execute.execute( validRequests.session_execute, - toolContext, + createRootToolContext("root-123"), ); const parsed = JSON.parse(serialized); @@ -1175,14 +2276,13 @@ describe("session-mcp-runtime", () => { try { const serialized = await runtime.tools.session_batch_execute.execute( { - root_session_id: "root-123", commands: [ { command: "first" }, { command: "second" }, { command: "third" }, ], }, - toolContext, + createRootToolContext("root-123"), ); const parsed = JSON.parse(serialized); @@ -1212,18 +2312,16 @@ describe("session-mcp-runtime", () => { try { await runtime.tools.session_index.execute( { - root_session_id: "root-123", 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); @@ -1245,11 +2343,11 @@ describe("session-mcp-runtime", () => { Promise.resolve({ status: "ok", summary: "SESSION TTL REPORT\n" + - "session ttl keeps local corpus search warm\n".repeat(400), + "session ttl keeps local corpus search warm\n".repeat(900), exit_code: 0, timed_out: false, truncated: false, - bytes_captured: SESSION_MCP_RESPONSE_BUDGET_BYTES + 4_096, + bytes_captured: SESSION_MCP_RESPONSE_BUDGET_BYTES + 8_192, }), }, } as never); @@ -1257,14 +2355,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); @@ -1304,7 +2401,7 @@ describe("session-mcp-runtime", () => { try { const serialized = await runtime.tools.session_execute.execute( validRequests.session_execute, - toolContext, + createRootToolContext("root-123"), ); const parsed = JSON.parse(serialized); const artifactId = String(parsed.artifact_ref).split("/").at(-1) ?? ""; @@ -1346,15 +2443,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); @@ -1381,18 +2477,16 @@ describe("session-mcp-runtime", () => { try { const indexedSerialized = await runtime.tools.session_index.execute( { - root_session_id: "root-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); @@ -1402,7 +2496,7 @@ describe("session-mcp-runtime", () => { indexed.corpus_ref, "session:group-runtime:root-runtime:corpus:corpus-1:meta", ); - assertEquals(search.corpus_refs, [indexed.corpus_ref]); + assertEquals(search.refs, [indexed.corpus_ref]); assertEquals(search.results.length > 0, true); } finally { await runtime.dispose(); @@ -1432,10 +2526,10 @@ describe("session-mcp-runtime", () => { try { await runtime.tools.session_index.execute( { - root_session_id: "root-path-index", path: localFile, }, createToolContext({ + sessionID: "root-path-index", worktree: worktreeDir, directory: worktreeDir, ask: (input) => { @@ -1447,10 +2541,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, }), @@ -1498,10 +2592,10 @@ describe("session-mcp-runtime", () => { try { await runtime.tools.session_index.execute( { - root_session_id: "root-path-index-external", path: externalFile, }, createToolContext({ + sessionID: "root-path-index-external", worktree: worktreeDir, directory: worktreeDir, ask: (input) => { @@ -1513,10 +2607,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, }), @@ -1561,10 +2655,9 @@ describe("session-mcp-runtime", () => { () => runtime.tools.session_index.execute( { - root_session_id: "root-path-error", path: "README.md", }, - toolContext, + createRootToolContext("root-path-error"), ), ) as Error & { code?: string; bounded?: boolean }; @@ -1590,39 +2683,35 @@ describe("session-mcp-runtime", () => { try { await runtime.tools.session_index.execute( { - root_session_id: "root-runtime-replacement", content: "old alpha body", source: "build-log", label: "latest", }, - toolContext, + createRootToolContext("root-runtime-replacement"), ); await runtime.tools.session_index.execute( { - root_session_id: "root-runtime-replacement", content: "new beta body", 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"), ), ); @@ -1644,11 +2733,11 @@ describe("session-mcp-runtime", () => { session_execute: (request: { command: string }) => Promise.resolve({ status: "ok", - summary: `${request.command}: ` + "x".repeat(6_000), + summary: `${request.command}: ` + "x".repeat(18_000), exit_code: 0, timed_out: false, truncated: false, - bytes_captured: 6_010, + bytes_captured: 18_010, }), }, } as never); @@ -1656,13 +2745,12 @@ describe("session-mcp-runtime", () => { try { const serialized = await runtime.tools.session_batch_execute.execute( { - root_session_id: "root-batch", commands: [ { command: "first" }, { command: "second" }, ], }, - toolContext, + createRootToolContext("root-batch"), ); const parsed = JSON.parse(serialized); @@ -1711,22 +2799,20 @@ describe("session-mcp-runtime", () => { try { await runtime.tools.session_index.execute( { - root_session_id: "root-mixed-order", content: "session continuity is preserved in the local corpus", }, - toolContext, + createRootToolContext("root-mixed-order"), ); const serialized = await runtime.tools.session_batch_execute.execute( { - root_session_id: "root-mixed-order", steps: [ { kind: "command", command: "first" }, { kind: "search", query: "session continuity" }, { kind: "command", command: "third" }, ], }, - toolContext, + createRootToolContext("root-mixed-order"), ); const parsed = JSON.parse(serialized); @@ -1759,18 +2845,16 @@ describe("session-mcp-runtime", () => { try { await runtime.tools.session_index.execute( { - root_session_id: "root-search-step", content: "local corpus search should find this indexed sentence", }, - toolContext, + createRootToolContext("root-search-step"), ); const serialized = await runtime.tools.session_batch_execute.execute( { - root_session_id: "root-search-step", steps: [{ kind: "search", query: "indexed sentence" }], }, - toolContext, + createRootToolContext("root-search-step"), ); const parsed = JSON.parse(serialized); @@ -1795,11 +2879,11 @@ describe("session-mcp-runtime", () => { session_execute: (request: { command: string }) => Promise.resolve({ status: "ok", - summary: `${request.command}: ` + "x".repeat(7_000), + summary: `${request.command}: ` + "x".repeat(18_000), exit_code: 0, timed_out: false, truncated: false, - bytes_captured: 7_010, + bytes_captured: 18_010, }), }, } as never); @@ -1807,22 +2891,20 @@ describe("session-mcp-runtime", () => { try { await runtime.tools.session_index.execute( { - root_session_id: "root-mixed-spill", content: "spill search term remains locally searchable", }, - toolContext, + createRootToolContext("root-mixed-spill"), ); const serialized = await runtime.tools.session_batch_execute.execute( { - root_session_id: "root-mixed-spill", steps: [ { kind: "command", command: "first" }, { kind: "search", query: "spill search term" }, { kind: "command", command: "second" }, ], }, - toolContext, + createRootToolContext("root-mixed-spill"), ); const parsed = JSON.parse(serialized); @@ -1856,18 +2938,16 @@ describe("session-mcp-runtime", () => { try { const indexedSerialized = await runtime.tools.session_index.execute( { - root_session_id: "root-stub", content: "stub body", }, - toolContext, + createRootToolContext("root-stub"), ); const fetchSerialized = await runtime.tools.session_fetch_and_index .execute( { - root_session_id: "root-stub", url: "https://example.com", }, - toolContext, + createRootToolContext("root-stub"), ); const indexed = JSON.parse(indexedSerialized); @@ -1906,10 +2986,9 @@ describe("session-mcp-runtime", () => { try { const serialized = await runtime.tools.session_fetch_and_index.execute( { - root_session_id: "root-runtime-fetch-error", url: "https://example.com/missing", }, - toolContext, + createRootToolContext("root-runtime-fetch-error"), ); const parsed = JSON.parse(serialized); @@ -1988,4 +3067,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..f848d72 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, @@ -16,6 +16,15 @@ import { SESSION_EXECUTOR_MAX_NORMALIZED_INDEXED_BODY_BYTES, type SessionExecutor, } from "./session-executor.ts"; +import { + createExactHistoryAdapter, + type ExactHistoryAdapter, +} from "./exact-history.ts"; +import { + createMemorySearchService, + createSummarySearchAdapter, + type SummarySearchAdapter, +} from "./memory-search.ts"; import { SESSION_MCP_TOOL_NAMES, type SessionMcpRequestMap, @@ -24,20 +33,161 @@ import { sessionMcpResponseSchemas, type SessionMcpToolName, } from "./session-mcp-types.ts"; +import { SessionNotesService } from "./session-notes.ts"; import type { RuntimeRootSessionValidator } from "../session.ts"; +import type { NormalizedMemoryResult } from "../types/index.ts"; import { readFile as readFileNode } from "node:fs/promises"; import path from "node:path"; -export const SESSION_MCP_RESPONSE_BUDGET_BYTES = 8 * 1024; +export const SESSION_MCP_RESPONSE_BUDGET_BYTES = 32 * 1024; +const SESSION_SEARCH_RESULT_LIMIT = 5; +const SEARCH_RESULT_CREATED_AT_FALLBACK = "1970-01-01T00:00:00.000Z"; + +export const SESSION_NOTES_WRITE_DESCRIPTION = [ + "Pin working context as a session note so it survives topic switches, long tool", + "loops, and compaction. Notes are the primary continuity surface — write or", + "update one BEFORE you stop, hand back to the user, or risk losing context.", + "Do not pass `root_session_id`; the runtime resolves the current canonical", + "root session automatically.", + "", + "Required lifecycle (follow this protocol — do not skip steps):", + "", + "1. **Starting a new task** → first run `session_search` and", + " `session_notes_read` on relevant note hits to check whether an existing", + " note already covers this work. If one does, upsert it (see step 2). If", + " none exists, create a new note with a short description and a markdown", + " checklist of the planned sub-tasks.", + "2. **Finishing a sub-task** → upsert the same note (call this tool with", + " non-empty `text` and `replace: `) to check off the completed item.", + "3. **Stopping mid-task** (blocker, finding, awaiting user input) → upsert", + " the note with current progress and findings BEFORE reporting back to the", + " user. Approaching ~75% context usage counts as stopping mid-task: upsert", + " with full detail so a post-compaction agent can resume without", + " re-derivation.", + "4. **Completing the task** → ONLY when the task is fully done, decide whether", + " the note is merely operational or now contains durable learning. For trivial", + " tasks or no lasting value, clear the note BEFORE reporting back. Clearing", + " means deleting it: call this tool with empty `text` and `replace: `", + ' (or `replace: "*"` with empty text to clear all notes for this scope). If', + " the task produced evergreen facts, system details, issue findings, or other", + " learnings that will help future sessions, replace the operational checklist", + " with a concise durable note instead of deleting it. Do not clear a note for", + " a paused, blocked, or partially complete task — upsert per step 3 instead.", + " If `session_search` or `session_notes_read` revealed prior sessions with", + " stale facts, delete those stale note ids too, even when they belong to another", + " same-project root session.", + "", + "Also write/update a note:", + "", + "- After a user correction changes your assumptions", + "- Before switching to a different topic or task", + "- During long tool-calling sequences where key state lives only in your context", + "", + "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 that must survive topic", + "switches, compaction, or a fresh agent picking up the work.", + "", + "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", + "- any same-project session may delete a note by id; cross-project deletion is rejected", + "- non-empty writes (create/upsert) reject ownership conflicts", + '- 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. Capture the returned `id` so subsequent updates upsert the same", + "note rather than creating duplicates.", + "", + "Prefer concise markdown with a heading and a checklist:", + "", + " ## Current Task: Fix Redis TTL bug", + " - **File:** `src/services/redis-client.ts`", + " - **Root cause:** TTL not refreshed on read", + " - **Progress:**", + " - [x] Reproduce on staging", + " - [x] Identify missing EXPIRE in `refreshEntry()`", + " - [ ] Add regression test", + " - [ ] Land fix", + " - **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. This", + "is the second step of the recall protocol: after `session_search` surfaces a", + "matching note hit, call `session_notes_read` with that note `id` to load the", + "full body before acting.", + "Do not pass `root_session_id`; the runtime resolves the current canonical", + "root session automatically.", + "", + "Call this tool whenever you need authoritative pinned context, especially:", + "", + "- At the start of a new session, after compaction, or when resuming an", + " interrupted task — these are the highest-priority recall moments.", + '- When `session_search` returns a `type: "note"` hit relevant to your task.', + "- When you need the exact wording of a pinned user instruction, plan, or", + " checklist before acting on it.", + "", + "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. After", + "reading, update the note as you make progress by calling", + "`session_notes_write` with non-empty `text` and `replace: ` (passing", + "empty `text` would delete the note — only do that when the task is fully", + "complete).", +].join("\n"); + +export const SESSION_SEARCH_BASELINE_DESCRIPTION = [ + "Search local indexed content for the current root session. This is the FIRST", + "step of the recall protocol — run a `session_search` BEFORE doing other work", + "whenever prior context may exist, especially:", + "Do not pass `root_session_id`; the runtime resolves the current canonical", + "root session automatically.", + "", + "- At the start of a new session or immediately after compaction (highest", + " priority — pinned notes and prior decisions may not be in working memory).", + "- When resuming a topic you worked on earlier in this or a sibling session.", + "- Before re-solving a problem that may already have a solution in session", + " history, or contradicting an earlier decision.", + "- To check whether pinned session notes already contain the context you need.", + "", + 'Results may include exact indexed hits (type: "entry"), summaries (type: "summary"), and, when pinned', + 'session notes exist, matching notes (type: "note"). Note results include', + '`id`, `root_session_id`, `scope: "local" | "project"`, `created_at`, and', + "`updated_at` — when a note hit is relevant, immediately call", + "`session_notes_read` with that `id` to load the full note text. Do not", + "paraphrase a note from its snippet. Not every query returns note results;", + "notes only appear when they match the query and the session has pinned notes.", + "", + "Prefer `session_search` over reconstructing context from scratch. The full", + "continuity protocol is: (1) `session_search` to discover, (2)", + "`session_notes_read` for relevant note hits, (3) `session_notes_write` to", + "pin or update progress before stopping or reporting back.", +].join("\n"); + +export const SESSION_SEARCH_STRENGTHENED_DESCRIPTION = + SESSION_SEARCH_BASELINE_DESCRIPTION + + "\n\n" + + [ + "⚠️ 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 before", + "doing anything else: run `session_search` to recover earlier decisions, pinned", + "notes, and task state, then call `session_notes_read` on any relevant note", + "hits to load their exact text. This avoids re-solving problems, contradicting", + "earlier decisions, or duplicating notes that already track the work.", + ].join("\n"); type PluginToolArgs = Parameters[0]["args"]; const pluginSchema = tool.schema; -const pluginRootSessionIdArgs: PluginToolArgs = { - root_session_id: pluginSchema.string().min(1), -}; - const pluginSessionExecuteStepSchema = pluginSchema.object({ command: pluginSchema.string().min(1), timeout_seconds: pluginSchema.number().int().positive().max(120).optional(), @@ -52,47 +202,45 @@ const pluginSessionBatchStepSchema = pluginSchema.object({ const sessionMcpToolArgs: Record = { session_execute: { - ...pluginRootSessionIdArgs, command: pluginSchema.string().min(1), timeout_seconds: pluginSchema.number().int().positive().max(120).optional(), }, session_execute_file: { - ...pluginRootSessionIdArgs, paths: pluginSchema.array(pluginSchema.string().min(1)).min(1), }, session_batch_execute: { - ...pluginRootSessionIdArgs, commands: pluginSchema.array(pluginSessionExecuteStepSchema).min(1) .optional(), steps: pluginSchema.array(pluginSessionBatchStepSchema).min(1).optional(), }, session_index: { - ...pluginRootSessionIdArgs, content: pluginSchema.string().optional(), path: pluginSchema.string().min(1).optional(), source: pluginSchema.string().min(1).optional(), label: pluginSchema.string().min(1).optional(), }, session_search: { - ...pluginRootSessionIdArgs, - query: pluginSchema.string().min(1), + query: pluginSchema.string(), + when: pluginSchema.string().datetime().optional(), }, session_fetch_and_index: { - ...pluginRootSessionIdArgs, url: pluginSchema.string().url(), timeout_seconds: pluginSchema.number().int().positive().max(120).optional(), }, - session_stats: { - ...pluginRootSessionIdArgs, + session_stats: {}, + session_doctor: {}, + session_notes_write: { + text: pluginSchema.string(), + replace: pluginSchema.string().min(1).optional(), }, - session_doctor: { - ...pluginRootSessionIdArgs, + session_notes_read: { + id: pluginSchema.string().min(1), }, }; type SessionMcpHandler = ( request: SessionMcpRequestMap[TToolName], - context: ToolContext, + context: ToolContext & { rootSessionId: string }, ) => Promise; type SessionMcpHandlerMap = { @@ -103,11 +251,14 @@ type SessionMcpRuntimeOptions = { handlers?: Partial; redisClient?: RedisClient; graphitiCache?: RedisCacheService | object; + notesService?: SessionNotesService; sessionTtlSeconds?: number; groupId?: string; createSessionCorpusService?: typeof createSessionCorpusService; createSessionExecutor?: typeof createSessionExecutor; sessionExecutor?: SessionExecutor; + exactHistoryAdapter?: ExactHistoryAdapter; + summarySearchAdapter?: SummarySearchAdapter; sessionCanonicalizer?: RuntimeRootSessionValidator; readSessionIndexFile?: (filePath: string) => Promise; }; @@ -218,22 +369,6 @@ const validateResponsePreservingBatchShape = < return rawResponse as SessionMcpResponseMap[TToolName]; }; -const validateRuntimeRootSessionContract = async < - TToolName extends SessionMcpToolName, ->( - _toolName: TToolName, - request: SessionMcpRequestMap[TToolName], - context: ToolContext, - validator: RuntimeRootSessionValidator | undefined, -): Promise => { - const sessionId = context.sessionID; - if (!sessionId) return; - await validator?.validateRuntimeRootSessionId( - sessionId, - request.root_session_id, - ); -}; - const textEncoder = new TextEncoder(); const serialize = (value: unknown): string => JSON.stringify(value); @@ -264,6 +399,15 @@ const createBoundedSessionIndexError = ( const isWithinBudget = (value: string): boolean => byteLength(value) <= SESSION_MCP_RESPONSE_BUDGET_BYTES; +const serializeSessionNoteReadResponse = ( + note: { + id: string; + text: string; + created_at: string; + updated_at: string; + }, +): string => serialize({ note }); + const resolveSessionIndexPath = ( requestPath: string, context: ToolContext, @@ -340,6 +484,38 @@ const makeCorpusRef = ( const statsCounterKeyForTool = (toolName: SessionMcpToolName): string => `${toolName}_calls_total`; +const normalizeCorpusSearchResult = ( + result: { + corpus_ref?: string; + ref?: string; + snippet: string; + score: number; + type?: string; + id?: string; + root_session_id?: string; + scope?: "session" | "local" | "project"; + created_at?: string; + updated_at?: string; + granularity?: string; + source?: string; + }, +): NormalizedMemoryResult => ({ + ref: result.ref ?? result.corpus_ref ?? "", + snippet: result.snippet, + score: result.score, + type: result.type === "entry" || result.type === "note" || + result.type === "summary" + ? result.type + : "summary", + id: result.id, + root_session_id: result.root_session_id, + scope: result.scope, + created_at: result.created_at ?? SEARCH_RESULT_CREATED_AT_FALLBACK, + updated_at: result.updated_at, + granularity: result.granularity, + source: result.source, +}); + export const createSessionMcpRuntime = ( options: SessionMcpRuntimeOptions = {}, ): SessionMcpRuntime => { @@ -359,6 +535,37 @@ 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 }, + ); + const summarySearchAdapter = options.summarySearchAdapter ?? + (corpus + ? { + search: async ({ rootSessionId, query }) => { + const result = await corpus.search({ rootSessionId, query }); + return result.results.map(normalizeCorpusSearchResult); + }, + } + : createSummarySearchAdapter()); + const memorySearch = createMemorySearchService({ + exactHistoryAdapter: options.exactHistoryAdapter ?? + createExactHistoryAdapter(), + notesService: notes, + summarySearchAdapter, + groupId, + resultLimit: SESSION_SEARCH_RESULT_LIMIT, + }); + + 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, @@ -444,39 +651,32 @@ export const createSessionMcpRuntime = ( }, }); - const searchLocalCorpus = async ( + const searchMemory = async ( rootSessionId: string, query: string, + when: string, ): Promise => { - if (!corpus) { - return { - status: "ok", - results: [], - corpus_refs: [], - truncated: false, - }; - } - - const result = await corpus.search({ + return await memorySearch.search({ rootSessionId, query, + when, }); - return { - status: result.status, - results: result.results, - corpus_refs: result.corpusRefs, - truncated: result.truncated, - }; }; const defaultHandlers: SessionMcpHandlerMap = { session_execute: (request, context) => - sessionExecutor.executeCommand(request, { + sessionExecutor.executeCommand({ + ...request, + root_session_id: context.rootSessionId, + }, { worktree: context.worktree, directory: context.directory, }), session_execute_file: (request, context) => - sessionExecutor.executeFile(request, { + sessionExecutor.executeFile({ + ...request, + root_session_id: context.rootSessionId, + }, { worktree: context.worktree, directory: context.directory, }), @@ -494,7 +694,6 @@ export const createSessionMcpRuntime = ( if (step.kind === "command") { const result = await handlerMap.session_execute( { - root_session_id: request.root_session_id, command: step.command, timeout_seconds: step.timeout_seconds, }, @@ -504,9 +703,10 @@ export const createSessionMcpRuntime = ( continue; } - const result = await searchLocalCorpus( - request.root_session_id, + const result = await searchMemory( + context.rootSessionId, step.query, + new Date().toISOString(), ); results.push({ kind: "search", result }); } @@ -531,7 +731,7 @@ export const createSessionMcpRuntime = ( status: "ok", corpus_ref: makeCorpusRef( groupId, - request.root_session_id, + context.rootSessionId, "stub-index", ), chunk_count: 0, @@ -539,7 +739,7 @@ export const createSessionMcpRuntime = ( }; } const result = await corpus.index({ - rootSessionId: request.root_session_id, + rootSessionId: context.rootSessionId, content, source: request.source, label: request.label, @@ -551,16 +751,21 @@ 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 = context.rootSessionId; + return await searchMemory( + rootSessionId, + request.query, + request.when ?? new Date().toISOString(), + ); }, - session_fetch_and_index: async (request) => { + session_fetch_and_index: async (request, context) => { if (!corpus) { return { status: "ok", corpus_ref: makeCorpusRef( groupId, - request.root_session_id, + context.rootSessionId, "stub-fetch", ), summary: `Stub session_fetch_and_index accepted ${request.url}.`, @@ -571,7 +776,7 @@ export const createSessionMcpRuntime = ( }; } const result = await corpus.fetchAndIndex({ - rootSessionId: request.root_session_id, + rootSessionId: context.rootSessionId, url: request.url, timeoutSeconds: request.timeout_seconds, }); @@ -585,7 +790,7 @@ export const createSessionMcpRuntime = ( truncated: result.truncated, }; }, - session_stats: async (request) => { + session_stats: async (_request, context) => { if (!corpus) { return { status: "ok", @@ -595,7 +800,7 @@ export const createSessionMcpRuntime = ( bytes_saved_estimate: 0, }; } - const stats = await corpus.getStats(request.root_session_id); + const stats = await corpus.getStats(context.rootSessionId); return { status: "ok", counters: stats.counters, @@ -604,13 +809,13 @@ export const createSessionMcpRuntime = ( bytes_saved_estimate: stats.bytesSavedEstimate, }; }, - session_doctor: async (request) => { + session_doctor: async (_request, context) => { const redis = getRedisDoctorStatus(options.redisClient); const graphitiCache = getGraphitiCacheDoctorStatus( options.graphitiCache, options.redisClient, ); - const stats = await corpus?.getStats(request.root_session_id); + const stats = await corpus?.getStats(context.rootSessionId); return { status: "ok", checks: [ @@ -624,7 +829,7 @@ export const createSessionMcpRuntime = ( name: "session-mcp-local-stats", status: "ok" as const, detail: - `Local stats available for ${request.root_session_id} (corpora=${stats.corpusCount}, artifacts=${stats.artifactCount}).`, + `Local stats available for ${context.rootSessionId} (corpora=${stats.corpusCount}, artifacts=${stats.artifactCount}).`, }] : []), ], @@ -636,6 +841,34 @@ export const createSessionMcpRuntime = ( }, }; }, + session_notes_write: async (request, context) => { + const rootSessionId = context.rootSessionId; + if (request.text !== "") { + const timestamp = new Date().toISOString(); + const existingNote = request.replace && request.replace !== "*" + ? (await notes.readNotes(rootSessionId, request.replace)).notes[0] + : undefined; + const previewNote = { + id: request.replace && request.replace !== "*" + ? request.replace + : crypto.randomUUID(), + text: request.text, + created_at: existingNote?.created_at ?? timestamp, + updated_at: timestamp, + }; + if (!isWithinBudget(serializeSessionNoteReadResponse(previewNote))) { + throw new Error( + "session_notes_write note would exceed the shared response budget when read back; break the content into multiple cross-referencing session notes.", + ); + } + } + 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,19 +1063,18 @@ export const createSessionMcpRuntime = ( context: ToolContext, ): Promise => { const request = parseRequest(toolName, rawRequest); - await validateRuntimeRootSessionContract( - toolName, - request, - context, - sessionCanonicalizer, - ); - await recordToolCall(request.root_session_id, toolName); + const effectiveRootSessionId = await resolveCanonicalRootSessionId(context); + await recordToolCall(effectiveRootSessionId, toolName); + const handlerContext = { + ...context, + rootSessionId: effectiveRootSessionId, + }; let response = validateResponsePreservingBatchShape( toolName, await (handlerMap[toolName] as ( request: SessionMcpRequestMap[TToolName], - context: ToolContext, - ) => Promise)(request, context), + context: typeof handlerContext, + ) => Promise)(request, handlerContext), ); if (toolName === "session_execute") { @@ -851,7 +1083,7 @@ export const createSessionMcpRuntime = ( await persistInlineArtifactIfPresent( toolName, response as SessionMcpResponseMap["session_execute"], - request.root_session_id, + effectiveRootSessionId, ), ); } @@ -862,7 +1094,7 @@ export const createSessionMcpRuntime = ( await persistInlineArtifactIfPresent( toolName, response as SessionMcpResponseMap["session_execute_file"], - request.root_session_id, + effectiveRootSessionId, ), ); } @@ -875,7 +1107,7 @@ export const createSessionMcpRuntime = ( await coerceOversizedResponse( toolName, response, - request.root_session_id, + effectiveRootSessionId, ), ); serialized = serialize(response); @@ -891,7 +1123,7 @@ export const createSessionMcpRuntime = ( await persistCanonicalLocalArtifactIfNeeded( toolName, response as SessionMcpResponseMap["session_execute"], - request.root_session_id, + effectiveRootSessionId, ); } @@ -899,26 +1131,33 @@ 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; }; const descriptions: Record = { - session_execute: "Execute a bounded session command.", - 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_execute: + "Execute a bounded session command for the current canonical root session. Do not pass `root_session_id`; the runtime resolves the current canonical root session automatically.", + session_execute_file: + "Read local files through the session runtime for the current canonical root session. Do not pass `root_session_id`; the runtime resolves the current canonical root session automatically.", + session_batch_execute: + "Execute bounded session commands sequentially for the current canonical root session. Do not pass `root_session_id`; the runtime resolves the current canonical root session automatically.", + session_index: + "Index local content for the current canonical root session. Do not pass `root_session_id`; the runtime resolves the current canonical root session automatically.", + 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.", + "Fetch content and index it for the current canonical root session. Do not pass `root_session_id`; the runtime resolves the current canonical root session automatically.", + session_stats: + "Return local session MCP stats for the current canonical root session. Do not pass `root_session_id`; the runtime resolves the current canonical root session automatically.", + session_doctor: + "Return local session MCP health checks for the current canonical root session. Do not pass `root_session_id`; the runtime resolves the current canonical root session automatically.", + session_notes_write: SESSION_NOTES_WRITE_DESCRIPTION, + session_notes_read: SESSION_NOTES_READ_DESCRIPTION, }; const tools = Object.fromEntries( @@ -955,6 +1194,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..86d2418 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]; @@ -33,10 +35,6 @@ export const sessionMcpCheckStatusSchema = z.enum( ] satisfies SessionMcpCheckStatus[], ); -const rootSessionIdShape = { - root_session_id: z.string().min(1), -}; - const sessionExecuteStepSchema = z.object({ command: z.string().min(1), timeout_seconds: z.number().int().positive().max(120).optional(), @@ -58,27 +56,72 @@ export const sessionBatchStepSchema = z.discriminatedUnion("kind", [ sessionBatchSearchStepSchema, ]); +type SessionExecuteRequest = { + command: string; + timeout_seconds?: number; +}; + +type SessionExecuteFileRequest = { + paths: string[]; +}; + type SessionExecuteStep = z.infer; type SessionBatchStep = z.infer; type SessionBatchExecuteRequest = { - root_session_id: string; commands: SessionExecuteStep[]; steps?: SessionBatchStep[]; }; type SessionIndexRequest = { - root_session_id: string; content: string; path?: string; source?: string; label?: string; }; +type SessionSearchRequest = { + query: string; + when?: string; +}; + +type SessionFetchAndIndexRequest = { + url: string; + timeout_seconds?: number; +}; + +type SessionStatsRequest = Record; + +type SessionDoctorRequest = Record; + +type SessionNotesWriteRequest = { + text: string; + replace?: string; +}; + +type SessionNotesReadRequest = { + id: string; +}; + const searchResultSchema = z.object({ - corpus_ref: z.string().min(1), + ref: z.string().min(1), snippet: z.string(), score: z.number(), + type: z.enum(["entry", "note", "summary"]), + id: z.string().min(1).optional(), + root_session_id: z.string().min(1).optional(), + scope: z.enum(["session", "local", "project"]).optional(), + created_at: z.string().min(1), + updated_at: z.string().min(1).optional(), + granularity: z.string().min(1).optional(), + source: z.string().min(1).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({ @@ -93,12 +136,10 @@ const doctorSubsystemSchema = z.object({ }).strict(); const sessionBatchExecuteLegacyRequestSchema = z.object({ - ...rootSessionIdShape, commands: z.array(sessionExecuteStepSchema).min(1), }).strict(); const sessionBatchExecuteMixedRequestSchema = z.object({ - ...rootSessionIdShape, steps: z.array(sessionBatchStepSchema).min(1), }).strict(); @@ -108,7 +149,6 @@ const sessionBatchExecuteRequestSchema = z.union([ ]).transform((request) => { if ("steps" in request) { return { - root_session_id: request.root_session_id, steps: request.steps, commands: request.steps.flatMap((step) => step.kind === "command" @@ -119,7 +159,6 @@ const sessionBatchExecuteRequestSchema = z.union([ } return { - root_session_id: request.root_session_id, commands: request.commands, steps: request.commands.map((command) => ({ kind: "command" as const, @@ -129,7 +168,6 @@ const sessionBatchExecuteRequestSchema = z.union([ }); const sessionIndexRequestSchema = z.object({ - ...rootSessionIdShape, content: z.string().optional(), path: z.string().optional(), source: z.string().optional(), @@ -141,7 +179,6 @@ const sessionIndexRequestSchema = z.object({ message: "content or path is required", }, ).transform((request) => ({ - root_session_id: request.root_session_id, content: request.content ?? "", path: request.path, source: request.source, @@ -150,31 +187,41 @@ const sessionIndexRequestSchema = z.object({ export const sessionMcpRequestSchemas = { session_execute: z.object({ - ...rootSessionIdShape, command: z.string().min(1), timeout_seconds: z.number().int().positive().max(120).optional(), - }).strict(), + }).strict() satisfies z.ZodType, session_execute_file: z.object({ - ...rootSessionIdShape, paths: z.array(z.string().min(1)).min(1), - }).strict(), + }).strict() satisfies z.ZodType, session_batch_execute: sessionBatchExecuteRequestSchema, session_index: sessionIndexRequestSchema, session_search: z.object({ - ...rootSessionIdShape, - query: z.string().min(1), - }).strict(), + query: z.string(), + when: z.string().datetime().optional(), + }).strict().transform((request) => ({ + query: request.query, + when: request.when, + } satisfies SessionSearchRequest)), session_fetch_and_index: z.object({ - ...rootSessionIdShape, url: z.string().url(), timeout_seconds: z.number().int().positive().max(120).optional(), - }).strict(), - session_stats: z.object({ - ...rootSessionIdShape, - }).strict(), - session_doctor: z.object({ - ...rootSessionIdShape, - }).strict(), + }).strict() satisfies z.ZodType, + session_stats: z.object({}).strict() satisfies z.ZodType, + session_doctor: z.object({}).strict() satisfies z.ZodType< + SessionDoctorRequest + >, + session_notes_write: z.object({ + text: z.string(), + replace: z.string().min(1).optional(), + }).strict().transform((request) => ({ + text: request.text, + replace: request.replace, + } satisfies SessionNotesWriteRequest)), + session_notes_read: z.object({ + id: z.string().min(1), + }).strict().transform((request) => ({ + id: request.id, + } satisfies SessionNotesReadRequest)), }; export const sessionExecuteResponseSchema = z.object({ @@ -190,7 +237,7 @@ export const sessionExecuteResponseSchema = z.object({ export const sessionSearchResponseSchema = z.object({ status: sessionMcpStatusSchema, results: z.array(searchResultSchema), - corpus_refs: z.array(z.string()), + refs: z.array(z.string()), truncated: z.boolean(), }).strict(); @@ -263,6 +310,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 +331,24 @@ export type SessionMcpRequestMap = [ K in Exclude< SessionMcpToolName, - "session_batch_execute" | "session_index" + | "session_batch_execute" + | "session_index" + | "session_fetch_and_index" + | "session_stats" + | "session_doctor" + | "session_notes_write" + | "session_notes_read" > ]: SessionMcpInferredRequestMap[K]; } & { session_batch_execute: SessionBatchExecuteRequest; session_index: SessionIndexRequest; + session_fetch_and_index: SessionFetchAndIndexRequest; + session_stats: SessionStatsRequest; + session_doctor: SessionDoctorRequest; + 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..75e7359 --- /dev/null +++ b/src/services/session-notes.test.ts @@ -0,0 +1,730 @@ +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 with no TTL on session-local hash", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + 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") { + // Notes must be written without TTL (durable). + assertEquals(writtenSnapshot.ttlSeconds, undefined); + assertEquals(Object.keys(writtenSnapshot.values).sort(), [ + "note-1", + "note-2", + ]); + } + + 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], + }); + + // readNotes must NOT touch (refresh) the TTL — hash must still have no TTL. + const afterReadSnapshot = await redis.snapshot(key); + assertEquals(afterReadSnapshot.kind, "hash"); + if (afterReadSnapshot.kind === "hash") { + assertEquals(afterReadSnapshot.ttlSeconds, undefined); + } + }); + + it("readNote updates last_read_at in the project store on successful read", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + createNoteId: createSequence(["note-x"]), + now: createClock( + "2026-04-11T10:00:00.000Z", // write time + "2026-04-11T10:05:00.000Z", // read time + ), + }); + + await service.writeNote("root-1", "Some content"); + + // First read — last_read_at should be set. + const result = await service.readNote("note-x"); + assertEquals(result, { + note: { + id: "note-x", + text: "Some content", + created_at: "2026-04-11T10:00:00.000Z", + updated_at: "2026-04-11T10:00:00.000Z", + }, + }); + + // Verify last_read_at was persisted in the project store by inspecting + // the raw Redis hash field. + const rawHash = await redis.getHashAll("session:notes:project-1"); + const stored = JSON.parse(rawHash["note-x"] ?? "{}") as { + last_read_at?: string; + }; + assertEquals(stored.last_read_at, "2026-04-11T10:05:00.000Z"); + }); + + it("readNote on a missing note returns null and does not mutate project store", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + createNoteId: createSequence(["note-y"]), + now: createClock("2026-04-11T10:00:00.000Z"), + }); + + await service.writeNote("root-1", "Existing note"); + + const missBefore = await redis.getHashAll("session:notes:project-1"); + const result = await service.readNote("does-not-exist"); + assertEquals(result, { note: null }); + const missAfter = await redis.getHashAll("session:notes:project-1"); + + // Project store must be identical — no fields added, no TTL touched. + assertEquals(missBefore, missAfter); + }); + + it("supports replace and clear semantics within a single root session", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + 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:03.500Z", // readNote("note-1") stamps last_read_at + "2026-04-11T11:00:03.750Z", // restore: writeNote root-b "other session" replace note-3 + "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", + ); + + // Same-project delete: empty text with replace: id should succeed even for + // notes owned by another root session in the same project. + const foreignDeleted = await service.writeNote("root-a", "", { + replace: "note-3", + }); + assertEquals(foreignDeleted, { action: "deleted", id: "note-3" }); + // Owner session-local store must be empty after cross-session delete. + assertEquals(await service.readNotes("root-b"), { notes: [] }); + // Project-wide lookup must also return null. + assertEquals(await service.readNote("note-3"), { note: null }); + // Deleter session (root-a) must still have its own notes. + assertEquals(await service.readNotes("root-a"), { + notes: [ + { + id: "note-1", + text: "alpha updated", + created_at: "2026-04-11T11:00:00.000Z", + updated_at: "2026-04-11T11:00:03.000Z", + }, + { + id: "note-2", + text: "beta", + created_at: "2026-04-11T11:00:01.000Z", + updated_at: "2026-04-11T11:00:01.000Z", + }, + ], + }); + + // Restore root-b's note for the rest of the test. + await service.writeNote("root-b", "other session", { replace: "note-3" }); + + 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:03.750Z", + updated_at: "2026-04-11T11:00:03.750Z", + }], + }); + + 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", + createNoteId: createSequence(["note-1", "note-2", "note-3"]), + // All notes written at the same instant so write_freshness is equal. + now: createClock( + "2026-04-11T12:00:00.000Z", + "2026-04-11T12:00:00.000Z", + "2026-04-11T12:00:00.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.", + ); + // Exact match: score should be very high (near 1). Check shape including + // created_at/updated_at per spec requirement. + assertEquals(exact[0]?.id, "note-1"); + assertEquals(exact[0]?.root_session_id, "root-search"); + assertEquals(exact[0]?.scope, "local"); + assertEquals( + exact[0]?.snippet, + "## Redis TTL refresh Ensure session ttl refresh happens on note reads.", + ); + assertEquals(exact[0]?.created_at, "2026-04-11T12:00:00.000Z"); + assertEquals(exact[0]?.updated_at, "2026-04-11T12:00:00.000Z"); + assert(exact[0]!.score > 0.9); + + 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); + // Local note should rank first (same relevance + write_freshness, + // locality tie-break prefers local). + 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); + // With same write_freshness and no reads, scores should be equal or local + // slightly higher due to no fractional penalty. Either way local wins. + 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, + created_at: "2026-04-11T12:00:00.000Z", + updated_at: "2026-04-11T12:00:00.000Z", + }]); + assert(multi[0]!.score > 0); + assert(multi[0]!.score < 1); + + assertEquals(await service.searchNotes("root-search", "foreign"), []); + assertEquals(await service.searchNotes("root-search", " "), []); + }); + + it("search hits include created_at and updated_at fields", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + createNoteId: createSequence(["note-1", "note-2"]), + now: createClock( + "2026-04-11T12:00:00.000Z", + "2026-04-11T12:01:00.000Z", + ), + }); + + await service.writeNote("root-1", "searchable content here"); + await service.writeNote("root-2", "searchable content here"); + + const hits = await service.searchNotes("root-1", "searchable content"); + assertEquals(hits.length, 2); + for (const hit of hits) { + assert( + typeof hit.created_at === "string" && hit.created_at.length > 0, + "hit must include created_at", + ); + assert( + typeof hit.updated_at === "string" && hit.updated_at.length > 0, + "hit must include updated_at", + ); + // last_read_at must NOT be present in search hits + assert( + !("last_read_at" in hit), + "hit must not expose last_read_at", + ); + } + }); + + it("old unread notes rank below newer notes with comparable relevance", async () => { + const redis = createRedis(); + const oldTs = "2025-01-01T00:00:00.000Z"; + const newTs = "2026-04-11T12:00:00.000Z"; + const searchTs = "2026-04-11T12:00:00.000Z"; + const service = new SessionNotesService(redis, { + groupId: "project-1", + createNoteId: createSequence(["note-old", "note-new"]), + now: createClock(oldTs, newTs, searchTs), + }); + + await service.writeNote("root-1", "redis cache invalidation strategy"); + await service.writeNote("root-1", "redis cache invalidation strategy"); + + const hits = await service.searchNotes( + "root-1", + "redis cache invalidation", + ); + assertEquals(hits.length, 2); + // The newer note should rank first because write_freshness is higher. + assertEquals(hits[0]!.id, "note-new"); + assertEquals(hits[1]!.id, "note-old"); + assert( + hits[0]!.score > hits[1]!.score, + `newer note score ${hits[0]!.score} should exceed old note score ${ + hits[1]!.score + }`, + ); + }); + + it("old recently read note can outrank a newer weaker match", async () => { + const redis = createRedis(); + // note-read: old note (30 days ago), recently read (1 min ago). Full-text + // match on the query → high relevance, old writeFreshness, high + // readFreshness boost. + // note-newer: new note (just now), never read. Partial match on the query + // → lower relevance but fresh writeFreshness, no readFreshness boost. + // + // The read-boost on note-read must overcome the write-freshness advantage + // of note-newer, proving the two dimensions interact correctly. + const oldTs = "2026-03-12T00:00:00.000Z"; // ~30 days before search time + const newTs = "2026-04-11T11:55:00.000Z"; // ~5 min before search time + const readTs = "2026-04-11T11:59:00.000Z"; // very recent read (1 min ago) + const searchTs = "2026-04-11T12:00:00.000Z"; + + const service = new SessionNotesService(redis, { + groupId: "project-1", + createNoteId: createSequence(["note-read", "note-newer"]), + now: createClock( + oldTs, // write note-read (old) + newTs, // write note-newer (fresh, but partial match) + readTs, // readNote stamps last_read_at on note-read + searchTs, // searchNotes clock + ), + }); + + // note-read has all four query tokens → high relevance. + await service.writeNote( + "root-1", + "redis cache invalidation strategy details", + ); + // note-newer has only two of the four tokens → lower relevance. + await service.writeNote("root-1", "cache invalidation overview"); + + // Stamp last_read_at only on note-read. + await service.readNote("note-read"); + + const hits = await service.searchNotes( + "root-1", + "redis cache invalidation strategy", + ); + assertEquals(hits.length, 2); + // note-read wins: readFreshness boost + higher relevance outweigh + // note-newer's write-freshness advantage. + assertEquals( + hits[0]!.id, + "note-read", + `expected note-read to rank first but got ${hits[0]!.id} (scores: ${ + hits[0]!.score + } vs ${hits[1]!.score})`, + ); + assert( + hits[0]!.score > hits[1]!.score, + `note-read score ${hits[0]!.score} should exceed note-newer score ${ + hits[1]!.score + }`, + ); + }); + + it("local and project notes with equal scores prefer local via tie-break without broad penalty", async () => { + const redis = createRedis(); + const sameTs = "2026-04-11T12:00:00.000Z"; + const searchTs = "2026-04-11T12:00:00.000Z"; + const service = new SessionNotesService(redis, { + groupId: "project-1", + createNoteId: createSequence(["note-local", "note-project"]), + now: createClock(sameTs, sameTs, searchTs), + }); + + // Identical text and timestamp → same relevance and write_freshness. + await service.writeNote("root-local", "unique keyword alpha bravo"); + await service.writeNote("root-other", "unique keyword alpha bravo"); + + const hits = await service.searchNotes( + "root-local", + "unique keyword alpha bravo", + ); + assertEquals(hits.length, 2); + // Scores should be equal (or extremely close) because no broad project penalty. + const scoreDiff = Math.abs(hits[0]!.score - hits[1]!.score); + assert( + scoreDiff < 0.05, + `scores should be nearly equal without broad penalty: ${ + hits[0]!.score + } vs ${hits[1]!.score}`, + ); + // Local note wins due to tie-break. + assertEquals(hits[0]!.scope, "local"); + assertEquals(hits[1]!.scope, "project"); + }); + + it("anchors and truncates snippets around late matches in long notes", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + createNoteId: createSequence(["note-1"]), + now: createClock( + "2026-04-11T13:00:00.000Z", // write + "2026-04-11T13:00:00.000Z", // search + ), + }); + + 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", + createNoteId: createSequence(["note-1"]), + now: createClock( + "2026-04-11T14:00:00.000Z", // search + ), + }); + + 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("malformed timestamps in stored notes produce non-NaN scores", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + createNoteId: createSequence(["note-bad-ts"]), + now: createClock("2026-04-11T14:00:00.000Z"), + }); + + // Inject a note with a malformed updated_at directly into Redis. + // writeFreshness should return 0 (fully stale) rather than NaN. + await redis.setHashFields(sessionNotesKey("root-bad-ts"), { + "note-bad-ts": JSON.stringify({ + text: "searchable note with bad timestamp", + created_at: "not-a-date", + updated_at: "not-a-date", + }), + }); + + const hits = await service.searchNotes("root-bad-ts", "searchable note"); + // Score must be a finite number (not NaN). A zero writeFreshness means + // the final score will be 0, so the note is filtered out — that is the + // correct safe fallback (fully stale → no result). + for (const hit of hits) { + assert(!isNaN(hit.score), `score must not be NaN, got ${hit.score}`); + assert(isFinite(hit.score), `score must be finite, got ${hit.score}`); + } + }); + + it("retries note id generation until the project-scoped id is unique", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + 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", + }, + }); + }); + + it("locality tie-break applies when scores are within SCORE_EPSILON", async () => { + // This test verifies that compareSearchHits uses an epsilon-based + // comparison for scores, not strict equality. We create two notes with + // scores that differ by less than SCORE_EPSILON (produced by tweaking + // the text very slightly so the raw token/coverage scores round to the + // same float once multiplied by freshness). The local note must still + // win even though left.score !== right.score strictly. + // + // To trigger this reliably without depending on exact floating-point + // values, we write notes with the same query coverage fraction but one + // belonging to the local session and one to a project session. We then + // verify that the local note is ranked first despite both notes being + // written at exactly the same instant (equal writeFreshness, equal + // relevance), which would only be guaranteed if the epsilon tie-break + // path is reached and locality is used as a secondary key. + const sameTs = "2026-04-11T12:00:00.000Z"; + const service = new SessionNotesService( + createRedis(), + { + groupId: "project-epsilon", + createNoteId: createSequence(["note-local", "note-project"]), + now: createClock(sameTs, sameTs, sameTs), + }, + ); + + const text = "epsilon tie break test alpha bravo charlie"; + await service.writeNote("root-local", text); + await service.writeNote("root-other", text); + + const hits = await service.searchNotes( + "root-local", + "epsilon tie break test alpha bravo charlie", + ); + assertEquals(hits.length, 2); + // Both notes match identically; local scope must win via tie-break. + assertEquals( + hits[0]!.scope, + "local", + `expected local note first; scores: ${hits[0]!.score} vs ${ + hits[1]!.score + }`, + ); + }); + + it("migrateRootSessionState does not attach TTL to merged notes hash", async () => { + // mergeNoteSnapshots must not compute or carry a TTL — notes hashes are + // durable. This test migrates a source session into a target and checks + // that the resulting hash has no TTL on the key. + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + createNoteId: createSequence(["note-src", "note-tgt"]), + now: createClock( + "2026-04-11T16:00:00.000Z", // write source note + "2026-04-11T16:01:00.000Z", // write target note + ), + }); + + await service.writeNote("root-src", "source note content"); + await service.writeNote("root-tgt", "target note content"); + await service.migrateRootSessionState("root-src", "root-tgt"); + + // After migration, target key must have no TTL. + const snapshot = await redis.snapshot(sessionNotesKey("root-tgt")); + assertEquals( + snapshot.kind, + "hash", + "merged snapshot must be a hash", + ); + assert( + snapshot.kind === "hash" && snapshot.ttlSeconds === undefined, + `merged notes hash must have no TTL but got ttlSeconds=${ + snapshot.kind === "hash" ? snapshot.ttlSeconds : "n/a" + }`, + ); + // Both notes must be present after merge. + const notes = await service.readNotes("root-tgt"); + const ids = notes.notes.map((n) => n.id).sort(); + assertEquals(ids, ["note-src", "note-tgt"]); + }); +}); diff --git a/src/services/session-notes.ts b/src/services/session-notes.ts new file mode 100644 index 0000000..8f99fa3 --- /dev/null +++ b/src/services/session-notes.ts @@ -0,0 +1,596 @@ +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; + last_read_at?: string | null; +}; + +export type SessionNote = StoredNote & { + id: string; +}; + +export type SessionNoteSearchHit = { + id: string; + root_session_id: string; + scope: "local" | "project"; + snippet: string; + score: number; + created_at: string; + updated_at: string; +}; + +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; + 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, + last_read_at: typeof parsed.last_read_at === "string" + ? parsed.last_read_at + : null, + }; + } 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); +}; + +// Freshness scoring constants. +// writeFreshness half-life: ~30 days → lambda = ln(2) / (30 * 86400) +const WRITE_LAMBDA = Math.LN2 / (30 * 86400); +// readFreshness boost amplitude and half-life: ~7 days +const READ_ALPHA = 0.3; +const READ_LAMBDA = Math.LN2 / (7 * 86400); +// Scores within this tolerance are treated as equal so that locality acts as +// a deterministic tie-break rather than noise in floating-point arithmetic. +const SCORE_EPSILON = 1e-9; + +const computeWriteFreshness = (updatedAt: string, nowMs: number): number => { + const parsed = Date.parse(updatedAt); + // Malformed timestamp → treat note as fully stale. + if (isNaN(parsed)) return 0; + const ageSeconds = Math.max(0, (nowMs - parsed) / 1000); + return Math.exp(-WRITE_LAMBDA * ageSeconds); +}; + +const computeReadFreshness = ( + lastReadAt: string | null | undefined, + nowMs: number, +): number => { + // No read stamp → neutral multiplier (no boost, no penalty). + if (!lastReadAt) return 1; + const parsed = Date.parse(lastReadAt); + // Malformed read timestamp → treat as never read (neutral). + if (isNaN(parsed)) return 1; + const ageSeconds = Math.max(0, (nowMs - parsed) / 1000); + return Math.min( + 1 + READ_ALPHA, + 1 + READ_ALPHA * Math.exp(-READ_LAMBDA * ageSeconds), + ); +}; + +const compareSearchHits = ( + left: SessionNoteSearchHit, + right: SessionNoteSearchHit, +): number => { + // Higher score wins; treat scores within SCORE_EPSILON as equal so that + // locality acts as a deterministic tie-break rather than floating-point noise. + if (Math.abs(right.score - left.score) > SCORE_EPSILON) { + return right.score - left.score; + } + // Tie-break: prefer local scope. + const leftLocal = left.scope === "local" ? 0 : 1; + const rightLocal = right.scope === "local" ? 0 : 1; + if (leftLocal !== rightLocal) return leftLocal - rightLocal; + // Tie-break: newer updated_at. + if (right.updated_at !== left.updated_at) { + return right.updated_at.localeCompare(left.updated_at); + } + // Stable fallback. + 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)]), + ), + ); + } + + private async writeSingleNote( + rootSessionId: string, + noteId: string, + note: StoredNote, + ): Promise { + await this.redis.setHashFields( + sessionNotesKey(rootSessionId), + { [noteId]: JSON.stringify(note) }, + ); + } + + 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); + + // Empty text = delete. Allow cross-session deletes within the same project. + if (text === "") { + if (!projectNote) { + const deleted = notes.delete(replace); + if (deleted) { + await this.writeNotesHash(rootSessionId, notes); + } + return { action: "deleted", id: replace }; + } + + // Delete from the owning session's local store. + const ownerSessionId = projectNote.root_session_id; + const ownerNotes = ownerSessionId === rootSessionId + ? notes + : await this.loadNotes(ownerSessionId); + await this.deleteOwnedNote( + ownerSessionId, + replace, + ownerNotes, + projectNotes, + ); + return { action: "deleted", id: replace }; + } + + // Non-empty replace: ownership check still applies. + if (projectNote && projectNote.root_session_id !== rootSessionId) { + throw new Error(`Note ${replace} is owned by another session`); + } + + 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 notes = [...(await this.loadNotes(rootSessionId)).entries()] + .map(([id, note]) => ({ id, ...note })) + .sort(compareNotes); + + if (!noteId) return { notes }; + return { notes: notes.filter((note) => note.id === noteId) }; + } + + async readNote(noteId: string): Promise<{ note: SessionNote | null }> { + const projectNotes = await this.loadProjectNotes(); + const note = projectNotes.get(noteId); + if (!note) return { note: null }; + + // Stamp last_read_at in the project store without modifying other fields. + await this.writeSingleProjectNote(noteId, { + ...note, + last_read_at: this.now().toISOString(), + }); + + 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 nowMs = this.now().getTime(); + + const localNotes = (await this.readNotes(rootSessionId)).notes; + const allProjectNotes = await this.loadProjectNotes(); + const projectNoteEntries = [...allProjectNotes.entries()] + .filter(([, note]) => note.root_session_id !== rootSessionId); + + const localHits: SessionNoteSearchHit[] = localNotes.map((note) => { + const relevance = scoreNote(note.text, normalizedQuery); + const writeFreshness = computeWriteFreshness(note.updated_at, nowMs); + // Consult project store for last_read_at even for local notes. + const projectNote = allProjectNotes.get(note.id); + const readFreshness = computeReadFreshness( + projectNote?.last_read_at, + nowMs, + ); + // Multiplicative model: relevance gates the score while write/read + // freshness modulate it (write decays with age; read boosts recently + // revisited notes up to 1 + READ_ALPHA). + return { + id: note.id, + root_session_id: rootSessionId, + scope: "local" as const, + snippet: buildSnippet(note.text, normalizedQuery), + score: clampScore(relevance * writeFreshness * readFreshness), + created_at: note.created_at, + updated_at: note.updated_at, + }; + }); + + const projectHits: SessionNoteSearchHit[] = projectNoteEntries.map( + ([id, note]) => { + const relevance = scoreNote(note.text, normalizedQuery); + const writeFreshness = computeWriteFreshness(note.updated_at, nowMs); + const readFreshness = computeReadFreshness(note.last_read_at, nowMs); + return { + id, + root_session_id: note.root_session_id, + scope: "project" as const, + snippet: buildSnippet(note.text, normalizedQuery), + score: clampScore(relevance * writeFreshness * readFreshness), + created_at: note.created_at, + updated_at: note.updated_at, + }; + }, + ); + + return [...localHits, ...projectHits] + .filter((note) => note.score > 0) + .sort(compareSearchHits); + } + + 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"); + } + + // Notes hashes are durable — no TTL is ever applied. + return { + kind: "hash", + values: { + ...(target.kind === "hash" ? target.values : {}), + ...source.values, + }, + }; +}; diff --git a/src/services/session-snapshot.test.ts b/src/services/session-snapshot.test.ts index 8f7ce81..b1fb669 100644 --- a/src/services/session-snapshot.test.ts +++ b/src/services/session-snapshot.test.ts @@ -612,7 +612,7 @@ describe("SessionManager", () => { assertStringIncludes( prepared?.envelope ?? "", - '', + '', ); assertStringIncludes( prepared?.envelope ?? "", diff --git a/src/services/tool-routing.test.ts b/src/services/tool-routing.test.ts index 5003493..8252ee4 100644 --- a/src/services/tool-routing.test.ts +++ b/src/services/tool-routing.test.ts @@ -52,6 +52,7 @@ describe("tool routing", () => { } assertEquals(decision.reason, "webfetch-denied"); assertStringIncludes(decision.guidance, "WebFetch"); + assertStringIncludes(decision.guidance, "session_fetch_and_index"); }); it("rewrites Bash curl commands", () => { diff --git a/src/services/tool-routing.ts b/src/services/tool-routing.ts index 2134c3c..546fb3b 100644 --- a/src/services/tool-routing.ts +++ b/src/services/tool-routing.ts @@ -98,7 +98,7 @@ const routeWebFetch = (): RoutingDecision => ({ action: "deny", reason: "webfetch-denied", guidance: - "WebFetch is blocked. Use a safer search/fetch flow instead of raw page fetches.", + "WebFetch is blocked. Use session_fetch_and_index to fetch the URL, then session_search to query the fetched content.", }); const routeBash = ( diff --git a/src/session.test.ts b/src/session.test.ts index 738b6d2..9e403a2 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,146 @@ describe("SessionManager Task 6 runtime migration", () => { ); }); }); + +describe("SessionManager compaction notes injection", () => { + it("expects memory version 2 envelope with nested persistent_memory and no session_memory tag", async () => { + const { manager } = 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", + }, + ]); + + const prepared = await manager.prepareInjection( + "session-1", + undefined, + { forCompaction: true }, + ); + + assertStringIncludes(prepared?.envelope ?? "", ''); + assertStringIncludes(prepared?.envelope ?? "", " { + 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(" { + const notes = Array.from({ length: 12 }, (_, index) => ({ + id: `note-${index + 1}`, + text: `Note body ${index + 1}`, + created_at: `2026-04-10T${String(index).padStart(2, "0")}:00:00.000Z`, + updated_at: `2026-04-10T${String(index).padStart(2, "0")}:05:00.000Z`, + })); + const { manager } = createSessionManagerForInjection(notes); + + const prepared = await manager.prepareInjection( + "session-1", + undefined, + { forCompaction: true }, + ); + + assertEquals((prepared?.envelope.match(/ { + if (!value) return ""; + return value.replace(/]*>[\s\S]*?<\/entry>/gi, "") + .replace(/]*\/>/gi, "") + .trim(); +}; + const collectSectionValues = ( events: SessionEvent[], predicate: (event: SessionEvent) => boolean, @@ -214,8 +225,9 @@ type TemporaryRootRuntimeMigration = { export interface SessionManagerOptions { idleRetentionMs?: number; - setTimer?: (callback: () => void, delayMs: number) => TimerHandle; - clearTimer?: (timer: TimerHandle) => void; + setTimer?(callback: () => void, delayMs: number): TimerHandle; + clearTimer?(timer: TimerHandle): void; + notesService?: SessionNotesService; runtimeStateMigrator?: SessionRuntimeStateMigrator; } @@ -242,9 +254,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 +506,7 @@ const buildPreparedInjectionEnvelope = ( events: SessionEvent[], snapshot: string | null, latestRequest: string, + notes: SessionNote[] | null, persistent: { body: string; nodeRefs: string[] }, ): string => { const occupiedNormalized = new Set(); @@ -557,10 +575,20 @@ const buildPreparedInjectionEnvelope = ( ); addNormalizedValues(occupiedNormalized, subagentWork); - const filteredSnapshot = filterDuplicateSnapshotLeaves( - snapshot, - occupiedNormalized, + const filteredSnapshot = stripExactEntryBlocks( + filterDuplicateSnapshotLeaves(snapshot, occupiedNormalized), ); + const renderedNotes = notes && notes.length > 0 + ? `${ + notes.slice(0, 10).map((note) => + `${ + escapeXml(note.text) + }` + ).join("") + }` + : ""; const sections = [ `${escapeXml(latestRequest)}`, @@ -597,16 +625,15 @@ const buildPreparedInjectionEnvelope = ( filteredSnapshot ? `${filteredSnapshot}` : "", - persistent.body - ? `${persistent.body}` - : "", + renderedNotes, + ` 0 + ? ` node_refs="${escapeXml(persistent.nodeRefs.join(","))}"` + : "" + }>${stripExactEntryBlocks(persistent.body)}`, ].filter(Boolean); - return `${ - sections.join("") - }`; + return `${sections.join("")}`; }; export class SessionManager { @@ -617,6 +644,7 @@ export class SessionManager { private readonly assistantBuffer = new AssistantMessageBuffer(); private readonly lifecycleRegistry: SessionLifecycleRegistry; private readonly idleRetentionMs: number; + private readonly notesService?: SessionNotesService; private readonly setTimerImpl: ( callback: () => void, delayMs: number, @@ -647,6 +675,7 @@ export class SessionManager { this.setTimerImpl, this.clearTimerImpl, ); + this.notesService = options.notesService; this.runtimeStateMigrator = options.runtimeStateMigrator; } @@ -1080,6 +1109,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 +1120,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 +1142,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 +1180,7 @@ export class SessionManager { cacheMeta, events: mergeSessionEvents(recentEvents, recalledEvents), latestRequest, + notes: notesResult?.notes ?? null, snapshot, }; } @@ -1150,6 +1188,7 @@ export class SessionManager { private buildPreparedInjection( _state: SessionState, data: PreparedInjectionData, + _options: PrepareInjectionOptions = {}, ): PreparedSessionMemory { const persistent = this.redisCache.renderPersistentMemory( data.cache, @@ -1165,6 +1204,7 @@ export class SessionManager { data.events, data.snapshot, data.latestRequest, + data.notes, persistent, ), nodeRefs: persistent.nodeRefs, diff --git a/src/testing/detached-dream-proof.test.ts b/src/testing/detached-dream-proof.test.ts new file mode 100644 index 0000000..aa020a2 --- /dev/null +++ b/src/testing/detached-dream-proof.test.ts @@ -0,0 +1,217 @@ +import { assertEquals, assertStringIncludes } from "jsr:@std/assert@^1.0.0"; +import { + afterEach, + beforeEach, + describe, + it, +} from "jsr:@std/testing@^1.0.0/bdd"; +import { + createDetachedDreamProofPlugin, + PROOF_WAIT_MS, +} from "./detached-dream-proof.ts"; +import { setWarningTaskScheduler } from "../services/opencode-warning.ts"; + +const readJson = async (path: string): Promise> => { + const text = await Deno.readTextFile(path); + return JSON.parse(text) as Record; +}; + +describe("detached dream proof", () => { + beforeEach(() => { + // Run scheduled callbacks synchronously so toast assertions are deterministic. + setWarningTaskScheduler((cb) => cb()); + }); + + afterEach(() => { + setWarningTaskScheduler(undefined); + }); + + it("registers a TUI teardown task and writes a TUI artifact during shutdown", async () => { + const directory = await Deno.makeTempDir({ prefix: "dream-proof-tui-" }); + const toasts: string[] = []; + const logs: string[] = []; + const registrations: Array<{ + name: string; + run: () => void | Promise; + }> = []; + + try { + const plugin = createDetachedDreamProofPlugin({ + host: "tui", + waitMs: 0, + registerRuntimeTeardown: (tasks) => { + registrations.push(...tasks); + return { + run: async () => { + for (const task of tasks) await task.run(); + }, + dispose: () => {}, + }; + }, + }); + + const hooks = await plugin({ + client: { + tui: { + showToast: ({ body }: { body: { message: string } }) => { + toasts.push(body.message); + }, + }, + app: { + log: ({ body }: { body: { message: string } }) => { + logs.push(body.message); + }, + }, + } as never, + directory, + } as never); + + assertEquals(registrations.length, 0); + + const result = await hooks.tool!.detached_dream_proof_tui.execute( + {}, + {} as never, + ); + assertStringIncludes( + String(result), + "Detached dream proof for tui armed", + ); + + assertEquals(registrations.length, 1); + assertEquals(registrations[0].name, "detached_dream_proof_tui"); + + await registrations[0].run(); + + const artifact = await readJson( + `${directory}/.opencode-detached-dream-proof-tui.json`, + ); + assertEquals(artifact.proof, "detached_dream_proof_tui"); + assertEquals(artifact.host, "tui"); + assertEquals(artifact.wait_ms, 0); + assertEquals(toasts.length, 2); + assertStringIncludes( + toasts[0], + "Gracefully exit the TUI host", + ); + assertStringIncludes(toasts[1], "waiting about 0 seconds"); + assertEquals(logs.length, 1); + } finally { + await Deno.remove(directory, { recursive: true }).catch(() => undefined); + } + }); + + it("registers a startup-armed server teardown task and writes a server artifact during shutdown", async () => { + const directory = await Deno.makeTempDir({ prefix: "dream-proof-server-" }); + const toasts: string[] = []; + const logs: string[] = []; + const registrations: Array<{ + name: string; + run: () => void | Promise; + }> = []; + + try { + const plugin = createDetachedDreamProofPlugin({ + host: "server", + waitMs: 0, + registerRuntimeTeardown: (tasks) => { + registrations.push(...tasks); + return { + run: async () => { + for (const task of tasks) await task.run(); + }, + dispose: () => {}, + }; + }, + }); + + const hooks = await plugin({ + client: { + tui: { + showToast: ({ body }: { body: { message: string } }) => { + toasts.push(body.message); + }, + }, + app: { + log: ({ body }: { body: { message: string } }) => { + logs.push(body.message); + }, + }, + } as never, + directory, + } as never); + + assertEquals(registrations.length, 0); + + const result = await hooks.tool!.detached_dream_proof_server.execute( + {}, + {} as never, + ); + assertStringIncludes( + String(result), + "Detached dream proof for server armed", + ); + + assertEquals(registrations.length, 1); + assertEquals(registrations[0].name, "detached_dream_proof_server"); + + await registrations[0].run(); + + const artifact = await readJson( + `${directory}/.opencode-detached-dream-proof-server.json`, + ); + assertEquals(artifact.proof, "detached_dream_proof_server"); + assertEquals(artifact.host, "server"); + assertEquals(artifact.wait_ms, 0); + assertEquals(toasts.length, 2); + assertStringIncludes( + toasts[0], + "Gracefully exit the server/web/serve host", + ); + assertStringIncludes(toasts[1], "waiting about 0 seconds"); + assertEquals(logs.length, 1); + } finally { + await Deno.remove(directory, { recursive: true }).catch(() => undefined); + } + }); + + it("keeps the default proof wait at ten seconds", () => { + assertEquals(PROOF_WAIT_MS, 10_000); + }); + + it("returns already-armed message on second tool invocation", async () => { + const directory = await Deno.makeTempDir({ + prefix: "dream-proof-rearm-", + }); + + try { + const plugin = createDetachedDreamProofPlugin({ + host: "tui", + waitMs: 0, + registerRuntimeTeardown: () => ({ + run: () => Promise.resolve(), + dispose: () => {}, + }), + }); + + const hooks = await plugin({ + client: { + tui: { showToast: () => {} }, + app: { log: () => {} }, + } as never, + directory, + } as never); + + await hooks.tool!.detached_dream_proof_tui.execute({}, {} as never); + const result = await hooks.tool!.detached_dream_proof_tui.execute( + {}, + {} as never, + ); + assertStringIncludes( + String(result), + "Detached dream proof for tui already armed", + ); + } finally { + await Deno.remove(directory, { recursive: true }).catch(() => undefined); + } + }); +}); diff --git a/src/testing/detached-dream-proof.ts b/src/testing/detached-dream-proof.ts new file mode 100644 index 0000000..33e88e2 --- /dev/null +++ b/src/testing/detached-dream-proof.ts @@ -0,0 +1,143 @@ +import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"; +import { tool } from "@opencode-ai/plugin"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + notifyPluginWarning, + setOpenCodeClient, + showWarningToast, +} from "../services/opencode-warning.ts"; +import type { RuntimeTeardownRegistration } from "../services/runtime-teardown.ts"; +import { registerRuntimeTeardown } from "../services/runtime-teardown.ts"; + +export const PROOF_WAIT_MS = 10_000; + +export type DetachedDreamProofHost = "tui" | "server"; + +type DetachedDreamProofDependencies = { + registerRuntimeTeardown?: ( + tasks: Array<{ + name: string; + run: () => void | Promise; + }>, + ) => RuntimeTeardownRegistration; + waitMs?: number; +}; + +const hostLabel = (host: DetachedDreamProofHost): string => + host === "tui" ? "TUI" : "server/web/serve"; + +const toolIdForHost = (host: DetachedDreamProofHost): string => + `detached_dream_proof_${host}`; + +const proofFileNameForHost = (host: DetachedDreamProofHost): string => + `.opencode-detached-dream-proof-${host}.json`; + +const proofToastForHost = (host: DetachedDreamProofHost): string => + `Detached dream proof for ${host} armed. Gracefully exit the ${ + hostLabel(host) + } host after this session to test foreground waiting.`; + +const proofWaitToastForHost = ( + host: DetachedDreamProofHost, + waitMs: number, +): string => + `Detached dream proof for ${host} waiting about ${ + Math.floor(waitMs / 1000) + } seconds before writing its verification artifact. Keep OpenCode open.`; + +const wait = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +export const createDetachedDreamProofPlugin = ( + options: { + host: DetachedDreamProofHost; + } & DetachedDreamProofDependencies, +): Plugin => { + const proofHost = options.host; + const proofToolId = toolIdForHost(proofHost); + const proofWaitMs = options.waitMs ?? PROOF_WAIT_MS; + const registerTeardown = options.registerRuntimeTeardown ?? + registerRuntimeTeardown; + + return (input: PluginInput) => { + setOpenCodeClient(input.client); + + const proofFile = join(input.directory, proofFileNameForHost(proofHost)); + let teardownRegistration: RuntimeTeardownRegistration | null = null; + + const hooks: Hooks = { + tool: { + [proofToolId]: tool({ + description: + `Proof-only helper that verifies graceful-shutdown waiting behavior for the ${ + hostLabel(proofHost) + } host lifecycle.`, + args: {}, + execute: () => { + const newlyArmed = !teardownRegistration; + if (!teardownRegistration) { + teardownRegistration = registerTeardown([ + { + name: proofToolId, + run: async () => { + notifyPluginWarning( + proofWaitToastForHost(proofHost, proofWaitMs), + { + proof_only: true, + host: proofHost, + tool: proofToolId, + wait_ms: proofWaitMs, + }, + ); + await wait(proofWaitMs); + await writeFile( + proofFile, + JSON.stringify( + { + proof: proofToolId, + host: proofHost, + mode: "runtime_teardown_wait", + proof_only: true, + wait_ms: proofWaitMs, + finished_at: new Date().toISOString(), + }, + null, + 2, + ) + "\n", + "utf8", + ); + }, + }, + ]); + } + + showWarningToast(proofToastForHost(proofHost), { + proof_only: true, + temporary: true, + host: proofHost, + tool: proofToolId, + }); + return Promise.resolve( + newlyArmed + ? `Detached dream proof for ${proofHost} armed. Gracefully exit and keep OpenCode open until the proof completes.` + : `Detached dream proof for ${proofHost} already armed.`, + ); + }, + }), + }, + }; + + return Promise.resolve(hooks); + }; +}; + +export const detachedDreamProofTui = createDetachedDreamProofPlugin({ + host: "tui", +}); + +export const detachedDreamProofServer = createDetachedDreamProofPlugin({ + host: "server", +}); + +export const detachedDreamProof = detachedDreamProofTui; diff --git a/src/types/index.ts b/src/types/index.ts index 1139546..2a286ab 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -202,6 +202,22 @@ export interface PreparedSessionMemory { refreshDecision: CacheRefreshDecision; } +export type MemoryResultType = "entry" | "note" | "summary"; + +export interface NormalizedMemoryResult { + type: MemoryResultType; + ref: string; + snippet: string; + score: number; + created_at: string; + updated_at?: string; + id?: string; + root_session_id?: string; + scope?: "session" | "local" | "project"; + granularity?: string; + source?: string; +} + export type SessionMcpStatus = "ok" | "error"; export type SessionMcpCheckStatus =