diff --git a/README.md b/README.md index 9edaece..b4c47fa 100644 --- a/README.md +++ b/README.md @@ -148,17 +148,17 @@ Relevant Memories: The agent uses this context automatically - no manual prompting needed. -### Reasoned Recall +### Direct Recall -On **every** turn, the agent is shown a short directive asking it to silently -decide whether recalling saved memory would improve its answer to *this* -message. The model searches only when earlier work, saved conventions, or user -preferences are likely to help; trivial and self-contained messages skip the -network call. +On every substantive prompt, Supermemory directly searches the current project +and injects up to five strong, fresh matches. Short prompts and commands are +skipped, repeat results are suppressed per session, and recall fails open after +three seconds so it never blocks the agent indefinitely. -Recall uses the `supermemory` tool in `search` mode and is auto-approved. -Customize the directive with `recallDirective`. Set `SUPERMEMORY_DEBUG=1` to -show a `[recall-decision]` line in each reply while testing. +Set `recallMode` to `"advisory"` to retain model-decided tool recall, or to +`"off"` to disable automatic recall. `recallDirective` customizes advisory mode. +Legacy `autoRecallEveryPrompt: true` maps to direct mode and `false` maps to +advisory mode when `recallMode` is unset. ### Automatic Capture @@ -283,8 +283,10 @@ Create `~/.config/opencode/supermemory.jsonc`: // Save completed conversation batches every N turns (0 = session end only) "captureEveryNTurns": 3, - // Override the reasoned-recall directive shown to the agent each turn - // (null or unset = built-in default) + // "direct" (default for new installs), "advisory", or "off" + "recallMode": "direct", + + // Override the directive used in advisory mode "recallDirective": null, } ``` diff --git a/src/cli.ts b/src/cli.ts index bfad2e8..1928de1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -581,8 +581,8 @@ async function status(): Promise { lines.push(`API key: ${maskKey(SUPERMEMORY_API_KEY)} (${getKeySource()})`); lines.push(`API URL: ${apiUrl}`); lines.push("Memory scope: unified project container with personal/project metadata"); - lines.push(`Recall mode: per-turn reasoned recall${CONFIG.autoRecallEveryPrompt ? " + eager session-start dump" : ""}`); - lines.push(`Recall directive: ${CONFIG.recallDirective ? "custom" : "default"}`); + lines.push(`Recall mode: ${CONFIG.recallMode}`); + lines.push(`Recall directive: ${CONFIG.recallMode === "advisory" && CONFIG.recallDirective ? "custom" : "default"}`); lines.push(`Capture cadence: ${CONFIG.captureEveryNTurns > 0 ? `every ${CONFIG.captureEveryNTurns} turn${CONFIG.captureEveryNTurns === 1 ? "" : "s"} + session end` : "session end only"}`); lines.push(`Project container: ${tags.canonical}`); lines.push(`Personal reads: ${tags.personalReads.join(", ")}`); diff --git a/src/config.ts b/src/config.ts index 592cb9e..b9f401b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,8 @@ const CONFIG_FILES = [ export const DEFAULT_BASE_URL = "https://api.supermemory.ai"; +export type RecallMode = "direct" | "advisory" | "off"; + interface SupermemoryConfig { apiKey?: string; baseUrl?: string; @@ -30,6 +32,7 @@ interface SupermemoryConfig { autoRecallEveryPrompt?: boolean; captureEveryNTurns?: number; recallDirective?: string | null; + recallMode?: RecallMode; } const DEFAULT_KEYWORD_PATTERNS = [ @@ -63,6 +66,7 @@ const DEFAULTS: Required { const { directory } = ctx; const tags = getTags(directory); const injectedSessions = new Set(); + const recallSessions = new RecallSessionCache(); log("Plugin init", { directory, tags, configured: isConfigured() }); if (!isConfigured()) { @@ -156,104 +162,115 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { output.parts.push(nudgePart); } - const recallPart: Part = { - id: `prt_supermemory-recall-${Date.now()}`, - sessionID: input.sessionID, - messageID: output.message.id, - type: "text", - text: buildRecallDirective(), - synthetic: true, - }; - output.parts.push(recallPart); - const isFirstMessage = !injectedSessions.has(input.sessionID); + if (isFirstMessage) injectedSessions.add(input.sessionID); - if (isFirstMessage) { - injectedSessions.add(input.sessionID); - - let memoryContext = ""; - const updateCheck = checkNpmUpdate( - "opencode-supermemory", - PLUGIN_VERSION, - UPDATE_COMMAND - ).then((info) => (info ? formatUpdateNotice(info) : null)); + if (CONFIG.recallMode === "advisory") { + output.parts.push({ + id: `prt_supermemory-recall-${Date.now()}`, + sessionID: input.sessionID, + messageID: output.message.id, + type: "text", + text: buildRecallDirective(), + synthetic: true, + }); + } - if (CONFIG.autoRecallEveryPrompt) { - const [profileResult, userMemoriesResult, projectMemoriesListResult] = await Promise.all([ - supermemoryClient.getProfileScoped( + const profileRequest = + isFirstMessage && CONFIG.recallMode !== "off" && CONFIG.injectProfile + ? supermemoryClient.getProfileScoped( tags.canonical, tags.personalReads, "personal", - userMessage, - ), - supermemoryClient.searchMemoriesScoped( - userMessage, - tags.canonical, - tags.personalReads, - "personal", - ), - supermemoryClient.listMemoriesScoped( - tags.canonical, - tags.projectReads, - "project", - CONFIG.maxProjectMemories, + undefined, + { timeoutMs: DIRECT_RECALL_TIMEOUT_MS }, + ) + : Promise.resolve(null); + + const directRecall = + CONFIG.recallMode === "direct" + ? buildDirectRecallContext({ + prompt: userMessage, + sessionID: input.sessionID, + cache: recallSessions, + search: (query) => + supermemoryClient.searchMemoriesForRecall( + query, + tags.canonical, + tags.personalReads, + tags.projectReads, + { timeoutMs: DIRECT_RECALL_TIMEOUT_MS }, + ), + suppressTexts: isFirstMessage + ? profileRequest.then((result) => + result?.success && result.profile + ? [ + ...result.profile.static, + ...result.profile.dynamic, + ] + : [], + ) + : undefined, + }) + : Promise.resolve(""); + + const firstMessage = isFirstMessage + ? Promise.all([ + profileRequest, + checkNpmUpdate( + "opencode-supermemory", + PLUGIN_VERSION, + UPDATE_COMMAND, ), - ]); - - const profile = profileResult.success ? profileResult : null; - const userMemories = userMemoriesResult.success ? userMemoriesResult : { results: [] }; - const projectMemoriesList = projectMemoriesListResult.success ? projectMemoriesListResult : { memories: [] }; - - const projectMemories = { - results: (projectMemoriesList.memories || []).map((m: any) => ({ - id: m.id, - memory: m.summary || m.content || m.title || "", - similarity: 1, - title: m.title, - metadata: m.metadata, - })), - total: projectMemoriesList.memories?.length || 0, - timing: 0, - }; - - memoryContext = formatContextForPrompt( - profile, - userMemories, - projectMemories - ); - } else { - const profileResult = await supermemoryClient.getProfileScoped( - tags.canonical, - tags.personalReads, - "personal", - ); - const profile = profileResult.success ? profileResult : null; - memoryContext = formatContextForPrompt(profile, { results: [] }, { results: [] }); - } + ]).then(([profileResult, updateInfo]) => { + const profile = profileResult?.success ? profileResult : null; + const memoryContext = profile + ? formatContextForPrompt( + profile, + { results: [] }, + { results: [] }, + ) + : ""; + return combineContextParts([ + memoryContext, + updateInfo ? formatUpdateNotice(updateInfo) : null, + ]); + }) + : Promise.resolve(""); + + const [directRecallContext, firstMessageContext] = await Promise.all([ + directRecall, + firstMessage, + ]); + + if (firstMessageContext) { + output.parts.unshift({ + id: `prt_supermemory-context-${Date.now()}`, + sessionID: input.sessionID, + messageID: output.message.id, + type: "text", + text: firstMessageContext, + synthetic: true, + }); + } - const updateNotice = await updateCheck; - const firstMessageContext = combineContextParts([memoryContext, updateNotice]); - - if (firstMessageContext) { - const contextPart: Part = { - id: `prt_supermemory-context-${Date.now()}`, - sessionID: input.sessionID, - messageID: output.message.id, - type: "text", - text: firstMessageContext, - synthetic: true, - }; - - output.parts.unshift(contextPart); - - const duration = Date.now() - start; - log("chat.message: context injected", { - duration, - contextLength: firstMessageContext.length, - }); - } + if (directRecallContext) { + output.parts.push({ + id: `prt_supermemory-direct-recall-${Date.now()}`, + sessionID: input.sessionID, + messageID: output.message.id, + type: "text", + text: directRecallContext, + synthetic: true, + }); } + log("chat.message: context processed", { + duration: Date.now() - start, + firstMessageContextLength: firstMessageContext.length, + directRecallContextLength: directRecallContext.length, + }); + } catch (error) { log("chat.message: ERROR", { error: String(error) }); } @@ -582,6 +599,18 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { }, event: async (input: { event: { type: string; properties?: unknown } }) => { + const props = input.event.properties as Record | undefined; + if (input.event.type === "session.deleted") { + const sessionID = (props?.info as { id?: string } | undefined)?.id; + if (sessionID) { + injectedSessions.delete(sessionID); + recallSessions.delete(sessionID); + } + } else if (input.event.type === "server.instance.disposed") { + injectedSessions.clear(); + recallSessions.clear(); + } + if (compactionHook) { await compactionHook.event(input); } diff --git a/src/services/client.ts b/src/services/client.ts index 77e6a10..8fd7985 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -19,9 +19,14 @@ import type { } from "../types/index.js"; const TIMEOUT_MS = 30000; +const TIMEOUT_BACKSTOP_GRACE_MS = 250; const MAX_CONVERSATION_CHARS = 100_000; const OPENCODE_SOURCE = "opencode"; +export interface MemoryRequestOptions { + timeoutMs?: number; +} + export type MemoryScope = "personal" | "project"; export interface SearchResultItem { @@ -172,19 +177,28 @@ export class SupermemoryClient { query: string, containerTag: string, scope?: MemoryScope, + options?: MemoryRequestOptions, ): Promise { log("searchMemories: start", { containerTag, scope }); try { + const hookTimeout = options?.timeoutMs; const result = await withTimeout( - this.getClient().search.memories({ - q: query, - containerTag, - threshold: CONFIG.similarityThreshold, - limit: CONFIG.maxMemories, - searchMode: "hybrid", - filters: scope ? getScopeFilters(scope) : undefined, - }), - TIMEOUT_MS, + this.getClient().search.memories( + { + q: query, + containerTag, + threshold: CONFIG.similarityThreshold, + limit: CONFIG.maxMemories, + searchMode: "hybrid", + filters: scope ? getScopeFilters(scope) : undefined, + }, + hookTimeout + ? { timeout: hookTimeout, maxRetries: 0 } + : undefined, + ), + hookTimeout + ? hookTimeout + TIMEOUT_BACKSTOP_GRACE_MS + : TIMEOUT_MS, ); const results = (result.results as SearchResultItem[]).map((item) => ({ ...item, @@ -214,11 +228,12 @@ export class SupermemoryClient { async searchMemoriesMany( query: string, containerTags: string[], + options?: MemoryRequestOptions, ): Promise { const uniqueTags = [...new Set(containerTags.filter(Boolean))]; const responses = await Promise.all( uniqueTags.map((containerTag) => - this.searchMemories(query, containerTag), + this.searchMemories(query, containerTag, undefined, options), ), ); return mergeSearchResponses(responses, CONFIG.maxMemories); @@ -229,6 +244,7 @@ export class SupermemoryClient { canonicalTag: string, containerTags: string[], scope: MemoryScope, + options?: MemoryRequestOptions, ): Promise { const legacyTags = [ ...new Set( @@ -240,9 +256,36 @@ export class SupermemoryClient { query, canonicalTag, supportsScopedCanonicalTag(canonicalTag) ? scope : undefined, + options, ), ...legacyTags.map((containerTag) => - this.searchMemories(query, containerTag), + this.searchMemories(query, containerTag, undefined, options), + ), + ]); + return mergeSearchResponses(responses, CONFIG.maxMemories); + } + + async searchMemoriesForRecall( + query: string, + canonicalTag: string, + personalTags: string[], + projectTags: string[], + options?: MemoryRequestOptions, + ): Promise { + const responses = await Promise.all([ + this.searchMemoriesScoped( + query, + canonicalTag, + personalTags, + "personal", + options, + ), + this.searchMemoriesScoped( + query, + canonicalTag, + projectTags, + "project", + options, ), ]); return mergeSearchResponses(responses, CONFIG.maxMemories); @@ -252,9 +295,11 @@ export class SupermemoryClient { containerTag: string, query?: string, scope?: MemoryScope, + options?: MemoryRequestOptions, ): Promise { log("getProfile: start", { containerTag, scope }); try { + const hookTimeout = options?.timeoutMs; const result = await withTimeout( this.getClient().profile( { @@ -262,8 +307,13 @@ export class SupermemoryClient { q: query, filters: scope ? getScopeFilters(scope) : undefined, } as Parameters[0], + hookTimeout + ? { timeout: hookTimeout, maxRetries: 0 } + : undefined, ), - TIMEOUT_MS, + hookTimeout + ? hookTimeout + TIMEOUT_BACKSTOP_GRACE_MS + : TIMEOUT_MS, ); const searchResults = result.searchResults ? { @@ -301,11 +351,12 @@ export class SupermemoryClient { async getProfileMany( containerTags: string[], query?: string, + options?: MemoryRequestOptions, ): Promise { const uniqueTags = [...new Set(containerTags.filter(Boolean))]; const responses = await Promise.all( uniqueTags.map((containerTag) => - this.getProfile(containerTag, query), + this.getProfile(containerTag, query, undefined, options), ), ); return mergeProfileResponses(responses, CONFIG.maxMemories); @@ -316,6 +367,7 @@ export class SupermemoryClient { containerTags: string[], scope: MemoryScope, query?: string, + options?: MemoryRequestOptions, ): Promise { const legacyTags = [ ...new Set( @@ -327,9 +379,10 @@ export class SupermemoryClient { canonicalTag, query, supportsScopedCanonicalTag(canonicalTag) ? scope : undefined, + options, ), ...legacyTags.map((containerTag) => - this.getProfile(containerTag, query), + this.getProfile(containerTag, query, undefined, options), ), ]); return mergeProfileResponses(responses, CONFIG.maxMemories); diff --git a/src/services/recall.test.ts b/src/services/recall.test.ts new file mode 100644 index 0000000..8ab61ac --- /dev/null +++ b/src/services/recall.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildDirectRecallContext, + MAX_RECALL_QUERY_CHARS, + RecallSessionCache, +} from "./recall.js"; + +describe("direct recall", () => { + test("applies prompt policy, fails open, and bounds session dedupe", async () => { + const queries: string[] = []; + const cache = new RecallSessionCache(); + const recallPrompt = (prompt: string) => + buildDirectRecallContext({ + prompt, + sessionID: "session-1", + cache, + search: async (query) => { + queries.push(query); + return { success: true, results: [] }; + }, + }); + + await recallPrompt("hello"); + await recallPrompt("/search something important"); + await recallPrompt("!run something important"); + await recallPrompt("# heading with enough text"); + await recallPrompt("x".repeat(MAX_RECALL_QUERY_CHARS + 50)); + + expect(queries).toHaveLength(1); + expect(queries[0]).toHaveLength(MAX_RECALL_QUERY_CHARS); + + let text = "first decision"; + const boundedCache = new RecallSessionCache(2); + const recallSession = (sessionID: string) => + buildDirectRecallContext({ + prompt: "recall the decisions from earlier work", + sessionID, + cache: boundedCache, + search: async () => ({ + success: true, + results: [{ memory: text, similarity: 0.9 }], + }), + }); + + expect(await recallSession("session-1")).toContain("first decision"); + expect(await recallSession("session-1")).toBe(""); + expect(await recallSession("session-2")).toContain("first decision"); + text = "second decision"; + await recallSession("session-1"); + text = "third decision"; + await recallSession("session-1"); + text = "first decision"; + expect(await recallSession("session-1")).toContain("first decision"); + + expect( + await buildDirectRecallContext({ + prompt: "recall the decisions from earlier work", + sessionID: "session-3", + cache: boundedCache, + search: async () => { + throw new Error("network failed"); + }, + }), + ).toBe(""); + }); + + test("suppresses first-turn hits already injected from the profile", async () => { + const cache = new RecallSessionCache(); + const skippedContext = await buildDirectRecallContext({ + prompt: "hello", + sessionID: "session-1", + cache, + suppressTexts: Promise.resolve(["Use Bun for all package scripts"]), + search: async () => { + throw new Error("short prompts must not search"); + }, + }); + const context = await buildDirectRecallContext({ + prompt: "what did we decide about the build system", + sessionID: "session-1", + cache, + search: async () => ({ + success: true, + results: [ + { memory: "Use Bun for all package scripts", similarity: 0.9 }, + { memory: "Run typecheck before build", similarity: 0.8 }, + ], + }), + }); + + expect(skippedContext).toBe(""); + expect(context).not.toContain("Use Bun for all package scripts"); + expect(context).toContain("Run typecheck before build"); + }); +}); diff --git a/src/services/recall.ts b/src/services/recall.ts index 9890d62..a0bfb4f 100644 --- a/src/services/recall.ts +++ b/src/services/recall.ts @@ -1,4 +1,17 @@ +import { createHash } from "node:crypto"; + import { getRecallConfig } from "../config.js"; +import type { SearchResponse } from "./client.js"; +import { + formatRecallHit, + getRecallResultText, + normalizeRecallResults, + type RecallHit, +} from "./recall-results.js"; + +export const DIRECT_RECALL_TIMEOUT_MS = 3_000; +export const MAX_RECALL_QUERY_CHARS = 500; +const MAX_SESSION_RECALL_HASHES = 500; export const DEFAULT_RECALL_DIRECTIVE = ` Before responding, silently decide whether recalling saved memory (past sessions, decisions, conventions, the user's preferences) would materially improve your answer to THIS message. Reason first — don't search reflexively, and don't narrate the decision. @@ -27,3 +40,111 @@ export function buildRecallDirective(): string { } return text; } + +export function prepareRecallQuery(prompt: string): string | null { + const trimmed = prompt.trim(); + if (trimmed.length < 12 || /^[\/#\!]/.test(trimmed)) return null; + return trimmed.slice(0, MAX_RECALL_QUERY_CHARS); +} + +function recallTextHash(text: string): string { + const normalized = text + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); + return createHash("sha256").update(normalized).digest("hex"); +} + +function recallHash(hit: RecallHit): string { + return recallTextHash(getRecallResultText(hit.result)); +} + +export class RecallSessionCache { + private readonly sessions = new Map< + string, + { seen: Set; order: string[] } + >(); + + constructor(private readonly maxHashes = MAX_SESSION_RECALL_HASHES) {} + + private getState(sessionID: string): { seen: Set; order: string[] } { + let state = this.sessions.get(sessionID); + if (!state) { + state = { seen: new Set(), order: [] }; + this.sessions.set(sessionID, state); + } + return state; + } + + private rememberHash( + state: { seen: Set; order: string[] }, + hash: string, + ): boolean { + if (state.seen.has(hash)) return false; + state.seen.add(hash); + state.order.push(hash); + while (state.order.length > Math.max(1, this.maxHashes)) { + const oldest = state.order.shift(); + if (oldest) state.seen.delete(oldest); + } + return true; + } + + rememberTexts(sessionID: string, texts: Iterable): void { + const state = this.getState(sessionID); + for (const text of texts) { + const trimmed = text.trim(); + if (trimmed) this.rememberHash(state, recallTextHash(trimmed)); + } + } + + takeFresh(sessionID: string, hits: RecallHit[]): RecallHit[] { + const state = this.getState(sessionID); + return hits.filter((hit) => this.rememberHash(state, recallHash(hit))); + } + + delete(sessionID: string): void { + this.sessions.delete(sessionID); + } + + clear(): void { + this.sessions.clear(); + } +} + +function formatDirectRecallContext(hits: RecallHit[]): string { + return [ + "", + "Relevant memories automatically recalled for this prompt:", + ...hits.map((hit) => `- ${formatRecallHit(hit)}`), + "Use these memories only when relevant. Search Supermemory for deeper context if needed.", + "", + ].join("\n"); +} + +export async function buildDirectRecallContext(options: { + prompt: string; + sessionID: string; + cache: RecallSessionCache; + search: (query: string) => Promise; + suppressTexts?: Iterable | Promise>; +}): Promise { + try { + if (options.suppressTexts) { + options.cache.rememberTexts( + options.sessionID, + await options.suppressTexts, + ); + } + const query = prepareRecallQuery(options.prompt); + if (!query) return ""; + + const response = await options.search(query); + if (!response.success) return ""; + const hits = normalizeRecallResults(response.results ?? []); + const freshHits = options.cache.takeFresh(options.sessionID, hits); + return freshHits.length > 0 ? formatDirectRecallContext(freshHits) : ""; + } catch { + return ""; + } +}