From 63defe738371705dcdd380ec6901a8fa9e204cef Mon Sep 17 00:00:00 2001 From: "Vicary A." Date: Sat, 11 Apr 2026 20:39:09 +0800 Subject: [PATCH 1/4] feat(session): add anti-drift session notes --- .../2026-04-11-session-notes-anti-drift.md | 956 ++++++++++++++++++ ...6-04-11-session-notes-anti-drift-design.md | 429 ++++++++ src/handlers/chat.test.ts | 17 +- src/handlers/compacting.test.ts | 38 +- src/handlers/compacting.ts | 2 + src/handlers/messages.test.ts | 14 +- src/index.test.ts | 252 ++++- src/index.ts | 99 +- src/services/session-mcp-runtime.test.ts | 463 ++++++++- src/services/session-mcp-runtime.ts | 183 +++- src/services/session-mcp-types.ts | 53 +- src/services/session-notes.test.ts | 306 ++++++ src/services/session-notes.ts | 336 ++++++ src/session.test.ts | 182 +++- src/session.ts | 58 +- 15 files changed, 3325 insertions(+), 63 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-11-session-notes-anti-drift.md create mode 100644 docs/superpowers/specs/2026-04-11-session-notes-anti-drift-design.md create mode 100644 src/services/session-notes.test.ts create mode 100644 src/services/session-notes.ts diff --git a/docs/superpowers/plans/2026-04-11-session-notes-anti-drift.md b/docs/superpowers/plans/2026-04-11-session-notes-anti-drift.md new file mode 100644 index 0000000..053b4f4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-session-notes-anti-drift.md @@ -0,0 +1,956 @@ +# Session Notes Anti-Drift Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an agent-driven session-notes layer that preserves working context +across long sessions, topic switches, and compaction. Three MCP tools +(`session_notes_write`, `session_notes_read`, updated `session_search`) give +agents explicit control over pinning and recalling anti-drift context, with +complete note bodies injected into compaction input. + +**Architecture:** A dedicated Redis-backed note service on the existing hot tier +stores opaque markdown note bodies keyed by canonical root session. Notes +surface through `session_search` result merging and are injected as raw input +into compaction. Per-session `biasState` flags drive dynamic `session_search` +description strengthening via the `tool.definition` hook. + +**Tech Stack:** Deno, TypeScript, Redis (ioredis), Zod, `@opencode-ai/plugin`. + +**Done when:** All existing and new tests pass: + +- `deno test -A src/services/session-notes.test.ts` +- `deno test -A src/services/session-mcp-runtime.test.ts` +- `deno test -A src/handlers/compacting.test.ts` +- `deno test -A src/session.test.ts` +- `deno test -A src/index.test.ts` +- `deno test -A` +- `deno task check` +- `deno task lint` +- `deno task fmt` + +--- + +## Verbatim Tool Descriptions (Ship As-Is) + +These descriptions are deliberately prescriptive to bias agent behavior toward +the intended anti-drift workflows. They ship verbatim in the tool registrations. + +**Multi-line rendering note:** These descriptions are substantially longer and +more structured than the typical one-line tool descriptions. Before shipping, +verify that multi-line descriptions render correctly in the OpenCode tool +surface (the tool picker / description display). See Task 7 Step 4 for the +concrete validation step. + +### `session_notes_write` Description + +> **Ship this description verbatim in the tool registration.** + + + + Pin working context as a session note so it survives topic switches, long tool + loops, and compaction. Use this BEFORE drifting away from important context: + + - Before switching to a different topic or task + - After a user correction changes your assumptions + - When a small task stalls and work shifts elsewhere + - During long tool-calling sequences where key state lives only in your context + - Before compaction is likely (many messages into a session) + + Do NOT use this for ephemeral state that will be irrelevant within a few turns + (e.g., intermediate variable values, transient build errors you are about to + fix, or scratchpad reasoning). Notes are for context you need to survive + across topic switches or compaction — not for every observation. + + Accepts `text` (markdown body) and optional `replace` (a note_id to update one + note, or "*" to replace all notes). The response tells you exactly what + happened: + + - `{ action: "created", note_id }` for a new note + - `{ action: "replaced", note_id }` when replacing one note + - `{ action: "deleted", note_id }` when empty `text` deletes one note + - `{ action: "replaced", note_id, cleared_count }` when replacing all notes + - `{ action: "replaced", cleared_count }` when empty `text` clears all notes + + Always rely on the returned `action` instead of inferring the outcome from the + inputs alone. + + Prefer concise markdown with headings, bullets, and short code snippets: + + ## Current Task: Fix Redis TTL bug + - **File:** `src/services/redis-client.ts` + - **Root cause:** TTL not refreshed on read + - **Next step:** Add EXPIRE call after GET in `refreshEntry()` + - **User correction:** Use seconds not milliseconds for TTL + + + +**Response note:** `session_notes_write` intentionally omits `status` from its +response. This diverges from existing MCP tool responses that typically include +a `status` field. The omission is deliberate. The tool still makes outcomes +explicit by returning `action` and the relevant identifiers/counts directly. + +### `session_notes_read` Description + +> **Ship this description verbatim in the tool registration.** + + + + Reopen exact pinned note text instead of reconstructing it from memory. Use this + when you resume an interrupted topic, need the exact wording of a pinned user + instruction, or want to verify what you previously noted before acting on it. + + If `id` is provided, returns that single note. If `id` is omitted, returns all + notes for the current session. Returns + `{ notes: [{ note_id, text, created_at, updated_at }] }`. + + Always prefer reading a pinned note over reciting its contents from recall — + notes are the source of truth for intentionally preserved context. + + + +**Response note:** `session_notes_read` intentionally omits `status` from its +response. When `id` is omitted and no notes exist, returns `{ notes: [] }` +(empty array). When `id` is provided but the note does not exist, returns +`{ notes: [] }` (empty array, not an error). + +### `session_search` Description (Baseline) + +> **Ship this description verbatim in the tool registration.** + + + + Search local indexed content for the current root session. This is the default + recall path — use it FIRST when you need prior context, especially: + + - At the start of a new session or after compaction + - When resuming a topic you worked on earlier + - Before re-solving a problem that may already have a solution in session history + - To check whether pinned session notes already contain the context you need + + Results may include indexed memory content (type: "memory") and, when pinned + session notes exist, matching notes (type: "note"). Note results include a + `note_id` — use `session_notes_read` with that id to reopen the full note + text. Not every query will return note results; notes only appear when they + match the search query and the session has pinned notes. + + Prefer session_search over reconstructing context from scratch. If search + returns relevant note hits, read the note before duplicating its contents. + + + +### `session_search` Description (Dynamic Bias — New Session / Post-Compaction) + +This strengthened variant is emitted by the `tool.definition` hook when any +tracked session has `biasState` `"new-session"` or `"post-compaction"`. See Task +5 for the Map-based mechanism. + +> **Ship this description verbatim when bias is active.** + + + + Search local indexed content for the current root session. This is the default + recall path — use it FIRST when you need prior context, especially: + + - At the start of a new session or after compaction + - When resuming a topic you worked on earlier + - Before re-solving a problem that may already have a solution in session history + - To check whether pinned session notes already contain the context you need + + Results may include indexed memory content (type: "memory") and, when pinned + session notes exist, matching notes (type: "note"). Note results include a + `note_id` — use `session_notes_read` with that id to reopen the full note + text. Not every query will return note results; notes only appear when they + match the search query and the session has pinned notes. + + Prefer session_search over reconstructing context from scratch. If search + returns relevant note hits, read the note before duplicating its contents. + + ⚠️ This is a new session or a post-compaction turn. Prior context may have been + summarized or is not yet in your working memory. STRONGLY RECOMMENDED: run a + session_search query before starting work to recover earlier decisions, pinned + notes, and task state. This avoids re-solving problems or contradicting earlier + decisions that survived compaction. + + + +--- + +## File Map + +### Create + +| File | Purpose | +| ------------------------------------ | -------------------------------------------------- | +| `src/services/session-notes.ts` | Redis-backed note service: CRUD, TTL, search-merge | +| `src/services/session-notes.test.ts` | TDD test suite for the note service | + +### Modify + +| File | Purpose | +| ------------------------------------------ | --------------------------------------------------------------------------- | +| `src/services/session-mcp-types.ts` | Add note tool names, request/response schemas, extend search result | +| `src/services/session-mcp-runtime.ts` | Register note tools, merge note hits into search, update descriptions | +| `src/services/session-mcp-runtime.test.ts` | Tests for note tool routing, search merge, description bias | +| `src/session.ts` | Internal: extend compaction envelope with `` section | +| `src/session.test.ts` | Tests for note-aware compaction envelope (file already exists) | +| `src/handlers/compacting.ts` | Pass note service to enable note loading for compaction | +| `src/handlers/compacting.test.ts` | Tests for complete note injection in compaction | +| `src/index.ts` | Instantiate note service, wire `biasState`, register `tool.definition` hook | +| `src/index.test.ts` | Tests for note service wiring and `tool.definition` hook | + +**Note:** `src/session.test.ts` already exists with session-manager tests. New +compaction-envelope tests for notes will be added to this existing file. + +**Spec Reference:** +`docs/superpowers/specs/2026-04-11-session-notes-anti-drift-design.md` + +--- + +## Task 1: Note Service Core — Redis CRUD and TTL + +**Files:** + +- Create: `src/services/session-notes.ts` +- Create: `src/services/session-notes.test.ts` + +- [ ] **Step 1: Write failing tests for note append, read, and TTL** + + Write tests that exercise: + - `writeNote(rootSessionId, text)` → returns + `{ action: "created", note_id: string }` + - `readNotes(rootSessionId)` → returns all notes with + `{ note_id, text, created_at, updated_at }` + - `readNotes(rootSessionId, noteId)` → returns single note + - `readNotes(rootSessionId)` when no notes exist → returns `{ notes: [] }` + - `readNotes(rootSessionId, "nonexistent-id")` → returns `{ notes: [] }` + - Notes use Redis key namespace `session:{rootSessionId}:notes` + - Notes expire with `sessionTtlSeconds` TTL + - Note IDs are stable and unique per session + + Test dependencies: Provide a mock or stub Redis that implements only the + methods used by `SessionNotesService` (HSET, HGET, HGETALL, HDEL, DEL, + EXPIRE). Follow the same test-double pattern used in + `session-mcp-runtime.test.ts` — create minimal in-memory stubs rather than + mocking the full `RedisClient` class. + +- [ ] **Step 2: Write failing tests for replace and clear semantics** + + - `writeNote(rootSessionId, text, { replace: noteId })` → + `{ action: "replaced", note_id }` + - `writeNote(rootSessionId, text, { replace: "*" })` → + `{ action: "replaced", note_id, cleared_count }` + - `writeNote(rootSessionId, "", { replace: noteId })` → + `{ action: "deleted", note_id }` + - `writeNote(rootSessionId, "", { replace: "*" })` → + `{ action: "replaced", cleared_count }` + - Replace applies only within the canonical root session + +- [ ] **Step 3: Write failing tests for note search** + + - `searchNotes(rootSessionId, query)` → returns note hits with snippet, score, + note_id + - Note search uses simple substring/token matching on note text + - Results include enough metadata for `session_search` merging + - **Scoring contract:** Scores are `0`–`1` floats where `1.0` = exact full + match. The scoring must be deterministic for the same query/text pair. + Memory-hit scores from the existing `session_search` pipeline are also + `0`–`1` floats, so merged sorting by descending score produces a sensible + interleaved ranking without further normalization. + +- [ ] **Step 4: Implement `SessionNotesService`** + + Implement the minimal service to pass all Step 1–3 tests: + + ```ts + export class SessionNotesService { + constructor( + private readonly redis: RedisClient, + private readonly options: { sessionTtlSeconds: number }, + ) {} + + async writeNote( + rootSessionId: string, + text: string, + options?: { replace?: string }, + ): Promise< + | { action: "created"; note_id: string } + | { action: "replaced"; note_id: string } + | { action: "deleted"; note_id: string } + | { action: "replaced"; note_id?: string; cleared_count: number } + > { ... } + + async readNotes( + rootSessionId: string, + noteId?: string, + ): Promise<{ + notes: Array<{ + note_id: string; + text: string; + created_at: string; + updated_at: string; + }>; + }> { ... } + + async searchNotes( + rootSessionId: string, + query: string, + ): Promise> { ... } + } + ``` + + Storage model: use Redis HSET with `session:{rootSessionId}:notes` hash key. + Each field is a note ID; each value is JSON with + `{ text, created_at, updated_at }`. Set TTL via EXPIRE using + `sessionTtlSeconds`. + +- [ ] **Step 5: Verify** + + ```bash + deno test -A src/services/session-notes.test.ts + deno task check + ``` + +--- + +## Task 2: MCP Schema Extensions + +**Files:** + +- Modify: `src/services/session-mcp-types.ts` + +- [ ] **Step 1: Extend `SESSION_MCP_TOOL_NAMES`** + + Add `"session_notes_write"` and `"session_notes_read"` to + `SESSION_MCP_TOOL_NAMES`. + +- [ ] **Step 2: Add request schemas** + + ```ts + session_notes_write: z.object({ + ...rootSessionIdShape, + text: z.string(), + replace: z.string().optional(), + }).strict(), + + session_notes_read: z.object({ + ...rootSessionIdShape, + id: z.string().optional(), + }).strict(), + ``` + +- [ ] **Step 3: Add response schemas** + + ```ts + session_notes_write: z.object({ + action: z.enum(["created", "replaced", "deleted"]), + note_id: z.string().min(1).optional(), + cleared_count: z.number().int().nonnegative().optional(), + }).strict(), + + session_notes_read: z.object({ + notes: z.array(z.object({ + note_id: z.string().min(1), + text: z.string(), + created_at: z.string(), + updated_at: z.string(), + }).strict()), + }).strict(), + ``` + + **Note:** These response schemas intentionally omit `status`. Existing MCP + tool responses include `status`, but note tools return minimal payloads by + design. `session_notes_write` still makes outcomes explicit through `action` + and optional `note_id` / `cleared_count` so agents do not need to infer + deletion or clear behavior from the request inputs. `replaced` may omit + `note_id` when empty `text` clears all notes. + +- [ ] **Step 4: Extend search result schema** + + Add the new optional fields to `searchResultSchema` **while keeping + `.strict()`**: + + ```ts + const searchResultSchema = z.object({ + corpus_ref: z.string().min(1), + snippet: z.string(), + score: z.number(), + type: z.enum(["memory", "note"]).optional(), + note_id: z.string().min(1).optional(), + }).strict(); + ``` + + The existing `sessionSearchResponseSchema` references this schema, so the + extension propagates automatically. Do NOT remove `.strict()`. + +- [ ] **Step 5: Update type maps** + + Extend `SessionMcpRequestMap` and `SessionMcpResponseMap` to include the new + tool types. Ensure `SessionMcpToolName` union type updates automatically from + the const array. + +- [ ] **Step 6: Verify** + + ```bash + deno task check + deno test -A src/services/session-mcp-runtime.test.ts + ``` + +--- + +## Task 3: Tool Registration and Search Merge in MCP Runtime + +**Files:** + +- Modify: `src/services/session-mcp-runtime.ts` +- Modify: `src/services/session-mcp-runtime.test.ts` + +- [ ] **Step 1: Write failing tests for note tool registration** + + - Verify `session_notes_write` and `session_notes_read` are present in + `runtime.tools` + - Verify tool descriptions match the verbatim descriptions from this plan + - Verify args schemas match the request schemas + +- [ ] **Step 2: Write failing tests for note tool execution** + +- `session_notes_write` with text → returns `{ action: "created", note_id }` +- `session_notes_write` with replace one → returns + `{ action: "replaced", note_id }` +- `session_notes_write` with replace `"*"` → returns + `{ action: "replaced", note_id, cleared_count }` +- `session_notes_write` with empty text + replace one → returns + `{ action: "deleted", note_id }` +- `session_notes_write` with empty text + replace `"*"` → returns + `{ action: "replaced", cleared_count }` + - `session_notes_read` without id → returns all notes + - `session_notes_read` with id → returns single note + - `session_notes_read` with no notes → returns `{ notes: [] }` + - Responses validate against the Zod response schemas + +- [ ] **Step 3: Write failing tests for `session_search` note merge** + + - `session_search` returns note hits with `type: "note"` and `note_id` + - Existing memory results have `type: "memory"` (or undefined for backward + compat) + - Note hits and memory hits coexist in the results array, sorted by score + descending + - Note hits include snippet from note text + - When no notes exist, search returns only memory results (no empty note + entries) + +- [ ] **Step 4: Accept `SessionNotesService` as runtime option** + + Add `notesService?: SessionNotesService` to `SessionMcpRuntimeOptions`. + +- [ ] **Step 5: Register note tool handlers** + + Add `session_notes_write` and `session_notes_read` to `sessionMcpToolArgs`, + `descriptions`, and `defaultHandlers`. Wire handlers through the notes + service. + +- [ ] **Step 6: Merge note hits into `session_search`** + + In the `session_search` handler, after `searchLocalCorpus()`, also call + `notesService.searchNotes()`. Merge results: + - Memory hits: `type: "memory"` (or omit for backward compat) + - Note hits: `type: "note"`, `note_id` set, `corpus_ref` set to note ref + - Sort merged results by score descending — both sources produce `0`–`1` + floats so interleaving by score is meaningful + - Cap total results conservatively to avoid overwhelming output + +- [ ] **Step 7: Update `session_search` baseline description** + + Replace the existing `session_search` description with the verbatim baseline + description from this plan. + +- [ ] **Step 8: Verify** + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts + deno task check + ``` + +--- + +## Task 4: Compaction Note Injection + +**Files:** + +- Modify: `src/session.ts` (internal changes only — see scope note below) +- Modify: `src/session.test.ts` (already exists) +- Modify: `src/handlers/compacting.ts` +- Modify: `src/handlers/compacting.test.ts` + +**Scope note:** `buildPreparedInjectionEnvelope`, +`collectPreparedInjectionData`, and `buildPreparedInjection` are all +**private/internal** functions and methods within `src/session.ts`. Changes here +are internal modifications to the `SessionManager` class, not exported API +changes. The public `prepareInjection` method signature gains one new optional +parameter (see gating mechanism below) but remains backward-compatible. + +**Dependency ordering:** Step 3 wires `SessionNotesService` into +`SessionManager` as an optional constructor dependency. Steps 4 and 5 depend on +this wiring being in place, so Step 3 must complete before Steps 4–5. + +### Compaction-Only Gating Mechanism + +The note injection path must be gated so it only activates when building +compaction input, never during normal chat turns. The mechanism is an explicit +`options` parameter on `prepareInjection`: + +```ts +interface PrepareInjectionOptions { + /** When true, include in the envelope. Only the compaction + * handler should set this flag. Default: false. */ + forCompaction?: boolean; +} + +async prepareInjection( + sessionId: string, + lastRequest?: string, + options?: PrepareInjectionOptions, +): Promise +``` + +- `forCompaction` defaults to `false`. Normal chat-turn callers (`chat.message`, + `messages.transform`) do not pass this parameter, so notes are never loaded or + rendered for them. +- The compacting handler passes `{ forCompaction: true }`. +- `collectPreparedInjectionData` receives the flag and only calls + `notesService.readNotes(rootSessionId)` when `forCompaction === true`. +- `buildPreparedInjectionEnvelope` receives a `notes` parameter (array or + `null`) and only renders the `` section when notes are present. + When `forCompaction` is `false`, no notes data is passed through. + +This design is testable: + +- Call `prepareInjection(id)` → verify no `` in envelope even + when notes exist. +- Call `prepareInjection(id, undefined, { forCompaction: true })` → verify + `` is present when notes exist. +- Call `prepareInjection(id, undefined, { forCompaction: true })` → verify + `` is omitted when no notes exist. + +- [ ] **Step 1: Write failing test for `` in compaction + envelope** + + In `src/session.test.ts`, test that calling + `prepareInjection(id, undefined, { forCompaction: true })` produces an + envelope with a `` section when notes are present. The section + must contain: + - Complete note bodies (not summarized) + - Note boundaries with note IDs + - Provenance annotation indicating note-tool origin + - Separation from `` and `` + + Also test that when no notes exist, the `` section is omitted + entirely (not rendered as an empty tag). + + Example expected shape: + ```xml + + + ## Current Task: Fix Redis TTL bug + - Root cause: TTL not refreshed on read + + + ## Blocked: API schema migration + - Waiting on upstream PR #42 + + + ``` + +- [ ] **Step 2: Write failing negative test — normal chat turns do NOT include + ``** + + In `src/session.test.ts`, verify that `prepareInjection(id)` (no options) and + `prepareInjection(id, undefined, { forCompaction: false })` both produce an + envelope that does NOT include a `` section, even when notes + exist for the session. This confirms the `forCompaction` gate works. + +- [ ] **Step 3: Wire `SessionNotesService` into `SessionManager`** + + Accept `SessionNotesService` as an optional dependency in + `SessionManagerOptions`. Store it as a private field on `SessionManager`. This + step must complete before Steps 4–5 can use it. + + ```ts + // In SessionManagerOptions (internal type): + notesService?: SessionNotesService; + ``` + +- [ ] **Step 4: Extend `collectPreparedInjectionData` for compaction notes** + + Add `forCompaction: boolean` to the internal parameters of + `collectPreparedInjectionData`. When `forCompaction` is `true` and + `notesService` is available, load notes from + `SessionNotesService.readNotes(rootSessionId)` alongside the existing parallel + Redis fetches. Include notes in the returned `PreparedInjectionData`. When + `forCompaction` is `false`, skip the notes fetch entirely (do not load then + discard — avoid the I/O). + + **Critical:** The compaction hook feeds the complete note contents as input. + The compaction agent summarizes both the session and the notes. The plugin + must NOT pre-summarize, compress, or reinterpret note bodies before injecting + them. + +- [ ] **Step 5: Render `` XML section in envelope** + + In `buildPreparedInjectionEnvelope`, add an optional `notes` parameter (the + array from `readNotes`, or `null`/`undefined` when not in compaction mode). + After `` and before ``, render the + `` block if the notes array is non-empty. Use `escapeXml` for + note text. Preserve note boundaries and IDs. + + When notes are empty or the parameter is `null`/`undefined`, omit the + `` section entirely — do not render an empty + `` tag. + + **Scope guard:** The `notes` parameter is only populated when + `forCompaction === true` flows through `collectPreparedInjectionData` → + `buildPreparedInjection` → `buildPreparedInjectionEnvelope`. Normal chat-turn + callers never supply notes because `collectPreparedInjectionData` does not + fetch them unless the flag is set. + +- [ ] **Step 6: Wire note service into compacting handler** + + Update `CompactingHandlerDeps` to accept the note service. Pass it through to + `SessionManager` or ensure `SessionManager` already has it from Step 3. The + compaction handler calls `prepareInjection` with the `{ forCompaction: true }` + option: + + ```ts + const prepared = await sessionManager.prepareInjection( + canonicalSessionId, + undefined, + { forCompaction: true }, + ); + ``` + + No other caller (`chat.message`, `messages.transform`) passes this option, + ensuring notes are loaded and rendered exclusively for compaction input. + +- [ ] **Step 7: Write failing test — compaction handler loads notes** + + In `src/handlers/compacting.test.ts`, verify: + - The compaction handler calls `prepareInjection` with + `{ forCompaction: true }` as the third argument + - The resulting envelope in `output.context` includes the `` + block with pre-seeded notes rendered verbatim + - The mock note service's `readNotes` was called during the compaction path + +- [ ] **Step 8: Verify** + + ```bash + deno test -A src/session.test.ts + deno test -A src/handlers/compacting.test.ts + deno task check + ``` + +--- + +## Task 5: Dynamic `session_search` Description Bias via `tool.definition` + +**Files:** + +- Modify: `src/index.ts` +- Modify: `src/index.test.ts` + +### Design: Map-Based Bias State (No Single-Slot Race) + +The `tool.definition` hook receives only `{ toolID: string }` as input — no +session context. Because OpenCode may run multiple sessions concurrently, a +single-slot `activeBiasSessionId` would race. Instead, the plugin uses a +**Map-based approach**: + +```ts +type BiasState = "normal" | "new-session" | "post-compaction"; +const sessionBiasState = new Map(); +``` + +- `chat.message` sets `biasState = "new-session"` for the canonical session ID + when the session has no prior events. +- `session.compacting` sets `biasState = "post-compaction"` for the canonical + session ID. +- `tool.definition` checks **all tracked sessions** in the Map. If **any** + session has a non-`"normal"` bias state, emit the strengthened description. + After emitting, **delete all consumed (non-`"normal"`) entries** from the Map + to reset them. + +**Tradeoff (intentional):** Because `tool.definition` has no session context, +the strengthened description fires if _any_ tracked session is biased, not just +the one the LLM is currently serving. This means an unrelated session's +compaction could trigger one extra strengthened description for another session. +**This is a deliberate design choice, not an accidental side-effect.** The +alternatives considered were: + +1. _Single-slot bias_ — simpler but races under concurrent sessions. +2. _Suppress emission entirely when ambiguous_ — avoids false positives but + misses the critical post-compaction reminder, which is the higher-cost + failure mode. + +The Map approach was chosen because the bias is advisory ("STRONGLY RECOMMENDED: +run a session_search query") — an unnecessary reminder is harmless, while a +missed reminder after compaction actively hurts context recovery. Implementers +should preserve this "err on the side of reminding" behavior and not add +session-matching heuristics that could suppress a legitimate reminder. + +The actual `tool.definition` hook signature (from `@opencode-ai/plugin` +v1.2.26): + +```ts +"tool.definition"?: ( + input: { toolID: string }, + output: { description: string; parameters: any }, +) => Promise; +``` + +- [ ] **Step 1: Write failing test for `biasState` lifecycle** + + In `src/index.test.ts`, test: + - `sessionBiasState` Map is empty initially (no bias for unknown sessions) + - `biasState` = `"new-session"` is set when `chat.message` fires for a session + with no prior events + - `biasState` = `"post-compaction"` is set when `session.compacting` fires + - Entries are deleted from the Map after `tool.definition` emits the + strengthened description for `session_search` + +- [ ] **Step 2: Write failing test for `tool.definition` hook** + + - When any session has `biasState` `"new-session"` or `"post-compaction"`, + calling `tool.definition` with `{ toolID: "session_search" }` mutates + `output.description` to the strengthened variant + - When no session has non-`"normal"` state, description stays at baseline + - `tool.definition` for non-`session_search` tools is a no-op + - After one strengthened emit, the next call returns baseline (entries were + consumed) + - When multiple sessions are biased, one `tool.definition` call consumes all + of them + +- [ ] **Step 3: Implement per-session `biasState` tracking** + + Add module-scoped (or plugin-context-scoped) state: + + ```ts + type BiasState = "normal" | "new-session" | "post-compaction"; + const sessionBiasState = new Map(); + ``` + + - In `chat.message` handler: if the session has no prior events recorded in + Redis, set `sessionBiasState.set(canonicalSessionId, "new-session")` + - In `session.compacting` handler: set + `sessionBiasState.set(canonicalSessionId, "post-compaction")` + +- [ ] **Step 4: Register `tool.definition` hook** + + In the plugin return object, add: + + ```ts + "tool.definition": async ( + input: { toolID: string }, + output: { description: string; parameters: any }, + ) => { + if (input.toolID !== "session_search") return; + + // Check if any tracked session is biased + let anyBiased = false; + for (const [sessionId, state] of sessionBiasState) { + if (state !== "normal") { + anyBiased = true; + sessionBiasState.delete(sessionId); // consume + } + } + + if (anyBiased) { + output.description = STRENGTHENED_SESSION_SEARCH_DESCRIPTION; + } + }, + ``` + +- [ ] **Step 5: Verify** + + ```bash + deno test -A src/index.test.ts + deno task check + ``` + +--- + +## Task 6: Plugin Wiring + +**Files:** + +- Modify: `src/index.ts` +- Modify: `src/index.test.ts` + +- [ ] **Step 1: Write failing test for note service instantiation** + + Verify the plugin factory creates a `SessionNotesService` with the Redis + client and `sessionTtlSeconds` config, and passes it into + `createSessionMcpRuntime` and `SessionManager`. + +- [ ] **Step 2: Instantiate `SessionNotesService` in plugin factory** + + In the `graphiti` plugin function, after creating the `redisClient`, create: + ```ts + const sessionNotes = new SessionNotesService(redisClient, { + sessionTtlSeconds: config.redis.sessionTtlSeconds, + }); + ``` + + Pass `sessionNotes` to: + - `createSessionMcpRuntime({ ..., notesService: sessionNotes })` + - `new SessionManager(..., { ..., notesService: sessionNotes })` + +- [ ] **Step 3: Add `tool.definition` hook to plugin return** + + Ensure the `tool.definition` hook (from Task 5) is included in the returned + plugin hook map. + +- [ ] **Step 4: Update `GraphitiDependencies` type if needed** + + If `SessionNotesService` is injected via DI, add it to the dependencies type. + Otherwise, instantiate directly. + +- [ ] **Step 5: Verify full integration** + + ```bash + deno test -A src/index.test.ts + deno test -A + deno task check + deno task lint + deno task fmt + ``` + +--- + +## Task 7: End-to-End Validation + +- [ ] **Step 1: Run full test suite** + + ```bash + deno test -A + ``` + + All existing tests must pass. No regressions. + +- [ ] **Step 2: Run quality checks** + + ```bash + deno task check + deno task lint + deno task fmt + ``` + +- [ ] **Step 3: Verify critical evidence** + + Confirm through test output: + - Notes can be written, replaced, deleted, cleared via `replace: "*"`, and + read exactly + - `readNotes` with no notes returns `{ notes: [] }` + - `readNotes` with nonexistent ID returns `{ notes: [] }` + - `session_search` includes note hits with `type: "note"` and `note_id` + - `session_search` description is the verbatim baseline from this plan + - Compaction receives full note contents as input with explicit + `` provenance + - Compaction envelope includes notes as raw material alongside session + snapshot, not pre-summarized + - Empty notes produce no `` section (omitted, not empty tag) + - Normal chat-turn `prepareInjection(id)` does NOT include `` + - `prepareInjection(id, undefined, { forCompaction: false })` does NOT include + `` + - `prepareInjection(id, undefined, { forCompaction: true })` DOES include + `` when notes exist + - `tool.definition` strengthens `session_search` when any tracked session is + biased + - Bias entries are consumed (deleted from Map) after one strengthened emission + - Note tool responses omit `status` field + +- [ ] **Step 4: Validate multi-line tool description rendering** + + The new tool descriptions are multi-line and substantially longer than the + previous one-line descriptions. Run the plugin in a local OpenCode instance + (or inspect the tool registration output in test) and verify: + - `session_notes_write`, `session_notes_read`, and `session_search` + descriptions are rendered in full (not truncated) + - Line breaks, indentation, and markdown formatting survive the tool + registration → display pipeline + - No rendering artifacts (e.g., collapsed whitespace, escaped newlines) appear + in the tool picker or tool description surface + + If the OpenCode tool surface truncates or mangles multi-line descriptions, + file a follow-up issue and fall back to a condensed single-paragraph + description that preserves the core behavioral nudges. + +--- + +## Compaction Behavior — Explicit Contract + +The compaction hook injects the complete, unmodified note contents as input +context to the compaction agent. The spec requires: + +1. The plugin loads all notes for the canonical root session from + `SessionNotesService.readNotes(rootSessionId)`. +2. Note bodies are rendered verbatim inside a `` XML section + within the `` envelope. +3. The plugin does NOT pre-summarize, compress, or reinterpret note bodies. +4. The compaction agent receives both the session conversation/tool history AND + the injected note contents, and summarizes them together. +5. The `` section preserves note boundaries (individual `` + tags with IDs and timestamps) so the compaction agent can attribute + provenance. +6. Note injection is compaction-time only — gated by the `forCompaction` flag on + `prepareInjection`. Normal `chat.message` and `messages.transform` turns do + NOT pass this flag and therefore do NOT inject notes. +7. When no notes exist for the session, the `` section is omitted + entirely from the compaction envelope. + +--- + +## Known Risks and Follow-Ups + +### Note-Body Budget in Compaction + +The compaction envelope has a total size budget. Large or numerous notes could +consume a disproportionate share of the compaction context, potentially crowding +out session event history or persistent memory. The current design does not cap +note injection size separately from the overall envelope budget. + +**Follow-up:** After initial implementation, monitor compaction envelope sizes +in practice. If note bodies routinely exceed a significant fraction of the +compaction context limit, add a dedicated note-body budget (analogous to +`PERSISTENT_MEMORY_BODY_BUDGET`) that truncates the oldest notes first while +preserving the most recently updated ones. + +### Search Score Interoperability + +Note search scores (from `SessionNotesService.searchNotes`) and memory search +scores (from the existing corpus search pipeline) must both be `0`–`1` floats +for merged sorting to produce sensible interleaving. The note service implements +a simple substring/token-match scoring algorithm. The corpus search may use a +different scoring approach. If scoring distributions diverge significantly in +practice (e.g., all note scores cluster near `0.3` while memory scores cluster +near `0.9`), the merged results will be effectively partitioned rather than +interleaved. + +**Follow-up:** After initial implementation, sample merged search results to +verify that the score distributions are reasonably compatible. If not, consider +a lightweight normalization or boosting factor. + +--- + +## Out of Scope + +- TUI or GUI note display surfaces +- Structured note payloads or typed task-state columns +- Note injection into normal chat turns +- A standalone `session_note_search` / `session_notes_search` tool +- Heuristic pre-compaction reminder nudges +- Turn-local reminder nudges outside description shaping diff --git a/docs/superpowers/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..eaa01ab --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-session-notes-anti-drift-design.md @@ -0,0 +1,429 @@ +# Session Notes Anti-Drift Design + +## Goal + +Add an agent-driven session-notes layer of MCP tools that helps preserve working +context across long tool-calling sessions, interleaved user topics, and +compaction without introducing structured note storage. + +The design keeps note contents as opaque markdown bodies stored on the existing +Redis hot tier. Agents should be biased to use note tools naturally through tool +descriptions and search behavior rather than through rigid schemas or new UI +surfaces. + +## Why This Change + +The current plugin preserves session continuity through event extraction, +snapshot rebuilding, cached persistent memory, and compaction-time +`` injection. That is strong for ordinary conversation flow, but +it does not give agents an explicit way to pin deliberately written working +context for topics that stall, get interrupted, or return later in the same +session. + +The target workflow is an agent session with multiple interleaving topics, +frequent short task switches, and user corrections that must survive compaction. +In that workflow, relying only on latent recall, event summaries, and generic +search has proven too weak. A note layer should give the agent a small set of +tools to: + +- write pinned markdown notes when context is likely to drift +- reopen exact note contents later instead of reconstructing them from memory +- surface prior note hits through the existing recall path +- feed complete note bodies into compaction as source input so the compaction + model can synthesize session history and notes together + +The design explicitly avoids structured note fields. Agents may be encouraged to +write readable markdown sections, but the storage layer itself remains opaque +text plus minimal note metadata. + +## Required Behavior + +### Storage Model + +- Session notes must use the same Redis endpoint already used by the plugin hot + tier. +- Notes should live in a dedicated Redis namespace rather than being mixed into + ordinary event or memory keys. +- Notes are stored per canonical root session so child-agent activity remains + aligned with parent-session continuity. +- Note contents are opaque markdown bodies. +- Note keys should expire using the existing `sessionTtlSeconds` configuration + value (default 86400 seconds), matching the lifetime of other session-scoped + Redis data. +- Stored metadata is minimal and operational only: + - note id + - canonical root session id + - note creation timestamp + - note update timestamp + - note title or session title data only where needed for search result + annotation +- The design must not introduce structured note payload fields such as `status`, + `goal`, `blocker`, or other typed task-state columns. + +### MCP Tool Surface + +The note feature should expose exactly two dedicated note tools: + +- `session_notes_write(text: string, replace?: string)` → + `{ action: "created" | "replaced" | "deleted", note_id?: string, cleared_count?: number }` +- `session_notes_read(id?: string)` → + `{ notes: Array<{ note_id: string, text: string, created_at: string, updated_at: string }> }` + +`session_note_search` is explicitly out of scope and should not be added. + +### `session_notes_write` + +- Adds a note entry to session note storage and returns an explicit outcome + object so agents can tell whether the operation created, replaced, deleted, or + replaced all notes without inferring it from the inputs alone. +- If `replace` is omitted, append a new note. +- If `replace` is a note id, replace that single note entry. +- If `replace` is `"*"`, replace all current-session notes. +- If `text` is empty and `replace` is provided, clear the targeted note or note + set. +- `session_notes_write` responses must make deletion transparent to the agent: + - append new note → `{ action: "created", note_id }` + - replace one note → `{ action: "replaced", note_id }` + - delete one note → `{ action: "deleted", note_id }` + - replace all notes with one new note → + `{ action: "replaced", note_id, cleared_count }` + - clear all notes with empty `text` and `replace: "*"` → + `{ action: "replaced", cleared_count }` +- Replacement behavior applies only within the canonical root session note set. +- Tool descriptions should strongly bias usage toward anti-drift note taking, + including examples such as: + - before switching topics + - after a user correction changes assumptions + - when a small task stalls and work is about to shift elsewhere + - during long tool loops where state may otherwise live only in model context +- The description should encourage concise markdown formatting with headings, + bullets, and short code examples when useful, without making that format + mandatory. + +### `session_notes_read` + +- If `id` is omitted, return all notes for the current canonical root session. +- If `id` is provided, return the exact note contents for that note. +- Returns `{ notes: [{ note_id, text, created_at, updated_at }] }`. +- The response should preserve the original note text rather than paraphrasing + or transforming it. +- The tool exists primarily so agents can reload exact pinned context instead of + reciting it from latent memory. +- Tool descriptions should bias usage toward reopening note contents when the + agent resumes an interrupted topic or needs the exact wording of pinned user + instructions. + +### `session_search` + +- `session_search` remains the primary recall entrypoint. +- Note hits should be included in `session_search` results alongside existing + session or memory search results. +- Note hits must be clearly labeled as note-tool material so the agent can tell + that the result came from pinned notes rather than indexed memory content. +- The result item schema should be extended with optional + `type?: "memory" | + "note"` and `note_id?: string` fields so note hits are + unambiguous and agents can follow up with `session_notes_read` by note id. +- Existing memory results should default to `type: "memory"` and omit `note_id`. +- Note hits should include enough metadata for an obvious follow-up with + `session_notes_read`, such as note id, session id, session title, and snippet. +- Existing session-search behavior should remain intact for memory results. +- The implementation should merge note hits conservatively so note recall is + discoverable without overwhelming existing search output. + +## Agent Usage Bias + +The design should not rely on agents inferring note workflows on their own. +Usage bias is part of the feature. + +### Strong Tool-Description Bias + +The note-tool descriptions should be intentionally prescriptive rather than +neutral. + +`session_notes_write` should read as the preferred way to pin working context +that must survive: + +- long tool-calling sessions +- topic switches +- stalls or blockers +- user corrections +- compaction + +`session_notes_read` should read as the preferred way to reopen exact note text +instead of reconstructing note contents from memory. + +`session_search` should be reframed as the default recall tool for: + +- new sessions +- post-compaction turns +- resumed or repeated topics +- checking whether earlier work or pinned notes already contain the needed + context + +The intended descriptions should include concrete markdown examples so agents +are nudged toward useful freeform notes without the storage layer becoming +structured. + +### Dynamic `session_search` Description Bias + +Static descriptions alone are not strong enough. The plugin should use the +OpenCode `tool.definition` hook to dynamically strengthen `session_search` +guidance when it is most useful. + +The important dynamic-bias moments are: + +- new-session turns +- post-compaction turns + +At those times, the `session_search` description sent to the model should be +augmented to emphasize that agents should use it before re-solving earlier work +or when resuming context that may have drifted. + +This is a description-layer bias only. It does not add extra reminder text into +ordinary turn prompts and does not introduce heuristic pre-compaction reminders. + +#### Bias State Mechanism + +The `tool.definition` hook receives only `{ toolID }` as input — no session +state, turn count, or compaction flag. To work around this limitation, the +plugin should maintain a per-session `biasState` flag in module-scoped state: + +- Set `biasState = "new-session"` when a session is first seen in `chat.message` + (no prior events recorded for that session). +- Set `biasState = "post-compaction"` when `session.compacting` fires. +- Clear `biasState` back to `"normal"` after the first `tool.definition` call + for `session_search` has consumed the flag (i.e., after the strengthened + description has been emitted once). +- The `tool.definition` hook reads the current `biasState` and returns the + strengthened or normal description accordingly. + +This keeps the mechanism local to the plugin without requiring upstream changes +to the OpenCode plugin API. + +### Recall Workflow + +The intended default workflow becomes: + +1. Use `session_search` to broadly recall prior context and note hits. +2. Use `session_notes_read` to reopen exact note text once a note hit or current + note set looks relevant. +3. Use `session_notes_write` to pin new or updated working context before drift + is likely. + +This keeps the search path unified instead of splitting recall between multiple +specialized search tools that agents are unlikely to adopt consistently. + +## Compaction Behavior + +### Full Note Bodies As Compaction Input + +- The compaction hook must inject the complete current-session note contents as + input context to compaction. +- The plugin must not pre-summarize, compress, or reinterpret note bodies before + injecting them, beyond safe escaping and envelope formatting. +- The compaction prompt should explicitly state that the injected note contents + came from note tools and were intentionally written to preserve anti-drift + context. +- The compaction model should summarize both: + - the session conversation and tool history + - the injected note contents + +This means the note layer provides the raw note material and provenance, while +the compaction model performs the actual synthesis. + +### Compaction Envelope Shape + +The plugin should extend the compaction-time `` payload with a +dedicated note section, for example a `` block, so provenance is +explicit and the compaction model can treat note text as intentionally pinned +material. + +The rendered section should: + +- include complete note bodies for the canonical root session +- preserve note boundaries and note ids +- identify that the contents came from note tools +- remain separate from snapshot and persistent-memory sections + +This note section is required for compaction input. Injecting notes into normal +chat-message turns is out of scope. + +## Recommended Approach + +### Option A: Dedicated Redis Note Store Plus Search Integration + +Recommended. + +- Add a dedicated Redis-backed note service using the existing hot-tier Redis + endpoint. +- Keep note mutation and exact reads separate from event and memory storage. +- Extend `session_search` to merge note hits into the main recall path. +- Extend compaction rendering to include full note bodies with explicit + provenance. +- Use `tool.definition` to bias `session_search` descriptions on new-session and + post-compaction turns. + +This approach fits note semantics cleanly without contorting event or memory +storage into a mutable note store. + +### Option B: Store Notes As Ordinary Session Events + +Not recommended. + +- Reuse event storage and reconstruct note state from events. + +This makes replace semantics, exact reads, and compaction-time note rendering +awkward. Events are append-oriented and do not naturally model a mutable note +set. + +### Option C: Store Notes Inside Corpus Records Only + +Not recommended. + +- Reuse session corpus indexing as the primary note store. + +This overfits a chunked search index to a feature that needs exact note reads, +note replacement, and explicit compaction provenance. (Note: "corpus" here +refers to the internal implementation class name `SessionCorpus`, not the +user-facing terminology which uses "memory".) + +## Implementation Shape + +### `src/services/session-notes.ts` + +Add a new note service responsible for: + +- note storage keyed by canonical root session id +- note id generation +- append and replace semantics +- clear semantics through empty text plus `replace` +- current-session note reads +- migration of root-session note state if canonical roots change +- note-search indexing and retrieval for `session_search` + +This service should depend only on the existing Redis client and remain on the +hot tier. + +### `src/services/session-mcp-types.ts` + +- Add `session_notes_write` and `session_notes_read` to the MCP tool name set. +- Define request and response schemas for both tools: + - `session_notes_write` response: + `{ action: "created" | "replaced" | "deleted", note_id?: string, cleared_count?: number }` + - `session_notes_read` response: + `{ notes: Array<{ note_id: string, text: string, created_at: string, updated_at: string }> }` +- Extend `session_search` result item schema with optional + `type?: "memory" | + "note"` and `note_id?: string` fields. +- Keep `.strict()` on the result item schema and add the new optional fields + before the strict call. + +### `src/services/session-mcp-runtime.ts` + +- Register the new note tools. +- Route note tool handlers through the note service. +- Update `session_search` to merge note results with existing memory results. +- Rewrite tool descriptions for: + - `session_notes_write` + - `session_notes_read` + - `session_search` +- Ensure the `session_search` description is strong enough in its baseline form + to bias usage even before dynamic description augmentation. + +### `src/session.ts` + +- Extend session-memory composition to load current-session notes for compaction + rendering. +- Add note-aware XML rendering that preserves note boundaries and provenance. +- Keep note injection limited to compaction-time session memory rather than + normal turn injection. + +### `src/handlers/compacting.ts` + +- Continue to prepare compaction injection through the canonical session. +- Ensure the compaction context includes the complete note section inside the + rendered `` envelope. +- Preserve the local-first behavior: no Graphiti fetch should be required for + note injection. + +### `src/index.ts` + +- Instantiate the new note service on the existing Redis client. +- Pass note service dependencies into the MCP runtime and session manager. +- Maintain a per-session `biasState` flag (module-scoped or on the plugin + context) that tracks whether the current turn is new-session, post-compaction, + or normal. +- Set `biasState` in `chat.message` (new session detection) and + `session.compacting` hooks. +- Register a `tool.definition` hook that reads `biasState` to strengthen or + normalize the `session_search` description. +- Clear `biasState` back to `"normal"` after `tool.definition` emits the + strengthened description once. + +### Search Result Rendering + +If note hits and memory hits share one response array, the result shape should +distinguish them clearly enough that an agent can tell which follow-up to use. +The implementation may add a result discriminator or equivalent metadata, but it +should remain compact and obvious in plain JSON output. + +## Testing Strategy + +Follow TDD for the feature. + +### Red + +- Add MCP schema tests that fail until the new note tools exist. +- Add runtime tests that fail until: + - note writes append and replace correctly + - note reads return exact stored text + - `session_search` includes note hits + - compaction injection includes full note bodies with provenance + - `session_search` dynamic description bias changes on new-session and + post-compaction states + +### Green + +- Implement only the minimal note service, tool registration, search merge, and + compaction rendering changes required to satisfy the new failing tests. + +### Refactor + +- Extract small helpers only where repeated note-rendering, note-hit merging, or + description-bias logic would otherwise become unclear. +- Do not introduce structured note parsing or typed note sections. + +## Validation Plan + +At minimum, verify: + +- `deno test -A src/services/session-mcp-runtime.test.ts` +- `deno test -A src/handlers/compacting.test.ts` +- `deno test -A` +- `deno task check` +- `deno task lint` +- `deno task fmt` + +The critical evidence is: + +- notes can be written, replaced, deleted, cleared via `replace: "*"`, and read + exactly +- `session_notes_write` responses make deletion/clear outcomes explicit to the + agent instead of requiring inference from `text` and `replace` +- `session_search` visibly includes note hits and is described as the preferred + recall path +- compaction receives full note contents as input with explicit note-tool + provenance +- interleaved-topic note context survives compaction through the injected note + section + +## Out Of Scope + +- TUI or GUI note display surfaces +- any new external plugin UI system +- structured note payloads or typed task-state storage +- note injection into normal `chat.message` or `messages.transform` turns +- a standalone `session_note_search` or `session_notes_search` tool +- heuristic pre-compaction reminder nudges +- generic turn-local reminder nudges outside description shaping diff --git a/src/handlers/chat.test.ts b/src/handlers/chat.test.ts index ca6af25..8516c03 100644 --- a/src/handlers/chat.test.ts +++ b/src/handlers/chat.test.ts @@ -34,8 +34,11 @@ class MockSessionManager { threshold: 0.5, cachedQuery: null, }; - prepareInjectionCalls: Array<{ sessionId: string; lastRequest?: string }> = - []; + prepareInjectionCalls: Array<{ + sessionId: string; + lastRequest?: string; + options?: { forCompaction?: boolean }; + }> = []; state = { groupId: "group-1", userGroupId: "user-1", @@ -77,10 +80,15 @@ class MockSessionManager { }; } - prepareInjection(_sessionId: string, lastRequest?: string) { + prepareInjection( + _sessionId: string, + lastRequest?: string, + options?: { forCompaction?: boolean }, + ) { this.prepareInjectionCalls.push({ sessionId: _sessionId, lastRequest, + options, }); const prepared = this.prepareInjectionResult === undefined ? { @@ -182,6 +190,7 @@ describe("chat handler", () => { assertEquals(sessionManager.prepareInjectionCalls, [{ sessionId: "session-1", lastRequest: "Continue the migration", + options: undefined, }]); assertEquals(graphitiAsync.drainCalls, []); }); @@ -322,6 +331,7 @@ describe("chat handler", () => { assertEquals(sessionManager.prepareInjectionCalls, [{ sessionId: "parent-session", lastRequest: "Continue the child task", + options: undefined, }]); }); @@ -431,6 +441,7 @@ describe("chat handler", () => { assertEquals(sessionManager.prepareInjectionCalls, [{ sessionId: "session-1", lastRequest: "Race the refresh", + options: undefined, }]); assertEquals(sessionManager.state.injectedMemories, false); assertEquals(sessionManager.state.pendingInjection, undefined); diff --git a/src/handlers/compacting.test.ts b/src/handlers/compacting.test.ts index cf4eaf6..88e2829 100644 --- a/src/handlers/compacting.test.ts +++ b/src/handlers/compacting.test.ts @@ -10,7 +10,11 @@ class MockSessionManager { hotTierReady: true, pendingInjection: undefined as unknown, }; - prepareInjectionCalls: string[] = []; + prepareInjectionCalls: Array<{ + sessionId: string; + lastRequest?: string; + options?: { forCompaction?: boolean }; + }> = []; clearPendingInjectionCalls = 0; activeCalls: Array<{ sessionId: string; canonicalSessionId?: string }> = []; @@ -22,8 +26,12 @@ class MockSessionManager { }; } - prepareInjection(sessionId: string) { - this.prepareInjectionCalls.push(sessionId); + prepareInjection( + sessionId: string, + lastRequest?: string, + options?: { forCompaction?: boolean }, + ) { + this.prepareInjectionCalls.push({ sessionId, lastRequest, options }); const prepared = { envelope: '', @@ -64,7 +72,11 @@ describe("compacting handler", () => { assertEquals(output.context.length, 2); assertStringIncludes(output.context[1], " { it("preserves local-first session memory shape during compaction with cached persistent memory optional", async () => { const sessionManager = new MockSessionManager(); - sessionManager.prepareInjection = ((sessionId: string) => { - sessionManager.prepareInjectionCalls.push(sessionId); + sessionManager.prepareInjection = (( + sessionId: string, + lastRequest?: string, + options?: { forCompaction?: boolean }, + ) => { + sessionManager.prepareInjectionCalls.push({ + sessionId, + lastRequest, + options, + }); const prepared = { envelope: 'continuecached recall', @@ -117,7 +137,11 @@ describe("compacting handler", () => { assertEquals(output.context.length, 2); assertStringIncludes(output.context[1], " unknown; + prepareInjectionImpl?: ( + sessionId: string, + lastRequest?: string, + options?: { forCompaction?: boolean }, + ) => unknown; activeCalls: Array<{ sessionId: string; canonicalSessionId?: string }> = []; clearPendingInjection(state: typeof this.state, prepared?: unknown) { if (state.pendingInjection === prepared) { @@ -37,9 +41,13 @@ class MockSessionManager { }; } - prepareInjection(sessionId: string, lastRequest?: string) { + prepareInjection( + sessionId: string, + lastRequest?: string, + options?: { forCompaction?: boolean }, + ) { if (this.prepareInjectionImpl) { - return this.prepareInjectionImpl(sessionId, lastRequest); + return this.prepareInjectionImpl(sessionId, lastRequest, options); } return this.state.pendingInjection; } diff --git a/src/index.test.ts b/src/index.test.ts index ac573b7..6324eb2 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -11,6 +11,10 @@ import { } from "./index.ts"; import { logger } from "./services/logger.ts"; import { registerRuntimeTeardown } from "./services/runtime-teardown.ts"; +import { + SESSION_SEARCH_BASELINE_DESCRIPTION, + SESSION_SEARCH_STRENGTHENED_DESCRIPTION, +} from "./services/session-mcp-runtime.ts"; import { setOpenCodeClient, setWarningTaskScheduler, @@ -30,6 +34,7 @@ function createEntrypointHarnessWithOptions(options: { connected?: boolean; readyError?: Error; redisConnectError?: Error; + priorEventsBySessionId?: Record; teardownRun?: () => Promise; teardownDispose?: () => void; createSessionMcpRuntimeError?: Error; @@ -58,8 +63,12 @@ function createEntrypointHarnessWithOptions(options: { }; const hooks = { event: { kind: "event" }, - chat: { kind: "chat" }, - compacting: { kind: "compacting" }, + chat: (input: unknown, output: unknown) => { + records.chatHookCalls.push({ input, output }); + }, + compacting: (input: unknown, output: unknown) => { + records.compactingHookCalls.push({ input, output }); + }, messages: { kind: "messages" }, tool: { session_execute: { kind: "session_execute" }, @@ -102,6 +111,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 +123,11 @@ function createEntrypointHarnessWithOptions(options: { { ttlSeconds: number; driftThreshold: number }, ]>, redisCacheInstances: [] as unknown[], + sessionNotesArgs: [] as Array<[ + unknown, + { sessionTtlSeconds: number }, + ]>, + sessionNotesInstances: [] as unknown[], batchDrainArgs: [] as Array<[ unknown, unknown, @@ -126,7 +145,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 +158,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 +222,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 +250,13 @@ function createEntrypointHarnessWithOptions(options: { } } + class MockSessionNotesService { + constructor(redisClient: unknown, options: { sessionTtlSeconds: number }) { + records.sessionNotesArgs.push([redisClient, options]); + records.sessionNotesInstances.push(this); + } + } + class MockBatchDrainService { constructor( redisClient: unknown, @@ -367,6 +408,7 @@ function createEntrypointHarnessWithOptions(options: { RedisEventsService: MockRedisEventsService, RedisSnapshotService: MockRedisSnapshotService, RedisCacheService: MockRedisCacheService, + SessionNotesService: MockSessionNotesService, BatchDrainService: MockBatchDrainService, GraphitiAsyncService: MockGraphitiAsyncService, createSessionExecutor: (args?: Record) => @@ -902,6 +944,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 +977,13 @@ describe("index", () => { ttlSeconds: config.redis.cacheTtlSeconds, driftThreshold: config.graphiti.driftThreshold, }); + assertStrictEquals( + records.sessionNotesArgs[0][0], + records.redisClientInstances[0], + ); + assertEquals(records.sessionNotesArgs[0][1], { + sessionTtlSeconds: config.redis.sessionTtlSeconds, + }); assertStrictEquals( records.batchDrainArgs[0][0], records.redisClientInstances[0], @@ -985,6 +1035,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 +1128,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 +1140,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 +1158,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 +1180,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 +1202,168 @@ describe("index", () => { endpoint: config.redis.endpoint, }]); assertStrictEquals(plugin.event, hooks.event); - assertStrictEquals(plugin["chat.message"], hooks.chat); + assertEquals(typeof plugin["chat.message"], "function"); + }); + + it("strengthens session_search once for new-session bias and leaves other tools unchanged", async () => { + const { input, records, dependencies } = createEntrypointHarness(true); + + const plugin = await invokeGraphiti(input, dependencies) as Record< + string, + unknown + >; + const chatHook = plugin["chat.message"] as ( + input: { sessionID: string }, + output: { parts: unknown[] }, + ) => Promise; + const toolDefinitionHook = plugin["tool.definition"] as ( + input: { toolID: string }, + output: { description: string; parameters: unknown }, + ) => Promise; + + await chatHook( + { sessionID: "session-a" }, + { parts: [] }, + ); + + assertEquals(records.chatHookCalls.length, 1); + + const nonSearchOutput = { + description: "Execute a bounded session command.", + parameters: { type: "object" }, + }; + await toolDefinitionHook( + { toolID: "session_execute" }, + nonSearchOutput, + ); + assertEquals( + nonSearchOutput.description, + "Execute a bounded session command.", + ); + + const strengthenedOutput = { + description: SESSION_SEARCH_BASELINE_DESCRIPTION, + parameters: { type: "object" }, + }; + await toolDefinitionHook( + { toolID: "session_search" }, + strengthenedOutput, + ); + assertEquals( + strengthenedOutput.description, + SESSION_SEARCH_STRENGTHENED_DESCRIPTION, + ); + + const baselineOutput = { + description: SESSION_SEARCH_BASELINE_DESCRIPTION, + parameters: { type: "object" }, + }; + await toolDefinitionHook( + { toolID: "session_search" }, + baselineOutput, + ); + assertEquals( + baselineOutput.description, + SESSION_SEARCH_BASELINE_DESCRIPTION, + ); + }); + + it("does not set new-session bias when prior session events already exist", async () => { + const { input, records, dependencies } = + createEntrypointHarnessWithOptions({ + connected: true, + priorEventsBySessionId: { + "session-a": [{ id: "evt-1" }], + }, + }); + + const plugin = await invokeGraphiti(input, dependencies) as Record< + string, + unknown + >; + const chatHook = plugin["chat.message"] as ( + input: { sessionID: string }, + output: { parts: unknown[] }, + ) => Promise; + const toolDefinitionHook = plugin["tool.definition"] as ( + input: { toolID: string }, + output: { description: string; parameters: unknown }, + ) => Promise; + + await chatHook( + { sessionID: "session-a" }, + { parts: [] }, + ); + + assertEquals(records.redisEventsRecentCalls, [{ + sessionId: "session-a", + limit: 1, + chronological: false, + }]); + + const output = { + description: SESSION_SEARCH_BASELINE_DESCRIPTION, + parameters: { type: "object" }, + }; + await toolDefinitionHook( + { toolID: "session_search" }, + output, + ); + + assertEquals(output.description, SESSION_SEARCH_BASELINE_DESCRIPTION); + }); + + it("sets post-compaction bias and consumes multiple biased sessions together", async () => { + const { input, records, dependencies } = createEntrypointHarness(true); + + const plugin = await invokeGraphiti(input, dependencies) as Record< + string, + unknown + >; + const chatHook = plugin["chat.message"] as ( + input: { sessionID: string }, + output: { parts: unknown[] }, + ) => Promise; + const compactingHook = plugin["experimental.session.compacting"] as ( + input: { sessionID: string }, + output: { context: string[] }, + ) => Promise; + const toolDefinitionHook = plugin["tool.definition"] as ( + input: { toolID: string }, + output: { description: string; parameters: unknown }, + ) => Promise; + + await chatHook({ sessionID: "session-a" }, { parts: [] }); + await compactingHook({ sessionID: "session-b" }, { context: [] }); + + assertEquals(records.chatHookCalls.length, 1); + assertEquals(records.compactingHookCalls.length, 1); + + const firstOutput = { + description: SESSION_SEARCH_BASELINE_DESCRIPTION, + parameters: { type: "object" }, + }; + await toolDefinitionHook( + { toolID: "session_search" }, + firstOutput, + ); + assertEquals( + firstOutput.description, + SESSION_SEARCH_STRENGTHENED_DESCRIPTION, + ); + + const secondOutput = { + description: SESSION_SEARCH_BASELINE_DESCRIPTION, + parameters: { type: "object" }, + }; + await toolDefinitionHook( + { toolID: "session_search" }, + secondOutput, + ); + assertEquals( + secondOutput.description, + SESSION_SEARCH_BASELINE_DESCRIPTION, + ); }); it("passes live redis client, ttl, and groupId into session MCP runtime", async () => { @@ -1163,6 +1376,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 +1395,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 +1431,7 @@ describe("index", () => { const args = records.sessionMcpRuntimeArgs[0] ?? {}; assertStrictEquals(args.redisClient, records.redisClientInstances[0]); + assertStrictEquals(args.notesService, records.sessionNotesInstances[0]); assertEquals(args.sessionTtlSeconds, 60); assertEquals(args.groupId, "group-id"); }); diff --git a/src/index.ts b/src/index.ts index 483c48f..1b2dbfa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import type { Plugin, PluginInput } from "@opencode-ai/plugin"; +import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"; import { loadConfig } from "./config.ts"; import { createChatHandler } from "./handlers/chat.ts"; import { createCompactingHandler } from "./handlers/compacting.ts"; @@ -20,14 +20,30 @@ import { RedisCacheService } from "./services/redis-cache.ts"; import { RedisClient } from "./services/redis-client.ts"; import { RedisEventsService } from "./services/redis-events.ts"; import { logger } from "./services/logger.ts"; +import { SessionNotesService } from "./services/session-notes.ts"; import { RedisSnapshotService } from "./services/redis-snapshot.ts"; import { registerRuntimeTeardown } from "./services/runtime-teardown.ts"; import { createSessionExecutor } from "./services/session-executor.ts"; -import { createSessionMcpRuntime } from "./services/session-mcp-runtime.ts"; +import { + createSessionMcpRuntime, + SESSION_SEARCH_STRENGTHENED_DESCRIPTION, +} from "./services/session-mcp-runtime.ts"; import { ToolGuidanceCache } from "./services/tool-guidance-cache.ts"; import { ToolRoutingOutcomeCache } from "./services/tool-routing-outcome-cache.ts"; import { makeGroupId, makeUserGroupId } from "./utils.ts"; +type BiasState = "normal" | "new-session" | "post-compaction"; + +type ChatMessageHook = NonNullable; +type ChatMessageInput = Parameters[0]; +type ChatMessageOutput = Parameters[1]; +type CompactingHook = NonNullable; +type CompactingInput = Parameters[0]; +type CompactingOutput = Parameters[1]; +type ToolDefinitionHook = NonNullable; +type ToolDefinitionInput = Parameters[0]; +type ToolDefinitionOutput = Parameters[1]; + type GraphitiDependencies = { loadConfig: typeof loadConfig; setOpenCodeClient: typeof setOpenCodeClient; @@ -46,6 +62,7 @@ type GraphitiDependencies = { RedisEventsService: typeof RedisEventsService; RedisSnapshotService: typeof RedisSnapshotService; RedisCacheService: typeof RedisCacheService; + SessionNotesService: typeof SessionNotesService; BatchDrainService: typeof BatchDrainService; GraphitiAsyncService: typeof GraphitiAsyncService; createSessionExecutor: typeof createSessionExecutor; @@ -104,6 +121,7 @@ const defaultGraphitiDependencies: GraphitiDependencies = { RedisEventsService, RedisSnapshotService, RedisCacheService, + SessionNotesService, BatchDrainService, GraphitiAsyncService, createSessionExecutor, @@ -206,6 +224,9 @@ export const graphiti: Plugin = ( ttlSeconds: config.redis.cacheTtlSeconds, driftThreshold: config.graphiti.driftThreshold, }); + const notesService = new dependencies.SessionNotesService(redisClient, { + sessionTtlSeconds: config.redis.sessionTtlSeconds, + }); const batchDrain = new dependencies.BatchDrainService( redisClient, redisEvents, @@ -237,6 +258,7 @@ export const graphiti: Plugin = ( const sessionMcpRuntime = dependencies.createSessionMcpRuntime({ redisClient, graphitiCache: redisCache, + notesService, sessionTtlSeconds: config.redis.sessionTtlSeconds, groupId: defaultGroupId, sessionExecutor, @@ -256,12 +278,24 @@ export const graphiti: Plugin = ( redisCache, { idleRetentionMs: config.redis.sessionTtlSeconds * 1000, + notesService, runtimeStateMigrator: sessionMcpRuntime, }, ); sessionMcpRuntime.setSessionCanonicalizer(sessionManager); const toolGuidanceCache = new dependencies.ToolGuidanceCache(); const toolRoutingOutcomes = new dependencies.ToolRoutingOutcomeCache(); + const sessionBiasState = new Map(); + const chatHandler = dependencies.createChatHandler({ + sessionManager, + redisEvents, + graphitiAsync, + drainTriggerSize: config.redis.batchSize, + }); + const compactingHandler = dependencies + .createCompactingHandler({ + sessionManager, + }); startupTeardown = dependencies.registerRuntimeTeardown([ { @@ -302,21 +336,62 @@ export const graphiti: Plugin = ( sdkClient: input.client, directory: input.directory, }), - "chat.message": dependencies.createChatHandler({ - sessionManager, - redisEvents, - graphitiAsync, - drainTriggerSize: config.redis.batchSize, - }), - "experimental.session.compacting": dependencies - .createCompactingHandler({ - sessionManager, - }), + "chat.message": async ( + hookInput: ChatMessageInput, + output: ChatMessageOutput, + ) => { + const canonicalSessionId = sessionManager.getCachedCanonicalSessionId( + hookInput.sessionID, + ) ?? + await sessionManager.resolveCanonicalSessionId(hookInput.sessionID); + if (canonicalSessionId && !sessionBiasState.has(canonicalSessionId)) { + const priorEvents = await redisEvents.getRecentSessionEvents( + canonicalSessionId, + 1, + false, + ); + if (priorEvents.length === 0) { + sessionBiasState.set(canonicalSessionId, "new-session"); + } + } + await chatHandler(hookInput, output); + }, + "experimental.session.compacting": async ( + hookInput: CompactingInput, + output: CompactingOutput, + ) => { + const canonicalSessionId = sessionManager.getCachedCanonicalSessionId( + hookInput.sessionID, + ) ?? + await sessionManager.resolveCanonicalSessionId(hookInput.sessionID); + if (canonicalSessionId) { + sessionBiasState.set(canonicalSessionId, "post-compaction"); + } + await compactingHandler(hookInput, output); + }, "experimental.chat.messages.transform": dependencies .createMessagesHandler({ sessionManager, }), tool: sessionMcpRuntime.tools, + "tool.definition": ( + hookInput: ToolDefinitionInput, + output: ToolDefinitionOutput, + ) => { + if (hookInput.toolID !== "session_search") return Promise.resolve(); + + let anyBiased = false; + for (const [sessionId, state] of sessionBiasState) { + if (state === "normal") continue; + anyBiased = true; + sessionBiasState.delete(sessionId); + } + + if (anyBiased) { + output.description = SESSION_SEARCH_STRENGTHENED_DESCRIPTION; + } + return Promise.resolve(); + }, "tool.execute.before": dependencies.createToolBeforeHandler({ sessionCanonicalizer: sessionManager, guidanceThrottle: toolGuidanceCache, diff --git a/src/services/session-mcp-runtime.test.ts b/src/services/session-mcp-runtime.test.ts index 7fb1f47..4490179 100644 --- a/src/services/session-mcp-runtime.test.ts +++ b/src/services/session-mcp-runtime.test.ts @@ -9,6 +9,9 @@ import { describe, it } from "jsr:@std/testing@^1.0.0/bdd"; import { createSessionMcpRuntime, SESSION_MCP_RESPONSE_BUDGET_BYTES, + SESSION_NOTES_READ_DESCRIPTION, + SESSION_NOTES_WRITE_DESCRIPTION, + SESSION_SEARCH_BASELINE_DESCRIPTION, } from "./session-mcp-runtime.ts"; import type { SessionExecutor } from "./session-executor.ts"; import { @@ -197,8 +200,82 @@ const validRequests: Record> = { session_doctor: { root_session_id: "root-123", }, + session_notes_write: { + root_session_id: "root-123", + text: "remember this", + }, + session_notes_read: { + root_session_id: "root-123", + }, }; +Deno.test("note schema compatibility accepts approved note request and response contracts", () => { + const writeRequest = sessionMcpRequestSchemas.session_notes_write.safeParse({ + root_session_id: "root-123", + text: "remember this", + replace: "note-1", + }); + const deleteResponse = sessionMcpResponseSchemas.session_notes_write + .safeParse({ + action: "deleted", + note_id: "note-1", + }); + const clearedResponse = sessionMcpResponseSchemas.session_notes_write + .safeParse({ + action: "replaced", + cleared_count: 2, + }); + const readRequest = sessionMcpRequestSchemas.session_notes_read.safeParse({ + root_session_id: "root-123", + id: "note-1", + }); + const readResponse = sessionMcpResponseSchemas.session_notes_read.safeParse({ + notes: [{ + note_id: "note-1", + text: "remember this", + created_at: "2026-04-11T10:00:00.000Z", + updated_at: "2026-04-11T10:00:00.000Z", + }], + }); + + assertEquals(writeRequest.success, true); + assertEquals(deleteResponse.success, true); + assertEquals(clearedResponse.success, true); + assertEquals(readRequest.success, true); + assertEquals(readResponse.success, true); +}); + +Deno.test("search schema compatibility accepts note-flavored results and remains strict", () => { + const accepted = sessionMcpResponseSchemas.session_search.safeParse({ + status: "ok", + results: [{ + corpus_ref: "session:root:corpus:1", + snippet: "remember this", + score: 0.9, + type: "note", + note_id: "note-1", + }], + corpus_refs: ["session:root:corpus:1"], + truncated: false, + }); + const rejected = sessionMcpResponseSchemas.session_search.safeParse({ + status: "ok", + results: [{ + corpus_ref: "session:root:corpus:1", + snippet: "remember this", + score: 0.9, + type: "note", + note_id: "note-1", + extra: true, + }], + corpus_refs: ["session:root:corpus:1"], + truncated: false, + }); + + assertEquals(accepted.success, true); + assertEquals(rejected.success, false); +}); + Deno.test("mixed|batch schema compatibility", () => { const request = sessionMcpRequestSchemas.session_batch_execute.safeParse({ root_session_id: "root-123", @@ -290,7 +367,7 @@ Deno.test("index schema compatibility rejects requests without content or path", }); describe("session-mcp-runtime", () => { - it("registers exactly the 8 session tools", () => { + it("registers exactly the session tools in the declared order", () => { const runtime = createSessionMcpRuntime(); try { @@ -300,6 +377,318 @@ describe("session-mcp-runtime", () => { } }); + it("registers note tools with the shipped descriptions and expected args", () => { + const runtime = createSessionMcpRuntime(); + + try { + assertExists(runtime.tools.session_notes_write); + assertExists(runtime.tools.session_notes_read); + assertEquals( + runtime.tools.session_notes_write.description, + SESSION_NOTES_WRITE_DESCRIPTION, + ); + assertEquals( + runtime.tools.session_notes_read.description, + SESSION_NOTES_READ_DESCRIPTION, + ); + assertEquals( + runtime.tools.session_search.description, + SESSION_SEARCH_BASELINE_DESCRIPTION, + ); + assertEquals(Object.keys(runtime.tools.session_notes_write.args), [ + "root_session_id", + "text", + "replace", + ]); + assertEquals(Object.keys(runtime.tools.session_notes_read.args), [ + "root_session_id", + "id", + ]); + } finally { + void runtime.dispose(); + } + }); + + it("executes the full note action contract through the runtime", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + } as never); + + try { + const created = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + root_session_id: "root-notes-runtime", + text: "first note", + }, + toolContext, + ), + ); + assertEquals(created.action, "created"); + assertExists(created.note_id); + + const readCreated = JSON.parse( + await runtime.tools.session_notes_read.execute( + { + root_session_id: "root-notes-runtime", + }, + toolContext, + ), + ); + assertEquals(readCreated.notes.length, 1); + assertEquals(readCreated.notes[0].text, "first note"); + assertEquals( + sessionMcpResponseSchemas.session_notes_read.safeParse(readCreated) + .success, + true, + ); + + const replaced = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + root_session_id: "root-notes-runtime", + text: "updated note", + replace: created.note_id, + }, + toolContext, + ), + ); + assertEquals(replaced, { + action: "replaced", + note_id: created.note_id, + }); + assertEquals( + sessionMcpResponseSchemas.session_notes_write.safeParse(replaced) + .success, + true, + ); + + const createdSecond = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + root_session_id: "root-notes-runtime", + text: "second note", + }, + toolContext, + ), + ); + assertEquals(createdSecond.action, "created"); + assertExists(createdSecond.note_id); + + const replacedAll = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + root_session_id: "root-notes-runtime", + text: "replacement note", + replace: "*", + }, + toolContext, + ), + ); + assertEquals(replacedAll.action, "replaced"); + assertExists(replacedAll.note_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( + { + root_session_id: "root-notes-runtime", + id: replacedAll.note_id, + }, + toolContext, + ), + ); + assertEquals(readSingle.notes.length, 1); + assertEquals(readSingle.notes[0].text, "replacement note"); + + const deleted = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + root_session_id: "root-notes-runtime", + text: "", + replace: replacedAll.note_id, + }, + toolContext, + ), + ); + assertEquals(deleted, { + action: "deleted", + note_id: replacedAll.note_id, + }); + assertEquals( + sessionMcpResponseSchemas.session_notes_write.safeParse(deleted) + .success, + true, + ); + + const cleared = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + root_session_id: "root-notes-runtime", + 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( + { + root_session_id: "root-notes-runtime", + }, + toolContext, + ), + ); + assertEquals(readDeleted, { notes: [] }); + assertEquals( + sessionMcpResponseSchemas.session_notes_read.safeParse(readDeleted) + .success, + true, + ); + } finally { + await runtime.dispose(); + } + }); + + it("merges note and memory hits in session_search with typed results sorted by score", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + groupId: "group-note-search", + } as never); + + try { + await runtime.tools.session_index.execute( + { + root_session_id: "root-note-search", + content: + "Redis TTL memory entry mentions the active bug and prior mitigation.", + }, + toolContext, + ); + const created = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + root_session_id: "root-note-search", + text: "Redis TTL bug active bug mitigation note for follow-up.", + }, + toolContext, + ), + ); + + const serialized = await runtime.tools.session_search.execute( + { + root_session_id: "root-note-search", + query: "Redis TTL bug active bug mitigation note for follow-up.", + }, + toolContext, + ); + const parsed = JSON.parse(serialized); + const noteHit = parsed.results.find((result: { type?: string }) => + result.type === "note" + ); + const memoryHit = parsed.results.find((result: { type?: string }) => + result.type === "memory" + ); + + assertEquals( + sessionMcpResponseSchemas.session_search.safeParse(parsed).success, + true, + ); + assertExists(noteHit); + assertExists(memoryHit); + assertEquals(noteHit.note_id, created.note_id); + assertStringIncludes(noteHit.corpus_ref, created.note_id); + assertStringIncludes( + noteHit.snippet, + "Redis TTL bug active bug mitigation", + ); + assertStringIncludes( + runtime.tools.session_search.description, + "use `session_notes_read` with that id to reopen the full note", + ); + assertEquals(memoryHit.type, "memory"); + assertEquals(parsed.results[0].score >= parsed.results[1].score, true); + assertEquals( + parsed.results.some((result: { type?: string }) => + result.type === "note" + ), + true, + ); + assertEquals( + parsed.results.some((result: { type?: string }) => + result.type === "memory" + ), + true, + ); + } finally { + await runtime.dispose(); + } + }); + + it("returns only memory hits when no notes match or exist", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + } as never); + + try { + await runtime.tools.session_index.execute( + { + root_session_id: "root-no-notes", + content: "Local memory result without pinned note entries.", + }, + toolContext, + ); + + const parsed = JSON.parse( + await runtime.tools.session_search.execute( + { + root_session_id: "root-no-notes", + query: "Local memory result", + }, + toolContext, + ), + ); + + 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: { note_id?: string }) => + result.note_id === undefined + ), + true, + ); + } finally { + await runtime.dispose(); + } + }); + it("delegates execution tools to the injected shared executor when configured", async () => { const calls: Array<{ tool: string; payload: unknown }> = []; type ExecutorRequestMap = { @@ -1988,4 +2377,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..08308e7 100644 --- a/src/services/session-mcp-runtime.ts +++ b/src/services/session-mcp-runtime.ts @@ -3,7 +3,7 @@ import { type ToolContext, type ToolDefinition, } from "@opencode-ai/plugin"; -import type { RedisClient } from "./redis-client.ts"; +import { RedisClient } from "./redis-client.ts"; import type { RedisCacheService } from "./redis-cache.ts"; import { createSessionCorpusService, @@ -24,11 +24,107 @@ import { sessionMcpResponseSchemas, type SessionMcpToolName, } from "./session-mcp-types.ts"; +import { SessionNotesService } from "./session-notes.ts"; import type { RuntimeRootSessionValidator } from "../session.ts"; import { readFile as readFileNode } from "node:fs/promises"; import path from "node:path"; export const SESSION_MCP_RESPONSE_BUDGET_BYTES = 8 * 1024; +const SESSION_SEARCH_RESULT_LIMIT = 5; + +export const SESSION_NOTES_WRITE_DESCRIPTION = [ + "Pin working context as a session note so it survives topic switches, long tool", + "loops, and compaction. Use this BEFORE drifting away from important context:", + "", + "- Before switching to a different topic or task", + "- After a user correction changes your assumptions", + "- When a small task stalls and work shifts elsewhere", + "- During long tool-calling sequences where key state lives only in your context", + "- Before compaction is likely (many messages into a session)", + "", + "Do NOT use this for ephemeral state that will be irrelevant within a few turns", + "(e.g., intermediate variable values, transient build errors you are about to", + "fix, or scratchpad reasoning). Notes are for context you need to survive", + "across topic switches or compaction — not for every observation.", + "", + "Accepts `text` (markdown body) and optional `replace` (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", +].join("\n"); + +export const SESSION_NOTES_READ_DESCRIPTION = [ + "Reopen exact pinned note text instead of reconstructing it from memory. Use this", + "when you resume an interrupted topic, need the exact wording of a pinned user", + "instruction, or want to verify what you previously noted before acting on it.", + "", + "If `id` is provided, returns that single note. If `id` is omitted, returns all", + "notes for the current session. Returns", + "`{ notes: [{ note_id, text, created_at, updated_at }] }`.", + "", + "Always prefer reading a pinned note over reciting its contents from recall —", + "notes are the source of truth for intentionally preserved context.", +].join("\n"); + +export const SESSION_SEARCH_BASELINE_DESCRIPTION = [ + "Search local indexed content for the current root session. This is the default", + "recall path — use it FIRST when you need prior context, especially:", + "", + "- At the start of a new session or after compaction", + "- When resuming a topic you worked on earlier", + "- Before re-solving a problem that may already have a solution in session history", + "- To check whether pinned session notes already contain the context you need", + "", + 'Results may include indexed memory content (type: "memory") and, when pinned', + 'session notes exist, matching notes (type: "note"). Note results include a', + "`note_id` — use `session_notes_read` with that id to reopen the full note", + "text. Not every query will return note results; notes only appear when they", + "match the search query and the session has pinned notes.", + "", + "Prefer session_search over reconstructing context from scratch. If search", + "returns relevant note hits, read the note before duplicating its contents.", +].join("\n"); + +export const SESSION_SEARCH_STRENGTHENED_DESCRIPTION = [ + "Search local indexed content for the current root session. This is the default", + "recall path — use it FIRST when you need prior context, especially:", + "", + "- At the start of a new session or after compaction", + "- When resuming a topic you worked on earlier", + "- Before re-solving a problem that may already have a solution in session history", + "- To check whether pinned session notes already contain the context you need", + "", + 'Results may include indexed memory content (type: "memory") and, when pinned', + 'session notes exist, matching notes (type: "note"). Note results include a', + "`note_id` — use `session_notes_read` with that id to reopen the full note", + "text. Not every query will return note results; notes only appear when they", + "match the search query and the session has pinned notes.", + "", + "Prefer session_search over reconstructing context from scratch. If search", + "returns relevant note hits, read the note before duplicating its contents.", + "", + "⚠️ This is a new session or a post-compaction turn. Prior context may have been", + "summarized or is not yet in your working memory. STRONGLY RECOMMENDED: run a", + "session_search query before starting work to recover earlier decisions, pinned", + "notes, and task state. This avoids re-solving problems or contradicting earlier", + "decisions that survived compaction.", +].join("\n"); type PluginToolArgs = Parameters[0]["args"]; @@ -88,6 +184,15 @@ const sessionMcpToolArgs: Record = { session_doctor: { ...pluginRootSessionIdArgs, }, + session_notes_write: { + ...pluginRootSessionIdArgs, + text: pluginSchema.string(), + replace: pluginSchema.string().min(1).optional(), + }, + session_notes_read: { + ...pluginRootSessionIdArgs, + id: pluginSchema.string().min(1).optional(), + }, }; type SessionMcpHandler = ( @@ -103,6 +208,7 @@ type SessionMcpRuntimeOptions = { handlers?: Partial; redisClient?: RedisClient; graphitiCache?: RedisCacheService | object; + notesService?: SessionNotesService; sessionTtlSeconds?: number; groupId?: string; createSessionCorpusService?: typeof createSessionCorpusService; @@ -359,6 +465,10 @@ 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" }), + { sessionTtlSeconds: options.sessionTtlSeconds ?? 60 }, + ); const writeArtifact = ( toolName: SessionMcpToolName, @@ -448,25 +558,57 @@ export const createSessionMcpRuntime = ( rootSessionId: string, query: string, ): Promise => { - if (!corpus) { + const noteResults = (await notes.searchNotes(rootSessionId, query)).map( + (note) => ({ + corpus_ref: `session:${groupId}:${rootSessionId}:note:${note.note_id}`, + snippet: note.snippet, + score: note.score, + type: "note" as const, + note_id: note.note_id, + }), + ); + + const mergeResults = ( + memoryResults: SessionSearchResponse["results"], + memoryCorpusRefs: string[], + truncated: boolean, + status: SessionSearchResponse["status"], + ): SessionSearchResponse => { + const typedMemoryResults = memoryResults.map((result) => ({ + ...result, + type: result.type ?? "memory" as const, + })); + const mergedResults = [...typedMemoryResults, ...noteResults] + .sort((left, right) => right.score - left.score) + .slice(0, SESSION_SEARCH_RESULT_LIMIT); + const corpusRefs = [ + ...new Set(mergedResults.map((result) => result.corpus_ref)), + ]; + return { - status: "ok", - results: [], - corpus_refs: [], - truncated: false, + status, + results: mergedResults, + corpus_refs: corpusRefs.length > 0 ? corpusRefs : memoryCorpusRefs, + truncated: truncated || + typedMemoryResults.length + noteResults.length > + SESSION_SEARCH_RESULT_LIMIT, }; + }; + + if (!corpus) { + return mergeResults([], [], false, "ok"); } const result = await corpus.search({ rootSessionId, query, }); - return { - status: result.status, - results: result.results, - corpus_refs: result.corpusRefs, - truncated: result.truncated, - }; + return mergeResults( + result.results, + result.corpusRefs, + result.truncated, + result.status, + ); }; const defaultHandlers: SessionMcpHandlerMap = { @@ -636,6 +778,14 @@ export const createSessionMcpRuntime = ( }, }; }, + session_notes_write: async (request) => { + return await notes.writeNote(request.root_session_id, request.text, { + replace: request.replace, + }); + }, + session_notes_read: async (request) => { + return await notes.readNotes(request.root_session_id, request.id); + }, }; const handlerMap: SessionMcpHandlerMap = { @@ -913,12 +1063,13 @@ export const createSessionMcpRuntime = ( session_execute_file: "Read local files through the session runtime.", session_batch_execute: "Execute bounded session commands sequentially.", session_index: "Index local content for the current root session.", - session_search: - "Search local indexed content for the current root session.", + session_search: SESSION_SEARCH_BASELINE_DESCRIPTION, session_fetch_and_index: "Fetch content and index it for the current root session.", session_stats: "Return local session MCP stats.", session_doctor: "Return local session MCP health checks.", + session_notes_write: SESSION_NOTES_WRITE_DESCRIPTION, + session_notes_read: SESSION_NOTES_READ_DESCRIPTION, }; const tools = Object.fromEntries( @@ -955,6 +1106,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..27d2c1c 100644 --- a/src/services/session-mcp-types.ts +++ b/src/services/session-mcp-types.ts @@ -13,6 +13,8 @@ export const SESSION_MCP_TOOL_NAMES = [ "session_fetch_and_index", "session_stats", "session_doctor", + "session_notes_write", + "session_notes_read", ] as const; export type SessionMcpToolName = (typeof SESSION_MCP_TOOL_NAMES)[number]; @@ -75,10 +77,30 @@ type SessionIndexRequest = { label?: string; }; +type SessionNotesWriteRequest = { + root_session_id: string; + text: string; + replace?: string; +}; + +type SessionNotesReadRequest = { + root_session_id: string; + id?: string; +}; + const searchResultSchema = z.object({ corpus_ref: z.string().min(1), snippet: z.string(), score: z.number(), + type: z.enum(["memory", "note"]).optional(), + note_id: z.string().min(1).optional(), +}).strict(); + +const sessionNoteSchema = z.object({ + note_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({ @@ -175,6 +197,22 @@ export const sessionMcpRequestSchemas = { session_doctor: z.object({ ...rootSessionIdShape, }).strict(), + session_notes_write: z.object({ + ...rootSessionIdShape, + text: z.string(), + replace: z.string().min(1).optional(), + }).strict().transform((request) => ({ + root_session_id: request.root_session_id, + text: request.text, + replace: request.replace, + } satisfies SessionNotesWriteRequest)), + session_notes_read: z.object({ + ...rootSessionIdShape, + id: z.string().min(1).optional(), + }).strict().transform((request) => ({ + root_session_id: request.root_session_id, + id: request.id, + } satisfies SessionNotesReadRequest)), }; export const sessionExecuteResponseSchema = z.object({ @@ -263,6 +301,14 @@ export const sessionMcpResponseSchemas = { graphiti_cache: doctorSubsystemSchema, runtime: doctorSubsystemSchema, }).strict(), + 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(sessionNoteSchema), + }).strict(), }; type SessionMcpInferredRequestMap = { @@ -276,13 +322,18 @@ export type SessionMcpRequestMap = [ K in Exclude< SessionMcpToolName, - "session_batch_execute" | "session_index" + | "session_batch_execute" + | "session_index" + | "session_notes_write" + | "session_notes_read" > ]: SessionMcpInferredRequestMap[K]; } & { session_batch_execute: SessionBatchExecuteRequest; session_index: SessionIndexRequest; + session_notes_write: SessionNotesWriteRequest; + session_notes_read: SessionNotesReadRequest; }; type SessionExecuteResponse = z.infer; diff --git a/src/services/session-notes.test.ts b/src/services/session-notes.test.ts new file mode 100644 index 0000000..6bf9a98 --- /dev/null +++ b/src/services/session-notes.test.ts @@ -0,0 +1,306 @@ +import { assert, 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 { sessionNotesKey, SessionNotesService } from "./session-notes.ts"; + +const createRedis = () => new RedisClient({ endpoint: "redis://unused" }); + +const createSequence = (values: string[]) => { + let index = 0; + return () => values[index++] ?? `generated-${index}`; +}; + +const createClock = (...timestamps: string[]) => { + let index = 0; + return () => + new Date(timestamps[index++] ?? timestamps[timestamps.length - 1]!); +}; + +describe("session notes", () => { + it("appends and reads notes while refreshing the session TTL", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + sessionTtlSeconds: 60, + createNoteId: createSequence(["note-1", "note-2"]), + now: createClock( + "2026-04-11T10:00:00.000Z", + "2026-04-11T10:00:01.000Z", + ), + }); + + const first = await service.writeNote("root-1", "## First note"); + const second = await service.writeNote("root-1", "## Second note"); + + assertEquals(first, { action: "created", note_id: "note-1" }); + assertEquals(second, { action: "created", note_id: "note-2" }); + + const key = sessionNotesKey("root-1"); + const writtenSnapshot = await redis.snapshot(key); + assertEquals(writtenSnapshot.kind, "hash"); + if (writtenSnapshot.kind === "hash") { + assertEquals(writtenSnapshot.ttlSeconds, 60); + assertEquals(Object.keys(writtenSnapshot.values).sort(), [ + "note-1", + "note-2", + ]); + } + + await redis.touch(key, 5); + const touchedSnapshot = await redis.snapshot(key); + assertEquals(touchedSnapshot.kind, "hash"); + if (touchedSnapshot.kind === "hash") { + assertEquals(touchedSnapshot.ttlSeconds, 5); + } + + assertEquals(await service.readNotes("root-2"), { notes: [] }); + assertEquals(await service.readNotes("root-1", "missing"), { notes: [] }); + + const all = await service.readNotes("root-1"); + assertEquals(all, { + notes: [ + { + note_id: "note-1", + text: "## First note", + created_at: "2026-04-11T10:00:00.000Z", + updated_at: "2026-04-11T10:00:00.000Z", + }, + { + note_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.readNotes("root-1", "note-2"), { + notes: [all.notes[1]], + }); + + const refreshedSnapshot = await redis.snapshot(key); + assertEquals(refreshedSnapshot.kind, "hash"); + if (refreshedSnapshot.kind === "hash") { + assertEquals(refreshedSnapshot.ttlSeconds, 60); + } + }); + + it("supports replace and clear semantics within a single root session", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + sessionTtlSeconds: 120, + createNoteId: createSequence(["note-1", "note-2", "note-3", "note-4"]), + now: createClock( + "2026-04-11T11:00:00.000Z", + "2026-04-11T11:00:01.000Z", + "2026-04-11T11:00:02.000Z", + "2026-04-11T11:00:03.000Z", + "2026-04-11T11:00:04.000Z", + "2026-04-11T11:00:05.000Z", + "2026-04-11T11:00:06.000Z", + ), + }); + + await service.writeNote("root-a", "alpha"); + await service.writeNote("root-a", "beta"); + await service.writeNote("root-b", "other session"); + + const replacedOne = await service.writeNote("root-a", "alpha updated", { + replace: "note-1", + }); + assertEquals(replacedOne, { action: "replaced", note_id: "note-1" }); + assertEquals(await service.readNotes("root-a", "note-1"), { + notes: [{ + note_id: "note-1", + text: "alpha updated", + created_at: "2026-04-11T11:00:00.000Z", + updated_at: "2026-04-11T11:00:03.000Z", + }], + }); + + const replacedAll = await service.writeNote("root-a", "replacement", { + replace: "*", + }); + assertEquals(replacedAll, { + action: "replaced", + note_id: "note-4", + cleared_count: 2, + }); + assertEquals(await service.readNotes("root-a"), { + notes: [{ + note_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: [{ + note_id: "note-3", + text: "other session", + created_at: "2026-04-11T11:00:02.000Z", + updated_at: "2026-04-11T11:00:02.000Z", + }], + }); + + const deletedOne = await service.writeNote("root-b", "", { + replace: "note-3", + }); + assertEquals(deletedOne, { action: "deleted", note_id: "note-3" }); + assertEquals(await service.readNotes("root-b"), { notes: [] }); + + const createdByReplace = await service.writeNote("root-b", "created late", { + replace: "missing-note", + }); + assertEquals(createdByReplace, { + action: "replaced", + note_id: "missing-note", + }); + assertEquals(await service.readNotes("root-b"), { + notes: [{ + note_id: "missing-note", + text: "created late", + created_at: "2026-04-11T11:00:06.000Z", + updated_at: "2026-04-11T11:00:06.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, { + sessionTtlSeconds: 90, + createNoteId: createSequence(["note-1", "note-2", "note-3"]), + now: createClock( + "2026-04-11T12:00:00.000Z", + "2026-04-11T12:00:01.000Z", + "2026-04-11T12:00:02.000Z", + ), + }); + + await service.writeNote( + "root-search", + "## Redis TTL refresh\nEnsure session ttl refresh happens on note reads.", + ); + await service.writeNote( + "root-search", + "## Search scoring\nToken overlap should stay deterministic and normalized.", + ); + await service.writeNote("root-other", "## Foreign note\nredis ttl refresh"); + + const exact = await service.searchNotes( + "root-search", + "## Redis TTL refresh\nEnsure session ttl refresh happens on note reads.", + ); + assertEquals(exact[0], { + note_id: "note-1", + snippet: + "## Redis TTL refresh Ensure session ttl refresh happens on note reads.", + score: 1, + }); + + const firstPass = await service.searchNotes( + "root-search", + "redis ttl refresh", + ); + const secondPass = await service.searchNotes( + "root-search", + "redis ttl refresh", + ); + + assertEquals(firstPass, secondPass); + assertEquals(firstPass.length, 1); + assertEquals(firstPass[0]?.note_id, "note-1"); + assert(firstPass[0]!.score > 0); + assert(firstPass[0]!.score <= 1); + assertEquals( + firstPass[0]?.snippet.includes("session ttl refresh"), + true, + ); + + const multi = await service.searchNotes( + "root-search", + "deterministic normalized", + ); + assertEquals(multi, [{ + note_id: "note-2", + snippet: + "## Search scoring Token overlap should stay deterministic and normalized.", + score: multi[0]!.score, + }]); + assert(multi[0]!.score > 0); + assert(multi[0]!.score < 1); + + assertEquals(await service.searchNotes("root-search", "foreign"), []); + assertEquals(await service.searchNotes("root-search", " "), []); + }); + + it("anchors and truncates snippets around late matches in long notes", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + sessionTtlSeconds: 90, + createNoteId: createSequence(["note-1"]), + now: createClock("2026-04-11T13:00:00.000Z"), + }); + + const longPrefix = "prefix text ".repeat(30); + const longSuffix = " suffix text".repeat(20); + await service.writeNote( + "root-long", + `${longPrefix}target anchor phrase${longSuffix}`, + ); + + const [hit] = await service.searchNotes( + "root-long", + "target anchor phrase", + ); + + assert(hit); + assert(hit.snippet.length <= 160); + assert(hit.snippet.includes("target anchor phrase")); + assertEquals( + hit.snippet.startsWith("prefix text prefix text prefix text"), + false, + ); + }); + + it("ignores malformed stored note payloads safely", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + sessionTtlSeconds: 45, + createNoteId: createSequence(["note-1"]), + now: createClock("2026-04-11T14:00:00.000Z"), + }); + + await redis.setHashFields(sessionNotesKey("root-malformed"), { + broken_json: "{not-json", + wrong_shape: JSON.stringify({ + text: 123, + created_at: "x", + updated_at: "y", + }), + valid_note: JSON.stringify({ + text: "valid searchable note", + created_at: "2026-04-11T14:00:00.000Z", + updated_at: "2026-04-11T14:00:00.000Z", + }), + }, 45); + + assertEquals(await service.readNotes("root-malformed"), { + notes: [{ + note_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.note_id, "valid_note"); + assertEquals(hit.snippet, "valid searchable note"); + assert(hit.score > 0); + assert(hit.score < 1); + }); +}); diff --git a/src/services/session-notes.ts b/src/services/session-notes.ts new file mode 100644 index 0000000..2ec8ab5 --- /dev/null +++ b/src/services/session-notes.ts @@ -0,0 +1,336 @@ +import type { RedisClient } from "./redis-client.ts"; +import type { RedisKeySnapshot } from "./redis-client.ts"; + +type StoredNote = { + text: string; + created_at: string; + updated_at: string; +}; + +export type SessionNote = StoredNote & { + note_id: string; +}; + +export type SessionNoteSearchHit = { + note_id: string; + snippet: string; + score: number; +}; + +export type WriteNoteResult = + | { action: "created"; note_id: string } + | { action: "replaced"; note_id: string } + | { action: "deleted"; note_id: string } + | { action: "replaced"; note_id: string; cleared_count: number } + | { action: "replaced"; cleared_count: number }; + +export const sessionNotesKey = (rootSessionId: string): string => + `session:${rootSessionId}:notes`; + +type SessionNotesServiceOptions = { + sessionTtlSeconds: number; + now?: () => Date; + createNoteId?: () => string; +}; + +const TOKEN_PATTERN = /[a-z0-9]{2,}/g; +const SNIPPET_LIMIT = 160; + +const normalizeText = (value: string): string => + value.replace(/\s+/g, " ").trim(); + +const tokenize = (value: string): string[] => + normalizeText(value).toLowerCase().match(TOKEN_PATTERN) ?? []; + +const clampScore = (value: number): number => + Math.max(0, Math.min(1, Number(value.toFixed(6)))); + +const parseStoredNote = (value: string): StoredNote | null => { + try { + const parsed = JSON.parse(value) as Partial; + if ( + typeof parsed.text !== "string" || + typeof parsed.created_at !== "string" || + typeof parsed.updated_at !== "string" + ) { + return null; + } + return { + text: parsed.text, + created_at: parsed.created_at, + updated_at: parsed.updated_at, + }; + } catch { + return null; + } +}; + +const compareNotes = (left: SessionNote, right: SessionNote): number => { + if (left.created_at !== right.created_at) { + return left.created_at.localeCompare(right.created_at); + } + return left.note_id.localeCompare(right.note_id); +}; + +const compareSearchHits = ( + left: SessionNoteSearchHit & { created_at: string; updated_at: string }, + right: SessionNoteSearchHit & { created_at: string; updated_at: string }, +): number => { + if (right.score !== left.score) return right.score - left.score; + if (right.updated_at !== left.updated_at) { + return right.updated_at.localeCompare(left.updated_at); + } + if (right.created_at !== left.created_at) { + return right.created_at.localeCompare(left.created_at); + } + return left.note_id.localeCompare(right.note_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; + + constructor( + private readonly redis: RedisClient, + private readonly options: SessionNotesServiceOptions, + ) { + 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 writeNotesHash( + rootSessionId: string, + notes: ReadonlyMap, + ): Promise { + const key = sessionNotesKey(rootSessionId); + if (notes.size === 0) { + await this.redis.deleteKey(key); + return; + } + + await this.redis.deleteKey(key); + await this.redis.setHashFields( + key, + Object.fromEntries( + [...notes.entries()].map(( + [noteId, note], + ) => [noteId, JSON.stringify(note)]), + ), + this.options.sessionTtlSeconds, + ); + } + + private async writeSingleNote( + rootSessionId: string, + noteId: string, + note: StoredNote, + ): Promise { + await this.redis.setHashFields( + sessionNotesKey(rootSessionId), + { [noteId]: JSON.stringify(note) }, + this.options.sessionTtlSeconds, + ); + } + + async writeNote( + rootSessionId: string, + text: string, + options?: { replace?: string }, + ): Promise { + const replace = options?.replace; + const notes = await this.loadNotes(rootSessionId); + const timestamp = this.now().toISOString(); + + if (replace === "*") { + const clearedCount = notes.size; + if (text === "") { + await this.redis.deleteKey(sessionNotesKey(rootSessionId)); + return { action: "replaced", cleared_count: clearedCount }; + } + + const noteId = this.createNoteId(); + await this.writeNotesHash( + rootSessionId, + new Map([[noteId, { + text, + created_at: timestamp, + updated_at: timestamp, + }]]), + ); + return { + action: "replaced", + note_id: noteId, + cleared_count: clearedCount, + }; + } + + if (replace) { + if (text === "") { + notes.delete(replace); + // Field removal is not exposed by RedisClient yet, so deleting a single + // note still requires rewriting the remaining hash contents. + await this.writeNotesHash(rootSessionId, notes); + return { action: "deleted", note_id: replace }; + } + + const current = notes.get(replace); + const note = { + text, + created_at: current?.created_at ?? timestamp, + updated_at: timestamp, + }; + await this.writeSingleNote(rootSessionId, replace, note); + return { action: "replaced", note_id: replace }; + } + + const noteId = this.createNoteId(); + const note = { + text, + created_at: timestamp, + updated_at: timestamp, + }; + await this.writeSingleNote(rootSessionId, noteId, note); + return { action: "created", note_id: noteId }; + } + + async readNotes( + rootSessionId: string, + noteId?: string, + ): Promise<{ notes: SessionNote[] }> { + const key = sessionNotesKey(rootSessionId); + const notes = [...(await this.loadNotes(rootSessionId)).entries()] + .map(([id, note]) => ({ note_id: id, ...note })) + .sort(compareNotes); + + if (notes.length > 0) { + await this.redis.touch(key, this.options.sessionTtlSeconds); + } + + if (!noteId) return { notes }; + return { notes: notes.filter((note) => note.note_id === noteId) }; + } + + async searchNotes( + rootSessionId: string, + query: string, + ): Promise { + const normalizedQuery = normalizeText(query); + if (!normalizedQuery) return []; + + const notes = await this.readNotes(rootSessionId); + return notes.notes + .map((note) => ({ + note_id: note.note_id, + snippet: buildSnippet(note.text, normalizedQuery), + score: scoreNote(note.text, normalizedQuery), + created_at: note.created_at, + updated_at: note.updated_at, + })) + .filter((note) => note.score > 0) + .sort(compareSearchHits) + .map(({ created_at: _createdAt, updated_at: _updatedAt, ...hit }) => hit); + } + + async migrateRootSessionState( + sourceRootSessionId: string, + targetRootSessionId: string, + ): Promise { + if (sourceRootSessionId === targetRootSessionId) return; + + const sourceKey = sessionNotesKey(sourceRootSessionId); + const targetKey = sessionNotesKey(targetRootSessionId); + const sourceSnapshot = await this.redis.snapshot(sourceKey); + if (sourceSnapshot.kind === "missing") return; + + const targetSnapshot = await this.redis.snapshot(targetKey); + const mergedSnapshot = mergeNoteSnapshots(targetSnapshot, sourceSnapshot); + await this.redis.restoreSnapshot(targetKey, mergedSnapshot); + await this.redis.deleteKey(sourceKey); + } +} + +const mergeNoteSnapshots = ( + target: RedisKeySnapshot, + source: RedisKeySnapshot, +): RedisKeySnapshot => { + if (source.kind === "missing") return target; + if (source.kind !== "hash") { + throw new Error("Expected hash snapshot for source session notes"); + } + if (target.kind !== "missing" && target.kind !== "hash") { + throw new Error("Expected hash snapshot for target session notes"); + } + + return { + kind: "hash", + values: { + ...(target.kind === "hash" ? target.values : {}), + ...source.values, + }, + ttlSeconds: Math.max( + target.kind === "hash" ? target.ttlSeconds ?? 0 : 0, + source.ttlSeconds ?? 0, + ) || undefined, + }; +}; diff --git a/src/session.test.ts b/src/session.test.ts index 738b6d2..de77008 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<{ + note_id: string; + text: string; + created_at: string; + updated_at: string; + }> = [], +) => { + const readNotesCalls: Array<{ sessionId: string; noteId?: string }> = []; + const manager = new SessionManager( + "group-notes", + "user-notes", + { session: {} } as never, + { + getRecentSessionEvents() { + return [{ + id: "evt-1", + ts: Date.now(), + category: "intent", + priority: 0, + role: "user", + summary: "Continue compaction work", + }]; + }, + recallSessionEvents() { + return []; + }, + } as never, + { + getSnapshot() { + return "Current snapshot"; + }, + } as never, + emptyCache as never, + { + notesService: { + readNotes(sessionId: string, noteId?: string) { + readNotesCalls.push({ sessionId, noteId }); + return { notes }; + }, + } as never, + }, + ); + + manager.setParentId("session-1", null); + manager.setState( + "session-1", + manager.createDefaultState("group-notes", "user-notes"), + ); + + return { manager, readNotesCalls }; +}; + describe("SessionManager Task 6 runtime migration", () => { it("resolves child sessions to the canonical parent root session id", async () => { const manager = new SessionManager( @@ -347,3 +425,105 @@ describe("SessionManager Task 6 runtime migration", () => { ); }); }); + +describe("SessionManager compaction notes injection", () => { + it("includes full session_notes with note ids and timestamps for compaction", async () => { + const { manager, readNotesCalls } = createSessionManagerForInjection([ + { + note_id: "note-1", + text: "First full note body", + created_at: "2026-04-10T10:00:00.000Z", + updated_at: "2026-04-10T10:05:00.000Z", + }, + { + note_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([ + { + note_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([ + { + note_id: "note-1", + text: "Should stay out of normal injection", + created_at: "2026-04-10T10:00:00.000Z", + updated_at: "2026-04-10T10:05:00.000Z", + }, + ]); + + const prepared = await manager.prepareInjection("session-1", "continue"); + + assertEquals(readNotesCalls, []); + assertEquals( + (prepared?.envelope ?? "").includes(" void, delayMs: number) => TimerHandle; clearTimer?: (timer: TimerHandle) => void; + notesService?: SessionNotesService; runtimeStateMigrator?: SessionRuntimeStateMigrator; } @@ -242,9 +247,14 @@ type PreparedInjectionData = { cacheMeta: PersistentMemoryCacheMeta | null; events: SessionEvent[]; latestRequest: string; + notes: SessionNote[] | null; snapshot: string | null; }; +export interface PrepareInjectionOptions { + forCompaction?: boolean; +} + class AssistantMessageBuffer { private pendingMessages = new Map< string, @@ -489,6 +499,7 @@ const buildPreparedInjectionEnvelope = ( events: SessionEvent[], snapshot: string | null, latestRequest: string, + notes: SessionNote[] | null, persistent: { body: string; nodeRefs: string[] }, ): string => { const occupiedNormalized = new Set(); @@ -561,6 +572,17 @@ const buildPreparedInjectionEnvelope = ( snapshot, occupiedNormalized, ); + const renderedNotes = notes && notes.length > 0 + ? `${ + notes.map((note) => + `${ + escapeXml(note.text) + }` + ).join("") + }` + : ""; const sections = [ `${escapeXml(latestRequest)}`, @@ -597,6 +619,7 @@ const buildPreparedInjectionEnvelope = ( filteredSnapshot ? `${filteredSnapshot}` : "", + renderedNotes, persistent.body ? ` void, delayMs: number, @@ -647,6 +671,7 @@ export class SessionManager { this.setTimerImpl, this.clearTimerImpl, ); + this.notesService = options.notesService; this.runtimeStateMigrator = options.runtimeStateMigrator; } @@ -1080,6 +1105,7 @@ export class SessionManager { async prepareInjection( sessionId: string, lastRequest?: string, + options: PrepareInjectionOptions = {}, ): Promise { const state = this.sessions.get(sessionId); if (!state?.isMain) return null; @@ -1090,8 +1116,9 @@ export class SessionManager { sessionId, state, lastRequest, + options, ); - const prepared = this.buildPreparedInjection(state, data); + const prepared = this.buildPreparedInjection(state, data, options); if (!prepared) return null; const currentState = this.sessions.get(sessionId); @@ -1111,17 +1138,23 @@ export class SessionManager { sessionId: string, state: SessionState, lastRequest?: string, + options: PrepareInjectionOptions = {}, ): Promise { - const [recentEvents, snapshot, cache, cacheMeta] = await Promise.all([ - this.redisEvents.getRecentSessionEvents( - sessionId, - RECENT_BASELINE_LIMIT, - true, - ), - this.redisSnapshot.getSnapshot(sessionId), - this.redisCache.get(state.groupId), - this.redisCache.getMeta(state.groupId), - ]); + const [recentEvents, snapshot, cache, cacheMeta, notesResult] = + await Promise + .all([ + this.redisEvents.getRecentSessionEvents( + sessionId, + RECENT_BASELINE_LIMIT, + true, + ), + this.redisSnapshot.getSnapshot(sessionId), + this.redisCache.get(state.groupId), + this.redisCache.getMeta(state.groupId), + options.forCompaction && this.notesService + ? this.notesService.readNotes(sessionId) + : Promise.resolve(null), + ]); const canonicalLatestRequest = sanitizeMemoryInput( state.latestUserRequest ?? "", @@ -1143,6 +1176,7 @@ export class SessionManager { cacheMeta, events: mergeSessionEvents(recentEvents, recalledEvents), latestRequest, + notes: notesResult?.notes ?? null, snapshot, }; } @@ -1150,6 +1184,7 @@ export class SessionManager { private buildPreparedInjection( _state: SessionState, data: PreparedInjectionData, + _options: PrepareInjectionOptions = {}, ): PreparedSessionMemory { const persistent = this.redisCache.renderPersistentMemory( data.cache, @@ -1165,6 +1200,7 @@ export class SessionManager { data.events, data.snapshot, data.latestRequest, + data.notes, persistent, ), nodeRefs: persistent.nodeRefs, From 637fd6e6dafe40e001105ff5458695d89b355589 Mon Sep 17 00:00:00 2001 From: "Vicary A." Date: Sun, 19 Apr 2026 08:23:53 +0800 Subject: [PATCH 2/4] feat(session): support cross-session note recall --- docs/SmokeTests.md | 129 +-- ...4-19-session-notes-cross-session-recall.md | 790 ++++++++++++++++++ ...6-04-11-session-notes-anti-drift-design.md | 659 ++++++++------- src/index.test.ts | 45 +- src/index.ts | 18 +- src/services/session-mcp-runtime.test.ts | 408 +++++++-- src/services/session-mcp-runtime.ts | 98 ++- src/services/session-mcp-types.ts | 31 +- src/services/session-notes.test.ts | 133 ++- src/services/session-notes.ts | 243 +++++- src/session.test.ts | 10 +- src/session.ts | 2 +- 12 files changed, 1997 insertions(+), 569 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-19-session-notes-cross-session-recall.md diff --git a/docs/SmokeTests.md b/docs/SmokeTests.md index 50ef56a..2b8bbca 100644 --- a/docs/SmokeTests.md +++ b/docs/SmokeTests.md @@ -1,7 +1,7 @@ # Smoke Tests - Status: Active -- Last Updated: 2026-03-25 +- Last Updated: 2026-04-11 - Replaces: historical native-hook-first test plan This file, `docs/SmokeTests.md`, replaces the retiring @@ -262,15 +262,22 @@ the change that introduces it; do not assume one here, and do not invent a new - **Exact commands:** ```bash - deno test src/services/session-mcp-runtime.test.ts src/services/session-executor.test.ts src/services/session-corpus.test.ts src/index.test.ts + deno test src/services/session-mcp-runtime.test.ts src/services/session-executor.test.ts src/services/session-corpus.test.ts src/services/session-notes.test.ts src/index.test.ts deno task check ``` - **Expected result:** PASS. Coverage must include each public tool: `session_execute`, `session_execute_file`, `session_batch_execute`, `session_index`, `session_search`, `session_fetch_and_index`, `session_stats`, - and `session_doctor`, including required `root_session_id` contract - enforcement. + `session_doctor`, `session_notes_write`, and `session_notes_read`. Public + note/search coverage must prove the root-session identity is derived from the + runtime session context rather than accepted as a caller argument. Note-tool + coverage must prove explicit write outcomes (`created`, `replaced`, + `deleted`), delete-on-miss no-op success returning + `{ action: "deleted", id + }`, exact single-note reads via + `session_notes_read({ id })`, `{ note: null }` for unknown ids, and + status-less response shapes. - **Artifacts/evidence to save:** Full `deno test` output; failing test names if any; bounded serialized examples for each tool response; any type-check output from `deno task check`. @@ -327,27 +334,35 @@ the change that introduces it; do not assume one here, and do not invent a new raw output concatenated into batch summaries. - **Release-gate severity:** Critical. -### 5.4 Suite D — Local corpus search, ranking, and bounded retrieval semantics +### 5.4 Suite D — Local corpus and session-note search, ranking, and bounded retrieval semantics -- **Objective:** Prove local-first corpus behavior, including indexing, lexical - retrieval, ranking, snippet boundedness, and graceful TTL expiry handling. +- **Objective:** Prove local-first corpus behavior plus session-note recall, + including indexing, lexical retrieval, note-hit merging, ranking, snippet + boundedness, and graceful TTL expiry handling. - **Prerequisites:** Same as Suite A. Graphiti must remain irrelevant to PASS for this suite because local corpus behavior is a hot-tier proof target. - **Exact commands:** ```bash - deno test src/services/session-corpus.test.ts src/services/session-mcp-runtime.test.ts src/services/redis-client.test.ts + deno test src/services/session-corpus.test.ts src/services/session-mcp-runtime.test.ts src/services/session-notes.test.ts src/services/redis-client.test.ts deno task check ``` - **Expected result:** PASS. The small-corpus ranking baseline holds, snippets are bounded, partial-string/fuzzy/stemming/proximity behaviors remain covered - in the local corpus tests, and expired local corpus state returns structured - empty or expired results rather than throwing. + in the local corpus tests, `session_search` can merge matching pinned-note + hits with `type: "note"` plus `id`, `root_session_id`, and + `scope: "local" | "project"`, `session_notes_read` can reopen exact note text + from a note `id`, same-project foreign note hits rank below equivalent local + note hits, and expired local corpus state returns structured empty or expired + results rather than throwing. - **Artifacts/evidence to save:** Full test output; any asserted corpus refs, - snippets, and TTL-expiry results; evidence of ranking-order expectations. + snippets, note-hit metadata, exact note-read assertions, and TTL-expiry + results; evidence of ranking-order expectations. - **Common failure signatures:** Wrong top-ranked corpus for the baseline query; - flat unstructured retrieval; snippet overflow; corpus lookup exceptions after + flat unstructured retrieval; missing `type: "note"` / `id` / `root_session_id` + / `scope` metadata for pinned-note hits; project-scoped note hits outranking + equivalent local hits; snippet overflow; corpus lookup exceptions after expiry; search behavior depending on Graphiti availability. - **Release-gate severity:** Critical. @@ -420,13 +435,15 @@ the change that introduces it; do not assume one here, and do not invent a new - **Expected result:** PASS. Child and parent activity shares one canonical root namespace for corpus and continuity state; temporary-root migration behavior remains safe; deleting a child session does not delete root-owned state; - runtime teardown disposes owned resources exactly once. + root-session note state migrates with canonical-root repair; runtime teardown + disposes owned resources exactly once. - **Artifacts/evidence to save:** Full test output; any asserted canonical root - IDs, migrated namespace refs, teardown/dispose assertions, and child-deletion - safety evidence. + IDs, migrated namespace refs including session-note state, teardown/dispose + assertions, and child-deletion safety evidence. - **Common failure signatures:** Child-local instead of root-local state; mismatched `root_session_id` accepted; orphaned provisional-root keys; - duplicate teardown calls; child deletion removing root-owned artifacts. + duplicate teardown calls; child deletion removing root-owned artifacts; + session notes stranded under the provisional root after canonicalization. - **Release-gate severity:** Critical. ### 5.8 Suite H — Hook enforcement and attribution @@ -463,20 +480,23 @@ the change that introduces it; do not assume one here, and do not invent a new - **Exact commands:** ```bash - deno test src/handlers/chat.test.ts src/handlers/messages.test.ts src/handlers/compacting.test.ts src/handlers/event.test.ts src/services/session-snapshot.test.ts src/services/hot-tier-slice.test.ts + deno test src/session.test.ts src/handlers/chat.test.ts src/handlers/messages.test.ts src/handlers/compacting.test.ts src/handlers/event.test.ts src/services/session-snapshot.test.ts src/services/hot-tier-slice.test.ts deno task check ``` - **Expected result:** PASS. Local continuity sections and snapshots are assembled from hot-tier state, optional cached `` is - additive only, stale envelopes are scrubbed, and compaction preserves + additive only, stale envelopes are scrubbed, normal chat-turn injection omits + ``, compaction-only injection includes complete pinned note + bodies inside ``, and compaction preserves continuity for both direct and delegated work. - **Artifacts/evidence to save:** Full test output; representative emitted - `` blocks; compaction-hook assertions; snapshot-related - assertions. + `` blocks with and without `` as applicable; + compaction-hook assertions; snapshot-related assertions. - **Common failure signatures:** Missing or duplicated `` injection; compaction losing `session_*` continuity; stale envelopes left in - message bodies; Graphiti moved onto the synchronous path. + message bodies; notes injected on ordinary chat turns; compaction omitting or + pre-summarizing pinned note bodies; Graphiti moved onto the synchronous path. - **Release-gate severity:** Critical. ### 5.10 Suite J — Async Graphiti drain and cache refresh @@ -780,33 +800,47 @@ envelope or equivalent prompt-body/log export when the runtime exposes it. - **Objective:** Prove delegated work survives compaction and the root agent can resume from preserved continuity without the operator restating the work. -- **Guarantees covered:** RG-4, RG-7, RG-8. +- **Guarantees covered:** RG-4, RG-5, RG-8. - **Topology:** default topology. - **Procedure:** 1. Prompt the root agent to delegate two children that create at least two memorable sentinels and one explicit pending-task list item. - 2. Drive the live runtime to a natural compaction event. Use ordinary + 2. Before compaction, require one child to call `session_notes_write` with a + concise markdown note that pins the pending task, at least one sentinel, + and the intended next step for resumed execution. + 3. Have the root agent or a child confirm the note is readable via + `session_notes_read` before compaction occurs. + 4. Drive the live runtime to a natural compaction event. Use ordinary conversation pressure or the product's normal compaction control; do not use synthetic hook invocation as proof. - 3. After compaction completes, prompt the root agent: + 5. After compaction completes, prompt the root agent: `Resume the delegated task. What were the two sentinels and what work is still pending?` - 4. Require the root agent to spawn child agent A to verify one sentinel via - `session_search` and child agent B to continue one pending task step. + 6. Require the root agent to spawn child agent A to verify one sentinel via + `session_search` and child agent B to reopen the pinned note with + `session_notes_read` before continuing one pending task step. - **Expected runtime observations:** - pre-compaction delegated work appears in the compaction-preserved memory envelope; + - the compaction-time `` evidence includes a + `` section with the complete pinned note + body as input material; - the root resumes correctly after compaction without the operator replaying the history; - the resumed children continue from the preserved state rather than starting - a fresh branch. + a fresh branch, and the reopened note text still matches the pinned + pre-compaction note. - **Evidence to collect:** pre-compaction prompt/evidence; compaction occurrence - note or log; post-compaction root answer; post-compaction child tool results; - post-compaction `` envelope. + note or log; `session_notes_write` and `session_notes_read` responses; + post-compaction root answer; post-compaction child tool results; post- + compaction `` envelope. - **Pass interpretation:** PASS only if delegated continuity survives compaction - and the resumed execution demonstrably uses preserved memory. + and the resumed execution demonstrably uses preserved memory, including the + compaction-fed pinned note contents. - **Common failure signatures:** post-compaction amnesia; missing child-derived - continuity; resumed search cannot find pre-compaction indexed content. + continuity; resumed search cannot find pre-compaction indexed content; pinned + note omitted from compaction input; resumed note read returns empty or + paraphrased content instead of the stored note body. ### 6.7 Scenario L7 — Restart after delegated and indexed work with continuity and corpus recovery @@ -980,22 +1014,23 @@ Every release packet must be able to point from each critical proof target to its automated suite coverage, its live-runtime proof path or justified exception, and the evidence classes required by §4. -| Coverage row | Guarantees covered | Automated proof path | Live proof path | Required evidence focus | Notes | -| -------------------------------------------------------------- | ------------------ | -------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `session_*` primary bounded execution surface | RG-1 | Suites A, C, H | Scenarios L1, L3, L5, L11 | `session_*` responses, command output, logs/warnings when enforcement occurs | Baseline MCP-first proof row; native-tool success paths do not substitute. | -| `session_batch_execute` mixed-step behavior | RG-2 | Suites B, C | Scenarios L1, L3, L8 | Raw batch response with ordered typed results, bounded output evidence, follow-up summary | Must prove mixed command/search ordering and boundedness, not just command-only batching. | -| `session_index` replacement semantics | RG-3 | Suite E | Scenario L2 | Both index responses, replacement search results, root-visible continuity evidence | Required explicit row: same `(rootSessionId, source, label)` logical document must replace, not append. | -| Canonical root-session sharing across parent/child agents | RG-4 | Suite G | Scenarios L1, L2, L6, L7, L9 | Root/child prompts, tool responses, root-session state observations, emitted envelopes | Mocked child routing never closes this row by itself. | -| Local-first bounded corpus behavior | RG-5 | Suites C, D, E | Scenarios L1, L2, L3, L8, L11 | Search results, corpus refs, Redis/FalkorDB observations where persistence is claimed | Graphiti-backed proof is additive only here. | -| `` presence/omission and bounded formatting | RG-7 | Suites F, I, J | Scenarios L4, L8 | Full surrounding `` block with and without ``; bounded formatting evidence | Required explicit row. Presence and omission are both first-class proof targets. | -| Stale-cache behavior | RG-7 | Suites F, J | Scenario L4 (bounded-recall surface only) | Cache metadata, refresh observations, emitted envelope before/after refresh when exposed | Required explicit row. Deterministic stale-cache injection is automated-primary; live proof checks that recall stays additive and bounded rather than forcing a brittle stale-cache setup. | -| Cross-session recall | RG-6, RG-7 | Suites F, I, J, K | Scenario L4 | Phase-A and phase-B evidence, Graphiti drain/cache observations, later emitted `` context | Required explicit row. Proof fails if later recall is claimed without cache/drain evidence or emitted bounded context. | -| Graphiti off the hot path | RG-6 | Suites F, J | Scenarios L4, L8 | Hot-path success evidence plus drain/cache or degraded Graphiti observations | Must show original work succeeded before any fresh Graphiti read was required. | -| Compaction continuity | RG-8 | Suite I | Scenario L6 | Pre- and post-compaction envelopes, post-compaction tool responses, continuity observations | Synthetic hook calls alone do not satisfy this row. | -| Restart and recovery with Redis/FalkorDB intact | RG-9 | Suite K | Scenario L7 | Restart timing note, resumed-session proof, search/stats results, state observations | Requires true stop/start evidence, not same-process simulation only. | -| Graphiti-unavailable degradation | RG-6, RG-7, RG-9 | Suite K | Scenario L8 | Graphiti-down confirmation, warnings or doctor output, emitted omission of `` | Required explicit row. Live proof must show omission without hot-path failure. | -| Redis/FalkorDB degradation and reconnect boundaries | RG-9 | Suite K | Scenario L9 | Before/during/after doctor output, warnings, post-reconnect fresh index/search evidence | Do not overclaim persisted continuity from temporary degraded fallback. | -| Combined-backend degradation boundary | RG-9 | Suite K | Scenario L10 (explicit automated-only exception) | Automated degradation evidence bundle plus written exception note | Required explicit row. This is the one sanctioned automated-only live exception. | +| Coverage row | Guarantees covered | Automated proof path | Live proof path | Required evidence focus | Notes | +| -------------------------------------------------------------- | ------------------ | -------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `session_*` primary bounded execution surface | RG-1 | Suites A, C, H | Scenarios L1, L3, L5, L11 | `session_*` responses, command output, logs/warnings when enforcement occurs | Baseline MCP-first proof row; native-tool success paths do not substitute. | +| `session_batch_execute` mixed-step behavior | RG-2 | Suites B, C | Scenarios L1, L3, L8 | Raw batch response with ordered typed results, bounded output evidence, follow-up summary | Must prove mixed command/search ordering and boundedness, not just command-only batching. | +| `session_index` replacement semantics | RG-3 | Suite E | Scenario L2 | Both index responses, replacement search results, root-visible continuity evidence | Required explicit row: same `(rootSessionId, source, label)` logical document must replace, not append. | +| Canonical root-session sharing across parent/child agents | RG-4 | Suite G | Scenarios L1, L2, L6, L7, L9 | Root/child prompts, tool responses, root-session state observations, emitted envelopes | Mocked child routing never closes this row by itself. | +| Local-first bounded corpus behavior | RG-5 | Suites C, D, E | Scenarios L1, L2, L3, L8, L11 | Search results, corpus refs, Redis/FalkorDB observations where persistence is claimed | Graphiti-backed proof is additive only here. | +| Pinned session notes and compaction-only note injection | RG-4, RG-5, RG-8 | Suites A, D, G, I | Scenario L6 | `session_notes_write` / `session_notes_read` responses, note-tagged `session_search` hits, compaction envelopes with `` | Required explicit row. Proof must show exact note reads plus compaction-only injection of complete note bodies, not note summaries on ordinary chat turns. | +| `` presence/omission and bounded formatting | RG-7 | Suites F, I, J | Scenarios L4, L8 | Full surrounding `` block with and without ``; bounded formatting evidence | Required explicit row. Presence and omission are both first-class proof targets. | +| Stale-cache behavior | RG-7 | Suites F, J | Scenario L4 (bounded-recall surface only) | Cache metadata, refresh observations, emitted envelope before/after refresh when exposed | Required explicit row. Deterministic stale-cache injection is automated-primary; live proof checks that recall stays additive and bounded rather than forcing a brittle stale-cache setup. | +| Cross-session recall | RG-6, RG-7 | Suites F, I, J, K | Scenario L4 | Phase-A and phase-B evidence, Graphiti drain/cache observations, later emitted `` context | Required explicit row. Proof fails if later recall is claimed without cache/drain evidence or emitted bounded context. | +| Graphiti off the hot path | RG-6 | Suites F, J | Scenarios L4, L8 | Hot-path success evidence plus drain/cache or degraded Graphiti observations | Must show original work succeeded before any fresh Graphiti read was required. | +| Compaction continuity | RG-8 | Suite I | Scenario L6 | Pre- and post-compaction envelopes, post-compaction tool responses, continuity observations | Synthetic hook calls alone do not satisfy this row. | +| Restart and recovery with Redis/FalkorDB intact | RG-9 | Suite K | Scenario L7 | Restart timing note, resumed-session proof, search/stats results, state observations | Requires true stop/start evidence, not same-process simulation only. | +| Graphiti-unavailable degradation | RG-6, RG-7, RG-9 | Suite K | Scenario L8 | Graphiti-down confirmation, warnings or doctor output, emitted omission of `` | Required explicit row. Live proof must show omission without hot-path failure. | +| Redis/FalkorDB degradation and reconnect boundaries | RG-9 | Suite K | Scenario L9 | Before/during/after doctor output, warnings, post-reconnect fresh index/search evidence | Do not overclaim persisted continuity from temporary degraded fallback. | +| Combined-backend degradation boundary | RG-9 | Suite K | Scenario L10 (explicit automated-only exception) | Automated degradation evidence bundle plus written exception note | Required explicit row. This is the one sanctioned automated-only live exception. | ## 8. Release Gates diff --git a/docs/superpowers/plans/2026-04-19-session-notes-cross-session-recall.md b/docs/superpowers/plans/2026-04-19-session-notes-cross-session-recall.md new file mode 100644 index 0000000..6f7e47f --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-session-notes-cross-session-recall.md @@ -0,0 +1,790 @@ +# Session Notes Cross-Session Recall Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend session notes so `session_search` can surface same-project +notes from other sessions, `session_notes_read` can reopen any same-project note +by `id`, and note mutation stays ownership-safe while compaction remains +current-session-only. + +**Architecture:** Keep the existing session-scoped note hash for compaction and +local ownership, and add one project-scoped shared note hash keyed by globally +unique `id` within the project group. Public note/search tool contracts drop +public `root_session_id` for note and search tools, while the plugin still +resolves canonical root session internally before runtime execution. + +**Tech Stack:** Deno, TypeScript, Zod, Redis/FalkorDB hot tier, +`@opencode-ai/plugin`. + +--- + +## File Map + +### Modify + +| File | Responsibility | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `src/services/session-notes.ts` | Dual-store note persistence, uniqueness checks, direct read by `id`, local/project note search, ownership-aware mutation | +| `src/services/session-notes.test.ts` | Unit tests for dual-store behavior, upsert/delete semantics, collision retry, and cross-session search | +| `src/services/session-mcp-types.ts` | Public request/response schema updates for `id`, singular note read response, and note search hit metadata | +| `src/services/session-mcp-runtime.ts` | Tool descriptions, public tool args, internal root-session resolution, search merge, direct note read routing | +| `src/services/session-mcp-runtime.test.ts` | Schema compatibility, runtime tool behavior, cross-session search ranking, and direct read-by-id | +| `src/index.ts` | Continue wiring note service/runtime/canonicalization with no public root parameter exposure | +| `src/index.test.ts` | Verify exposed tool args and description behavior still match the runtime contract | +| `docs/SmokeTests.md` | Update live note-search expectations and exact runtime contracts | + +### Keep unchanged in behavior + +| File | Why | +| ---------------------------- | ------------------------------------------------------- | +| `src/session.ts` | Compaction should still read only current-session notes | +| `src/handlers/compacting.ts` | Compaction remains current-session scoped | + +--- + +## Task 1: Lock The New Public Contracts In Tests First + +**Files:** + +- Modify: `src/services/session-mcp-types.ts` +- Modify: `src/services/session-mcp-runtime.test.ts` +- Modify: `src/index.test.ts` + +- [ ] **Step 1: Write failing schema tests for the new note/search requests and + responses** + + Add/replace schema assertions in `src/services/session-mcp-runtime.test.ts` so + the public contracts become: + + ```ts + Deno.test("note schema compatibility accepts approved note request and response contracts", () => { + const writeRequest = sessionMcpRequestSchemas.session_notes_write.safeParse( + { + text: "remember this", + replace: "note-1", + }, + ); + const deleteResponse = sessionMcpResponseSchemas.session_notes_write + .safeParse({ + action: "deleted", + id: "note-1", + }); + const readRequest = sessionMcpRequestSchemas.session_notes_read.safeParse({ + id: "note-1", + }); + const readResponse = sessionMcpResponseSchemas.session_notes_read.safeParse( + { + note: { + id: "note-1", + text: "remember this", + created_at: "2026-04-11T10:00:00.000Z", + updated_at: "2026-04-11T10:00:00.000Z", + }, + }, + ); + const readMiss = sessionMcpResponseSchemas.session_notes_read.safeParse({ + note: null, + }); + + assertEquals(writeRequest.success, true); + assertEquals(deleteResponse.success, true); + assertEquals(readRequest.success, true); + assertEquals(readResponse.success, true); + assertEquals(readMiss.success, true); + }); + + Deno.test("search schema compatibility accepts note hits with id, root_session_id, and scope", () => { + const accepted = sessionMcpResponseSchemas.session_search.safeParse({ + status: "ok", + results: [{ + corpus_ref: "session:root:corpus:1", + snippet: "remember this", + score: 0.9, + type: "note", + id: "note-1", + root_session_id: "root-123", + scope: "project", + }], + corpus_refs: ["session:root:corpus:1"], + truncated: false, + }); + + assertEquals(accepted.success, true); + }); + ``` + +- [ ] **Step 2: Write failing runtime-registration tests for rootless public + note/search args** + + Update the existing args assertions in + `src/services/session-mcp-runtime.test.ts` and `src/index.test.ts` so they + expect: + + ```ts + assertEquals(Object.keys(runtime.tools.session_notes_write.args), [ + "text", + "replace", + ]); + assertEquals(Object.keys(runtime.tools.session_notes_read.args), ["id"]); + assertEquals(Object.keys(runtime.tools.session_search.args), ["query"]); + ``` + +- [ ] **Step 3: Run the narrow schema/runtime test slice and confirm it fails + for the old contract** + + Run: + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts src/index.test.ts + ``` + + Expected: FAIL because the current runtime and schemas still require + `root_session_id`, still use `note_id`, and still return `{ notes: [...] }`. + +- [ ] **Step 4: Update `src/services/session-mcp-types.ts` to the new public + shapes** + + Make the request/response shape changes directly in + `src/services/session-mcp-types.ts`: + + ```ts + type SessionNotesWriteRequest = { + text: string; + replace?: string; + }; + + type SessionNotesReadRequest = { + id: string; + }; + + const searchResultSchema = z.object({ + corpus_ref: z.string().min(1), + snippet: z.string(), + score: z.number(), + type: z.enum(["memory", "note"]).optional(), + id: z.string().min(1).optional(), + root_session_id: z.string().min(1).optional(), + scope: z.enum(["local", "project"]).optional(), + }).strict(); + + const sessionNoteSchema = z.object({ + id: z.string().min(1), + text: z.string(), + created_at: z.string().min(1), + updated_at: z.string().min(1), + }).strict(); + + session_notes_write: z.object({ + text: z.string(), + replace: z.string().min(1).optional(), + }).strict(), + + session_notes_read: z.object({ + id: z.string().min(1), + }).strict(), + + session_search: z.object({ + query: z.string().min(1), + }).strict(), + + session_notes_write: z.object({ + action: z.enum(["created", "replaced", "deleted"]), + id: z.string().min(1).optional(), + cleared_count: z.number().int().nonnegative().optional(), + }).strict(), + + session_notes_read: z.object({ + note: sessionNoteSchema.nullable(), + }).strict(), + ``` + +- [ ] **Step 5: Re-run the same narrow slice and confirm the schema layer now + passes** + + Run: + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts src/index.test.ts + ``` + + Expected: still FAIL, but now deeper in runtime behavior rather than the old + public contract. + +--- + +## Task 2: Rebuild The Note Service Around Dual Stores And Global `id` + +**Files:** + +- Modify: `src/services/session-notes.ts` +- Modify: `src/services/session-notes.test.ts` + +- [ ] **Step 1: Write failing unit tests for project-scoped read/search and + ownership rules** + + Add tests in `src/services/session-notes.test.ts` covering: + + ```ts + it("reads one same-project note by id and returns null on miss", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + sessionTtlSeconds: 60, + groupId: "group-a", + createNoteId: createSequence(["note-1"]), + now: createClock("2026-04-19T10:00:00.000Z"), + }); + + await service.writeNote("root-a", "remember this"); + + assertEquals(await service.readNoteById("group-a", "note-1"), { + note: { + id: "note-1", + text: "remember this", + created_at: "2026-04-19T10:00:00.000Z", + updated_at: "2026-04-19T10:00:00.000Z", + }, + }); + assertEquals(await service.readNoteById("group-a", "missing"), { + note: null, + }); + }); + + it("searches local and same-project foreign notes with a project penalty", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + sessionTtlSeconds: 60, + groupId: "group-a", + createNoteId: createSequence(["note-1", "note-2"]), + now: createClock( + "2026-04-19T10:00:00.000Z", + "2026-04-19T10:00:01.000Z", + ), + }); + + await service.writeNote("root-local", "redis ttl drift note"); + await service.writeNote("root-other", "redis ttl drift note"); + + const hits = await service.searchProjectNotes( + "root-local", + "redis ttl drift note", + ); + assertEquals(hits.map((hit) => ({ id: hit.id, scope: hit.scope })), [ + { id: "note-1", scope: "local" }, + { id: "note-2", scope: "project" }, + ]); + assertEquals(hits[0]!.score > hits[1]!.score, true); + }); + + it("allows replace-on-miss, delete-on-miss, and blocks foreign ownership conflicts", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + sessionTtlSeconds: 60, + groupId: "group-a", + createNoteId: createSequence(["note-1"]), + now: createClock( + "2026-04-19T10:00:00.000Z", + "2026-04-19T10:00:01.000Z", + "2026-04-19T10:00:02.000Z", + ), + }); + + await service.writeNote("root-foreign", "foreign", { replace: "note-1" }); + assertEquals( + await service.writeNote("root-local", "local replacement", { + replace: "missing-local", + }), + { action: "replaced", id: "missing-local" }, + ); + assertEquals( + await service.writeNote("root-local", "", { + replace: "already-gone", + }), + { action: "deleted", id: "already-gone" }, + ); + await assertRejects( + () => + service.writeNote("root-local", "cannot steal", { replace: "note-1" }), + Error, + "owned by another session", + ); + }); + ``` + +- [ ] **Step 2: Add a failing collision-retry test for project-wide `id` + uniqueness** + + Add: + + ```ts + it("retries note id generation until the project id is unique", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + sessionTtlSeconds: 60, + groupId: "group-a", + createNoteId: createSequence(["collision", "collision", "note-unique"]), + now: createClock( + "2026-04-19T10:00:00.000Z", + "2026-04-19T10:00:01.000Z", + ), + }); + + await service.writeNote("root-a", "existing", { replace: "collision" }); + assertEquals(await service.writeNote("root-b", "new note"), { + action: "created", + id: "note-unique", + }); + }); + ``` + +- [ ] **Step 3: Run the note-service unit tests and confirm they fail** + + Run: + + ```bash + deno test -A src/services/session-notes.test.ts + ``` + + Expected: FAIL because the service is still single-store, `note_id`-based, and + root-session-only. + +- [ ] **Step 4: Implement the dual-store service with normalized `id` shapes** + + Update `src/services/session-notes.ts` to add the second store and the new + read/search API. The central service shape should look like: + + ```ts + export type SessionNote = { + id: string; + text: string; + created_at: string; + updated_at: string; + }; + + export type SessionNoteSearchHit = { + id: string; + root_session_id: string; + scope: "local" | "project"; + snippet: string; + score: number; + }; + + export type WriteNoteResult = + | { action: "created"; id: string } + | { action: "replaced"; id: string } + | { action: "deleted"; id: string } + | { action: "replaced"; id: string; cleared_count: number } + | { action: "replaced"; cleared_count: number }; + + export const sessionNotesKey = (rootSessionId: string): string => + `session:${rootSessionId}:notes`; + + export const projectNotesKey = (groupId: string): string => + `project:${groupId}:notes`; + ``` + + Implement the core methods with these signatures: + + ```ts + async writeNote( + rootSessionId: string, + text: string, + options?: { replace?: string }, + ): Promise + + async readNoteById( + groupId: string, + id: string, + ): Promise<{ note: SessionNote | null }> + + async searchProjectNotes( + rootSessionId: string, + query: string, + ): Promise + ``` + + Required implementation rules: + + ```ts + const projectHitPenalty = 0.85; + + if (replace && text !== "") { + if (!projectNote) { + // upsert by exact id into current session + } else if (projectNote.root_session_id !== rootSessionId) { + throw new Error(`Note ${replace} is owned by another session`); + } + } + + if (replace && text === "") { + if (!projectNote) { + return { action: "deleted", id: replace }; + } + if (projectNote.root_session_id !== rootSessionId) { + throw new Error(`Note ${replace} is owned by another session`); + } + } + ``` + +- [ ] **Step 5: Re-run the note-service tests and confirm they pass** + + Run: + + ```bash + deno test -A src/services/session-notes.test.ts + ``` + + Expected: PASS. + +--- + +## Task 3: Rewire The Runtime To Use Internal Root Resolution And Direct Read By `id` + +**Files:** + +- Modify: `src/services/session-mcp-runtime.ts` +- Modify: `src/services/session-mcp-runtime.test.ts` + +- [ ] **Step 1: Add failing runtime tests for rootless execution and direct + same-project read** + + Replace the old note runtime tests in + `src/services/session-mcp-runtime.test.ts` with assertions shaped like: + + ```ts + it("executes the updated note action contract through the runtime", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + groupId: "group-runtime", + } as never); + + const localContext = createToolContext({ sessionID: "child-local" }); + const foreignContext = createToolContext({ sessionID: "child-foreign" }); + + runtime.setSessionCanonicalizer({ + getCachedCanonicalSessionId(sessionId: string) { + return sessionId === "child-local" ? "root-local" : "root-foreign"; + }, + async resolveCanonicalSessionId(sessionId: string) { + return sessionId === "child-local" ? "root-local" : "root-foreign"; + }, + async validateRuntimeRootSessionId() {}, + } as never); + + const created = JSON.parse( + await runtime.tools.session_notes_write.execute( + { text: "first note" }, + localContext, + ), + ); + const read = JSON.parse( + await runtime.tools.session_notes_read.execute( + { id: created.id }, + foreignContext, + ), + ); + + assertEquals(read.note.id, created.id); + assertEquals(read.note.text, "first note"); + }); + ``` + +- [ ] **Step 2: Add a failing runtime test for `session_search` local/project + note ranking** + + Add: + + ```ts + it("returns local note hits above same-project foreign note hits", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + groupId: "group-note-search", + } as never); + + runtime.setSessionCanonicalizer({ + getCachedCanonicalSessionId() { + return "root-local"; + }, + async resolveCanonicalSessionId() { + return "root-local"; + }, + async validateRuntimeRootSessionId() {}, + } as never); + + await runtime.tools.session_notes_write.execute({ + text: "redis ttl drift note", + }, createToolContext({ sessionID: "local-child" })); + runtime.setSessionCanonicalizer({ + getCachedCanonicalSessionId(sessionId: string) { + return sessionId === "local-child" ? "root-local" : "root-other"; + }, + async resolveCanonicalSessionId(sessionId: string) { + return sessionId === "local-child" ? "root-local" : "root-other"; + }, + async validateRuntimeRootSessionId() {}, + } as never); + await runtime.tools.session_notes_write.execute({ + text: "redis ttl drift note", + }, createToolContext({ sessionID: "other-child" })); + + runtime.setSessionCanonicalizer({ + getCachedCanonicalSessionId() { + return "root-local"; + }, + async resolveCanonicalSessionId() { + return "root-local"; + }, + async validateRuntimeRootSessionId() {}, + } as never); + + const parsed = JSON.parse( + await runtime.tools.session_search.execute( + { query: "redis ttl drift note" }, + createToolContext({ sessionID: "local-child" }), + ), + ); + + const noteHits = parsed.results.filter((result: { type?: string }) => + result.type === "note" + ); + assertEquals(noteHits[0].scope, "local"); + assertEquals(noteHits[1].scope, "project"); + assertEquals(noteHits[0].score > noteHits[1].score, true); + }); + ``` + +- [ ] **Step 3: Run the runtime test slice and confirm it fails** + + Run: + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts + ``` + + Expected: FAIL because the runtime still expects public `root_session_id`, + still reads notes by current root, and does not merge same-project foreign + note hits. + +- [ ] **Step 4: Update `src/services/session-mcp-runtime.ts` to resolve root + internally for note/search tools** + + Add a helper near the runtime setup: + + ```ts + const resolveCanonicalRuntimeRootSessionId = async ( + context: ToolContext, + validator: RuntimeRootSessionValidator | undefined, + ): Promise => { + const sessionId = context.sessionID; + if (!sessionId) { + throw new Error("session_search requires a session context"); + } + return await validator?.resolveCanonicalSessionId(sessionId) ?? sessionId; + }; + ``` + + Then update the handlers: + + ```ts + session_search: async (request, context) => { + const rootSessionId = await resolveCanonicalRuntimeRootSessionId( + context, + sessionCanonicalizer, + ); + return await searchLocalCorpus(rootSessionId, request.query); + }, + + session_notes_write: async (request, context) => { + const rootSessionId = await resolveCanonicalRuntimeRootSessionId( + context, + sessionCanonicalizer, + ); + return await notes.writeNote(rootSessionId, request.text, { + replace: request.replace, + }); + }, + + session_notes_read: async (request) => { + return await notes.readNoteById(groupId, request.id); + }, + ``` + + Also remove public `root_session_id` from the registered `args` for these + tools. + +- [ ] **Step 5: Re-run the runtime test slice and confirm it passes** + + Run: + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts + ``` + + Expected: PASS. + +--- + +## Task 4: Update Tool Descriptions And Search-Hit Metadata + +**Files:** + +- Modify: `src/services/session-mcp-runtime.ts` +- Modify: `src/services/session-mcp-runtime.test.ts` +- Modify: `src/index.test.ts` + +- [ ] **Step 1: Write failing description assertions for delete semantics and + new read/search contracts** + + Replace the old description-string checks in + `src/services/session-mcp-runtime.test.ts` with assertions like: + + ```ts + assertStringIncludes( + runtime.tools.session_notes_write.description, + "If the `id` does not exist, deletion is a no-op and still returns `deleted`.", + ); + assertStringIncludes( + runtime.tools.session_notes_write.description, + "If the `id` exists but is owned by another session in the same project, the delete is rejected.", + ); + assertStringIncludes( + runtime.tools.session_notes_read.description, + '{ "note": null }', + ); + assertStringIncludes( + runtime.tools.session_search.description, + 'scope: "local" | "project"', + ); + ``` + +- [ ] **Step 2: Run the description-focused test slice and confirm it fails** + + Run: + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts src/index.test.ts + ``` + + Expected: FAIL because descriptions still mention `note_id`, root-session-only + reads, and the old `{ notes: [...] }` shape. + +- [ ] **Step 3: Replace the shipped tool-description strings in + `src/services/session-mcp-runtime.ts`** + + Replace the note tool descriptions with the new contract language. The key + wording that must ship is: + + ```ts + export const SESSION_NOTES_WRITE_DESCRIPTION = [ + "Pin working context as a session note so it survives topic switches, long tool", + "loops, and compaction.", + "", + 'Accepts `text` (markdown body) and optional `replace` (`id` for one note, or `"*"` to replace all notes for the current session).', + "", + "Mutation semantics:", + "- No `replace`: create a new note with a fresh `id`.", + '- `replace: ""` with non-empty `text`: upsert that note into the current session.', + '- `replace: ""` with empty `text`: delete that note from the current session.', + "- If the `id` does not exist, deletion is a no-op and still returns `deleted`.", + "- If the `id` exists but is owned by another session in the same project, the write or delete is rejected.", + '- `replace: "*"` with non-empty `text`: replace all notes for the current session with one new note.', + '- `replace: "*"` with empty `text`: clear all notes for the current session.', + ].join("\n"); + ``` + + And update the read/search descriptions to mention: + + ```ts + "Returns `{ note: { id, text, created_at, updated_at } }` when found and `{ note: null }` when the id is unknown.", + "Note hits include `id`, `root_session_id`, and `scope: \"local\" | \"project\"`.", + ``` + +- [ ] **Step 4: Re-run the description tests and confirm they pass** + + Run: + + ```bash + deno test -A src/services/session-mcp-runtime.test.ts src/index.test.ts + ``` + + Expected: PASS. + +--- + +## Task 5: Update Docs And End-To-End Verification + +**Files:** + +- Modify: `docs/SmokeTests.md` + +- [ ] **Step 1: Update the smoke-test docs for the new runtime contract** + + In `docs/SmokeTests.md`, replace old note expectations so the live evidence + now requires: + + ```md + - `session_search({ query })` may return note hits with `id`, `root_session_id`, + and `scope: "local" | "project"`. + - `session_notes_read({ id })` reopens one note by id and returns + `{ note: null }` on miss. + - Same-project foreign note hits should rank below equivalent local note hits. + - Delete-on-miss remains a successful `{ action: "deleted", id }` no-op. + ``` + +- [ ] **Step 2: Run the targeted test suite for the modified files** + + Run: + + ```bash + deno test -A src/services/session-notes.test.ts src/services/session-mcp-runtime.test.ts src/index.test.ts + ``` + + Expected: PASS. + +- [ ] **Step 3: Run the full verification suite** + + Run: + + ```bash + deno test -A + deno task check + deno task lint + deno task fmt + ``` + + Expected: all PASS. + +- [ ] **Step 4: Perform the final spec-to-plan coverage check before + implementation handoff** + + Confirm each spec requirement maps to a task: + + ```md + - dual store: Task 2 + - project-unique id: Task 2 + - rootless public note/search contracts: Tasks 1 and 3 + - delete semantics in tool descriptions: Task 4 + - cross-session note search ranking: Tasks 2 and 3 + - current-session-only compaction behavior: preserved by architecture; no code + change required, but covered by regression awareness during full test run + ``` + + Expected: no uncovered spec requirements remain. + +--- + +## Notes For The Implementer + +- Do not add routing nudges, bootstrap prompt logic, or subagent logic here. + That work is intentionally out of scope for this plan. +- Do not run git commands unless explicitly requested by the user. +- Keep compaction behavior current-session-only even though note search becomes + same-project aware. +- Preserve legacy note reads/searches by normalizing legacy values on read and + rewriting touched entries in the new shape on write. diff --git a/docs/superpowers/specs/2026-04-11-session-notes-anti-drift-design.md b/docs/superpowers/specs/2026-04-11-session-notes-anti-drift-design.md index eaa01ab..abec203 100644 --- 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 @@ -1,429 +1,454 @@ -# Session Notes Anti-Drift Design +# Session Notes Cross-Session Recall Design ## Goal -Add an agent-driven session-notes layer of MCP tools that helps preserve working -context across long tool-calling sessions, interleaved user topics, and -compaction without introducing structured note storage. +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 note contents as opaque markdown bodies stored on the existing -Redis hot tier. Agents should be biased to use note tools naturally through tool -descriptions and search behavior rather than through rigid schemas or new UI -surfaces. +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 plugin preserves session continuity through event extraction, -snapshot rebuilding, cached persistent memory, and compaction-time -`` injection. That is strong for ordinary conversation flow, but -it does not give agents an explicit way to pin deliberately written working -context for topics that stall, get interrupted, or return later in the same -session. - -The target workflow is an agent session with multiple interleaving topics, -frequent short task switches, and user corrections that must survive compaction. -In that workflow, relying only on latent recall, event summaries, and generic -search has proven too weak. A note layer should give the agent a small set of -tools to: - -- write pinned markdown notes when context is likely to drift -- reopen exact note contents later instead of reconstructing them from memory -- surface prior note hits through the existing recall path -- feed complete note bodies into compaction as source input so the compaction - model can synthesize session history and notes together - -The design explicitly avoids structured note fields. Agents may be encouraged to -write readable markdown sections, but the storage layer itself remains opaque -text plus minimal note metadata. +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 -- Session notes must use the same Redis endpoint already used by the plugin hot - tier. -- Notes should live in a dedicated Redis namespace rather than being mixed into - ordinary event or memory keys. -- Notes are stored per canonical root session so child-agent activity remains - aligned with parent-session continuity. -- Note contents are opaque markdown bodies. -- Note keys should expire using the existing `sessionTtlSeconds` configuration - value (default 86400 seconds), matching the lifetime of other session-scoped - Redis data. -- Stored metadata is minimal and operational only: - - note id - - canonical root session id - - note creation timestamp - - note update timestamp - - note title or session title data only where needed for search result - annotation -- The design must not introduce structured note payload fields such as `status`, - `goal`, `blocker`, or other typed task-state columns. +Use two Redis hashes: + +1. `session:{rootSessionId}:notes` + +- session-local authoritative note store +- field: `id` +- value: + - `text` + - `created_at` + - `updated_at` + +2. `project:{groupId}:notes` + +- same-project cross-session note store +- field: `id` +- value: + - `root_session_id` + - `text` + - `created_at` + - `updated_at` + +The session store remains authoritative for: + +- compaction note injection +- current-session note enumeration and ordering +- current-session ownership semantics + +The project store exists for: + +- same-project cross-session note search +- direct note reopen by `id` +- project-scoped uniqueness checks + +There must not be an unscoped global `session:notes` key. Redis/FalkorDB may be +shared across multiple projects, so the shared note store must remain project +scoped. + +### Note Identity + +Public note identity is `id`, not `note_id`. + +`id` must be unique within `project:{groupId}:notes`. + +On note creation: + +1. Generate a UUID. +2. Check whether `project:{groupId}:notes` already contains that `id`. +3. If yes, generate a new UUID and retry until unique. +4. Persist the new note to both stores. + +This makes one `id` sufficient for: + +- `session_search` note hits +- `session_notes_read({ id })` +- owned-session mutation via `replace: id` ### MCP Tool Surface -The note feature should expose exactly two dedicated note tools: - -- `session_notes_write(text: string, replace?: string)` → - `{ action: "created" | "replaced" | "deleted", note_id?: string, cleared_count?: number }` -- `session_notes_read(id?: string)` → - `{ notes: Array<{ note_id: string, text: string, created_at: string, updated_at: string }> }` - -`session_note_search` is explicitly out of scope and should not be added. - -### `session_notes_write` - -- Adds a note entry to session note storage and returns an explicit outcome - object so agents can tell whether the operation created, replaced, deleted, or - replaced all notes without inferring it from the inputs alone. -- If `replace` is omitted, append a new note. -- If `replace` is a note id, replace that single note entry. -- If `replace` is `"*"`, replace all current-session notes. -- If `text` is empty and `replace` is provided, clear the targeted note or note - set. -- `session_notes_write` responses must make deletion transparent to the agent: - - append new note → `{ action: "created", note_id }` - - replace one note → `{ action: "replaced", note_id }` - - delete one note → `{ action: "deleted", note_id }` - - replace all notes with one new note → - `{ action: "replaced", note_id, cleared_count }` - - clear all notes with empty `text` and `replace: "*"` → - `{ action: "replaced", cleared_count }` -- Replacement behavior applies only within the canonical root session note set. -- Tool descriptions should strongly bias usage toward anti-drift note taking, - including examples such as: - - before switching topics - - after a user correction changes assumptions - - when a small task stalls and work is about to shift elsewhere - - during long tool loops where state may otherwise live only in model context -- The description should encourage concise markdown formatting with headings, - bullets, and short code examples when useful, without making that format - mandatory. - -### `session_notes_read` - -- If `id` is omitted, return all notes for the current canonical root session. -- If `id` is provided, return the exact note contents for that note. -- Returns `{ notes: [{ note_id, text, created_at, updated_at }] }`. -- The response should preserve the original note text rather than paraphrasing - or transforming it. -- The tool exists primarily so agents can reload exact pinned context instead of - reciting it from latent memory. -- Tool descriptions should bias usage toward reopening note contents when the - agent resumes an interrupted topic or needs the exact wording of pinned user - instructions. +Expose exactly two note tools: -### `session_search` +- `session_notes_write(text: string, replace?: string)` +- `session_notes_read(id: string)` -- `session_search` remains the primary recall entrypoint. -- Note hits should be included in `session_search` results alongside existing - session or memory search results. -- Note hits must be clearly labeled as note-tool material so the agent can tell - that the result came from pinned notes rather than indexed memory content. -- The result item schema should be extended with optional - `type?: "memory" | - "note"` and `note_id?: string` fields so note hits are - unambiguous and agents can follow up with `session_notes_read` by note id. -- Existing memory results should default to `type: "memory"` and omit `note_id`. -- Note hits should include enough metadata for an obvious follow-up with - `session_notes_read`, such as note id, session id, session title, and snippet. -- Existing session-search behavior should remain intact for memory results. -- The implementation should merge note hits conservatively so note recall is - discoverable without overwhelming existing search output. +Do not add a dedicated note-search tool. `session_search` remains the primary +recall entrypoint. -## Agent Usage Bias +### Public Tool Contracts + +#### `session_notes_write` + +Request: + +```json +{ + "text": "...", + "replace": "optional id or *" +} +``` -The design should not rely on agents inferring note workflows on their own. -Usage bias is part of the feature. +Response: -### Strong Tool-Description Bias +```json +{ "action": "created", "id": "uuid" } +``` -The note-tool descriptions should be intentionally prescriptive rather than -neutral. +```json +{ "action": "replaced", "id": "uuid" } +``` -`session_notes_write` should read as the preferred way to pin working context -that must survive: +```json +{ "action": "deleted", "id": "uuid" } +``` -- long tool-calling sessions -- topic switches -- stalls or blockers -- user corrections -- compaction +```json +{ "action": "replaced", "id": "uuid", "cleared_count": 3 } +``` -`session_notes_read` should read as the preferred way to reopen exact note text -instead of reconstructing note contents from memory. +```json +{ "action": "replaced", "cleared_count": 3 } +``` -`session_search` should be reframed as the default recall tool for: +Mutation semantics: -- new sessions -- post-compaction turns -- resumed or repeated topics -- checking whether earlier work or pinned notes already contain the needed - context +- 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. -The intended descriptions should include concrete markdown examples so agents -are nudged toward useful freeform notes without the storage layer becoming -structured. +Only ownership conflicts are exceptional. Missing targets are normal control +flow and must not throw for upsert or delete. -### Dynamic `session_search` Description Bias +#### `session_notes_read` -Static descriptions alone are not strong enough. The plugin should use the -OpenCode `tool.definition` hook to dynamically strengthen `session_search` -guidance when it is most useful. +Request: -The important dynamic-bias moments are: +```json +{ "id": "uuid" } +``` -- new-session turns -- post-compaction turns +Response when found: -At those times, the `session_search` description sent to the model should be -augmented to emphasize that agents should use it before re-solving earlier work -or when resuming context that may have drifted. +```json +{ + "note": { + "id": "uuid", + "text": "...", + "created_at": "...", + "updated_at": "..." + } +} +``` -This is a description-layer bias only. It does not add extra reminder text into -ordinary turn prompts and does not introduce heuristic pre-compaction reminders. +Response when missing: -#### Bias State Mechanism +```json +{ "note": null } +``` -The `tool.definition` hook receives only `{ toolID }` as input — no session -state, turn count, or compaction flag. To work around this limitation, the -plugin should maintain a per-session `biasState` flag in module-scoped state: +Behavior: -- Set `biasState = "new-session"` when a session is first seen in `chat.message` - (no prior events recorded for that session). -- Set `biasState = "post-compaction"` when `session.compacting` fires. -- Clear `biasState` back to `"normal"` after the first `tool.definition` call - for `session_search` has consumed the flag (i.e., after the strengthened - description has been emitted once). -- The `tool.definition` hook reads the current `biasState` and returns the - strengthened or normal description accordingly. +- `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. -This keeps the mechanism local to the plugin without requiring upstream changes -to the OpenCode plugin API. +### `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 -### Recall Workflow +Note hits must use this shape: -The intended default workflow becomes: +```json +{ + "type": "note", + "id": "uuid", + "root_session_id": "ses_...", + "scope": "local", + "snippet": "...", + "score": 0.91 +} +``` -1. Use `session_search` to broadly recall prior context and note hits. -2. Use `session_notes_read` to reopen exact note text once a note hit or current - note set looks relevant. -3. Use `session_notes_write` to pin new or updated working context before drift - is likely. +or -This keeps the search path unified instead of splitting recall between multiple -specialized search tools that agents are unlikely to adopt consistently. +```json +{ + "type": "note", + "id": "uuid", + "root_session_id": "ses_other...", + "scope": "project", + "snippet": "...", + "score": 0.77 +} +``` -## Compaction Behavior +Rules: -### Full Note Bodies As Compaction Input +- `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. -- The compaction hook must inject the complete current-session note contents as - input context to compaction. -- The plugin must not pre-summarize, compress, or reinterpret note bodies before - injecting them, beyond safe escaping and envelope formatting. -- The compaction prompt should explicitly state that the injected note contents - came from note tools and were intentionally written to preserve anti-drift - context. -- The compaction model should summarize both: - - the session conversation and tool history - - the injected note contents +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 -This means the note layer provides the raw note material and provenance, while -the compaction model performs the actual synthesis. +### `session_search` Is The Default Recall Tool -### Compaction Envelope Shape +`session_search` should explicitly describe itself as the first tool to use: -The plugin should extend the compaction-time `` payload with a -dedicated note section, for example a `` block, so provenance is -explicit and the compaction model can treat note text as intentionally pinned -material. +- 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 rendered section should: +The description should explain that note hits may come from: -- include complete note bodies for the canonical root session -- preserve note boundaries and note ids -- identify that the contents came from note tools -- remain separate from snapshot and persistent-memory sections +- the current session (`scope: "local"`) +- another session in the same project (`scope: "project"`) -This note section is required for compaction input. Injecting notes into normal -chat-message turns is out of scope. +### `session_notes_read` Is The Exact Reopen Tool -## Recommended Approach +`session_notes_read` should describe itself as the way to reopen exact pinned +note text by `id` instead of reconstructing it from memory. -### Option A: Dedicated Redis Note Store Plus Search Integration +The description should explicitly say: -Recommended. +- it reads one note by `id` +- it does not require `root_session_id` +- unknown ids return `{ note: null }` -- Add a dedicated Redis-backed note service using the existing hot-tier Redis - endpoint. -- Keep note mutation and exact reads separate from event and memory storage. -- Extend `session_search` to merge note hits into the main recall path. -- Extend compaction rendering to include full note bodies with explicit - provenance. -- Use `tool.definition` to bias `session_search` descriptions on new-session and - post-compaction turns. +### `session_notes_write` Must Document Delete Semantics -This approach fits note semantics cleanly without contorting event or memory -storage into a mutable note store. +The write tool description must document mutation semantics precisely, +especially deletion behavior. -### Option B: Store Notes As Ordinary Session Events +It must explain: -Not recommended. +- `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 -- Reuse event storage and reconstruct note state from events. +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. -This makes replace semantics, exact reads, and compaction-time note rendering -awkward. Events are append-oriented and do not naturally model a mutable note -set. +## Legacy Compatibility -### Option C: Store Notes Inside Corpus Records Only +Do not run a migration. -Not recommended. +Instead: -- Reuse session corpus indexing as the primary note store. +- 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 overfits a chunked search index to a feature that needs exact note reads, -note replacement, and explicit compaction provenance. (Note: "corpus" here -refers to the internal implementation class name `SessionCorpus`, not the -user-facing terminology which uses "memory".) +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` -Add a new note service responsible for: - -- note storage keyed by canonical root session id -- note id generation -- append and replace semantics -- clear semantics through empty text plus `replace` -- current-session note reads -- migration of root-session note state if canonical roots change -- note-search indexing and retrieval for `session_search` +Extend the note service to own: -This service should depend only on the existing Redis client and remain on the -hot tier. +- 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` -- Add `session_notes_write` and `session_notes_read` to the MCP tool name set. -- Define request and response schemas for both tools: - - `session_notes_write` response: - `{ action: "created" | "replaced" | "deleted", note_id?: string, cleared_count?: number }` - - `session_notes_read` response: - `{ notes: Array<{ note_id: string, text: string, created_at: string, updated_at: string }> }` -- Extend `session_search` result item schema with optional - `type?: "memory" | - "note"` and `note_id?: string` fields. -- Keep `.strict()` on the result item schema and add the new optional fields - before the strict call. +- 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 new note tools. -- Route note tool handlers through the note service. -- Update `session_search` to merge note results with existing memory results. +- 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` -- Ensure the `session_search` description is strong enough in its baseline form - to bias usage even before dynamic description augmentation. + +### `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` -- Extend session-memory composition to load current-session notes for compaction - rendering. -- Add note-aware XML rendering that preserves note boundaries and provenance. -- Keep note injection limited to compaction-time session memory rather than - normal turn injection. +- Continue to load current-session notes only for compaction injection. +- Preserve note boundaries and ids inside ``. ### `src/handlers/compacting.ts` -- Continue to prepare compaction injection through the canonical session. -- Ensure the compaction context includes the complete note section inside the - rendered `` envelope. -- Preserve the local-first behavior: no Graphiti fetch should be required for - note injection. - -### `src/index.ts` - -- Instantiate the new note service on the existing Redis client. -- Pass note service dependencies into the MCP runtime and session manager. -- Maintain a per-session `biasState` flag (module-scoped or on the plugin - context) that tracks whether the current turn is new-session, post-compaction, - or normal. -- Set `biasState` in `chat.message` (new session detection) and - `session.compacting` hooks. -- Register a `tool.definition` hook that reads `biasState` to strengthen or - normalize the `session_search` description. -- Clear `biasState` back to `"normal"` after `tool.definition` emits the - strengthened description once. - -### Search Result Rendering - -If note hits and memory hits share one response array, the result shape should -distinguish them clearly enough that an agent can tell which follow-up to use. -The implementation may add a result discriminator or equivalent metadata, but it -should remain compact and obvious in plain JSON output. +- 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 for the feature. +Follow TDD. ### Red -- Add MCP schema tests that fail until the new note tools exist. -- Add runtime tests that fail until: - - note writes append and replace correctly - - note reads return exact stored text - - `session_search` includes note hits - - compaction injection includes full note bodies with provenance - - `session_search` dynamic description bias changes on new-session and - post-compaction states +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 minimal note service, tool registration, search merge, and - compaction rendering changes required to satisfy the new failing tests. +Implement only the smallest set of storage, schema, runtime, and search changes +required to satisfy those tests. ### Refactor -- Extract small helpers only where repeated note-rendering, note-hit merging, or - description-bias logic would otherwise become unclear. -- Do not introduce structured note parsing or typed note sections. +- 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/handlers/compacting.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` -The critical evidence is: +Critical evidence: -- notes can be written, replaced, deleted, cleared via `replace: "*"`, and read - exactly -- `session_notes_write` responses make deletion/clear outcomes explicit to the - agent instead of requiring inference from `text` and `replace` -- `session_search` visibly includes note hits and is described as the preferred - recall path -- compaction receives full note contents as input with explicit note-tool - provenance -- interleaved-topic note context survives compaction through the injected note - section +- `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 -- TUI or GUI note display surfaces -- any new external plugin UI system -- structured note payloads or typed task-state storage -- note injection into normal `chat.message` or `messages.transform` turns -- a standalone `session_note_search` or `session_notes_search` tool -- heuristic pre-compaction reminder nudges -- generic turn-local reminder nudges outside description shaping +- 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/src/index.test.ts b/src/index.test.ts index 6324eb2..01cec16 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -2,6 +2,7 @@ import { assertEquals, assertRejects, assertStrictEquals, + assertStringIncludes, } from "jsr:@std/assert@^1.0.0"; import { afterEach, describe, it } from "jsr:@std/testing@^1.0.0/bdd"; import { @@ -15,6 +16,7 @@ import { SESSION_SEARCH_BASELINE_DESCRIPTION, SESSION_SEARCH_STRENGTHENED_DESCRIPTION, } from "./services/session-mcp-runtime.ts"; +import { createSessionMcpRuntime } from "./services/session-mcp-runtime.ts"; import { setOpenCodeClient, setWarningTaskScheduler, @@ -125,7 +127,7 @@ function createEntrypointHarnessWithOptions(options: { redisCacheInstances: [] as unknown[], sessionNotesArgs: [] as Array<[ unknown, - { sessionTtlSeconds: number }, + { groupId: string; sessionTtlSeconds: number }, ]>, sessionNotesInstances: [] as unknown[], batchDrainArgs: [] as Array<[ @@ -251,7 +253,10 @@ function createEntrypointHarnessWithOptions(options: { } class MockSessionNotesService { - constructor(redisClient: unknown, options: { sessionTtlSeconds: number }) { + constructor( + redisClient: unknown, + options: { groupId: string; sessionTtlSeconds: number }, + ) { records.sessionNotesArgs.push([redisClient, options]); records.sessionNotesInstances.push(this); } @@ -894,6 +899,41 @@ describe("index", () => { }); describe("graphiti entrypoint", () => { + it("exposes public note/search tool args without root_session_id", () => { + const runtime = createSessionMcpRuntime(); + + try { + assertStringIncludes( + runtime.tools.session_notes_write.description, + "delete on missing id is a no-op success returning deleted", + ); + assertStringIncludes( + runtime.tools.session_notes_write.description, + "only ownership conflicts reject mutation", + ); + assertStringIncludes( + runtime.tools.session_notes_read.description, + "returns `{ note: null }`", + ); + assertStringIncludes( + runtime.tools.session_search.description, + '`id`, `root_session_id`, and `scope: "local" | "project"`', + ); + assertEquals(Object.keys(runtime.tools.session_notes_write.args), [ + "text", + "replace", + ]); + assertEquals(Object.keys(runtime.tools.session_notes_read.args), [ + "id", + ]); + assertEquals(Object.keys(runtime.tools.session_search.args), [ + "query", + ]); + } finally { + void runtime.dispose(); + } + }); + it("exports graphiti as the plugin entrypoint", () => { assertEquals(typeof graphiti, "function"); }); @@ -982,6 +1022,7 @@ describe("index", () => { records.redisClientInstances[0], ); assertEquals(records.sessionNotesArgs[0][1], { + groupId: "group-id", sessionTtlSeconds: config.redis.sessionTtlSeconds, }); assertStrictEquals( diff --git a/src/index.ts b/src/index.ts index 1b2dbfa..bd3de95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -224,7 +224,16 @@ export const graphiti: Plugin = ( ttlSeconds: config.redis.cacheTtlSeconds, driftThreshold: config.graphiti.driftThreshold, }); + const defaultGroupId = dependencies.makeGroupId( + config.graphiti.groupIdPrefix, + input.directory, + ); + const defaultUserGroupId = dependencies.makeUserGroupId( + config.graphiti.groupIdPrefix, + input.directory, + ); const notesService = new dependencies.SessionNotesService(redisClient, { + groupId: defaultGroupId, sessionTtlSeconds: config.redis.sessionTtlSeconds, }); const batchDrain = new dependencies.BatchDrainService( @@ -236,15 +245,6 @@ export const graphiti: Plugin = ( drainRetryMax: config.redis.drainRetryMax, }, ); - const defaultGroupId = dependencies.makeGroupId( - config.graphiti.groupIdPrefix, - input.directory, - ); - const defaultUserGroupId = dependencies.makeUserGroupId( - config.graphiti.groupIdPrefix, - input.directory, - ); - const graphitiAsync = new dependencies.GraphitiAsyncService( graphitiClient, redisCache, diff --git a/src/services/session-mcp-runtime.test.ts b/src/services/session-mcp-runtime.test.ts index 4490179..c91e8f0 100644 --- a/src/services/session-mcp-runtime.test.ts +++ b/src/services/session-mcp-runtime.test.ts @@ -169,6 +169,15 @@ const createToolContext = (overrides: Partial = {}) => ({ ...overrides, }); +const createRootToolContext = ( + rootSessionId: string, + overrides: Partial = {}, +) => + createToolContext({ + sessionID: rootSessionId, + ...overrides, + }); + const validRequests: Record> = { session_execute: { root_session_id: "root-123", @@ -187,7 +196,6 @@ const validRequests: Record> = { content: "hello world", }, session_search: { - root_session_id: "root-123", query: "hello", }, session_fetch_and_index: { @@ -201,24 +209,27 @@ const validRequests: Record> = { root_session_id: "root-123", }, session_notes_write: { - root_session_id: "root-123", text: "remember this", }, session_notes_read: { - root_session_id: "root-123", + id: "note-1", }, }; Deno.test("note schema compatibility accepts approved note request and response contracts", () => { const writeRequest = sessionMcpRequestSchemas.session_notes_write.safeParse({ - root_session_id: "root-123", 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", - note_id: "note-1", + id: "note-1", }); const clearedResponse = sessionMcpResponseSchemas.session_notes_write .safeParse({ @@ -226,26 +237,45 @@ Deno.test("note schema compatibility accepts approved note request and response cleared_count: 2, }); const readRequest = sessionMcpRequestSchemas.session_notes_read.safeParse({ - root_session_id: "root-123", 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({ - notes: [{ - note_id: "note-1", + note: { + id: "note-1", text: "remember this", created_at: "2026-04-11T10:00:00.000Z", updated_at: "2026-04-11T10:00:00.000Z", - }], + }, }); + const missingReadResponse = sessionMcpResponseSchemas.session_notes_read + .safeParse({ note: null }); assertEquals(writeRequest.success, true); + assertEquals(rejectedWriteRequest.success, false); assertEquals(deleteResponse.success, true); assertEquals(clearedResponse.success, true); assertEquals(readRequest.success, true); + assertEquals(missingReadRequest.success, false); + assertEquals(rejectedReadRequest.success, false); assertEquals(readResponse.success, true); + assertEquals(missingReadResponse.success, true); }); Deno.test("search schema compatibility accepts note-flavored results and remains strict", () => { + const request = sessionMcpRequestSchemas.session_search.safeParse({ + query: "remember this", + }); + const rejectedRequest = sessionMcpRequestSchemas.session_search.safeParse({ + root_session_id: "root-123", + query: "remember this", + }); const accepted = sessionMcpResponseSchemas.session_search.safeParse({ status: "ok", results: [{ @@ -253,7 +283,9 @@ Deno.test("search schema compatibility accepts note-flavored results and remains snippet: "remember this", score: 0.9, type: "note", - note_id: "note-1", + id: "note-1", + root_session_id: "root-123", + scope: "local", }], corpus_refs: ["session:root:corpus:1"], truncated: false, @@ -265,15 +297,33 @@ Deno.test("search schema compatibility accepts note-flavored results and remains snippet: "remember this", score: 0.9, type: "note", - note_id: "note-1", + id: "note-1", + root_session_id: "root-123", + scope: "local", extra: true, }], corpus_refs: ["session:root:corpus:1"], truncated: false, }); + const rejectedLegacyIdentity = sessionMcpResponseSchemas.session_search + .safeParse({ + status: "ok", + results: [{ + corpus_ref: "session:root:corpus:1", + snippet: "remember this", + score: 0.9, + type: "note", + note_id: "note-1", + }], + corpus_refs: ["session:root:corpus:1"], + truncated: false, + }); + assertEquals(request.success, true); + assertEquals(rejectedRequest.success, false); assertEquals(accepted.success, true); assertEquals(rejected.success, false); + assertEquals(rejectedLegacyIdentity.success, false); }); Deno.test("mixed|batch schema compatibility", () => { @@ -383,6 +433,30 @@ describe("session-mcp-runtime", () => { try { assertExists(runtime.tools.session_notes_write); assertExists(runtime.tools.session_notes_read); + assertStringIncludes( + SESSION_NOTES_WRITE_DESCRIPTION, + "replace id + non-empty text is upsert", + ); + assertStringIncludes( + SESSION_NOTES_WRITE_DESCRIPTION, + 'replace "*" + non-empty text replaces all notes', + ); + assertStringIncludes( + SESSION_NOTES_WRITE_DESCRIPTION, + 'replace "*" + empty text clears all notes', + ); + assertStringIncludes( + SESSION_NOTES_READ_DESCRIPTION, + "returns that single note as", + ); + assertStringIncludes( + SESSION_NOTES_READ_DESCRIPTION, + "returns `{ note: null }`", + ); + assertStringIncludes( + SESSION_SEARCH_BASELINE_DESCRIPTION, + '`id`, `root_session_id`, and `scope: "local" | "project"`', + ); assertEquals( runtime.tools.session_notes_write.description, SESSION_NOTES_WRITE_DESCRIPTION, @@ -396,14 +470,15 @@ describe("session-mcp-runtime", () => { SESSION_SEARCH_BASELINE_DESCRIPTION, ); assertEquals(Object.keys(runtime.tools.session_notes_write.args), [ - "root_session_id", "text", "replace", ]); assertEquals(Object.keys(runtime.tools.session_notes_read.args), [ - "root_session_id", "id", ]); + assertEquals(Object.keys(runtime.tools.session_search.args), [ + "query", + ]); } finally { void runtime.dispose(); } @@ -420,25 +495,23 @@ describe("session-mcp-runtime", () => { const created = JSON.parse( await runtime.tools.session_notes_write.execute( { - root_session_id: "root-notes-runtime", text: "first note", }, toolContext, ), ); assertEquals(created.action, "created"); - assertExists(created.note_id); + assertExists(created.id); const readCreated = JSON.parse( await runtime.tools.session_notes_read.execute( { - root_session_id: "root-notes-runtime", + id: created.id, }, toolContext, ), ); - assertEquals(readCreated.notes.length, 1); - assertEquals(readCreated.notes[0].text, "first note"); + assertEquals(readCreated.note.text, "first note"); assertEquals( sessionMcpResponseSchemas.session_notes_read.safeParse(readCreated) .success, @@ -448,16 +521,15 @@ describe("session-mcp-runtime", () => { const replaced = JSON.parse( await runtime.tools.session_notes_write.execute( { - root_session_id: "root-notes-runtime", text: "updated note", - replace: created.note_id, + replace: created.id, }, toolContext, ), ); assertEquals(replaced, { action: "replaced", - note_id: created.note_id, + id: created.id, }); assertEquals( sessionMcpResponseSchemas.session_notes_write.safeParse(replaced) @@ -468,19 +540,17 @@ describe("session-mcp-runtime", () => { const createdSecond = JSON.parse( await runtime.tools.session_notes_write.execute( { - root_session_id: "root-notes-runtime", text: "second note", }, toolContext, ), ); assertEquals(createdSecond.action, "created"); - assertExists(createdSecond.note_id); + assertExists(createdSecond.id); const replacedAll = JSON.parse( await runtime.tools.session_notes_write.execute( { - root_session_id: "root-notes-runtime", text: "replacement note", replace: "*", }, @@ -488,7 +558,7 @@ describe("session-mcp-runtime", () => { ), ); assertEquals(replacedAll.action, "replaced"); - assertExists(replacedAll.note_id); + assertExists(replacedAll.id); assertEquals(replacedAll.cleared_count, 2); assertEquals( sessionMcpResponseSchemas.session_notes_write.safeParse(replacedAll) @@ -499,28 +569,25 @@ describe("session-mcp-runtime", () => { const readSingle = JSON.parse( await runtime.tools.session_notes_read.execute( { - root_session_id: "root-notes-runtime", - id: replacedAll.note_id, + id: replacedAll.id, }, toolContext, ), ); - assertEquals(readSingle.notes.length, 1); - assertEquals(readSingle.notes[0].text, "replacement note"); + assertEquals(readSingle.note.text, "replacement note"); const deleted = JSON.parse( await runtime.tools.session_notes_write.execute( { - root_session_id: "root-notes-runtime", text: "", - replace: replacedAll.note_id, + replace: replacedAll.id, }, toolContext, ), ); assertEquals(deleted, { action: "deleted", - note_id: replacedAll.note_id, + id: replacedAll.id, }); assertEquals( sessionMcpResponseSchemas.session_notes_write.safeParse(deleted) @@ -531,7 +598,6 @@ describe("session-mcp-runtime", () => { const cleared = JSON.parse( await runtime.tools.session_notes_write.execute( { - root_session_id: "root-notes-runtime", text: "", replace: "*", }, @@ -551,12 +617,12 @@ describe("session-mcp-runtime", () => { const readDeleted = JSON.parse( await runtime.tools.session_notes_read.execute( { - root_session_id: "root-notes-runtime", + id: replacedAll.id, }, toolContext, ), ); - assertEquals(readDeleted, { notes: [] }); + assertEquals(readDeleted, { note: null }); assertEquals( sessionMcpResponseSchemas.session_notes_read.safeParse(readDeleted) .success, @@ -567,6 +633,187 @@ describe("session-mcp-runtime", () => { } }); + it("resolves rootless search and note writes from the canonical tool context session", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const manager = new SessionManager( + "group-runtime-rootless", + "user-runtime-rootless", + { + session: { + get() { + throw new Error("unexpected session lookup"); + }, + }, + } as never, + {} as never, + {} as never, + {} as never, + ); + manager.setParentId("root-session", null); + manager.setParentId("child-session", "root-session"); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + sessionCanonicalizer: manager, + groupId: "group-runtime-rootless", + } as never); + + try { + await runtime.tools.session_index.execute( + { + root_session_id: "root-session", + content: "canonical root search corpus", + }, + createRootToolContext("root-session"), + ); + + const created = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + text: "canonical root pinned note", + }, + { + ...toolContext, + sessionID: "child-session", + }, + ), + ); + const search = JSON.parse( + await runtime.tools.session_search.execute( + { + query: "canonical root pinned note", + }, + { + ...toolContext, + sessionID: "child-session", + }, + ), + ); + + assertEquals(created.action, "created"); + assertExists(created.id); + assertEquals(search.status, "ok"); + assertEquals( + search.results.some((result: { id?: string }) => + result.id === created.id + ), + true, + ); + assertEquals( + search.results.some((result: { root_session_id?: string }) => + result.root_session_id === "root-session" + ), + true, + ); + } finally { + await runtime.dispose(); + } + }); + + it("reads a note directly by id across same-project sessions", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + groupId: "group-runtime-direct-read", + } as never); + + try { + const created = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + text: "same project note body", + }, + { + ...toolContext, + sessionID: "session-a", + }, + ), + ); + const read = JSON.parse( + await runtime.tools.session_notes_read.execute( + { + id: created.id, + }, + { + ...toolContext, + sessionID: "session-b", + }, + ), + ); + + assertEquals(read, { + note: { + id: created.id, + text: "same project note body", + created_at: read.note.created_at, + updated_at: read.note.updated_at, + }, + }); + } finally { + await runtime.dispose(); + } + }); + + it("ranks local note hits ahead of project note hits for the same query", async () => { + const redis = new RedisClient({ endpoint: "redis://unused" }); + const runtime = createSessionMcpRuntime({ + redisClient: redis, + sessionTtlSeconds: 60, + groupId: "group-runtime-note-ranking", + } as never); + + try { + const project = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + text: "redis ttl ranking note exact phrase", + }, + { + ...toolContext, + sessionID: "project-session", + }, + ), + ); + const local = JSON.parse( + await runtime.tools.session_notes_write.execute( + { + text: "redis ttl ranking note exact phrase", + }, + { + ...toolContext, + sessionID: "local-session", + }, + ), + ); + const search = JSON.parse( + await runtime.tools.session_search.execute( + { + query: "redis ttl ranking note exact phrase", + }, + { + ...toolContext, + sessionID: "local-session", + }, + ), + ); + const noteHits = search.results.filter((result: { type?: string }) => + result.type === "note" + ); + + assertEquals(noteHits.length >= 2, true); + assertEquals(noteHits[0].id, local.id); + assertEquals(noteHits[0].scope, "local"); + assertEquals(noteHits[0].root_session_id, "local-session"); + assertEquals(noteHits[1].id, project.id); + assertEquals(noteHits[1].scope, "project"); + assertEquals(noteHits[1].root_session_id, "project-session"); + assertEquals(noteHits[0].score >= noteHits[1].score, true); + } finally { + await runtime.dispose(); + } + }); + it("merges note and memory hits in session_search with typed results sorted by score", async () => { const redis = new RedisClient({ endpoint: "redis://unused" }); const runtime = createSessionMcpRuntime({ @@ -582,24 +829,22 @@ describe("session-mcp-runtime", () => { content: "Redis TTL memory entry mentions the active bug and prior mitigation.", }, - toolContext, + createRootToolContext("root-note-search"), ); const created = JSON.parse( await runtime.tools.session_notes_write.execute( { - root_session_id: "root-note-search", text: "Redis TTL bug active bug mitigation note for follow-up.", }, - toolContext, + createRootToolContext("root-note-search"), ), ); const serialized = await runtime.tools.session_search.execute( { - root_session_id: "root-note-search", query: "Redis TTL bug active bug mitigation note for follow-up.", }, - toolContext, + createRootToolContext("root-note-search"), ); const parsed = JSON.parse(serialized); const noteHit = parsed.results.find((result: { type?: string }) => @@ -615,15 +860,17 @@ describe("session-mcp-runtime", () => { ); assertExists(noteHit); assertExists(memoryHit); - assertEquals(noteHit.note_id, created.note_id); - assertStringIncludes(noteHit.corpus_ref, created.note_id); + assertEquals(noteHit.id, created.id); + assertEquals(noteHit.root_session_id, "root-note-search"); + assertEquals(noteHit.scope, "local"); + assertStringIncludes(noteHit.corpus_ref, created.id); assertStringIncludes( noteHit.snippet, "Redis TTL bug active bug mitigation", ); assertStringIncludes( runtime.tools.session_search.description, - "use `session_notes_read` with that id to reopen the full note", + "session_notes_read", ); assertEquals(memoryHit.type, "memory"); assertEquals(parsed.results[0].score >= parsed.results[1].score, true); @@ -657,16 +904,15 @@ describe("session-mcp-runtime", () => { root_session_id: "root-no-notes", content: "Local memory result without pinned note entries.", }, - toolContext, + createRootToolContext("root-no-notes"), ); const parsed = JSON.parse( await runtime.tools.session_search.execute( { - root_session_id: "root-no-notes", query: "Local memory result", }, - toolContext, + createRootToolContext("root-no-notes"), ), ); @@ -679,8 +925,8 @@ describe("session-mcp-runtime", () => { true, ); assertEquals( - parsed.results.every((result: { note_id?: string }) => - result.note_id === undefined + parsed.results.every((result: { id?: string }) => + result.id === undefined ), true, ); @@ -767,13 +1013,23 @@ describe("session-mcp-runtime", () => { } }); - it("rejects requests without root_session_id for every tool schema", () => { + it("keeps root_session_id private only for note/search public request schemas", () => { + const toolsWithPrivateRoot = new Set([ + "session_search", + "session_notes_write", + "session_notes_read", + ]); + for (const toolName of SESSION_MCP_TOOL_NAMES) { const request = { ...validRequests[toolName] }; delete request.root_session_id; const parsed = sessionMcpRequestSchemas[toolName].safeParse(request); - assertEquals(parsed.success, false, toolName); + assertEquals( + parsed.success, + toolsWithPrivateRoot.has(toolName), + toolName, + ); } }); @@ -997,7 +1253,6 @@ describe("session-mcp-runtime", () => { try { const serialized = await runtime.tools.session_search.execute( { - root_session_id: "root-session", query: "indexed", }, { @@ -1340,33 +1595,34 @@ describe("session-mcp-runtime", () => { } as never); try { + const rootContext = createRootToolContext("root-123"); await runtime.tools.session_execute.execute( validRequests.session_execute, - toolContext, + rootContext, ); await runtime.tools.session_execute_file.execute( validRequests.session_execute_file, - toolContext, + rootContext, ).catch(() => undefined); await runtime.tools.session_batch_execute.execute( validRequests.session_batch_execute, - toolContext, + rootContext, ); await runtime.tools.session_index.execute( validRequests.session_index, - toolContext, + rootContext, ); await runtime.tools.session_search.execute( validRequests.session_search, - toolContext, + rootContext, ); await runtime.tools.session_fetch_and_index.execute( validRequests.session_fetch_and_index, - toolContext, + rootContext, ); const statsSerialized = await runtime.tools.session_stats.execute( validRequests.session_stats, - toolContext, + rootContext, ); const stats = JSON.parse(statsSerialized); @@ -1605,14 +1861,13 @@ describe("session-mcp-runtime", () => { content: "# Redis Session TTLs\n\nSession TTL refreshes the local session corpus.", }, - toolContext, + createRootToolContext("root-123"), ); const serialized = await runtime.tools.session_search.execute( { - root_session_id: "root-123", query: "session ttl", }, - toolContext, + createRootToolContext("root-123"), ); const parsed = JSON.parse(serialized); @@ -1646,14 +1901,13 @@ describe("session-mcp-runtime", () => { try { const executeSerialized = await runtime.tools.session_execute.execute( validRequests.session_execute, - toolContext, + createRootToolContext("root-123"), ); const searchSerialized = await runtime.tools.session_search.execute( { - root_session_id: "root-123", query: "session ttl", }, - toolContext, + createRootToolContext("root-123"), ); const executed = JSON.parse(executeSerialized); const search = JSON.parse(searchSerialized); @@ -1735,15 +1989,14 @@ describe("session-mcp-runtime", () => { try { const executeSerialized = await runtime.tools.session_execute.execute( validRequests.session_execute, - toolContext, + createRootToolContext("root-123"), ); const execute = JSON.parse(executeSerialized); const searchSerialized = await runtime.tools.session_search.execute( { - root_session_id: "root-123", query: "searchable hidden marker", }, - toolContext, + createRootToolContext("root-123"), ); const search = JSON.parse(searchSerialized); @@ -1774,14 +2027,13 @@ describe("session-mcp-runtime", () => { content: "# Runtime Search\n\nSession TTL remains available through the live corpus.", }, - toolContext, + createRootToolContext("root-runtime"), ); const searchSerialized = await runtime.tools.session_search.execute( { - root_session_id: "root-runtime", query: "session ttl", }, - toolContext, + createRootToolContext("root-runtime"), ); const indexed = JSON.parse(indexedSerialized); @@ -1825,6 +2077,7 @@ describe("session-mcp-runtime", () => { path: localFile, }, createToolContext({ + sessionID: "root-path-index", worktree: worktreeDir, directory: worktreeDir, ask: (input) => { @@ -1836,10 +2089,10 @@ describe("session-mcp-runtime", () => { const searchSerialized = await runtime.tools.session_search.execute( { - root_session_id: "root-path-index", query: "Index local content for the current root session", }, createToolContext({ + sessionID: "root-path-index", worktree: worktreeDir, directory: worktreeDir, }), @@ -1891,6 +2144,7 @@ describe("session-mcp-runtime", () => { path: externalFile, }, createToolContext({ + sessionID: "root-path-index-external", worktree: worktreeDir, directory: worktreeDir, ask: (input) => { @@ -1902,10 +2156,10 @@ describe("session-mcp-runtime", () => { const searchSerialized = await runtime.tools.session_search.execute( { - root_session_id: "root-path-index-external", query: "Graphiti is never on the hot path", }, createToolContext({ + sessionID: "root-path-index-external", worktree: worktreeDir, directory: worktreeDir, }), @@ -1984,7 +2238,7 @@ describe("session-mcp-runtime", () => { source: "build-log", label: "latest", }, - toolContext, + createRootToolContext("root-runtime-replacement"), ); await runtime.tools.session_index.execute( { @@ -1993,25 +2247,23 @@ describe("session-mcp-runtime", () => { source: "build-log", label: "latest", }, - toolContext, + createRootToolContext("root-runtime-replacement"), ); const oldSearch = JSON.parse( await runtime.tools.session_search.execute( { - root_session_id: "root-runtime-replacement", query: "alpha", }, - toolContext, + createRootToolContext("root-runtime-replacement"), ), ); const newSearch = JSON.parse( await runtime.tools.session_search.execute( { - root_session_id: "root-runtime-replacement", query: "beta", }, - toolContext, + createRootToolContext("root-runtime-replacement"), ), ); diff --git a/src/services/session-mcp-runtime.ts b/src/services/session-mcp-runtime.ts index 08308e7..6f36055 100644 --- a/src/services/session-mcp-runtime.ts +++ b/src/services/session-mcp-runtime.ts @@ -47,15 +47,15 @@ export const SESSION_NOTES_WRITE_DESCRIPTION = [ "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:", + "Accepts `text` (markdown body) and optional `replace`:", "", - '- `{ 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', + "- replace id + non-empty text is upsert", + "- replace id + empty text is delete", + "- delete on missing id is a no-op success returning deleted", + "- only ownership conflicts reject mutation", + '- replace "*" + non-empty text replaces all notes and returns `{ action: "replaced", id, cleared_count }`', + '- replace "*" + empty text clears all notes and returns `{ action: "replaced", cleared_count }`', + '- omit `replace` to create a new note and return `{ action: "created", id }`', "", "Always rely on the returned `action` instead of inferring the outcome from the", "inputs alone.", @@ -74,9 +74,9 @@ export const SESSION_NOTES_READ_DESCRIPTION = [ "when you resume an interrupted topic, need the exact wording of a pinned user", "instruction, or want to verify what you previously noted before acting on it.", "", - "If `id` is provided, returns that single note. If `id` is omitted, returns all", - "notes for the current session. Returns", - "`{ notes: [{ note_id, text, created_at, updated_at }] }`.", + "If `id` is provided, returns that single note as", + "`{ note: { id, text, created_at, updated_at } }`; when the id does not exist,", + "returns `{ note: null }`.", "", "Always prefer reading a pinned note over reciting its contents from recall —", "notes are the source of truth for intentionally preserved context.", @@ -92,10 +92,11 @@ export const SESSION_SEARCH_BASELINE_DESCRIPTION = [ "- To check whether pinned session notes already contain the context you need", "", 'Results may include indexed memory content (type: "memory") and, when pinned', - 'session notes exist, matching notes (type: "note"). Note results include a', - "`note_id` — use `session_notes_read` with that id to reopen the full note", - "text. Not every query will return note results; notes only appear when they", - "match the search query and the session has pinned notes.", + 'session notes exist, matching notes (type: "note"). Note results include', + '`id`, `root_session_id`, and `scope: "local" | "project"` — use', + "`session_notes_read` with the note `id` to reopen the full note text. Not every", + "query will return note results; notes only appear when they match the search", + "query and the session has pinned notes.", "", "Prefer session_search over reconstructing context from scratch. If search", "returns relevant note hits, read the note before duplicating its contents.", @@ -111,10 +112,11 @@ export const SESSION_SEARCH_STRENGTHENED_DESCRIPTION = [ "- To check whether pinned session notes already contain the context you need", "", 'Results may include indexed memory content (type: "memory") and, when pinned', - 'session notes exist, matching notes (type: "note"). Note results include a', - "`note_id` — use `session_notes_read` with that id to reopen the full note", - "text. Not every query will return note results; notes only appear when they", - "match the search query and the session has pinned notes.", + 'session notes exist, matching notes (type: "note"). Note results include', + '`id`, `root_session_id`, and `scope: "local" | "project"` — use', + "`session_notes_read` with the note `id` to reopen the full note text. Not every", + "query will return note results; notes only appear when they match the search", + "query and the session has pinned notes.", "", "Prefer session_search over reconstructing context from scratch. If search", "returns relevant note hits, read the note before duplicating its contents.", @@ -170,7 +172,6 @@ const sessionMcpToolArgs: Record = { label: pluginSchema.string().min(1).optional(), }, session_search: { - ...pluginRootSessionIdArgs, query: pluginSchema.string().min(1), }, session_fetch_and_index: { @@ -185,12 +186,10 @@ const sessionMcpToolArgs: Record = { ...pluginRootSessionIdArgs, }, session_notes_write: { - ...pluginRootSessionIdArgs, text: pluginSchema.string(), replace: pluginSchema.string().min(1).optional(), }, session_notes_read: { - ...pluginRootSessionIdArgs, id: pluginSchema.string().min(1).optional(), }, }; @@ -467,9 +466,19 @@ export const createSessionMcpRuntime = ( const readSessionIndexFile = options.readSessionIndexFile ?? readTextFile; const notes = options.notesService ?? new SessionNotesService( options.redisClient ?? new RedisClient({ endpoint: "redis://unused" }), - { sessionTtlSeconds: options.sessionTtlSeconds ?? 60 }, + { groupId, sessionTtlSeconds: options.sessionTtlSeconds ?? 60 }, ); + const resolveCanonicalRootSessionId = async ( + context: ToolContext, + fallbackRootSessionId?: string, + ): Promise => { + const sessionId = context.sessionID; + if (!sessionId) return fallbackRootSessionId ?? ""; + return await sessionCanonicalizer?.resolveCanonicalSessionId(sessionId) ?? + (fallbackRootSessionId || sessionId); + }; + const writeArtifact = ( toolName: SessionMcpToolName, body: string, @@ -560,11 +569,14 @@ export const createSessionMcpRuntime = ( ): Promise => { const noteResults = (await notes.searchNotes(rootSessionId, query)).map( (note) => ({ - corpus_ref: `session:${groupId}:${rootSessionId}:note:${note.note_id}`, + corpus_ref: + `session:${groupId}:${note.root_session_id}:note:${note.id}`, snippet: note.snippet, score: note.score, type: "note" as const, - note_id: note.note_id, + id: note.id, + root_session_id: note.root_session_id, + scope: note.scope, }), ); @@ -693,8 +705,9 @@ export const createSessionMcpRuntime = ( query_hints: result.queryHints, }; }, - session_search: async (request) => { - return await searchLocalCorpus(request.root_session_id, request.query); + session_search: async (request, context) => { + const rootSessionId = await resolveCanonicalRootSessionId(context); + return await searchLocalCorpus(rootSessionId, request.query); }, session_fetch_and_index: async (request) => { if (!corpus) { @@ -778,13 +791,14 @@ export const createSessionMcpRuntime = ( }, }; }, - session_notes_write: async (request) => { - return await notes.writeNote(request.root_session_id, request.text, { + session_notes_write: async (request, context) => { + const rootSessionId = await resolveCanonicalRootSessionId(context); + return await notes.writeNote(rootSessionId, request.text, { replace: request.replace, }); }, session_notes_read: async (request) => { - return await notes.readNotes(request.root_session_id, request.id); + return await notes.readNote(request.id); }, }; @@ -980,13 +994,21 @@ export const createSessionMcpRuntime = ( context: ToolContext, ): Promise => { const request = parseRequest(toolName, rawRequest); + const effectiveRootSessionId = toolName === "session_search" || + toolName === "session_notes_write" || + toolName === "session_notes_read" + ? await resolveCanonicalRootSessionId(context) + : request.root_session_id; await validateRuntimeRootSessionContract( toolName, - request, + { + ...request, + root_session_id: effectiveRootSessionId, + } as SessionMcpRequestMap[TToolName], context, sessionCanonicalizer, ); - await recordToolCall(request.root_session_id, toolName); + await recordToolCall(effectiveRootSessionId, toolName); let response = validateResponsePreservingBatchShape( toolName, await (handlerMap[toolName] as ( @@ -1001,7 +1023,7 @@ export const createSessionMcpRuntime = ( await persistInlineArtifactIfPresent( toolName, response as SessionMcpResponseMap["session_execute"], - request.root_session_id, + effectiveRootSessionId, ), ); } @@ -1012,7 +1034,7 @@ export const createSessionMcpRuntime = ( await persistInlineArtifactIfPresent( toolName, response as SessionMcpResponseMap["session_execute_file"], - request.root_session_id, + effectiveRootSessionId, ), ); } @@ -1025,7 +1047,7 @@ export const createSessionMcpRuntime = ( await coerceOversizedResponse( toolName, response, - request.root_session_id, + effectiveRootSessionId, ), ); serialized = serialize(response); @@ -1041,7 +1063,7 @@ export const createSessionMcpRuntime = ( await persistCanonicalLocalArtifactIfNeeded( toolName, response as SessionMcpResponseMap["session_execute"], - request.root_session_id, + effectiveRootSessionId, ); } @@ -1049,11 +1071,11 @@ export const createSessionMcpRuntime = ( await persistCanonicalLocalArtifactIfNeeded( toolName, response as SessionMcpResponseMap["session_execute_file"], - request.root_session_id, + effectiveRootSessionId, ); } - await recordReturnedBytes(request.root_session_id, serialized); + await recordReturnedBytes(effectiveRootSessionId, serialized); return serialized; }; diff --git a/src/services/session-mcp-types.ts b/src/services/session-mcp-types.ts index 27d2c1c..7310658 100644 --- a/src/services/session-mcp-types.ts +++ b/src/services/session-mcp-types.ts @@ -77,6 +77,11 @@ type SessionIndexRequest = { label?: string; }; +type SessionSearchRequest = { + root_session_id: string; + query: string; +}; + type SessionNotesWriteRequest = { root_session_id: string; text: string; @@ -85,7 +90,7 @@ type SessionNotesWriteRequest = { type SessionNotesReadRequest = { root_session_id: string; - id?: string; + id: string; }; const searchResultSchema = z.object({ @@ -93,11 +98,13 @@ const searchResultSchema = z.object({ snippet: z.string(), score: z.number(), type: z.enum(["memory", "note"]).optional(), - note_id: z.string().min(1).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({ - note_id: z.string().min(1), + id: z.string().min(1), text: z.string(), created_at: z.string().min(1), updated_at: z.string().min(1), @@ -183,9 +190,11 @@ export const sessionMcpRequestSchemas = { session_batch_execute: sessionBatchExecuteRequestSchema, session_index: sessionIndexRequestSchema, session_search: z.object({ - ...rootSessionIdShape, query: z.string().min(1), - }).strict(), + }).strict().transform((request) => ({ + root_session_id: "", + query: request.query, + } satisfies SessionSearchRequest)), session_fetch_and_index: z.object({ ...rootSessionIdShape, url: z.string().url(), @@ -198,19 +207,17 @@ export const sessionMcpRequestSchemas = { ...rootSessionIdShape, }).strict(), session_notes_write: z.object({ - ...rootSessionIdShape, text: z.string(), replace: z.string().min(1).optional(), }).strict().transform((request) => ({ - root_session_id: request.root_session_id, + root_session_id: "", text: request.text, replace: request.replace, } satisfies SessionNotesWriteRequest)), session_notes_read: z.object({ - ...rootSessionIdShape, - id: z.string().min(1).optional(), + id: z.string().min(1), }).strict().transform((request) => ({ - root_session_id: request.root_session_id, + root_session_id: "", id: request.id, } satisfies SessionNotesReadRequest)), }; @@ -303,11 +310,11 @@ export const sessionMcpResponseSchemas = { }).strict(), session_notes_write: z.object({ action: z.enum(["created", "replaced", "deleted"]), - note_id: z.string().min(1).optional(), + id: z.string().min(1).optional(), cleared_count: z.number().int().nonnegative().optional(), }).strict(), session_notes_read: z.object({ - notes: z.array(sessionNoteSchema), + note: sessionNoteSchema.nullable(), }).strict(), }; diff --git a/src/services/session-notes.test.ts b/src/services/session-notes.test.ts index 6bf9a98..c977778 100644 --- a/src/services/session-notes.test.ts +++ b/src/services/session-notes.test.ts @@ -1,4 +1,4 @@ -import { assert, assertEquals } 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 { RedisClient } from "./redis-client.ts"; import { sessionNotesKey, SessionNotesService } from "./session-notes.ts"; @@ -20,6 +20,7 @@ describe("session notes", () => { it("appends and reads notes while refreshing the session TTL", async () => { const redis = createRedis(); const service = new SessionNotesService(redis, { + groupId: "project-1", sessionTtlSeconds: 60, createNoteId: createSequence(["note-1", "note-2"]), now: createClock( @@ -31,8 +32,8 @@ describe("session notes", () => { const first = await service.writeNote("root-1", "## First note"); const second = await service.writeNote("root-1", "## Second note"); - assertEquals(first, { action: "created", note_id: "note-1" }); - assertEquals(second, { action: "created", note_id: "note-2" }); + 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); @@ -53,27 +54,27 @@ describe("session notes", () => { } assertEquals(await service.readNotes("root-2"), { notes: [] }); - assertEquals(await service.readNotes("root-1", "missing"), { notes: [] }); + assertEquals(await service.readNote("missing"), { note: null }); const all = await service.readNotes("root-1"); assertEquals(all, { notes: [ { - note_id: "note-1", + id: "note-1", text: "## First note", created_at: "2026-04-11T10:00:00.000Z", updated_at: "2026-04-11T10:00:00.000Z", }, { - note_id: "note-2", + 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.readNotes("root-1", "note-2"), { - notes: [all.notes[1]], + assertEquals(await service.readNote("note-2"), { + note: all.notes[1], }); const refreshedSnapshot = await redis.snapshot(key); @@ -86,6 +87,7 @@ describe("session notes", () => { it("supports replace and clear semantics within a single root session", async () => { const redis = createRedis(); const service = new SessionNotesService(redis, { + groupId: "project-1", sessionTtlSeconds: 120, createNoteId: createSequence(["note-1", "note-2", "note-3", "note-4"]), now: createClock( @@ -106,27 +108,40 @@ describe("session notes", () => { const replacedOne = await service.writeNote("root-a", "alpha updated", { replace: "note-1", }); - assertEquals(replacedOne, { action: "replaced", note_id: "note-1" }); - assertEquals(await service.readNotes("root-a", "note-1"), { - notes: [{ - note_id: "note-1", + assertEquals(replacedOne, { action: "replaced", id: "note-1" }); + assertEquals(await service.readNote("note-1"), { + note: { + id: "note-1", text: "alpha updated", created_at: "2026-04-11T11:00:00.000Z", updated_at: "2026-04-11T11:00:03.000Z", - }], + }, }); + await assertRejects( + () => + service.writeNote("root-a", "foreign overwrite", { replace: "note-3" }), + Error, + "owned by another session", + ); + + await assertRejects( + () => service.writeNote("root-a", "", { replace: "note-3" }), + Error, + "owned by another session", + ); + const replacedAll = await service.writeNote("root-a", "replacement", { replace: "*", }); assertEquals(replacedAll, { action: "replaced", - note_id: "note-4", + id: "note-4", cleared_count: 2, }); assertEquals(await service.readNotes("root-a"), { notes: [{ - note_id: "note-4", + id: "note-4", text: "replacement", created_at: "2026-04-11T11:00:04.000Z", updated_at: "2026-04-11T11:00:04.000Z", @@ -134,7 +149,7 @@ describe("session notes", () => { }); assertEquals(await service.readNotes("root-b"), { notes: [{ - note_id: "note-3", + id: "note-3", text: "other session", created_at: "2026-04-11T11:00:02.000Z", updated_at: "2026-04-11T11:00:02.000Z", @@ -144,22 +159,27 @@ describe("session notes", () => { const deletedOne = await service.writeNote("root-b", "", { replace: "note-3", }); - assertEquals(deletedOne, { action: "deleted", note_id: "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", - note_id: "missing-note", + id: "missing-note", }); assertEquals(await service.readNotes("root-b"), { notes: [{ - note_id: "missing-note", + id: "missing-note", text: "created late", - created_at: "2026-04-11T11:00:06.000Z", - updated_at: "2026-04-11T11:00:06.000Z", + created_at: "2026-04-11T11:00:05.000Z", + updated_at: "2026-04-11T11:00:05.000Z", }], }); @@ -171,6 +191,7 @@ describe("session notes", () => { it("returns deterministic normalized note search results with snippets", async () => { const redis = createRedis(); const service = new SessionNotesService(redis, { + groupId: "project-1", sessionTtlSeconds: 90, createNoteId: createSequence(["note-1", "note-2", "note-3"]), now: createClock( @@ -188,14 +209,19 @@ describe("session notes", () => { "root-search", "## Search scoring\nToken overlap should stay deterministic and normalized.", ); - await service.writeNote("root-other", "## Foreign note\nredis ttl refresh"); + await service.writeNote( + "root-other", + "## Redis TTL refresh\nEnsure session ttl refresh happens on note reads.", + ); const exact = await service.searchNotes( "root-search", "## Redis TTL refresh\nEnsure session ttl refresh happens on note reads.", ); assertEquals(exact[0], { - note_id: "note-1", + id: "note-1", + root_session_id: "root-search", + scope: "local", snippet: "## Redis TTL refresh Ensure session ttl refresh happens on note reads.", score: 1, @@ -211,10 +237,17 @@ describe("session notes", () => { ); assertEquals(firstPass, secondPass); - assertEquals(firstPass.length, 1); - assertEquals(firstPass[0]?.note_id, "note-1"); + assertEquals(firstPass.length, 2); + assertEquals(firstPass[0]?.id, "note-1"); + assertEquals(firstPass[0]?.root_session_id, "root-search"); + assertEquals(firstPass[0]?.scope, "local"); + assertEquals(firstPass[1]?.id, "note-3"); + assertEquals(firstPass[1]?.root_session_id, "root-other"); + assertEquals(firstPass[1]?.scope, "project"); assert(firstPass[0]!.score > 0); assert(firstPass[0]!.score <= 1); + assert(firstPass[1]!.score > 0); + assert(firstPass[0]!.score > firstPass[1]!.score); assertEquals( firstPass[0]?.snippet.includes("session ttl refresh"), true, @@ -225,7 +258,9 @@ describe("session notes", () => { "deterministic normalized", ); assertEquals(multi, [{ - note_id: "note-2", + id: "note-2", + root_session_id: "root-search", + scope: "local", snippet: "## Search scoring Token overlap should stay deterministic and normalized.", score: multi[0]!.score, @@ -240,6 +275,7 @@ describe("session notes", () => { it("anchors and truncates snippets around late matches in long notes", async () => { const redis = createRedis(); const service = new SessionNotesService(redis, { + groupId: "project-1", sessionTtlSeconds: 90, createNoteId: createSequence(["note-1"]), now: createClock("2026-04-11T13:00:00.000Z"), @@ -269,6 +305,7 @@ describe("session notes", () => { it("ignores malformed stored note payloads safely", async () => { const redis = createRedis(); const service = new SessionNotesService(redis, { + groupId: "project-1", sessionTtlSeconds: 45, createNoteId: createSequence(["note-1"]), now: createClock("2026-04-11T14:00:00.000Z"), @@ -290,7 +327,7 @@ describe("session notes", () => { assertEquals(await service.readNotes("root-malformed"), { notes: [{ - note_id: "valid_note", + id: "valid_note", text: "valid searchable note", created_at: "2026-04-11T14:00:00.000Z", updated_at: "2026-04-11T14:00:00.000Z", @@ -298,9 +335,49 @@ describe("session notes", () => { }); const [hit] = await service.searchNotes("root-malformed", "searchable"); assert(hit); - assertEquals(hit.note_id, "valid_note"); + assertEquals(hit.id, "valid_note"); + assertEquals(hit.root_session_id, "root-malformed"); + assertEquals(hit.scope, "local"); assertEquals(hit.snippet, "valid searchable note"); assert(hit.score > 0); assert(hit.score < 1); }); + + it("retries note id generation until the project-scoped id is unique", async () => { + const redis = createRedis(); + const service = new SessionNotesService(redis, { + groupId: "project-1", + sessionTtlSeconds: 45, + createNoteId: createSequence(["dup", "dup", "unique"]), + now: createClock( + "2026-04-11T15:00:00.000Z", + "2026-04-11T15:00:01.000Z", + ), + }); + + assertEquals(await service.writeNote("root-a", "first"), { + action: "created", + id: "dup", + }); + assertEquals(await service.writeNote("root-b", "second"), { + action: "created", + id: "unique", + }); + assertEquals(await service.readNote("dup"), { + note: { + id: "dup", + text: "first", + created_at: "2026-04-11T15:00:00.000Z", + updated_at: "2026-04-11T15:00:00.000Z", + }, + }); + assertEquals(await service.readNote("unique"), { + note: { + id: "unique", + text: "second", + created_at: "2026-04-11T15:00:01.000Z", + updated_at: "2026-04-11T15:00:01.000Z", + }, + }); + }); }); diff --git a/src/services/session-notes.ts b/src/services/session-notes.ts index 2ec8ab5..f220383 100644 --- a/src/services/session-notes.ts +++ b/src/services/session-notes.ts @@ -7,27 +7,36 @@ type StoredNote = { updated_at: string; }; +type StoredProjectNote = StoredNote & { + root_session_id: string; +}; + export type SessionNote = StoredNote & { - note_id: string; + id: string; }; export type SessionNoteSearchHit = { - note_id: string; + id: string; + root_session_id: string; + scope: "local" | "project"; snippet: string; score: number; }; export type WriteNoteResult = - | { action: "created"; note_id: string } - | { action: "replaced"; note_id: string } - | { action: "deleted"; note_id: string } - | { action: "replaced"; note_id: string; cleared_count: number } + | { action: "created"; id: string } + | { action: "replaced"; id: string } + | { action: "deleted"; id: string } + | { action: "replaced"; id: string; cleared_count: number } | { action: "replaced"; cleared_count: number }; export const sessionNotesKey = (rootSessionId: string): string => `session:${rootSessionId}:notes`; +const projectNotesKey = (groupId: string): string => `session:notes:${groupId}`; + type SessionNotesServiceOptions = { + groupId: string; sessionTtlSeconds: number; now?: () => Date; createNoteId?: () => string; @@ -65,11 +74,42 @@ const parseStoredNote = (value: string): StoredNote | null => { } }; +const parseStoredProjectNote = (value: string): StoredProjectNote | null => { + try { + const parsed = JSON.parse(value) as Partial & { + rootSessionId?: string; + }; + if ( + typeof parsed.text !== "string" || + typeof parsed.created_at !== "string" || + typeof parsed.updated_at !== "string" + ) { + return null; + } + + const rootSessionId = typeof parsed.root_session_id === "string" + ? parsed.root_session_id + : typeof parsed.rootSessionId === "string" + ? parsed.rootSessionId + : null; + if (!rootSessionId) return null; + + return { + text: parsed.text, + created_at: parsed.created_at, + updated_at: parsed.updated_at, + root_session_id: rootSessionId, + }; + } catch { + return null; + } +}; + const compareNotes = (left: SessionNote, right: SessionNote): number => { if (left.created_at !== right.created_at) { return left.created_at.localeCompare(right.created_at); } - return left.note_id.localeCompare(right.note_id); + return left.id.localeCompare(right.id); }; const compareSearchHits = ( @@ -83,7 +123,7 @@ const compareSearchHits = ( if (right.created_at !== left.created_at) { return right.created_at.localeCompare(left.created_at); } - return left.note_id.localeCompare(right.note_id); + return left.id.localeCompare(right.id); }; const buildSnippet = (text: string, query: string): string => { @@ -138,11 +178,13 @@ const scoreNote = (text: string, query: string): number => { 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()); } @@ -159,6 +201,16 @@ export class SessionNotesService { ); } + 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, @@ -193,6 +245,56 @@ export class SessionNotesService { ); } + 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, @@ -200,58 +302,92 @@ export class SessionNotesService { ): Promise { const replace = options?.replace; const notes = await this.loadNotes(rootSessionId); - const timestamp = this.now().toISOString(); + 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 noteId = this.createNoteId(); + 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, { - text, - created_at: timestamp, - updated_at: timestamp, - }]]), + new Map([[noteId, note]]), ); + remainingProjectNotes.set(noteId, { + ...note, + root_session_id: rootSessionId, + }); + await this.writeProjectNotesHash(remainingProjectNotes); return { action: "replaced", - note_id: noteId, + id: noteId, cleared_count: clearedCount, }; } if (replace) { + const projectNote = projectNotes.get(replace); + if (projectNote && projectNote.root_session_id !== rootSessionId) { + throw new Error(`Note ${replace} is owned by another session`); + } + if (text === "") { - notes.delete(replace); - // Field removal is not exposed by RedisClient yet, so deleting a single - // note still requires rewriting the remaining hash contents. - await this.writeNotesHash(rootSessionId, notes); - return { action: "deleted", note_id: replace }; + if (!projectNote) { + notes.delete(replace); + await this.writeNotesHash(rootSessionId, notes); + return { action: "deleted", id: replace }; + } + + await this.deleteOwnedNote(rootSessionId, replace, notes, projectNotes); + return { action: "deleted", id: replace }; } - const current = notes.get(replace); + const timestamp = this.now().toISOString(); + const current = notes.get(replace) ?? projectNote; const note = { text, created_at: current?.created_at ?? timestamp, updated_at: timestamp, }; await this.writeSingleNote(rootSessionId, replace, note); - return { action: "replaced", note_id: replace }; + await this.writeSingleProjectNote(replace, { + ...note, + root_session_id: rootSessionId, + }); + return { action: "replaced", id: replace }; } - const noteId = this.createNoteId(); + 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); - return { action: "created", note_id: noteId }; + await this.writeSingleProjectNote(noteId, { + ...note, + root_session_id: rootSessionId, + }); + return { action: "created", id: noteId }; } async readNotes( @@ -260,7 +396,7 @@ export class SessionNotesService { ): Promise<{ notes: SessionNote[] }> { const key = sessionNotesKey(rootSessionId); const notes = [...(await this.loadNotes(rootSessionId)).entries()] - .map(([id, note]) => ({ note_id: id, ...note })) + .map(([id, note]) => ({ id, ...note })) .sort(compareNotes); if (notes.length > 0) { @@ -268,7 +404,20 @@ export class SessionNotesService { } if (!noteId) return { notes }; - return { notes: notes.filter((note) => note.note_id === noteId) }; + return { notes: notes.filter((note) => note.id === noteId) }; + } + + async readNote(noteId: string): Promise<{ note: SessionNote | null }> { + const note = (await this.loadProjectNotes()).get(noteId); + if (!note) return { note: null }; + return { + note: { + id: noteId, + text: note.text, + created_at: note.created_at, + updated_at: note.updated_at, + }, + }; } async searchNotes( @@ -278,15 +427,31 @@ export class SessionNotesService { const normalizedQuery = normalizeText(query); if (!normalizedQuery) return []; - const notes = await this.readNotes(rootSessionId); - return notes.notes - .map((note) => ({ - note_id: note.note_id, + const localNotes = (await this.readNotes(rootSessionId)).notes; + const projectNotes = [...(await this.loadProjectNotes()).entries()] + .filter(([, note]) => note.root_session_id !== rootSessionId) + .map(([id, note]) => ({ + id, + root_session_id: note.root_session_id, + scope: "project" as const, + snippet: buildSnippet(note.text, normalizedQuery), + score: clampScore(scoreNote(note.text, normalizedQuery) * 0.85), + created_at: note.created_at, + updated_at: note.updated_at, + })); + + return [ + ...localNotes.map((note) => ({ + id: note.id, + root_session_id: rootSessionId, + scope: "local" as const, snippet: buildSnippet(note.text, normalizedQuery), score: scoreNote(note.text, normalizedQuery), created_at: note.created_at, updated_at: note.updated_at, - })) + })), + ...projectNotes, + ] .filter((note) => note.score > 0) .sort(compareSearchHits) .map(({ created_at: _createdAt, updated_at: _updatedAt, ...hit }) => hit); @@ -307,6 +472,20 @@ export class SessionNotesService { 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); + } } } diff --git a/src/session.test.ts b/src/session.test.ts index de77008..cec3d60 100644 --- a/src/session.test.ts +++ b/src/session.test.ts @@ -38,7 +38,7 @@ const emptyCache = { const createSessionManagerForInjection = ( notes: Array<{ - note_id: string; + id: string; text: string; created_at: string; updated_at: string; @@ -430,13 +430,13 @@ describe("SessionManager compaction notes injection", () => { it("includes full session_notes with note ids and timestamps for compaction", async () => { const { manager, readNotesCalls } = createSessionManagerForInjection([ { - note_id: "note-1", + id: "note-1", text: "First full note body", created_at: "2026-04-10T10:00:00.000Z", updated_at: "2026-04-10T10:05:00.000Z", }, { - note_id: "note-2", + id: "note-2", text: "Second full note body", created_at: "2026-04-10T11:00:00.000Z", updated_at: "2026-04-10T11:05:00.000Z", @@ -470,7 +470,7 @@ describe("SessionManager compaction notes injection", () => { it("escapes XML special characters in rendered compaction notes", async () => { const { manager } = createSessionManagerForInjection([ { - note_id: `note-&<>'"`, + id: `note-&<>'"`, text: `Keep & "quotes" and 'apostrophes' safe`, created_at: `2026-04-10T10:00:00&<>'"Z`, updated_at: `2026-04-10T10:05:00&<>'"Z`, @@ -511,7 +511,7 @@ describe("SessionManager compaction notes injection", () => { it("omits session_notes on the normal non-compaction prepareInjection path", async () => { const { manager, readNotesCalls } = createSessionManagerForInjection([ { - note_id: "note-1", + 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", diff --git a/src/session.ts b/src/session.ts index d1aa5e7..68a4504 100644 --- a/src/session.ts +++ b/src/session.ts @@ -575,7 +575,7 @@ const buildPreparedInjectionEnvelope = ( const renderedNotes = notes && notes.length > 0 ? `${ notes.map((note) => - `${ escapeXml(note.text) From cfac4be56cce8f959954bf4ab379032f578699a4 Mon Sep 17 00:00:00 2001 From: "Vicary A." Date: Sun, 10 May 2026 19:50:10 +0800 Subject: [PATCH 3/4] feat(session): add unified memory search --- deno.lock | 4 + docs/SmokeTests.md | 134 +- ...rch-first-unified-memory-implementation.md | 1107 +++++++++++++++++ ...-10-session-notes-freshness-without-ttl.md | 676 ++++++++++ ...4-21-search-first-unified-memory-design.md | 502 ++++++++ ...sion-notes-freshness-without-ttl-design.md | 274 ++++ src/handlers/chat.ts | 4 +- src/handlers/compacting.test.ts | 16 +- src/handlers/compacting.ts | 4 +- src/handlers/messages.test.ts | 194 ++- src/handlers/messages.ts | 34 +- src/index.test.ts | 150 ++- src/index.ts | 42 +- src/services/detached-dream-worker.ts | 12 + src/services/dream-jobs.test.ts | 60 + src/services/dream-jobs.ts | 102 ++ src/services/dream-runner.test.ts | 122 ++ src/services/dream-runner.ts | 53 + src/services/dream-store.ts | 145 +++ src/services/exact-history.ts | 13 + src/services/hot-tier-slice.test.ts | 21 +- src/services/memory-results.ts | 34 + src/services/memory-search.test.ts | 300 +++++ src/services/memory-search.ts | 94 ++ src/services/opencode-warning.ts | 6 + src/services/redis-snapshot.ts | 22 + src/services/runtime-teardown.test.ts | 178 ++- src/services/runtime-teardown.ts | 19 +- src/services/session-mcp-runtime.test.ts | 419 ++++++- src/services/session-mcp-runtime.ts | 178 +-- src/services/session-mcp-types.ts | 17 +- src/services/session-notes.test.ts | 425 ++++++- src/services/session-notes.ts | 171 ++- src/services/session-snapshot.test.ts | 2 +- src/session.test.ts | 41 + src/session.ts | 28 +- src/testing/detached-dream-proof.test.ts | 217 ++++ src/testing/detached-dream-proof.ts | 143 +++ src/types/index.ts | 16 + 39 files changed, 5610 insertions(+), 369 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-21-search-first-unified-memory-implementation.md create mode 100644 docs/superpowers/plans/2026-05-10-session-notes-freshness-without-ttl.md create mode 100644 docs/superpowers/specs/2026-04-21-search-first-unified-memory-design.md create mode 100644 docs/superpowers/specs/2026-05-10-session-notes-freshness-without-ttl-design.md create mode 100644 src/services/detached-dream-worker.ts create mode 100644 src/services/dream-jobs.test.ts create mode 100644 src/services/dream-jobs.ts create mode 100644 src/services/dream-runner.test.ts create mode 100644 src/services/dream-runner.ts create mode 100644 src/services/dream-store.ts create mode 100644 src/services/exact-history.ts create mode 100644 src/services/memory-results.ts create mode 100644 src/services/memory-search.test.ts create mode 100644 src/services/memory-search.ts create mode 100644 src/testing/detached-dream-proof.test.ts create mode 100644 src/testing/detached-dream-proof.ts 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 2b8bbca..f155af3 100644 --- a/docs/SmokeTests.md +++ b/docs/SmokeTests.md @@ -1,7 +1,7 @@ # Smoke Tests - Status: Active -- Last Updated: 2026-04-11 +- Last Updated: 2026-05-10 - Replaces: historical native-hook-first test plan This file, `docs/SmokeTests.md`, replaces the retiring @@ -337,8 +337,8 @@ the change that introduces it; do not assume one here, and do not invent a new ### 5.4 Suite D — Local corpus and session-note search, ranking, and bounded retrieval semantics - **Objective:** Prove local-first corpus behavior plus session-note recall, - including indexing, lexical retrieval, note-hit merging, ranking, snippet - boundedness, and graceful TTL expiry handling. + 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:** @@ -350,20 +350,33 @@ the change that introduces it; do not assume one here, and do not invent a new - **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, `session_search` can merge matching pinned-note + 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"`, `session_notes_read` can reopen exact note text - from a note `id`, same-project foreign note hits rank below equivalent local - note hits, and expired local corpus state returns structured empty or expired - results rather than throwing. + `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, note-hit metadata, exact note-read assertions, 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; missing `type: "note"` / `id` / `root_session_id` - / `scope` metadata for pinned-note hits; project-scoped note hits outranking - equivalent local hits; snippet overflow; corpus lookup exceptions after - expiry; search behavior depending on Graphiti availability. + / `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 @@ -488,15 +501,18 @@ the change that introduces it; do not assume one here, and do not invent a new assembled from hot-tier state, optional cached `` is additive only, stale envelopes are scrubbed, normal chat-turn injection omits ``, compaction-only injection includes complete pinned note - bodies inside ``, and compaction preserves - continuity for both direct and delegated work. + 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 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; notes injected on ordinary chat turns; compaction omitting or - pre-summarizing pinned note bodies; Graphiti moved onto the synchronous path. + 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 @@ -1008,29 +1024,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:** `opencode.json` loads 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 this repository's `opencode.json` 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. | -| Pinned session notes and compaction-only note injection | RG-4, RG-5, RG-8 | Suites A, D, G, I | Scenario L6 | `session_notes_write` / `session_notes_read` responses, note-tagged `session_search` hits, compaction envelopes with `` | Required explicit row. Proof must show exact note reads plus compaction-only injection of complete note bodies, not note summaries on ordinary chat turns. | -| `` presence/omission and bounded formatting | RG-7 | Suites F, I, J | Scenarios L4, L8 | Full surrounding `` block with and without ``; bounded formatting evidence | Required explicit row. Presence and omission are both first-class proof targets. | -| Stale-cache behavior | RG-7 | Suites F, J | Scenario L4 (bounded-recall surface only) | Cache metadata, refresh observations, emitted envelope before/after refresh when exposed | Required explicit row. Deterministic stale-cache injection is automated-primary; live proof checks that recall stays additive and bounded rather than forcing a brittle stale-cache setup. | -| Cross-session recall | RG-6, RG-7 | Suites F, I, J, K | Scenario L4 | Phase-A and phase-B evidence, Graphiti drain/cache observations, later emitted `` context | Required explicit row. Proof fails if later recall is claimed without cache/drain evidence or emitted bounded context. | -| Graphiti off the hot path | RG-6 | Suites F, J | Scenarios L4, L8 | Hot-path success evidence plus drain/cache or degraded Graphiti observations | Must show original work succeeded before any fresh Graphiti read was required. | -| Compaction continuity | RG-8 | Suite I | Scenario L6 | Pre- and post-compaction envelopes, post-compaction tool responses, continuity observations | Synthetic hook calls alone do not satisfy this row. | -| Restart and recovery with Redis/FalkorDB intact | RG-9 | Suite K | Scenario L7 | Restart timing note, resumed-session proof, search/stats results, state observations | Requires true stop/start evidence, not same-process simulation only. | -| Graphiti-unavailable degradation | RG-6, RG-7, RG-9 | Suite K | Scenario L8 | Graphiti-down confirmation, warnings or doctor output, emitted omission of `` | Required explicit row. Live proof must show omission without hot-path failure. | -| Redis/FalkorDB degradation and reconnect boundaries | RG-9 | Suite K | Scenario L9 | Before/during/after doctor output, warnings, post-reconnect fresh index/search evidence | Do not overclaim persisted continuity from temporary degraded fallback. | -| Combined-backend degradation boundary | RG-9 | Suite K | Scenario L10 (explicit automated-only exception) | Automated degradation evidence bundle plus written exception note | Required explicit row. This is the one sanctioned automated-only live exception. | +| 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 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/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..bd1530b --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-search-first-unified-memory-design.md @@ -0,0 +1,502 @@ +# 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..970fae5 --- /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 +- `project:{groupId}:notes` 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 + +`project:{groupId}:notes` + +- 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/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 88e2829..bac3f5e 100644 --- a/src/handlers/compacting.test.ts +++ b/src/handlers/compacting.test.ts @@ -34,7 +34,7 @@ class MockSessionManager { this.prepareInjectionCalls.push({ sessionId, lastRequest, options }); const prepared = { envelope: - '', + '', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -61,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, @@ -71,7 +71,9 @@ 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, @@ -99,7 +101,7 @@ describe("compacting handler", () => { }); const prepared = { envelope: - 'continuecached recall', + 'continuecached recall', nodeRefs: ["node-1"], refreshDecision: { classification: "aligned", @@ -120,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], " { @@ -136,7 +140,7 @@ 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, diff --git a/src/handlers/compacting.ts b/src/handlers/compacting.ts index 3279973..05cb1d4 100644 --- a/src/handlers/compacting.ts +++ b/src/handlers/compacting.ts @@ -37,13 +37,13 @@ export function createCompactingHandler( 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 2768d30..f3e33b3 100644 --- a/src/handlers/messages.test.ts +++ b/src/handlers/messages.test.ts @@ -62,7 +62,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'fresh', + 'fresh', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -84,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", @@ -140,7 +181,7 @@ describe("messages handler", () => { assertEquals(lastRequest, "fallback request"); return { envelope: - 'fallback request', + 'fallback request', nodeRefs: [], refreshDecision: { classification: "miss", @@ -163,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 () => { @@ -176,7 +220,8 @@ describe("messages handler", () => { assertEquals(sessionId, "session-1"); assertEquals(lastRequest, "fallback request"); return { - envelope: '', + envelope: + '', nodeRefs: [], refreshDecision: { classification: "miss", @@ -202,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 () => { @@ -216,7 +264,7 @@ describe("messages handler", () => { assertEquals(lastRequest, "message body query"); return { envelope: - 'message body query', + 'message body query', nodeRefs: [], refreshDecision: { classification: "miss", @@ -239,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", @@ -277,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, '', @@ -288,7 +343,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'next', + 'next', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -314,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( '', @@ -334,7 +392,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'next', + 'next', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -356,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"); } }); @@ -366,7 +430,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'next', + 'next', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -393,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>", @@ -404,7 +471,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'inspect example', + 'inspect example', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -431,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>", @@ -442,7 +512,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'inspect example', + 'inspect example', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -493,7 +563,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'inspect example', + 'inspect example', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -530,7 +600,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'inspect example', + 'inspect example', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -567,7 +637,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'inspect example', + 'inspect example', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -595,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", @@ -637,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( @@ -654,7 +724,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'continue', + 'continue', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -703,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", @@ -751,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", @@ -777,7 +853,7 @@ describe("messages handler", () => { sessionManager.state.pendingInjection = newerPrepared; return { envelope: - 'older', + 'older', nodeRefs: [], refreshDecision: { classification: "miss", @@ -838,7 +914,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'continue', + 'continue', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -870,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", @@ -899,7 +978,7 @@ describe("messages handler", () => { parts: [{ type: "text", text: - `stale\n\n${trailingExample}`, + `stale\n\n${trailingExample}`, }], }], }; @@ -908,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", @@ -936,7 +1015,7 @@ describe("messages handler", () => { parts: [{ type: "text", text: - 'stale\n\ncontinue', + 'stale\n\ncontinue', }], }], }; @@ -945,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", @@ -973,7 +1052,7 @@ describe("messages handler", () => { parts: [{ type: "text", text: - 'stale\n\nolder stale\n\ncontinue', + 'stale\n\nolder stale\n\ncontinue', }], }], }; @@ -982,7 +1061,7 @@ describe("messages handler", () => { assertEquals( output.messages[0].parts[0].text, - 'continue\n\ncontinue', + 'continue\n\ncontinue', ); }); @@ -990,7 +1069,7 @@ describe("messages handler", () => { const sessionManager = new MockSessionManager(); sessionManager.state.pendingInjection = { envelope: - 'continue', + 'continue', nodeRefs: [], refreshDecision: { classification: "aligned", @@ -1019,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", @@ -1049,7 +1129,10 @@ describe("messages handler", () => { }; await handler({}, output as never); - assertStringIncludes(output.messages[0].parts[0].text, "', + ); assertEquals(sessionManager.state.pendingInjection, undefined); }); @@ -1064,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", @@ -1088,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/index.test.ts b/src/index.test.ts index 01cec16..8f82414 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -18,9 +18,11 @@ import { } 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 ( @@ -37,6 +39,10 @@ function createEntrypointHarnessWithOptions(options: { readyError?: Error; redisConnectError?: Error; priorEventsBySessionId?: Record; + dreamWatermarksBySessionId?: Record; + trackedRootSessionIds?: string[]; + spawnDetachedDreamWorkerResult?: boolean; + nowValues?: string[]; teardownRun?: () => Promise; teardownDispose?: () => void; createSessionMcpRuntimeError?: Error; @@ -94,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 >, @@ -127,7 +142,7 @@ function createEntrypointHarnessWithOptions(options: { redisCacheInstances: [] as unknown[], sessionNotesArgs: [] as Array<[ unknown, - { groupId: string; sessionTtlSeconds: number }, + { groupId: string }, ]>, sessionNotesInstances: [] as unknown[], batchDrainArgs: [] as Array<[ @@ -255,7 +270,7 @@ function createEntrypointHarnessWithOptions(options: { class MockSessionNotesService { constructor( redisClient: unknown, - options: { groupId: string; sessionTtlSeconds: number }, + options: { groupId: string }, ) { records.sessionNotesArgs.push([redisClient, options]); records.sessionNotesInstances.push(this); @@ -277,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, @@ -313,6 +342,10 @@ function createEntrypointHarnessWithOptions(options: { return ["group-id"]; } + getTrackedRootSessionIds() { + return options.trackedRootSessionIds ?? ["root-session"]; + } + constructor( defaultGroupId: string, defaultUserGroupId: string, @@ -415,7 +448,23 @@ function createEntrypointHarnessWithOptions(options: { 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) => @@ -898,6 +947,50 @@ 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(); @@ -909,7 +1002,7 @@ describe("index", () => { ); assertStringIncludes( runtime.tools.session_notes_write.description, - "only ownership conflicts reject mutation", + "any same-project session may delete a note by id", ); assertStringIncludes( runtime.tools.session_notes_read.description, @@ -917,7 +1010,7 @@ describe("index", () => { ); assertStringIncludes( runtime.tools.session_search.description, - '`id`, `root_session_id`, and `scope: "local" | "project"`', + '`id`, `root_session_id`, `scope: "local" | "project"`, `created_at`, and', ); assertEquals(Object.keys(runtime.tools.session_notes_write.args), [ "text", @@ -928,6 +1021,7 @@ describe("index", () => { ]); assertEquals(Object.keys(runtime.tools.session_search.args), [ "query", + "when", ]); } finally { void runtime.dispose(); @@ -963,6 +1057,7 @@ describe("index", () => { assertEquals( records.teardownRegistrations[0].tasks.map((task) => task.name), [ + "dream-shutdown-warning", "graphiti-drain-flush", "graphiti-async", "session-mcp-runtime", @@ -976,6 +1071,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); @@ -1023,7 +1119,6 @@ describe("index", () => { ); assertEquals(records.sessionNotesArgs[0][1], { groupId: "group-id", - sessionTtlSeconds: config.redis.sessionTtlSeconds, }); assertStrictEquals( records.batchDrainArgs[0][0], @@ -1703,5 +1798,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 bd3de95..a733989 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,12 +13,14 @@ 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"; @@ -44,9 +46,27 @@ 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, @@ -62,6 +82,7 @@ type GraphitiDependencies = { RedisEventsService: typeof RedisEventsService; RedisSnapshotService: typeof RedisSnapshotService; RedisCacheService: typeof RedisCacheService; + DreamStore: typeof DreamStore; SessionNotesService: typeof SessionNotesService; BatchDrainService: typeof BatchDrainService; GraphitiAsyncService: typeof GraphitiAsyncService; @@ -78,6 +99,7 @@ type GraphitiDependencies = { ToolRoutingOutcomeCache: typeof ToolRoutingOutcomeCache; makeGroupId: typeof makeGroupId; makeUserGroupId: typeof makeUserGroupId; + now: () => string; }; let activeRuntimeTeardown: @@ -112,6 +134,7 @@ export const warnOnRedisStartupUnavailable = ( const defaultGraphitiDependencies: GraphitiDependencies = { loadConfig, setOpenCodeClient, + notifyDreamShutdownDelay, warnOnGraphitiStartupUnavailable, warnOnRedisStartupUnavailable, GraphitiConnectionManager, @@ -121,6 +144,7 @@ const defaultGraphitiDependencies: GraphitiDependencies = { RedisEventsService, RedisSnapshotService, RedisCacheService, + DreamStore, SessionNotesService, BatchDrainService, GraphitiAsyncService, @@ -137,6 +161,7 @@ const defaultGraphitiDependencies: GraphitiDependencies = { ToolRoutingOutcomeCache, makeGroupId, makeUserGroupId, + now: () => new Date().toISOString(), }; export const graphiti: Plugin = ( @@ -224,6 +249,7 @@ 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, @@ -234,7 +260,6 @@ export const graphiti: Plugin = ( ); const notesService = new dependencies.SessionNotesService(redisClient, { groupId: defaultGroupId, - sessionTtlSeconds: config.redis.sessionTtlSeconds, }); const batchDrain = new dependencies.BatchDrainService( redisClient, @@ -298,6 +323,21 @@ export const graphiti: Plugin = ( }); 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: () => 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/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-snapshot.ts b/src/services/redis-snapshot.ts index 5c991fa..56adeaf 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 ?? "session", + 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-mcp-runtime.test.ts b/src/services/session-mcp-runtime.test.ts index c91e8f0..4455b34 100644 --- a/src/services/session-mcp-runtime.test.ts +++ b/src/services/session-mcp-runtime.test.ts @@ -24,6 +24,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< @@ -216,7 +225,7 @@ const validRequests: Record> = { }, }; -Deno.test("note schema compatibility accepts approved note request and response contracts", () => { +it("note schema compatibility accepts approved note request and response contracts", () => { const writeRequest = sessionMcpRequestSchemas.session_notes_write.safeParse({ text: "remember this", replace: "note-1", @@ -268,65 +277,86 @@ Deno.test("note schema compatibility accepts approved note request and response assertEquals(missingReadResponse.success, true); }); -Deno.test("search schema compatibility accepts note-flavored results and remains strict", () => { - const request = sessionMcpRequestSchemas.session_search.safeParse({ - query: "remember this", +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: "remember this", + query: "memory redesign", }); const accepted = sessionMcpResponseSchemas.session_search.safeParse({ status: "ok", - results: [{ - corpus_ref: "session:root:corpus:1", - snippet: "remember this", - score: 0.9, - type: "note", - id: "note-1", - root_session_id: "root-123", - scope: "local", - }], - corpus_refs: ["session:root:corpus:1"], + 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", - snippet: "remember this", - score: 0.9, - type: "note", - id: "note-1", - root_session_id: "root-123", - scope: "local", - extra: true, }], - corpus_refs: ["session:root:corpus:1"], + refs: ["session:root:entry:turn-1"], truncated: false, }); - const rejectedLegacyIdentity = sessionMcpResponseSchemas.session_search - .safeParse({ - status: "ok", - results: [{ - corpus_ref: "session:root:corpus:1", - snippet: "remember this", - score: 0.9, - type: "note", - note_id: "note-1", - }], - corpus_refs: ["session:root:corpus:1"], - truncated: false, - }); - assertEquals(request.success, true); + assertEquals(queryRequest.success, true); + assertEquals(reflectionRequest.success, true); assertEquals(rejectedRequest.success, false); assertEquals(accepted.success, true); assertEquals(rejected.success, false); - assertEquals(rejectedLegacyIdentity.success, false); }); -Deno.test("mixed|batch schema compatibility", () => { +it("mixed|batch schema compatibility", () => { const request = sessionMcpRequestSchemas.session_batch_execute.safeParse({ root_session_id: "root-123", steps: [ @@ -355,12 +385,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, }, }, @@ -381,7 +416,7 @@ Deno.test("mixed|batch schema compatibility", () => { assertEquals(response.success, true); }); -Deno.test("index schema compatibility accepts critical request fields", () => { +it("index schema compatibility accepts critical request fields", () => { const inlineRequest = sessionMcpRequestSchemas.session_index.safeParse({ root_session_id: "root-123", content: "hello world", @@ -406,7 +441,7 @@ Deno.test("index schema compatibility accepts critical request fields", () => { } }); -Deno.test("index schema compatibility rejects requests without content or path", () => { +it("index schema compatibility rejects requests without content or path", () => { const request = sessionMcpRequestSchemas.session_index.safeParse({ root_session_id: "root-123", source: "local-file", @@ -417,6 +452,238 @@ Deno.test("index schema compatibility rejects requests without content or path", }); 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(); @@ -455,7 +722,7 @@ describe("session-mcp-runtime", () => { ); assertStringIncludes( SESSION_SEARCH_BASELINE_DESCRIPTION, - '`id`, `root_session_id`, and `scope: "local" | "project"`', + '`id`, `root_session_id`, `scope: "local" | "project"`, `created_at`, and', ); assertEquals( runtime.tools.session_notes_write.description, @@ -478,6 +745,7 @@ describe("session-mcp-runtime", () => { ]); assertEquals(Object.keys(runtime.tools.session_search.args), [ "query", + "when", ]); } finally { void runtime.dispose(); @@ -850,8 +1118,8 @@ describe("session-mcp-runtime", () => { const noteHit = parsed.results.find((result: { type?: string }) => result.type === "note" ); - const memoryHit = parsed.results.find((result: { type?: string }) => - result.type === "memory" + const summaryHit = parsed.results.find((result: { type?: string }) => + result.type === "summary" ); assertEquals( @@ -859,11 +1127,11 @@ describe("session-mcp-runtime", () => { true, ); assertExists(noteHit); - assertExists(memoryHit); + assertExists(summaryHit); assertEquals(noteHit.id, created.id); assertEquals(noteHit.root_session_id, "root-note-search"); assertEquals(noteHit.scope, "local"); - assertStringIncludes(noteHit.corpus_ref, created.id); + assertStringIncludes(noteHit.ref, created.id); assertStringIncludes( noteHit.snippet, "Redis TTL bug active bug mitigation", @@ -872,8 +1140,15 @@ describe("session-mcp-runtime", () => { runtime.tools.session_search.description, "session_notes_read", ); - assertEquals(memoryHit.type, "memory"); - assertEquals(parsed.results[0].score >= parsed.results[1].score, true); + 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" @@ -882,7 +1157,7 @@ describe("session-mcp-runtime", () => { ); assertEquals( parsed.results.some((result: { type?: string }) => - result.type === "memory" + result.type === "summary" ), true, ); @@ -891,6 +1166,39 @@ describe("session-mcp-runtime", () => { } }); + 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); + + try { + await runtime.tools.session_notes_write.execute( + { text: "timestamp freshness contract note for search" }, + createRootToolContext("root-note-timestamps"), + ); + + 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", + ); + + 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({ @@ -1128,12 +1436,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, }, }, @@ -2043,7 +2354,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(); diff --git a/src/services/session-mcp-runtime.ts b/src/services/session-mcp-runtime.ts index 6f36055..fe9f995 100644 --- a/src/services/session-mcp-runtime.ts +++ b/src/services/session-mcp-runtime.ts @@ -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, @@ -26,11 +35,13 @@ import { } 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; 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", @@ -52,7 +63,8 @@ export const SESSION_NOTES_WRITE_DESCRIPTION = [ "- replace id + non-empty text is upsert", "- replace id + empty text is delete", "- delete on missing id is a no-op success returning deleted", - "- only ownership conflicts reject mutation", + "- 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 }`', @@ -93,40 +105,25 @@ export const SESSION_SEARCH_BASELINE_DESCRIPTION = [ "", 'Results may include indexed memory content (type: "memory") and, when pinned', 'session notes exist, matching notes (type: "note"). Note results include', - '`id`, `root_session_id`, and `scope: "local" | "project"` — use', - "`session_notes_read` with the note `id` to reopen the full note text. Not every", - "query will return note results; notes only appear when they match the search", - "query and the session has pinned notes.", + '`id`, `root_session_id`, `scope: "local" | "project"`, `created_at`, and', + "`updated_at` — use `session_notes_read` with the note `id` to reopen the full", + "note text. Not every query will return note results; notes only appear when they", + "match the search query and the session has pinned notes.", "", "Prefer session_search over reconstructing context from scratch. If search", "returns relevant note hits, read the note before duplicating its contents.", ].join("\n"); -export const SESSION_SEARCH_STRENGTHENED_DESCRIPTION = [ - "Search local indexed content for the current root session. This is the default", - "recall path — use it FIRST when you need prior context, especially:", - "", - "- At the start of a new session or after compaction", - "- When resuming a topic you worked on earlier", - "- Before re-solving a problem that may already have a solution in session history", - "- To check whether pinned session notes already contain the context you need", - "", - 'Results may include indexed memory content (type: "memory") and, when pinned', - 'session notes exist, matching notes (type: "note"). Note results include', - '`id`, `root_session_id`, and `scope: "local" | "project"` — use', - "`session_notes_read` with the note `id` to reopen the full note text. Not every", - "query will return note results; notes only appear when they match the search", - "query and the session has pinned notes.", - "", - "Prefer session_search over reconstructing context from scratch. If search", - "returns relevant note hits, read the note before duplicating its contents.", - "", - "⚠️ This is a new session or a post-compaction turn. Prior context may have been", - "summarized or is not yet in your working memory. STRONGLY RECOMMENDED: run a", - "session_search query before starting work to recover earlier decisions, pinned", - "notes, and task state. This avoids re-solving problems or contradicting earlier", - "decisions that survived compaction.", -].join("\n"); +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: run a", + "session_search query before starting work to recover earlier decisions, pinned", + "notes, and task state. This avoids re-solving problems or contradicting earlier", + "decisions that survived compaction.", + ].join("\n"); type PluginToolArgs = Parameters[0]["args"]; @@ -172,7 +169,8 @@ const sessionMcpToolArgs: Record = { label: pluginSchema.string().min(1).optional(), }, session_search: { - query: pluginSchema.string().min(1), + query: pluginSchema.string(), + when: pluginSchema.string().datetime().optional(), }, session_fetch_and_index: { ...pluginRootSessionIdArgs, @@ -213,6 +211,8 @@ type SessionMcpRuntimeOptions = { createSessionCorpusService?: typeof createSessionCorpusService; createSessionExecutor?: typeof createSessionExecutor; sessionExecutor?: SessionExecutor; + exactHistoryAdapter?: ExactHistoryAdapter; + summarySearchAdapter?: SummarySearchAdapter; sessionCanonicalizer?: RuntimeRootSessionValidator; readSessionIndexFile?: (filePath: string) => Promise; }; @@ -445,6 +445,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 => { @@ -466,8 +498,25 @@ export const createSessionMcpRuntime = ( const readSessionIndexFile = options.readSessionIndexFile ?? readTextFile; const notes = options.notesService ?? new SessionNotesService( options.redisClient ?? new RedisClient({ endpoint: "redis://unused" }), - { groupId, sessionTtlSeconds: options.sessionTtlSeconds ?? 60 }, + { 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, @@ -563,64 +612,16 @@ export const createSessionMcpRuntime = ( }, }); - const searchLocalCorpus = async ( + const searchMemory = async ( rootSessionId: string, query: string, + when: string, ): Promise => { - const noteResults = (await notes.searchNotes(rootSessionId, query)).map( - (note) => ({ - corpus_ref: - `session:${groupId}:${note.root_session_id}:note:${note.id}`, - snippet: note.snippet, - score: note.score, - type: "note" as const, - id: note.id, - root_session_id: note.root_session_id, - scope: note.scope, - }), - ); - - const mergeResults = ( - memoryResults: SessionSearchResponse["results"], - memoryCorpusRefs: string[], - truncated: boolean, - status: SessionSearchResponse["status"], - ): SessionSearchResponse => { - const typedMemoryResults = memoryResults.map((result) => ({ - ...result, - type: result.type ?? "memory" as const, - })); - const mergedResults = [...typedMemoryResults, ...noteResults] - .sort((left, right) => right.score - left.score) - .slice(0, SESSION_SEARCH_RESULT_LIMIT); - const corpusRefs = [ - ...new Set(mergedResults.map((result) => result.corpus_ref)), - ]; - - return { - status, - results: mergedResults, - corpus_refs: corpusRefs.length > 0 ? corpusRefs : memoryCorpusRefs, - truncated: truncated || - typedMemoryResults.length + noteResults.length > - SESSION_SEARCH_RESULT_LIMIT, - }; - }; - - if (!corpus) { - return mergeResults([], [], false, "ok"); - } - - const result = await corpus.search({ + return await memorySearch.search({ rootSessionId, query, + when, }); - return mergeResults( - result.results, - result.corpusRefs, - result.truncated, - result.status, - ); }; const defaultHandlers: SessionMcpHandlerMap = { @@ -658,9 +659,10 @@ export const createSessionMcpRuntime = ( continue; } - const result = await searchLocalCorpus( + const result = await searchMemory( request.root_session_id, step.query, + new Date().toISOString(), ); results.push({ kind: "search", result }); } @@ -707,7 +709,11 @@ export const createSessionMcpRuntime = ( }, session_search: async (request, context) => { const rootSessionId = await resolveCanonicalRootSessionId(context); - return await searchLocalCorpus(rootSessionId, request.query); + return await searchMemory( + rootSessionId, + request.query, + request.when ?? new Date().toISOString(), + ); }, session_fetch_and_index: async (request) => { if (!corpus) { diff --git a/src/services/session-mcp-types.ts b/src/services/session-mcp-types.ts index 7310658..66b1799 100644 --- a/src/services/session-mcp-types.ts +++ b/src/services/session-mcp-types.ts @@ -80,6 +80,7 @@ type SessionIndexRequest = { type SessionSearchRequest = { root_session_id: string; query: string; + when?: string; }; type SessionNotesWriteRequest = { @@ -94,13 +95,17 @@ type SessionNotesReadRequest = { }; const searchResultSchema = z.object({ - corpus_ref: z.string().min(1), + ref: z.string().min(1), snippet: z.string(), score: z.number(), - type: z.enum(["memory", "note"]).optional(), + type: z.enum(["entry", "note", "summary"]), id: z.string().min(1).optional(), root_session_id: z.string().min(1).optional(), - scope: z.enum(["local", "project"]).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({ @@ -190,10 +195,12 @@ export const sessionMcpRequestSchemas = { session_batch_execute: sessionBatchExecuteRequestSchema, session_index: sessionIndexRequestSchema, session_search: z.object({ - query: z.string().min(1), + query: z.string(), + when: z.string().datetime().optional(), }).strict().transform((request) => ({ root_session_id: "", query: request.query, + when: request.when, } satisfies SessionSearchRequest)), session_fetch_and_index: z.object({ ...rootSessionIdShape, @@ -235,7 +242,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(); diff --git a/src/services/session-notes.test.ts b/src/services/session-notes.test.ts index c977778..75e7359 100644 --- a/src/services/session-notes.test.ts +++ b/src/services/session-notes.test.ts @@ -17,11 +17,10 @@ const createClock = (...timestamps: string[]) => { }; describe("session notes", () => { - it("appends and reads notes while refreshing the session TTL", async () => { + it("appends and reads notes with no TTL on session-local hash", async () => { const redis = createRedis(); const service = new SessionNotesService(redis, { groupId: "project-1", - sessionTtlSeconds: 60, createNoteId: createSequence(["note-1", "note-2"]), now: createClock( "2026-04-11T10:00:00.000Z", @@ -39,20 +38,14 @@ describe("session notes", () => { const writtenSnapshot = await redis.snapshot(key); assertEquals(writtenSnapshot.kind, "hash"); if (writtenSnapshot.kind === "hash") { - assertEquals(writtenSnapshot.ttlSeconds, 60); + // Notes must be written without TTL (durable). + assertEquals(writtenSnapshot.ttlSeconds, undefined); assertEquals(Object.keys(writtenSnapshot.values).sort(), [ "note-1", "note-2", ]); } - await redis.touch(key, 5); - const touchedSnapshot = await redis.snapshot(key); - assertEquals(touchedSnapshot.kind, "hash"); - if (touchedSnapshot.kind === "hash") { - assertEquals(touchedSnapshot.ttlSeconds, 5); - } - assertEquals(await service.readNotes("root-2"), { notes: [] }); assertEquals(await service.readNote("missing"), { note: null }); @@ -77,24 +70,78 @@ describe("session notes", () => { note: all.notes[1], }); - const refreshedSnapshot = await redis.snapshot(key); - assertEquals(refreshedSnapshot.kind, "hash"); - if (refreshedSnapshot.kind === "hash") { - assertEquals(refreshedSnapshot.ttlSeconds, 60); + // 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", - sessionTtlSeconds: 120, createNoteId: createSequence(["note-1", "note-2", "note-3", "note-4"]), now: createClock( "2026-04-11T11:00:00.000Z", "2026-04-11T11:00:01.000Z", "2026-04-11T11:00:02.000Z", "2026-04-11T11:00:03.000Z", + "2026-04-11T11:00: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", @@ -125,11 +172,36 @@ describe("session notes", () => { "owned by another session", ); - await assertRejects( - () => service.writeNote("root-a", "", { 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: "*", @@ -151,8 +223,8 @@ describe("session notes", () => { notes: [{ id: "note-3", text: "other session", - created_at: "2026-04-11T11:00:02.000Z", - updated_at: "2026-04-11T11:00:02.000Z", + created_at: "2026-04-11T11:00:03.750Z", + updated_at: "2026-04-11T11:00:03.750Z", }], }); @@ -192,12 +264,12 @@ describe("session notes", () => { const redis = createRedis(); const service = new SessionNotesService(redis, { groupId: "project-1", - sessionTtlSeconds: 90, 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:01.000Z", - "2026-04-11T12:00:02.000Z", + "2026-04-11T12:00:00.000Z", + "2026-04-11T12:00:00.000Z", ), }); @@ -218,14 +290,18 @@ describe("session notes", () => { "root-search", "## Redis TTL refresh\nEnsure session ttl refresh happens on note reads.", ); - assertEquals(exact[0], { - id: "note-1", - root_session_id: "root-search", - scope: "local", - snippet: - "## Redis TTL refresh Ensure session ttl refresh happens on note reads.", - score: 1, - }); + // 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", @@ -238,6 +314,8 @@ describe("session notes", () => { 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"); @@ -247,7 +325,9 @@ describe("session notes", () => { assert(firstPass[0]!.score > 0); assert(firstPass[0]!.score <= 1); assert(firstPass[1]!.score > 0); - assert(firstPass[0]!.score > firstPass[1]!.score); + // 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, @@ -264,6 +344,8 @@ describe("session notes", () => { 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); @@ -272,13 +354,169 @@ describe("session notes", () => { 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", - sessionTtlSeconds: 90, createNoteId: createSequence(["note-1"]), - now: createClock("2026-04-11T13:00:00.000Z"), + now: createClock( + "2026-04-11T13:00:00.000Z", // write + "2026-04-11T13:00:00.000Z", // search + ), }); const longPrefix = "prefix text ".repeat(30); @@ -306,9 +544,10 @@ describe("session notes", () => { const redis = createRedis(); const service = new SessionNotesService(redis, { groupId: "project-1", - sessionTtlSeconds: 45, createNoteId: createSequence(["note-1"]), - now: createClock("2026-04-11T14:00:00.000Z"), + now: createClock( + "2026-04-11T14:00:00.000Z", // search + ), }); await redis.setHashFields(sessionNotesKey("root-malformed"), { @@ -343,11 +582,38 @@ describe("session notes", () => { 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", - sessionTtlSeconds: 45, createNoteId: createSequence(["dup", "dup", "unique"]), now: createClock( "2026-04-11T15:00:00.000Z", @@ -380,4 +646,85 @@ describe("session notes", () => { }, }); }); + + 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 index f220383..8f99fa3 100644 --- a/src/services/session-notes.ts +++ b/src/services/session-notes.ts @@ -9,6 +9,7 @@ type StoredNote = { type StoredProjectNote = StoredNote & { root_session_id: string; + last_read_at?: string | null; }; export type SessionNote = StoredNote & { @@ -21,6 +22,8 @@ export type SessionNoteSearchHit = { scope: "local" | "project"; snippet: string; score: number; + created_at: string; + updated_at: string; }; export type WriteNoteResult = @@ -37,7 +40,6 @@ const projectNotesKey = (groupId: string): string => `session:notes:${groupId}`; type SessionNotesServiceOptions = { groupId: string; - sessionTtlSeconds: number; now?: () => Date; createNoteId?: () => string; }; @@ -99,6 +101,9 @@ const parseStoredProjectNote = (value: string): StoredProjectNote | null => { 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; @@ -112,17 +117,58 @@ const compareNotes = (left: SessionNote, right: SessionNote): number => { 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 & { created_at: string; updated_at: string }, - right: SessionNoteSearchHit & { created_at: string; updated_at: string }, + left: SessionNoteSearchHit, + right: SessionNoteSearchHit, ): number => { - if (right.score !== left.score) return right.score - left.score; + // 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); } - if (right.created_at !== left.created_at) { - return right.created_at.localeCompare(left.created_at); - } + // Stable fallback. return left.id.localeCompare(right.id); }; @@ -229,7 +275,6 @@ export class SessionNotesService { [noteId, note], ) => [noteId, JSON.stringify(note)]), ), - this.options.sessionTtlSeconds, ); } @@ -241,7 +286,6 @@ export class SessionNotesService { await this.redis.setHashFields( sessionNotesKey(rootSessionId), { [noteId]: JSON.stringify(note) }, - this.options.sessionTtlSeconds, ); } @@ -345,21 +389,36 @@ export class SessionNotesService { if (replace) { const projectNote = projectNotes.get(replace); - if (projectNote && projectNote.root_session_id !== rootSessionId) { - throw new Error(`Note ${replace} is owned by another session`); - } + // Empty text = delete. Allow cross-session deletes within the same project. if (text === "") { if (!projectNote) { - notes.delete(replace); - await this.writeNotesHash(rootSessionId, notes); + const deleted = notes.delete(replace); + if (deleted) { + await this.writeNotesHash(rootSessionId, notes); + } return { action: "deleted", id: replace }; } - await this.deleteOwnedNote(rootSessionId, replace, notes, projectNotes); + // 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 = { @@ -394,22 +453,25 @@ export class SessionNotesService { rootSessionId: string, noteId?: string, ): Promise<{ notes: SessionNote[] }> { - const key = sessionNotesKey(rootSessionId); const notes = [...(await this.loadNotes(rootSessionId)).entries()] .map(([id, note]) => ({ id, ...note })) .sort(compareNotes); - if (notes.length > 0) { - await this.redis.touch(key, this.options.sessionTtlSeconds); - } - if (!noteId) return { notes }; return { notes: notes.filter((note) => note.id === noteId) }; } async readNote(noteId: string): Promise<{ note: SessionNote | null }> { - const note = (await this.loadProjectNotes()).get(noteId); + 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, @@ -427,34 +489,56 @@ export class SessionNotesService { const normalizedQuery = normalizeText(query); if (!normalizedQuery) return []; - const localNotes = (await this.readNotes(rootSessionId)).notes; - const projectNotes = [...(await this.loadProjectNotes()).entries()] - .filter(([, note]) => note.root_session_id !== rootSessionId) - .map(([id, note]) => ({ - id, - root_session_id: note.root_session_id, - scope: "project" as const, - snippet: buildSnippet(note.text, normalizedQuery), - score: clampScore(scoreNote(note.text, normalizedQuery) * 0.85), - created_at: note.created_at, - updated_at: note.updated_at, - })); + const nowMs = this.now().getTime(); - return [ - ...localNotes.map((note) => ({ + 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: scoreNote(note.text, normalizedQuery), + score: clampScore(relevance * writeFreshness * readFreshness), created_at: note.created_at, updated_at: note.updated_at, - })), - ...projectNotes, - ] + }; + }); + + 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) - .map(({ created_at: _createdAt, updated_at: _updatedAt, ...hit }) => hit); + .sort(compareSearchHits); } async migrateRootSessionState( @@ -501,15 +585,12 @@ const mergeNoteSnapshots = ( 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, }, - ttlSeconds: Math.max( - target.kind === "hash" ? target.ttlSeconds ?? 0 : 0, - source.ttlSeconds ?? 0, - ) || undefined, }; }; 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/session.test.ts b/src/session.test.ts index cec3d60..9e403a2 100644 --- a/src/session.test.ts +++ b/src/session.test.ts @@ -427,6 +427,28 @@ 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([ { @@ -526,4 +548,23 @@ describe("SessionManager compaction notes injection", () => { false, ); }); + + it("injects at most 10 session notes and never injects exact entries", async () => { + 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, @@ -568,13 +575,12 @@ const buildPreparedInjectionEnvelope = ( ); addNormalizedValues(occupiedNormalized, subagentWork); - const filteredSnapshot = filterDuplicateSnapshotLeaves( - snapshot, - occupiedNormalized, + const filteredSnapshot = stripExactEntryBlocks( + filterDuplicateSnapshotLeaves(snapshot, occupiedNormalized), ); const renderedNotes = notes && notes.length > 0 ? `${ - notes.map((note) => + notes.slice(0, 10).map((note) => `${ @@ -620,16 +626,14 @@ const buildPreparedInjectionEnvelope = ( ? `${filteredSnapshot}` : "", renderedNotes, - persistent.body - ? `${persistent.body}` - : "", + ` 0 + ? ` node_refs="${escapeXml(persistent.nodeRefs.join(","))}"` + : "" + }>${stripExactEntryBlocks(persistent.body)}`, ].filter(Boolean); - return `${ - sections.join("") - }`; + return `${sections.join("")}`; }; export class SessionManager { 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 = From 3086e6aebc829de200acefd0aacab6313ce10615 Mon Sep 17 00:00:00 2001 From: "Vicary A." Date: Sun, 10 May 2026 21:05:16 +0800 Subject: [PATCH 4/4] test(redis): stabilize fallback hash TTL assertion --- src/services/redis-client.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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(); });