diff --git a/README.md b/README.md index 48b8e15..67c62ea 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ Key modules ported from Claude Code's `src/memdir/`: | Module | Source | Purpose | |---|---|---| | `memoryScan.ts` | `memoryScan.ts` | Recursive directory scan + frontmatter header parsing | -| `recall.ts` | `findRelevantMemories.ts` | Memory recall via keyword scoring (heuristic, no LLM side-query) | +| `recall.ts` + `recallSelector.ts` | `findRelevantMemories.ts` | LLM-selected memory recall + selected memory formatting | | `prompt.ts` | `memoryTypes.ts` + `memdir.ts` | System prompt sections, type taxonomy, truncation | | `memory.ts` | `memdir.ts` | `truncateEntrypoint()` aligned with `truncateEntrypointContent()` | @@ -213,6 +213,8 @@ Yes. Set `OPENCODE_MEMORY_AUTODREAM=0`. You can also tune gates with: - `OPENCODE_MEMORY_TERMINAL_LOG` (default `foreground-only`): set `1` to force terminal logs on, `0` to force them off - `OPENCODE_MEMORY_MODEL`: override model used for extraction - `OPENCODE_MEMORY_AGENT`: override agent used for extraction +- `OPENCODE_MEMORY_RECALL_MODEL`: override model used for LLM memory recall selection +- `OPENCODE_MEMORY_RECALL_AGENT` (default `opencode-memory-recall`): override agent used for LLM memory recall selection - `OPENCODE_MEMORY_AUTODREAM` (default `1`): set `0` to disable auto-dream consolidation - `OPENCODE_MEMORY_AUTODREAM_MIN_HOURS` (default `24`): min hours between consolidation runs - `OPENCODE_MEMORY_AUTODREAM_MIN_SESSIONS` (default `5`): min touched sessions since last consolidation diff --git a/bin/opencode-memory b/bin/opencode-memory index 506ee0f..967f778 100755 --- a/bin/opencode-memory +++ b/bin/opencode-memory @@ -441,7 +441,7 @@ has_new_memories() { } cleanup_timestamp() { - rm -f "$TIMESTAMP_FILE" + rm -f "$TIMESTAMP_FILE" "${TRANSCRIPT_CHECKPOINT_FILE:-}" } get_session_list_json() { @@ -1126,6 +1126,122 @@ session_has_conversation() { return 0 } +transcript_fingerprint() { + local transcript_file="$1" + local stat_output + + if [ ! -f "$transcript_file" ]; then + return 1 + fi + + if command -v python3 >/dev/null 2>&1; then + python3 - "$transcript_file" <<'PY' +import os +import sys + +path = sys.argv[1] + +try: + file_stat = os.stat(path) +except OSError: + raise SystemExit(1) + +mtime = getattr(file_stat, "st_mtime_ns", int(file_stat.st_mtime * 1_000_000_000)) +print(f"{file_stat.st_size}\t{mtime}") +PY + return $? + fi + + if stat_output=$(stat -f '%z %m' "$transcript_file" 2>/dev/null); then + printf '%s\n' "$stat_output" + return 0 + fi + + if stat_output=$(stat -c '%s %Y' "$transcript_file" 2>/dev/null); then + printf '%s\n' "$stat_output" + return 0 + fi + + return 1 +} + +write_transcript_checkpoint() { + local output_file="$1" + local transcripts_dir + transcripts_dir="$(get_transcripts_dir)" + + : > "$output_file" + [ -d "$transcripts_dir" ] || return 0 + + if command -v python3 >/dev/null 2>&1; then + python3 - "$transcripts_dir" > "$output_file" <<'PY' +import os +import stat +import sys + +transcripts_dir = sys.argv[1] + +try: + filenames = os.listdir(transcripts_dir) +except OSError: + raise SystemExit(0) + +for filename in filenames: + if not filename.endswith(".jsonl"): + continue + path = os.path.join(transcripts_dir, filename) + try: + file_stat = os.stat(path) + except OSError: + continue + if not stat.S_ISREG(file_stat.st_mode): + continue + session_id = filename[:-6] + if not session_id: + continue + mtime = getattr(file_stat, "st_mtime_ns", int(file_stat.st_mtime * 1_000_000_000)) + print(f"{session_id}\t{file_stat.st_size}\t{mtime}") +PY + return 0 + fi + + find "$transcripts_dir" -maxdepth 1 -type f -name '*.jsonl' -print 2>/dev/null | while IFS= read -r transcript_file; do + local filename session_id fingerprint + filename="$(basename "$transcript_file")" + session_id="${filename%.jsonl}" + fingerprint="$(transcript_fingerprint "$transcript_file" || true)" + [ -n "$session_id" ] && [ -n "$fingerprint" ] || continue + printf '%s\t%s\n' "$session_id" "$fingerprint" + done > "$output_file" +} + +read_transcript_checkpoint() { + local session_id="$1" + + [ -f "$TRANSCRIPT_CHECKPOINT_FILE" ] || return 1 + awk -F '\t' -v id="$session_id" '$1 == id { print $2 "\t" $3; found=1; exit } END { exit found ? 0 : 1 }' "$TRANSCRIPT_CHECKPOINT_FILE" +} + +session_has_incremental_activity() { + local session_id="$1" + local transcript_file previous current + transcript_file="$(get_transcripts_dir)/${session_id}.jsonl" + + if [ -f "$transcript_file" ]; then + current="$(transcript_fingerprint "$transcript_file" || true)" + previous="$(read_transcript_checkpoint "$session_id" || true)" + if [ -z "$previous" ]; then + return 0 + fi + [ "$current" != "$previous" ] + return $? + fi + + # If no transcript is available, keep the existing conservative behavior: + # session_diff/session-list discovery may still have found a valid new turn. + return 0 +} + run_extraction_if_needed() { local session_id="$1" local memory_written_during_session="$2" @@ -1246,12 +1362,51 @@ run_post_session_tasks() { run_autodream_if_needed "$session_id" } +run_post_session_for_invocation() { + local session_id + local memory_written_during_session + + # Capture the session ID for this invocation. In background mode this wait + # must not delay the visible exit of the wrapped opencode process. + session_id=$(wait_for_session_target_id "$PRE_SESSION_JSON" "$SESSION_CAPTURE_STARTED_AT_MS" "$SESSION_WAIT_SECONDS" || true) + if [ -z "$session_id" ]; then + log "No session found, skipping post-session memory maintenance" + cleanup_timestamp + return 0 + fi + + # Skip if session had no real conversation (e.g. user opened TUI and exited). + if ! session_has_conversation "$session_id"; then + log "Session $session_id has no conversation, skipping post-session memory maintenance" + cleanup_timestamp + return 0 + fi + + if ! session_has_incremental_activity "$session_id"; then + log "Session $session_id has no new transcript activity, skipping post-session memory maintenance" + cleanup_timestamp + return 0 + fi + + memory_written_during_session=0 + if has_new_memories; then + memory_written_during_session=1 + fi + + # Timestamp file is no longer needed after the check above. + cleanup_timestamp + + run_post_session_tasks "$session_id" "$memory_written_during_session" +} + # ============================================================================ # Main # ============================================================================ # Step 0: Create timestamp marker before running opencode TIMESTAMP_FILE=$(mktemp) +TRANSCRIPT_CHECKPOINT_FILE=$(mktemp) +write_transcript_checkpoint "$TRANSCRIPT_CHECKPOINT_FILE" SESSION_CAPTURE_STARTED_AT_MS=$(( $(date +%s) * 1000 )) PRE_SESSION_JSON=$(get_session_list_json "$AUTODREAM_SCAN_LIMIT" 2>/dev/null || true) @@ -1269,35 +1424,11 @@ if [ "$EXTRACT_ENABLED" = "0" ] && [ "$AUTODREAM_ENABLED" = "0" ]; then exit $opencode_exit fi -# Step 3: Capture the session ID for this invocation -session_id=$(wait_for_session_target_id "$PRE_SESSION_JSON" "$SESSION_CAPTURE_STARTED_AT_MS" "$SESSION_WAIT_SECONDS" || true) -if [ -z "$session_id" ]; then - log "No session found, skipping post-session memory maintenance" - cleanup_timestamp - exit $opencode_exit -fi - -# Step 3.5: Skip if session had no real conversation (e.g. user opened TUI and exited) -if ! session_has_conversation "$session_id"; then - log "Session $session_id has no conversation, skipping post-session memory maintenance" - cleanup_timestamp - exit $opencode_exit -fi - -# Step 4: Check whether main session already wrote memory files -memory_written_during_session=0 -if has_new_memories; then - memory_written_during_session=1 -fi - -# Timestamp file is no longer needed after the check above. -cleanup_timestamp - -# Step 5: Run tasks (foreground for debug, background by default) +# Step 3: Run post-session maintenance (foreground for debug, background by default). if [ "$FOREGROUND" = "1" ]; then - run_post_session_tasks "$session_id" "$memory_written_during_session" + run_post_session_for_invocation else - run_post_session_tasks "$session_id" "$memory_written_during_session" & + run_post_session_for_invocation & disown log "Post-session memory maintenance started in background (PID $!)" fi diff --git a/src/index.ts b/src/index.ts index 02c28c7..f97f322 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,9 @@ import type { Plugin } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin" import { buildMemorySystemPrompt } from "./prompt.js" -import { recallRelevantMemories, formatRecalledMemories } from "./recall.js" +import { formatRecalledMemories, recallSelectedMemories, type RecalledMemory } from "./recall.js" +import { assertSupportedRecallSelectorClient, selectRelevantMemoryFilenames, type SessionClient } from "./recallSelector.js" +import { scanMemoryFiles, type MemoryHeader } from "./memoryScan.js" import { saveMemory, deleteMemory, @@ -17,12 +19,22 @@ import { getMemoryDir } from "./paths.js" // resets both alreadySurfaced and recentTools (the messages shrink after compact, // so the derived state shrinks with them). type TurnContext = { + turnID: string query?: string alreadySurfaced: Set recentTools: string[] + recallPrefetch?: RecallPrefetch +} + +type RecallPrefetch = { + turnID: string + settled: boolean + consumed: boolean + result: RecalledMemory[] } const turnContextBySession = new Map() +const selectorSessionIDs = new Set() function shouldIgnoreMemoryContext(query: string | undefined): boolean { if (process.env.OPENCODE_MEMORY_IGNORE === "1") return true @@ -64,9 +76,11 @@ function extractUserQuery(message: unknown): string | undefined { return undefined } -function getLastUserQuery(messages: Array<{ info?: { role?: unknown; sessionID?: unknown }; parts?: unknown }>): { +function getLastUserQuery(messages: Array<{ info?: { id?: unknown; role?: unknown; sessionID?: unknown }; parts?: unknown }>): { query?: string sessionID?: string + messageID?: string + messageIndex?: number } { for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] @@ -74,7 +88,8 @@ function getLastUserQuery(messages: Array<{ info?: { role?: unknown; sessionID?: const query = extractUserQuery(message) const sessionID = typeof message.info?.sessionID === "string" ? message.info.sessionID : undefined - return { query, sessionID } + const messageID = typeof message.info?.id === "string" ? message.info.id : undefined + return { query, sessionID, messageID, messageIndex: i } } return {} @@ -123,6 +138,98 @@ function extractRecentTools( return tools } +function getRecallAgent(): string { + return process.env.OPENCODE_MEMORY_RECALL_AGENT || "opencode-memory-recall" +} + +function getRecallModel(): { providerID: string; modelID: string } | undefined { + const raw = process.env.OPENCODE_MEMORY_RECALL_MODEL + if (!raw) return undefined + + const slashIdx = raw.indexOf("/") + if (slashIdx <= 0 || slashIdx === raw.length - 1) return undefined + return { + providerID: raw.slice(0, slashIdx), + modelID: raw.slice(slashIdx + 1), + } +} + +function isUsefulRecallQuery(query: string | undefined): query is string { + const trimmed = query?.trim() + if (!trimmed) return false + if (/\s/.test(trimmed)) return true + return /[\u3400-\u9fff]/.test(trimmed) && trimmed.length >= 4 +} + +function buildTurnID( + sessionID: string, + messageID: string | undefined, + messageIndex: number | undefined, + query: string | undefined, +): string { + return `${sessionID}:${messageID ?? `${messageIndex ?? -1}:${query ?? ""}`}` +} + +function alreadySurfacedKey(header: MemoryHeader): string { + return `${header.name ?? header.filename.replace(/\.md$/, "").replace(/.*\//, "")}|${header.type ?? "user"}` +} + +function startRecallPrefetch(input: { + client: SessionClient | undefined + directory: string + worktree: string + parentSessionID: string + turnID: string + query: string | undefined + alreadySurfaced: ReadonlySet + recentTools: readonly string[] +}): RecallPrefetch | undefined { + if (!input.client || !isUsefulRecallQuery(input.query)) return undefined + + assertSupportedRecallSelectorClient(input.client) + + const memoryDir = getMemoryDir(input.worktree) + const headers = scanMemoryFiles(memoryDir).filter((header) => !input.alreadySurfaced.has(alreadySurfacedKey(header))) + if (headers.length === 0) return undefined + + const handle: RecallPrefetch = { + turnID: input.turnID, + settled: false, + consumed: false, + result: [], + } + + const promise = selectRelevantMemoryFilenames({ + client: input.client, + directory: input.directory, + parentSessionID: input.parentSessionID, + query: input.query, + memories: headers, + recentTools: input.recentTools, + selectorSessionIDs, + agent: getRecallAgent(), + model: getRecallModel(), + }) + .then((selectedFilenames) => recallSelectedMemories(headers, selectedFilenames, input.alreadySurfaced)) + .catch(() => []) + + void promise.then((result) => { + handle.result = result + }).finally(() => { + handle.settled = true + }) + + return handle +} + +function consumeRecallPrefetch(ctx: TurnContext | undefined): RecalledMemory[] { + const prefetch = ctx?.recallPrefetch + if (!prefetch || !prefetch.settled || prefetch.consumed) return [] + + prefetch.consumed = true + return prefetch.result +} + // Tracks how many memory entries a memory_list call saw so tool.execute.after // can render a meaningful title without re-reading the filesystem. Keyed by // callID, which uniquely identifies a single tool invocation. @@ -174,10 +281,33 @@ function getCallID(ctx: unknown): string | undefined { return typeof v === "string" ? v : undefined } -export const MemoryPlugin: Plugin = async ({ worktree }) => { +export const MemoryPlugin: Plugin = async ({ worktree, directory, client }) => { + directory ??= worktree getMemoryDir(worktree) return { + config: async (config) => { + const agentName = getRecallAgent() + const mutable = config as { + agent?: Record> + } + mutable.agent ??= {} + mutable.agent[agentName] ??= { + mode: "all", + hidden: true, + prompt: "Select up to 5 relevant memory filenames for the current user query. Return only the requested structured output.", + } + }, + + "chat.params": async (input, output) => { + if (input.agent !== getRecallAgent()) return + output.temperature = 0 + output.options = { + ...output.options, + maxOutputTokens: 256, + } + }, + "tool.execute.after": async (input, output) => { if (!input.tool.startsWith("memory_")) return const title = buildMemoryToolTitle(input.tool, input.args, input.callID) @@ -185,7 +315,8 @@ export const MemoryPlugin: Plugin = async ({ worktree }) => { }, "experimental.chat.messages.transform": async (_input, output) => { - const { query, sessionID } = getLastUserQuery(output.messages) + const { query, sessionID, messageID, messageIndex } = getLastUserQuery(output.messages) + if (sessionID && selectorSessionIDs.has(sessionID)) return if (sessionID) { const alreadySurfaced = new Set() @@ -207,7 +338,26 @@ export const MemoryPlugin: Plugin = async ({ worktree }) => { output.messages as Array<{ info?: { role?: unknown }; parts?: unknown[] }>, ) - turnContextBySession.set(sessionID, { query, alreadySurfaced, recentTools }) + const turnID = buildTurnID(sessionID, messageID, messageIndex, query) + const existing = turnContextBySession.get(sessionID) + const ignoreMemoryContext = process.env.OPENCODE_MEMORY_IGNORE === "1" || shouldIgnoreMemoryContext(query) + let recallPrefetch: RecallPrefetch | undefined + if (!ignoreMemoryContext) { + recallPrefetch = existing?.turnID === turnID + ? existing.recallPrefetch + : startRecallPrefetch({ + client: client as unknown as SessionClient, + directory, + worktree, + parentSessionID: sessionID, + turnID, + query, + alreadySurfaced, + recentTools, + }) + } + + turnContextBySession.set(sessionID, { turnID, query, alreadySurfaced, recentTools, recallPrefetch }) } if (shouldIgnoreMemoryContext(query)) { @@ -226,18 +376,17 @@ export const MemoryPlugin: Plugin = async ({ worktree }) => { "experimental.chat.system.transform": async (_input, output) => { let sessionID: string | undefined if (_input && typeof _input === "object") { - sessionID = (typeof (_input as { sessionID?: unknown }).sessionID === "string" + sessionID = typeof (_input as { sessionID?: unknown }).sessionID === "string" ? (_input as { sessionID?: string }).sessionID - : undefined) + : undefined } + if (sessionID && selectorSessionIDs.has(sessionID)) return const ctx = sessionID ? turnContextBySession.get(sessionID) : undefined const query = ctx?.query - const alreadySurfaced = ctx?.alreadySurfaced ?? new Set() - const recentTools = ctx?.recentTools ?? [] const ignoreMemoryContext = process.env.OPENCODE_MEMORY_IGNORE === "1" || shouldIgnoreMemoryContext(query) - const recalled = ignoreMemoryContext ? [] : recallRelevantMemories(worktree, query, alreadySurfaced, recentTools) + const recalled = ignoreMemoryContext ? [] : consumeRecallPrefetch(ctx) const recalledSection = formatRecalledMemories(recalled) const memoryPrompt = buildMemorySystemPrompt(worktree, recalledSection, { diff --git a/src/recall.ts b/src/recall.ts index 86fc1e2..af97114 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -1,6 +1,5 @@ import { readFileSync } from "fs" -import { scanMemoryFiles, type MemoryHeader } from "./memoryScan.js" -import { getMemoryDir } from "./paths.js" +import type { MemoryHeader } from "./memoryScan.js" export type RecalledMemory = { fileName: string @@ -18,10 +17,6 @@ const MAX_MEMORY_BYTES = 4096 const encoder = new TextEncoder() -function tokenizeQuery(query: string): string[] { - return [...new Set(query.toLowerCase().split(/\s+/).map((token) => token.trim()).filter((token) => token.length >= 2))] -} - function readMemoryContent(filePath: string): string { try { const raw = readFileSync(filePath, "utf-8") @@ -42,24 +37,6 @@ function readMemoryContent(filePath: string): string { } } -function scoreHeader(header: MemoryHeader, content: string, terms: string[]): number { - if (terms.length === 0) return 0 - - const nameHaystack = (header.name ?? "").toLowerCase() - const descHaystack = (header.description ?? "").toLowerCase() - const filenameHaystack = header.filename.toLowerCase() - const contentHaystack = content.toLowerCase() - - let score = 0 - for (const term of terms) { - if (nameHaystack.includes(term)) score += 3 - if (descHaystack.includes(term)) score += 3 - if (filenameHaystack.includes(term)) score += 1 - if (contentHaystack.includes(term)) score += 1 - } - return score -} - function truncateMemoryContent(content: string): string { const maxLines = content.split("\n").slice(0, MAX_MEMORY_LINES) const lineTruncated = maxLines.join("\n") @@ -82,63 +59,47 @@ function truncateMemoryContent(content: string): string { return kept.join("\n") } -// Port of Claude Code's findRelevantMemories pattern, adapted for -// keyword-based selection (no LLM side query available in plugin context). -function isToolReferenceMemory(header: MemoryHeader, content: string, recentTools: readonly string[]): boolean { - if (recentTools.length === 0) return false - const type = header.type - if (type !== "reference") return false - - const haystack = `${header.name ?? ""}\n${header.description ?? ""}\n${content}`.toLowerCase() - const warningSignals = ["warning", "gotcha", "issue", "bug", "caveat", "pitfall", "known issue"] - if (warningSignals.some((w) => haystack.includes(w))) return false +function memorySurfaceKey(header: MemoryHeader): string { + return `${header.name ?? header.filename.replace(/\.md$/, "").replace(/.*\//, "")}|${header.type ?? "user"}` +} - const toolHaystack = recentTools.map((t) => t.toLowerCase()) - return toolHaystack.some((tool) => haystack.includes(tool)) +function recalledMemoryFromHeader(header: MemoryHeader, content: string, now: number): RecalledMemory { + const nameFromFilename = header.filename.replace(/\.md$/, "").replace(/.*\//, "") + return { + fileName: header.filename, + filePath: header.filePath, + name: header.name ?? nameFromFilename, + type: header.type ?? "user", + description: header.description ?? "", + content: truncateMemoryContent(content), + ageInDays: Math.max(0, Math.floor((now - header.mtimeMs) / (1000 * 60 * 60 * 24))), + } } -export function recallRelevantMemories( - worktree: string, - query?: string, +export function recallSelectedMemories( + headers: readonly MemoryHeader[], + selectedFilenames: readonly string[], alreadySurfaced: ReadonlySet = new Set(), - recentTools: readonly string[] = [], ): RecalledMemory[] { - const memoryDir = getMemoryDir(worktree) - const headers = scanMemoryFiles(memoryDir).filter( - (h) => !alreadySurfaced.has(`${h.name ?? h.filename.replace(/\.md$/, "").replace(/.*\//, "")}|${h.type ?? "user"}`), - ) - if (headers.length === 0) return [] + if (selectedFilenames.length === 0) return [] const now = Date.now() - const terms = query ? tokenizeQuery(query) : [] - - const scored = headers.map((header) => { - const content = readMemoryContent(header.filePath) - return { - header, - content, - score: scoreHeader(header, content, terms), - } - }).filter(({ header, content }) => !isToolReferenceMemory(header, content, recentTools)) + const byFilename = new Map(headers.map((header) => [header.filename, header])) + const recalled: RecalledMemory[] = [] + const seen = new Set() + + for (const filename of selectedFilenames) { + if (seen.has(filename)) continue + seen.add(filename) - if (terms.length > 0 && scored.some((s) => s.score > 0)) { - scored.sort((a, b) => b.score - a.score || b.header.mtimeMs - a.header.mtimeMs) - } else { - scored.sort((a, b) => b.header.mtimeMs - a.header.mtimeMs) + const header = byFilename.get(filename) + if (!header || alreadySurfaced.has(memorySurfaceKey(header))) continue + + recalled.push(recalledMemoryFromHeader(header, readMemoryContent(header.filePath), now)) + if (recalled.length >= MAX_RECALLED_MEMORIES) break } - return scored.slice(0, MAX_RECALLED_MEMORIES).map(({ header, content }) => { - const nameFromFilename = header.filename.replace(/\.md$/, "").replace(/.*\//, "") - return { - fileName: header.filename, - filePath: header.filePath, - name: header.name ?? nameFromFilename, - type: header.type ?? "user", - description: header.description ?? "", - content: truncateMemoryContent(content), - ageInDays: Math.max(0, Math.floor((now - header.mtimeMs) / (1000 * 60 * 60 * 24))), - } - }) + return recalled } function formatAgeWarning(ageInDays: number): string { diff --git a/src/recallSelector.ts b/src/recallSelector.ts new file mode 100644 index 0000000..d9090c7 --- /dev/null +++ b/src/recallSelector.ts @@ -0,0 +1,201 @@ +import { formatMemoryManifest, type MemoryHeader } from "./memoryScan.js" + +export const SELECT_MEMORIES_SYSTEM_PROMPT = `You are selecting memories that will be useful to OpenCode as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions. + +Return a list of filenames for the memories that will clearly be useful to OpenCode as it processes the user's query (up to 5). Only include memories that you are certain will be helpful based on their name and description. +- If you are unsure if a memory will be useful in processing the user's query, then do not include it in your list. Be selective and discerning. +- If there are no memories in the list that would clearly be useful, feel free to return an empty list. +- If a list of recently-used tools is provided, do not select memories that are usage reference or API documentation for those tools (OpenCode is already exercising them). DO still select memories containing warnings, gotchas, or known issues about those tools — active use is exactly when those matter. +` + +const SELECT_MEMORIES_FORMAT = { + type: "json_schema", + schema: { + type: "object", + properties: { + selected_memories: { type: "array", items: { type: "string" } }, + }, + required: ["selected_memories"], + additionalProperties: false, + }, +} as const + +export const UNSUPPORTED_RECALL_SELECTOR_CLIENT_MESSAGE = + "opencode-claude-memory LLM recall requires an OpenCode SDK with structured output session.prompt support. Please upgrade OpenCode/@opencode-ai/plugin." + +export type SessionClient = { + session?: { + create?: (...args: unknown[]) => Promise + prompt?: (...args: unknown[]) => Promise + delete?: (...args: unknown[]) => Promise + } +} + +export type SelectRelevantMemoryFilenamesInput = { + client: SessionClient | undefined + directory: string + parentSessionID: string + query: string + memories: MemoryHeader[] + recentTools: readonly string[] + selectorSessionIDs: Set + agent: string + model?: { providerID: string; modelID: string } +} + +function unwrapData(response: unknown): unknown { + if (!response || typeof response !== "object") return response + if ("data" in response) return (response as { data?: unknown }).data + return response +} + +function extractSessionID(response: unknown): string | undefined { + const data = unwrapData(response) + if (!data || typeof data !== "object") return undefined + const id = (data as { id?: unknown; sessionID?: unknown }).id ?? (data as { sessionID?: unknown }).sessionID + return typeof id === "string" ? id : undefined +} + +function tryParseSelectedMemories(raw: string): string[] | undefined { + try { + const parsed = JSON.parse(raw) as { selected_memories?: unknown } + if (!Array.isArray(parsed.selected_memories)) return undefined + return parsed.selected_memories.filter((item): item is string => typeof item === "string") + } catch { + return undefined + } +} + +function extractSelectedMemories(response: unknown): string[] { + const data = unwrapData(response) + if (!data || typeof data !== "object") return [] + + const structured = (data as { info?: { structured?: unknown } }).info?.structured + if (structured && typeof structured === "object") { + const selected = (structured as { selected_memories?: unknown }).selected_memories + if (Array.isArray(selected)) { + return selected.filter((item): item is string => typeof item === "string") + } + } + + const parts = (data as { parts?: unknown }).parts + if (!Array.isArray(parts)) return [] + for (const part of parts) { + if (!part || typeof part !== "object") continue + const text = (part as { text?: unknown }).text + if (typeof text !== "string") continue + const parsed = tryParseSelectedMemories(text) + if (parsed) return parsed + } + return [] +} + +export function isSupportedRecallSelectorClient(client: SessionClient | undefined): boolean { + const session = client?.session + return Boolean( + session?.create && + session?.prompt && + session?.delete && + session.create.length >= 2 && + session.prompt.length >= 2 && + session.delete.length >= 2, + ) +} + +export function assertSupportedRecallSelectorClient(client: SessionClient | undefined): asserts client is SessionClient { + if (!isSupportedRecallSelectorClient(client)) { + throw new Error(UNSUPPORTED_RECALL_SELECTOR_CLIENT_MESSAGE) + } +} + +async function createSelectorSession( + client: SessionClient, + directory: string, + parentSessionID: string, +): Promise { + if (!client.session?.create) return undefined + + const response = await client.session.create({ + directory, + parentID: parentSessionID, + title: "opencode-memory recall selector", + }) + + return extractSessionID(response) +} + +async function promptSelectorSession( + client: SessionClient, + sessionID: string, + directory: string, + agent: string, + model: { providerID: string; modelID: string } | undefined, + content: string, +): Promise { + if (!client.session?.prompt) return undefined + + const body = { + agent, + ...(model ? { model } : {}), + tools: {}, + system: SELECT_MEMORIES_SYSTEM_PROMPT, + format: SELECT_MEMORIES_FORMAT, + parts: [{ type: "text", text: content }], + } + + return client.session.prompt({ sessionID, directory, ...body }) +} + +async function deleteSelectorSession( + client: SessionClient, + sessionID: string, + directory: string, +): Promise { + if (!client.session?.delete) return + + try { + await client.session.delete({ sessionID, directory }) + } catch { + // Best-effort cleanup. A failed selector deletion should not affect recall. + } +} + +export async function selectRelevantMemoryFilenames( + input: SelectRelevantMemoryFilenamesInput, +): Promise { + if (input.memories.length === 0) return [] + assertSupportedRecallSelectorClient(input.client) + + let selectorSessionID: string | undefined + try { + selectorSessionID = await createSelectorSession(input.client, input.directory, input.parentSessionID) + if (!selectorSessionID) return [] + + input.selectorSessionIDs.add(selectorSessionID) + + const toolsSection = input.recentTools.length > 0 + ? `\n\nRecently used tools: ${input.recentTools.join(", ")}` + : "" + const manifest = formatMemoryManifest(input.memories) + const response = await promptSelectorSession( + input.client, + selectorSessionID, + input.directory, + input.agent, + input.model, + `Query: ${input.query}\n\nAvailable memories:\n${manifest}${toolsSection}`, + ) + + const validFilenames = new Set(input.memories.map((memory) => memory.filename)) + return extractSelectedMemories(response) + .filter((filename) => validFilenames.has(filename)) + .slice(0, 5) + } catch { + return [] + } finally { + if (selectorSessionID) { + input.selectorSessionIDs.delete(selectorSessionID) + await deleteSelectorSession(input.client, selectorSessionID, input.directory) + } + } +} diff --git a/test/index.test.ts b/test/index.test.ts index a021a4e..86762ff 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -36,6 +36,55 @@ type SystemTransform = ( output: { system: string[] }, ) => Promise +type Deferred = { + promise: Promise + resolve(value: T): void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +async function flushPromises(): Promise { + await Promise.resolve() + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +function makeCompletedSelectorClient(selections: string[][]) { + let promptCount = 0 + let sessionCount = 0 + return { + session: { + async create(_parameters?: unknown, _requestOptions?: unknown) { + sessionCount += 1 + return { data: { id: `selector-session-${sessionCount}` } } + }, + async prompt(_parameters: unknown, _requestOptions?: unknown) { + const selected = selections[promptCount] ?? selections.at(-1) ?? [] + promptCount += 1 + return { + data: { + info: { + structured: { + selected_memories: selected, + }, + }, + parts: [], + }, + } + }, + async delete(_parameters: unknown, _requestOptions?: unknown) { + return { data: true } + }, + }, + } +} + describe("MemoryPlugin system transform", () => { test("suppresses memory context when user explicitly asks to ignore memory", async () => { const repo = makeTempGitRepo() @@ -179,6 +228,187 @@ describe("MemoryPlugin system transform", () => { }) }) +describe("MemoryPlugin LLM recall prefetch", () => { + test("fails fast when recall prefetch receives an unsupported session client", async () => { + const repo = makeTempGitRepo() + saveMemory(repo, "testing_pref_unsupported", "Testing Preference", "Database integration test guidance", "feedback", "Use real databases in integration tests.") + const client = { + session: { + async create(_parameters?: unknown) { + return { data: { id: "selector-session" } } + }, + async prompt(_parameters: unknown) { + return { data: { parts: [] } } + }, + async delete(_parameters: unknown) { + return { data: true } + }, + }, + } + + const plugin = await MemoryPlugin({ worktree: repo, directory: repo, client } as never) + const messagesTransform = plugin["experimental.chat.messages.transform"] as unknown as MessagesTransform + + let error: unknown + try { + await messagesTransform( + {}, + { + messages: [ + { + info: { role: "user", sessionID: "ses_unsupported_prefetch" }, + parts: [{ type: "text", text: "How should we test database changes?" }], + }, + ], + }, + ) + } catch (caught) { + error = caught + } + + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain("requires an OpenCode SDK with structured output session.prompt support") + }) + + test("does not wait for an unfinished selector and injects completed recall on the next loop", async () => { + const repo = makeTempGitRepo() + saveMemory(repo, "testing_pref", "Testing Preference", "Database integration test guidance", "feedback", "Use real databases in integration tests.") + + const promptResult = deferred() + const client = { + session: { + async create(_parameters?: unknown, _requestOptions?: unknown) { + return { data: { id: "selector-session" } } + }, + async prompt(_parameters: unknown, _requestOptions?: unknown) { + return promptResult.promise + }, + async delete(_parameters: unknown, _requestOptions?: unknown) { + return { data: true } + }, + }, + } + + const plugin = await MemoryPlugin({ worktree: repo, directory: repo, client } as never) + const messagesTransform = plugin["experimental.chat.messages.transform"] as unknown as MessagesTransform + const transform = plugin["experimental.chat.system.transform"] as unknown as SystemTransform + + await messagesTransform( + {}, + { + messages: [ + { + info: { role: "user", sessionID: "ses_prefetch" }, + parts: [{ type: "text", text: "How should we test database changes?" }], + }, + ], + }, + ) + + const first = { system: [] as string[] } + await transform({ model: "test-model", sessionID: "ses_prefetch" }, first) + expect(first.system[0]).toContain("## MEMORY.md") + expect(first.system[0]).not.toContain("## Recalled Memories") + + promptResult.resolve({ + data: { + info: { + structured: { + selected_memories: ["testing_pref.md"], + }, + }, + parts: [], + }, + }) + await flushPromises() + + const second = { system: [] as string[] } + await transform({ model: "test-model", sessionID: "ses_prefetch" }, second) + expect(second.system[0]).toContain("## Recalled Memories") + expect(second.system[0]).toContain("Testing Preference") + expect(second.system[0]).toContain("Use real databases in integration tests.") + }) + + test("starts recall prefetch for CJK queries without spaces", async () => { + const repo = makeTempGitRepo() + saveMemory(repo, "testing_pref_cjk", "Testing Preference", "Database integration test guidance", "feedback", "Use real databases in integration tests.") + + const client = makeCompletedSelectorClient([["testing_pref_cjk.md"]]) + const plugin = await MemoryPlugin({ worktree: repo, directory: repo, client } as never) + const messagesTransform = plugin["experimental.chat.messages.transform"] as unknown as MessagesTransform + const transform = plugin["experimental.chat.system.transform"] as unknown as SystemTransform + + await messagesTransform( + {}, + { + messages: [ + { + info: { role: "user", sessionID: "ses_prefetch_cjk" }, + parts: [{ type: "text", text: "数据库测试怎么做" }], + }, + ], + }, + ) + await flushPromises() + + const output = { system: [] as string[] } + await transform({ model: "test-model", sessionID: "ses_prefetch_cjk" }, output) + + expect(output.system[0]).toContain("## Recalled Memories") + expect(output.system[0]).toContain("Testing Preference") + }) + + test("does not restart selector after recall is consumed in the same user turn", async () => { + const repo = makeTempGitRepo() + saveMemory(repo, "testing_pref_once", "Testing Preference", "Database integration test guidance", "feedback", "Use real databases in integration tests.") + saveMemory(repo, "other_pref_once", "Other Preference", "Should not be selected by a restarted selector", "feedback", "This would indicate a duplicate selector run.") + + const client = makeCompletedSelectorClient([["testing_pref_once.md"], ["other_pref_once.md"]]) + const plugin = await MemoryPlugin({ worktree: repo, directory: repo, client } as never) + const messagesTransform = plugin["experimental.chat.messages.transform"] as unknown as MessagesTransform + const transform = plugin["experimental.chat.system.transform"] as unknown as SystemTransform + + await messagesTransform( + {}, + { + messages: [ + { + info: { role: "user", sessionID: "ses_prefetch_once" }, + parts: [{ type: "text", text: "How should we test database changes?" }], + }, + ], + }, + ) + await flushPromises() + + const first = { system: [] as string[] } + await transform({ model: "test-model", sessionID: "ses_prefetch_once" }, first) + expect(first.system[0]).toContain("Testing Preference") + + await messagesTransform( + {}, + { + messages: [ + { + info: { role: "user", sessionID: "ses_prefetch_once" }, + parts: [{ type: "text", text: "How should we test database changes?" }], + }, + { + info: { role: "assistant" as string, sessionID: "ses_prefetch_once" }, + parts: [{ type: "tool", tool: "grep", state: { status: "completed" } }], + }, + ], + }, + ) + await flushPromises() + + const second = { system: [] as string[] } + await transform({ model: "test-model", sessionID: "ses_prefetch_once" }, second) + expect(second.system[0]).not.toContain("## Recalled Memories") + expect(second.system[0]).not.toContain("This would indicate a duplicate selector run.") + }) +}) + describe("MemoryPlugin recentTools from message parts", () => { test("filters tool-reference memories when completed tool parts exist in messages", async () => { const repo = makeTempGitRepo() @@ -245,7 +475,8 @@ describe("MemoryPlugin recentTools from message parts", () => { const repo = makeTempGitRepo() saveMemory(repo, "grep_ref3", "Grep Tool API", "Usage reference for grep tool", "reference", "How to use grep tool") - const plugin = await MemoryPlugin({ worktree: repo } as never) + const client = makeCompletedSelectorClient([[], ["grep_ref3.md"]]) + const plugin = await MemoryPlugin({ worktree: repo, directory: repo, client } as never) const messagesTransform = plugin["experimental.chat.messages.transform"] as unknown as MessagesTransform const transform = plugin["experimental.chat.system.transform"] as unknown as SystemTransform @@ -264,6 +495,7 @@ describe("MemoryPlugin recentTools from message parts", () => { ], }, ) + await flushPromises() const out1 = { system: [] as string[] } await transform({ model: "test-model", sessionID: "ses_compact_tools" }, out1) @@ -285,6 +517,7 @@ describe("MemoryPlugin recentTools from message parts", () => { ], }, ) + await flushPromises() const out2 = { system: [] as string[] } await transform({ model: "test-model", sessionID: "ses_compact_tools" }, out2) @@ -298,7 +531,8 @@ describe("MemoryPlugin alreadySurfaced tracking", () => { const repo = makeTempGitRepo() saveMemory(repo, "only_mem", "Only Memory", "The sole memory", "user", "Single memory content") - const plugin = await MemoryPlugin({ worktree: repo } as never) + const client = makeCompletedSelectorClient([["only_mem.md"], ["only_mem.md"]]) + const plugin = await MemoryPlugin({ worktree: repo, directory: repo, client } as never) const messagesTransform = plugin["experimental.chat.messages.transform"] as unknown as MessagesTransform const transform = plugin["experimental.chat.system.transform"] as unknown as SystemTransform @@ -308,6 +542,7 @@ describe("MemoryPlugin alreadySurfaced tracking", () => { parts: [{ type: "text", text: "Tell me about the only memory" }], }], }) + await flushPromises() const output1 = { system: [] as string[] } await transform({ model: "test-model", sessionID: "ses_surfaced" }, output1) @@ -326,6 +561,7 @@ describe("MemoryPlugin alreadySurfaced tracking", () => { }, ], }) + await flushPromises() const output2 = { system: [] as string[] } await transform({ model: "test-model", sessionID: "ses_surfaced" }, output2) diff --git a/test/integration.test.ts b/test/integration.test.ts index e0a8cc2..8279a03 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect, test } from "bun:test" -import { mkdtempSync, mkdirSync, rmSync, writeFileSync, utimesSync } from "fs" +import { mkdtempSync, mkdirSync, rmSync } from "fs" import { tmpdir } from "os" import { join } from "path" import { saveMemory, deleteMemory, listMemories, searchMemories, readMemory, readIndex } from "../src/memory.js" -import { recallRelevantMemories, formatRecalledMemories } from "../src/recall.js" +import { recallSelectedMemories, formatRecalledMemories } from "../src/recall.js" +import { scanMemoryFiles } from "../src/memoryScan.js" import { buildMemorySystemPrompt } from "../src/prompt.js" -import { getMemoryDir, getMemoryEntrypoint } from "../src/paths.js" +import { getMemoryDir } from "../src/paths.js" const tempDirs: string[] = [] @@ -23,6 +24,10 @@ afterEach(() => { } }) +function recallByFilename(repo: string, filenames: string[], alreadySurfaced: ReadonlySet = new Set()) { + return recallSelectedMemories(scanMemoryFiles(getMemoryDir(repo)), filenames, alreadySurfaced) +} + describe("end-to-end memory lifecycle", () => { test("save → list → search → read → recall → delete", () => { const repo = makeTempGitRepo() @@ -69,7 +74,7 @@ describe("end-to-end memory lifecycle", () => { expect(index).toContain("feedback_testing.md") expect(index).toContain("project_freeze.md") - const recalled = recallRelevantMemories(repo, "testing database mock") + const recalled = recallByFilename(repo, ["feedback_testing.md"]) expect(recalled.length).toBeGreaterThan(0) expect(recalled[0]!.name).toBe("Testing Approach") @@ -99,7 +104,7 @@ describe("end-to-end memory lifecycle", () => { "This should appear in recalled section", ) - const recalled = recallRelevantMemories(repo, "prompt integration") + const recalled = recallByFilename(repo, ["prompt_test.md"]) const recalledSection = formatRecalledMemories(recalled) const prompt = buildMemorySystemPrompt(repo, recalledSection) @@ -115,7 +120,7 @@ describe("end-to-end memory lifecycle", () => { saveMemory(repo, "seen", "Already Seen", "Was shown before", "user", "Already surfaced content") saveMemory(repo, "unseen", "Not Seen", "Fresh content", "feedback", "New content") - const result = recallRelevantMemories(repo, undefined, new Set(["Already Seen|user"])) + const result = recallByFilename(repo, ["seen.md", "unseen.md"], new Set(["Already Seen|user"])) expect(result).toHaveLength(1) expect(result[0]!.name).toBe("Not Seen") diff --git a/test/memory-recall-prefetch-e2e.test.ts b/test/memory-recall-prefetch-e2e.test.ts new file mode 100644 index 0000000..c106a11 --- /dev/null +++ b/test/memory-recall-prefetch-e2e.test.ts @@ -0,0 +1,194 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdtempSync, mkdirSync, rmSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" +import { MemoryPlugin } from "../src/index.js" + +const tempDirs: string[] = [] + +function makeTempGitRepo(): string { + const root = mkdtempSync(join(tmpdir(), "memory-recall-prefetch-e2e-")) + mkdirSync(join(root, ".git"), { recursive: true }) + tempDirs.push(root) + return root +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) rmSync(dir, { recursive: true, force: true }) + } +}) + +type ToolCallContext = { callID?: string } + +type MemoryTools = { + memory_save: { + execute: ( + args: { + file_name: string + name: string + description: string + type: "user" | "feedback" | "project" | "reference" + content: string + }, + ctx: ToolCallContext, + ) => Promise + } +} + +type MessagesTransform = ( + input: {}, + output: { + messages: Array<{ + info: { id?: string; role: string; sessionID?: string } + parts: Array<{ type: string; text?: string }> + }> + }, +) => Promise + +type SystemTransform = ( + input: { model: unknown; sessionID?: string }, + output: { system: string[] }, +) => Promise + +type ConfigHook = (config: Record) => Promise + +type ChatParamsHook = ( + input: { agent?: string }, + output: { temperature?: number; options?: Record }, +) => Promise + +async function flushPromises(): Promise { + await Promise.resolve() + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +function selectorPromptText(options: unknown): string { + const parts = (options as { parts?: Array<{ text?: string }> }).parts + return parts?.[0]?.text ?? "" +} + +function makeManifestSelectingClient() { + const calls = { + create: 0, + prompt: 0, + delete: 0, + promptText: "", + } + + return { + calls, + client: { + session: { + async create(_parameters?: unknown, _requestOptions?: unknown) { + calls.create += 1 + return { data: { id: `selector-session-${calls.create}` } } + }, + async prompt(options: unknown, _requestOptions?: unknown) { + calls.prompt += 1 + calls.promptText = selectorPromptText(options) + const selected = calls.promptText.includes("database_rules.md") ? ["database_rules.md"] : [] + + return { + data: { + info: { + structured: { + selected_memories: selected, + }, + }, + parts: [], + }, + } + }, + async delete(_parameters: unknown, _requestOptions?: unknown) { + calls.delete += 1 + return { data: true } + }, + }, + }, + } +} + +describe("memory recall prefetch end-to-end", () => { + test("prefetches through a selector child session and injects selected memory on the next system hook", async () => { + const repo = makeTempGitRepo() + const { calls, client } = makeManifestSelectingClient() + const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = join(repo, ".claude-test") + + try { + const plugin = await MemoryPlugin({ worktree: repo, directory: repo, client } as never) + const tools = plugin.tool as unknown as MemoryTools + const configHook = plugin.config as unknown as ConfigHook + const chatParamsHook = plugin["chat.params"] as unknown as ChatParamsHook + const messagesTransform = plugin["experimental.chat.messages.transform"] as unknown as MessagesTransform + const systemTransform = plugin["experimental.chat.system.transform"] as unknown as SystemTransform + + const config: Record = {} + await configHook(config) + expect(config).toHaveProperty("agent.opencode-memory-recall.hidden", true) + + const recallParams: { temperature?: number; options?: Record } = {} + await chatParamsHook({ agent: "opencode-memory-recall" }, recallParams) + expect(recallParams.temperature).toBe(0) + expect(recallParams.options?.maxOutputTokens).toBe(256) + + await tools.memory_save.execute( + { + file_name: "database_rules", + name: "Database Test Rules", + description: "Rules for database integration tests", + type: "feedback", + content: "Run integration tests against a real database, not mocks.", + }, + { callID: "save-database-rules" }, + ) + await tools.memory_save.execute( + { + file_name: "release_notes", + name: "Release Notes", + description: "Release process checklist", + type: "project", + content: "Update the changelog before publishing.", + }, + { callID: "save-release-notes" }, + ) + + await messagesTransform( + {}, + { + messages: [ + { + info: { id: "user-message-1", role: "user", sessionID: "real-session" }, + parts: [{ type: "text", text: "How should we test database changes?" }], + }, + ], + }, + ) + await flushPromises() + + const output = { system: [] as string[] } + await systemTransform({ model: "test-model", sessionID: "real-session" }, output) + + expect(calls.create).toBe(1) + expect(calls.prompt).toBe(1) + expect(calls.delete).toBe(1) + expect(calls.promptText).toContain("Query: How should we test database changes?") + expect(calls.promptText).toContain("database_rules.md") + expect(calls.promptText).toContain("Rules for database integration tests") + expect(calls.promptText).toContain("release_notes.md") + + expect(output.system[0]).toContain("## Recalled Memories") + const recalledSection = output.system[0]?.split("## Recalled Memories")[1] ?? "" + expect(recalledSection).toContain("Database Test Rules") + expect(recalledSection).toContain("Run integration tests against a real database, not mocks.") + expect(recalledSection).not.toContain("Release Notes") + expect(recalledSection).not.toContain("Update the changelog before publishing.") + } finally { + if (originalClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + } + }) +}) diff --git a/test/opencode-memory.test.ts b/test/opencode-memory.test.ts index 74b3e52..0fc86c8 100644 --- a/test/opencode-memory.test.ts +++ b/test/opencode-memory.test.ts @@ -190,6 +190,170 @@ exit 0 expect(readFileSync(logPath, "utf-8")).toContain("extraction ok") }) + test("returns immediately in background mode while session discovery waits", async () => { + const root = makeTempRoot() + const fakeBin = join(root, "bin") + const homeDir = join(root, "home") + const tmpDir = join(root, "tmp") + const claudeDir = join(root, "claude") + + mkdirSync(fakeBin, { recursive: true }) + mkdirSync(homeDir, { recursive: true }) + mkdirSync(tmpDir, { recursive: true }) + mkdirSync(claudeDir, { recursive: true }) + + writeExecutable( + join(fakeBin, "opencode"), + `#!/usr/bin/env bash +set -euo pipefail +if [ "\${1:-}" = "session" ] && [ "\${2:-}" = "list" ]; then + echo '[]' + exit 0 +fi +if [ "\${1:-}" = "--help" ]; then + echo "fake help" + exit 0 +fi +exit 0 +`, + ) + + const started = Date.now() + const result = spawnSync("bash", [scriptPath, "--help"], { + cwd: root, + encoding: "utf-8", + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + HOME: homeDir, + TMPDIR: tmpDir, + CLAUDE_CONFIG_DIR: claudeDir, + OPENCODE_MEMORY_SESSION_WAIT_SECONDS: "2", + OPENCODE_MEMORY_AUTODREAM: "0", + }, + }) + const elapsedMs = Date.now() - started + + expect(result.status).toBe(0) + expect(result.stdout).toContain("fake help") + expect(elapsedMs).toBeLessThan(1000) + + await new Promise((resolve) => setTimeout(resolve, 2200)) + }) + + test("skips extraction when a resumed session has no new transcript activity", () => { + const root = makeTempRoot() + const fakeBin = join(root, "bin") + const homeDir = join(root, "home") + const tmpDir = join(root, "tmp") + const claudeDir = join(root, "claude") + const transcriptsDir = join(claudeDir, "transcripts") + const stateFile = join(root, "state") + + mkdirSync(fakeBin, { recursive: true }) + mkdirSync(homeDir, { recursive: true }) + mkdirSync(tmpDir, { recursive: true }) + mkdirSync(transcriptsDir, { recursive: true }) + writeFileSync( + join(transcriptsDir, "ses_existing_resume.jsonl"), + '{"type":"user","content":"old prompt"}\n{"type":"assistant","content":"old answer"}\n', + "utf-8", + ) + + writeExecutable( + join(fakeBin, "opencode"), + `#!/usr/bin/env bash +set -euo pipefail +if [ "\${1:-}" = "session" ] && [ "\${2:-}" = "list" ]; then + echo '[{"id":"ses_existing_resume","directory":"${root}","updated":1,"created":1}]' + exit 0 +fi +if [ "\${1:-}" = "run" ] && [ "\${2:-}" = "-s" ]; then + echo "fork session:\${3:-}" > "${stateFile}" + exit 0 +fi +if [ "\${1:-}" = "--help" ]; then + echo "fake help" + exit 0 +fi +exit 0 +`, + ) + + const result = spawnSync("bash", [scriptPath, "--help"], { + cwd: root, + encoding: "utf-8", + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + HOME: homeDir, + TMPDIR: tmpDir, + CLAUDE_CONFIG_DIR: claudeDir, + OPENCODE_MEMORY_SESSION_WAIT_SECONDS: "1", + OPENCODE_MEMORY_FOREGROUND: "1", + OPENCODE_MEMORY_AUTODREAM: "0", + }, + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain("fake help") + expect(existsSync(stateFile)).toBe(false) + }) + + test("snapshots many transcripts without per-file process overhead", () => { + const root = makeTempRoot() + const fakeBin = join(root, "bin") + const homeDir = join(root, "home") + const tmpDir = join(root, "tmp") + const claudeDir = join(root, "claude") + const transcriptsDir = join(claudeDir, "transcripts") + + mkdirSync(fakeBin, { recursive: true }) + mkdirSync(homeDir, { recursive: true }) + mkdirSync(tmpDir, { recursive: true }) + mkdirSync(transcriptsDir, { recursive: true }) + + for (let index = 0; index < 1000; index += 1) { + writeFileSync( + join(transcriptsDir, `ses_perf_${index}.jsonl`), + '{"type":"user","content":"old prompt"}\n{"type":"assistant","content":"old answer"}\n', + "utf-8", + ) + } + + writeExecutable( + join(fakeBin, "opencode"), + `#!/usr/bin/env bash +set -euo pipefail +if [ "\${1:-}" = "session" ] && [ "\${2:-}" = "list" ]; then + echo '[]' + exit 0 +fi +exit 0 +`, + ) + + const started = Date.now() + const result = spawnSync("bash", [scriptPath, "noop"], { + cwd: root, + encoding: "utf-8", + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + HOME: homeDir, + TMPDIR: tmpDir, + CLAUDE_CONFIG_DIR: claudeDir, + OPENCODE_MEMORY_EXTRACT: "0", + OPENCODE_MEMORY_AUTODREAM: "0", + OPENCODE_MEMORY_TERMINAL_LOG: "0", + }, + }) + const elapsedMs = Date.now() - started + + expect(result.status).toBe(0) + expect(elapsedMs).toBeLessThan(1500) + }, 10000) + test("prints version correctly from a global-style symlinked install layout", () => { const root = makeTempRoot() const fakePrefix = join(root, "prefix") diff --git a/test/recall.test.ts b/test/recall.test.ts index 45f6ed3..736c58b 100644 --- a/test/recall.test.ts +++ b/test/recall.test.ts @@ -2,7 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test" import { mkdtempSync, mkdirSync, rmSync, writeFileSync, utimesSync } from "fs" import { tmpdir } from "os" import { join } from "path" -import { recallRelevantMemories, formatRecalledMemories, type RecalledMemory } from "../src/recall.js" +import { formatRecalledMemories, recallSelectedMemories, type RecalledMemory } from "../src/recall.js" +import { scanMemoryFiles } from "../src/memoryScan.js" import { getMemoryDir } from "../src/paths.js" const tempDirs: string[] = [] @@ -30,6 +31,10 @@ function writeMemoryFile( } } +function scan(repo: string) { + return scanMemoryFiles(getMemoryDir(repo)) +} + afterEach(() => { while (tempDirs.length > 0) { const dir = tempDirs.pop() @@ -37,14 +42,14 @@ afterEach(() => { } }) -describe("recallRelevantMemories", () => { - test("returns empty array when no memories exist", () => { +describe("recallSelectedMemories", () => { + test("returns empty array for empty selections", () => { const repo = makeTempGitRepo() - const result = recallRelevantMemories(repo) + const result = recallSelectedMemories(scan(repo), []) expect(result).toEqual([]) }) - test("returns memories sorted by mtime when no query", () => { + test("materializes selected filenames in selector order", () => { const repo = makeTempGitRepo() const memDir = getMemoryDir(repo) @@ -63,62 +68,16 @@ describe("recallRelevantMemories", () => { new Date("2025-06-01"), ) - const result = recallRelevantMemories(repo) - expect(result).toHaveLength(2) - expect(result[0]!.fileName).toBe("new.md") - expect(result[1]!.fileName).toBe("old.md") - }) - - test("scores and ranks by query relevance", () => { - const repo = makeTempGitRepo() - const memDir = getMemoryDir(repo) - - writeMemoryFile( - memDir, - "auth.md", - { name: "Auth Config", description: "Authentication setup", type: "project" }, - "JWT tokens and auth middleware", - new Date("2024-01-01"), - ) - writeMemoryFile( - memDir, - "style.md", - { name: "Code Style", description: "Formatting rules", type: "feedback" }, - "Use prettier with tabs", - new Date("2025-06-01"), - ) - - const result = recallRelevantMemories(repo, "authentication JWT") - expect(result).toHaveLength(2) - expect(result[0]!.fileName).toBe("auth.md") - }) - - test("matches query against frontmatter name", () => { - const repo = makeTempGitRepo() - const memDir = getMemoryDir(repo) - - writeMemoryFile( - memDir, - "auth.md", - { name: "Auth Config", description: "Setup note", type: "project" }, - "Implementation details unrelated to title search", - new Date("2024-01-01"), - ) - writeMemoryFile( - memDir, - "newer.md", - { name: "Recent Note", description: "More recent but not matching title", type: "user" }, - "Fresh unrelated content", - new Date("2025-06-01"), - ) - - const result = recallRelevantMemories(repo, "Auth Config") + const result = recallSelectedMemories(scan(repo), ["old.md", "new.md"]) expect(result).toHaveLength(2) - expect(result[0]!.fileName).toBe("auth.md") - expect(result[0]!.name).toBe("Auth Config") + expect(result[0]!.fileName).toBe("old.md") + expect(result[0]!.name).toBe("Old Memory") + expect(result[0]!.type).toBe("user") + expect(result[0]!.content).toBe("Old content") + expect(result[1]!.fileName).toBe("new.md") }) - test("respects alreadySurfaced filter", () => { + test("filters missing, duplicate, and already surfaced selections", () => { const repo = makeTempGitRepo() const memDir = getMemoryDir(repo) @@ -131,17 +90,21 @@ describe("recallRelevantMemories", () => { writeMemoryFile( memDir, "fresh.md", - { name: "Fresh", description: "Not yet shown", type: "user" }, + { name: "Fresh", description: "Not yet shown", type: "feedback" }, "Fresh content", ) - const result = recallRelevantMemories(repo, undefined, new Set(["Already Shown|user"])) + const result = recallSelectedMemories( + scan(repo), + ["missing.md", "surfaced.md", "fresh.md", "fresh.md"], + new Set(["Already Shown|user"]), + ) expect(result).toHaveLength(1) expect(result[0]!.fileName).toBe("fresh.md") }) - test("limits to MAX_RECALLED_MEMORIES (5)", () => { + test("limits selected memories to five", () => { const repo = makeTempGitRepo() const memDir = getMemoryDir(repo) @@ -151,46 +114,41 @@ describe("recallRelevantMemories", () => { `mem_${i}.md`, { name: `Memory ${i}`, description: `Desc ${i}`, type: "user" }, `Content ${i}`, - new Date(Date.now() - i * 86400_000), ) } - const result = recallRelevantMemories(repo) + const result = recallSelectedMemories( + scan(repo), + Array.from({ length: 10 }, (_, i) => `mem_${i}.md`), + ) expect(result).toHaveLength(5) + expect(result.map((memory) => memory.fileName)).toEqual([ + "mem_0.md", + "mem_1.md", + "mem_2.md", + "mem_3.md", + "mem_4.md", + ]) }) - test("extracts name from filename when no frontmatter name", () => { + test("extracts filename name and default type when frontmatter is absent", () => { const repo = makeTempGitRepo() const memDir = getMemoryDir(repo) - writeFileSync(join(memDir, "plain_note.md"), "Just plain text, no frontmatter\n", "utf-8") - const result = recallRelevantMemories(repo) + const result = recallSelectedMemories(scan(repo), ["plain_note.md"]) expect(result).toHaveLength(1) expect(result[0]!.name).toBe("plain_note") + expect(result[0]!.type).toBe("user") + expect(result[0]!.content).toBe("Just plain text, no frontmatter") + expect(result[0]!.filePath).toContain("plain_note.md") }) - test("prefers frontmatter name over filename slug", () => { - const repo = makeTempGitRepo() - const memDir = getMemoryDir(repo) - - writeMemoryFile( - memDir, - "slug_only.md", - { name: "Readable Title", description: "Named memory", type: "user" }, - "Named content", - ) - - const result = recallRelevantMemories(repo) - expect(result).toHaveLength(1) - expect(result[0]!.name).toBe("Readable Title") - }) - - test("calculates ageInDays correctly", () => { + test("calculates ageInDays from memory mtime", () => { const repo = makeTempGitRepo() const memDir = getMemoryDir(repo) - const threeDaysAgo = new Date(Date.now() - 3 * 86400_000) + writeMemoryFile( memDir, "aged.md", @@ -199,121 +157,10 @@ describe("recallRelevantMemories", () => { threeDaysAgo, ) - const result = recallRelevantMemories(repo) + const result = recallSelectedMemories(scan(repo), ["aged.md"]) expect(result).toHaveLength(1) expect(result[0]!.ageInDays).toBe(3) }) - - test("defaults type to 'user' when missing", () => { - const repo = makeTempGitRepo() - const memDir = getMemoryDir(repo) - - writeMemoryFile( - memDir, - "notype.md", - { name: "No Type", description: "Missing type field" }, - "Content without type", - ) - - const result = recallRelevantMemories(repo) - expect(result).toHaveLength(1) - expect(result[0]!.type).toBe("user") - }) - - test("includes filePath in recalled memory", () => { - const repo = makeTempGitRepo() - const memDir = getMemoryDir(repo) - - writeMemoryFile( - memDir, - "with_path.md", - { name: "Path Test", description: "Has file path", type: "user" }, - "Test content", - ) - - const result = recallRelevantMemories(repo) - expect(result).toHaveLength(1) - expect(result[0]!.filePath).toContain("with_path.md") - expect(result[0]!.filePath).toContain(memDir) - }) - - test("weights name and description matches higher than content", () => { - const repo = makeTempGitRepo() - const memDir = getMemoryDir(repo) - - writeMemoryFile( - memDir, - "desc_match.md", - { name: "Auth Config", description: "Authentication setup details", type: "project" }, - "Unrelated body text about nothing", - new Date("2024-01-01"), - ) - writeMemoryFile( - memDir, - "body_match.md", - { name: "Other Note", description: "Random unrelated description", type: "user" }, - "The authentication setup is here", - new Date("2025-06-01"), - ) - - const result = recallRelevantMemories(repo, "authentication") - expect(result).toHaveLength(2) - expect(result[0]!.fileName).toBe("desc_match.md") - }) - - test("filters out reference memories for recently used tools", () => { - const repo = makeTempGitRepo() - const memDir = getMemoryDir(repo) - - writeMemoryFile( - memDir, - "grep_ref.md", - { name: "Grep Tool API", description: "Usage reference for grep tool", type: "reference" }, - "How to use the grep tool with various options", - ) - writeMemoryFile( - memDir, - "other.md", - { name: "Project Setup", description: "Project configuration", type: "project" }, - "General project info", - ) - - const result = recallRelevantMemories(repo, undefined, new Set(), ["grep"]) - expect(result).toHaveLength(1) - expect(result[0]!.fileName).toBe("other.md") - }) - - test("keeps warning/gotcha reference memories even for recently used tools", () => { - const repo = makeTempGitRepo() - const memDir = getMemoryDir(repo) - - writeMemoryFile( - memDir, - "grep_warning.md", - { name: "Grep Known Issues", description: "Warning about grep tool edge cases", type: "reference" }, - "Known issue: grep fails on binary files", - ) - - const result = recallRelevantMemories(repo, undefined, new Set(), ["grep"]) - expect(result).toHaveLength(1) - expect(result[0]!.fileName).toBe("grep_warning.md") - }) - - test("does not filter non-reference memories for recently used tools", () => { - const repo = makeTempGitRepo() - const memDir = getMemoryDir(repo) - - writeMemoryFile( - memDir, - "grep_feedback.md", - { name: "Grep Preferences", description: "User prefers grep over find", type: "feedback" }, - "Always use grep for searching", - ) - - const result = recallRelevantMemories(repo, undefined, new Set(), ["grep"]) - expect(result).toHaveLength(1) - expect(result[0]!.fileName).toBe("grep_feedback.md") - }) }) describe("formatRecalledMemories", () => { @@ -394,12 +241,14 @@ describe("formatRecalledMemories", () => { type: "feedback", description: "Second", content: "Content B", - ageInDays: 0, + ageInDays: 2, }, ] const result = formatRecalledMemories(memories) - expect(result).toContain("### Memory A (user)") - expect(result).toContain("### Memory B (feedback)") + expect(result).toContain("Memory A") + expect(result).toContain("Memory B") + expect(result).toContain("Content A") + expect(result).toContain("Content B") }) }) diff --git a/test/recallSelector.test.ts b/test/recallSelector.test.ts new file mode 100644 index 0000000..a3782cb --- /dev/null +++ b/test/recallSelector.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test } from "bun:test" +import { join } from "path" +import { SELECT_MEMORIES_SYSTEM_PROMPT, selectRelevantMemoryFilenames } from "../src/recallSelector.js" +import type { MemoryHeader } from "../src/memoryScan.js" + +function header(filename: string, description: string): MemoryHeader { + return { + filename, + filePath: join("/tmp/memory", filename), + mtimeMs: new Date("2026-05-01T00:00:00Z").getTime(), + name: filename.replace(/\.md$/, ""), + description, + type: "project", + } +} + +describe("selectRelevantMemoryFilenames", () => { + test("throws a clear error when the session client does not support v2 structured output", async () => { + const calls: string[] = [] + const selectorSessionIDs = new Set() + const client = { + session: { + async create(_options: unknown) { + calls.push("create") + return { data: { id: "selector-session" } } + }, + async prompt(_options: unknown) { + calls.push("prompt") + return { data: { parts: [] } } + }, + async delete(_options: unknown) { + calls.push("delete") + return { data: true } + }, + }, + } + + let error: unknown + try { + await selectRelevantMemoryFilenames({ + client, + directory: "/repo", + parentSessionID: "parent-session", + query: "Anything relevant?", + memories: [header("testing.md", "Database integration test guidance")], + recentTools: [], + selectorSessionIDs, + agent: "opencode-memory-recall", + }) + } catch (caught) { + error = caught + } + + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain("requires an OpenCode SDK with structured output session.prompt support") + expect(calls).toEqual([]) + expect(selectorSessionIDs.size).toBe(0) + }) + + test("asks a temporary child session for structured filenames and deletes it", async () => { + const calls: Array<{ method: string; options: unknown }> = [] + const selectorSessionIDs = new Set() + const client = { + session: { + async create(options: unknown, _requestOptions?: unknown) { + calls.push({ method: "create", options }) + return { data: { id: "selector-session" } } + }, + async prompt(options: unknown, _requestOptions?: unknown) { + calls.push({ method: "prompt", options }) + expect(selectorSessionIDs.has("selector-session")).toBe(true) + return { + data: { + info: { + structured: { + selected_memories: ["testing.md", "missing.md"], + }, + }, + parts: [], + }, + } + }, + async delete(options: unknown, _requestOptions?: unknown) { + calls.push({ method: "delete", options }) + return { data: true } + }, + }, + } + + const selected = await selectRelevantMemoryFilenames({ + client, + directory: "/repo", + parentSessionID: "parent-session", + query: "How should we run database integration tests?", + memories: [ + header("testing.md", "Database integration test guidance"), + header("release.md", "Release process"), + ], + recentTools: ["grep"], + selectorSessionIDs, + agent: "opencode-memory-recall", + }) + + expect(selected).toEqual(["testing.md"]) + expect(selectorSessionIDs.has("selector-session")).toBe(false) + expect(calls.map((c) => c.method)).toEqual(["create", "prompt", "delete"]) + + const createOptions = calls[0]!.options as { + parentID?: string + title?: string + directory?: string + } + expect(createOptions.parentID).toBe("parent-session") + expect(createOptions.directory).toBe("/repo") + + const promptOptions = calls[1]!.options as { + sessionID?: string + directory?: string + agent?: string + system?: string + format?: { type?: string } + parts?: Array<{ text?: string }> + } + expect(promptOptions.sessionID).toBe("selector-session") + expect(promptOptions.directory).toBe("/repo") + expect(promptOptions.agent).toBe("opencode-memory-recall") + expect(promptOptions.system).toBe(SELECT_MEMORIES_SYSTEM_PROMPT) + expect(promptOptions.format?.type).toBe("json_schema") + expect(promptOptions.parts?.[0]?.text).toContain("Query: How should we run database integration tests?") + expect(promptOptions.parts?.[0]?.text).toContain("Available memories:") + expect(promptOptions.parts?.[0]?.text).toContain("Recently used tools: grep") + + const deleteOptions = calls[2]!.options as { sessionID?: string; directory?: string } + expect(deleteOptions.sessionID).toBe("selector-session") + expect(deleteOptions.directory).toBe("/repo") + }) + + test("calls session methods with their client receiver intact", async () => { + const selectorSessionIDs = new Set() + const session = { + sessionID: "selector-session", + deleted: false, + async create(_parameters?: unknown, _requestOptions?: unknown) { + return { data: { id: this.sessionID } } + }, + async prompt(_parameters: unknown, _requestOptions?: unknown) { + return { + data: { + info: { + structured: { + selected_memories: [`${this.sessionID}.md`], + }, + }, + parts: [], + }, + } + }, + async delete(_parameters: unknown, _requestOptions?: unknown) { + this.deleted = true + return { data: true } + }, + } + + const selected = await selectRelevantMemoryFilenames({ + client: { session }, + directory: "/repo", + parentSessionID: "parent-session", + query: "Anything relevant?", + memories: [header("selector-session.md", "Selector session guidance")], + recentTools: [], + selectorSessionIDs, + agent: "opencode-memory-recall", + }) + + expect(selected).toEqual(["selector-session.md"]) + expect(session.deleted).toBe(true) + }) + + test("returns empty selection on selector failure and still deletes the child session", async () => { + const calls: string[] = [] + const selectorSessionIDs = new Set() + const client = { + session: { + async create(_parameters?: unknown, _requestOptions?: unknown) { + calls.push("create") + return { data: { id: "selector-session" } } + }, + async prompt(_parameters: unknown, _requestOptions?: unknown) { + calls.push("prompt") + throw new Error("selector failed") + }, + async delete(_parameters: unknown, _requestOptions?: unknown) { + calls.push("delete") + return { data: true } + }, + }, + } + + const selected = await selectRelevantMemoryFilenames({ + client, + directory: "/repo", + parentSessionID: "parent-session", + query: "Anything relevant?", + memories: [header("testing.md", "Database integration test guidance")], + recentTools: [], + selectorSessionIDs, + agent: "opencode-memory-recall", + }) + + expect(selected).toEqual([]) + expect(selectorSessionIDs.has("selector-session")).toBe(false) + expect(calls).toEqual(["create", "prompt", "delete"]) + }) +}) diff --git a/test/tool-titles-e2e.test.ts b/test/tool-titles-e2e.test.ts index 8761f79..c13fba9 100644 --- a/test/tool-titles-e2e.test.ts +++ b/test/tool-titles-e2e.test.ts @@ -69,69 +69,77 @@ async function runToolWithAfter( describe("memory tool titles end-to-end", () => { test("persists human-readable titles across the full plugin tool lifecycle", async () => { const repo = makeTempGitRepo() - const plugin = await MemoryPlugin({ worktree: repo } as never) - const tools = plugin.tool as unknown as MemoryTools - const afterHook = plugin["tool.execute.after"] as unknown as ToolExecuteAfter - - const save = await runToolWithAfter( - afterHook, - "memory_save", - tools.memory_save.execute, - { - file_name: "title_verification", - name: "Title Verification Test", - description: "Verifies final tool titles are persisted", - type: "reference", - content: "Used to validate the completed tool title in end-to-end flow.", - }, - "call-save", - ) - - expect(save.result).toContain("Memory saved to") - expect(save.title).toBe("reference: Title Verification Test") - - const list = await runToolWithAfter(afterHook, "memory_list", tools.memory_list.execute, {}, "call-list") - expect(list.result).toContain("Title Verification Test") - expect(list.title).toBe("1 memory") - - const search = await runToolWithAfter( - afterHook, - "memory_search", - tools.memory_search.execute, - { query: "verification" }, - "call-search", - ) - expect(search.result).toContain("Title Verification Test") - expect(search.title).toBe('"verification" · 1 match') - - const read = await runToolWithAfter( - afterHook, - "memory_read", - tools.memory_read.execute, - { file_name: "title_verification.md" }, - "call-read", - ) - expect(read.result).toContain("# Title Verification Test") - expect(read.title).toBe("title_verification.md") - - const remove = await runToolWithAfter( - afterHook, - "memory_delete", - tools.memory_delete.execute, - { file_name: "title_verification.md" }, - "call-delete", - ) - expect(remove.result).toContain('Memory "title_verification.md" deleted.') - expect(remove.title).toBe("title_verification.md") - - const emptyList = await runToolWithAfter( - afterHook, - "memory_list", - tools.memory_list.execute, - {}, - "call-empty-list", - ) - expect(emptyList.result).toBe("No memories saved yet.") - expect(emptyList.title).toBe("0 memories") + const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = join(repo, ".claude-test") + + try { + const plugin = await MemoryPlugin({ worktree: repo } as never) + const tools = plugin.tool as unknown as MemoryTools + const afterHook = plugin["tool.execute.after"] as unknown as ToolExecuteAfter + + const save = await runToolWithAfter( + afterHook, + "memory_save", + tools.memory_save.execute, + { + file_name: "title_verification", + name: "Title Verification Test", + description: "Verifies final tool titles are persisted", + type: "reference", + content: "Used to validate the completed tool title in end-to-end flow.", + }, + "call-save", + ) + + expect(save.result).toContain("Memory saved to") + expect(save.title).toBe("reference: Title Verification Test") + + const list = await runToolWithAfter(afterHook, "memory_list", tools.memory_list.execute, {}, "call-list") + expect(list.result).toContain("Title Verification Test") + expect(list.title).toBe("1 memory") + + const search = await runToolWithAfter( + afterHook, + "memory_search", + tools.memory_search.execute, + { query: "verification" }, + "call-search", + ) + expect(search.result).toContain("Title Verification Test") + expect(search.title).toBe('"verification" · 1 match') + + const read = await runToolWithAfter( + afterHook, + "memory_read", + tools.memory_read.execute, + { file_name: "title_verification.md" }, + "call-read", + ) + expect(read.result).toContain("# Title Verification Test") + expect(read.title).toBe("title_verification.md") + + const remove = await runToolWithAfter( + afterHook, + "memory_delete", + tools.memory_delete.execute, + { file_name: "title_verification.md" }, + "call-delete", + ) + expect(remove.result).toContain('Memory "title_verification.md" deleted.') + expect(remove.title).toBe("title_verification.md") + + const emptyList = await runToolWithAfter( + afterHook, + "memory_list", + tools.memory_list.execute, + {}, + "call-empty-list", + ) + expect(emptyList.result).toBe("No memories saved yet.") + expect(emptyList.title).toBe("0 memories") + } finally { + if (originalClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + } }) })