From 9b41a3bd2d236c397fec0408937daa75997ae2a9 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Wed, 12 Aug 2026 20:59:15 +0530 Subject: [PATCH 01/10] Use native OpenCode compaction lifecycle --- README.md | 32 +- bun.lock | 2 +- package.json | 3 +- src/cli.ts | 84 +---- src/config.test.ts | 27 ++ src/config.ts | 31 +- src/index.ts | 40 +- src/services/compaction.test.ts | 286 ++++++++++++++ src/services/compaction.ts | 646 +++++++++++--------------------- 9 files changed, 588 insertions(+), 563 deletions(-) create mode 100644 src/config.test.ts create mode 100644 src/services/compaction.test.ts diff --git a/README.md b/README.md index a488d8f..9ed7fde 100644 --- a/README.md +++ b/README.md @@ -185,15 +185,16 @@ Add custom triggers via `keywordPatterns` config. Run `/supermemory-init` to explore and memorize your codebase structure, patterns, and conventions. -### Preemptive Compaction +### Native Compaction Integration -When context hits 80% capacity: +When OpenCode compacts a session, Supermemory: -1. Triggers OpenCode's summarization -2. Injects project memories into summary context -3. Saves session summary as a memory +1. Injects project memories and continuity instructions into OpenCode's compaction context +2. Lets OpenCode select the compaction model and restore the session's original model +3. Saves the completed session summary as a memory -This preserves conversation context across compaction events. +Supermemory does not run a separate token threshold, summarization request, or +synthetic continuation, so it cannot race OpenCode's native auto-compaction. ### Privacy @@ -277,8 +278,11 @@ Create `~/.config/opencode/supermemory.jsonc`: // Extra keyword patterns for memory detection (regex) "keywordPatterns": ["log\\s+this", "write\\s+down"], - // Context usage ratio that triggers compaction (0-1) - "compactionThreshold": 0.8, + // Inject Supermemory context into OpenCode's native compaction lifecycle + "compactionEnabled": true, + + // Legacy disable setting; 0 disables compaction integration + // "compactionThreshold": 0, // Save completed conversation batches every N turns (0 = session end only) "captureEveryNTurns": 3, @@ -321,15 +325,9 @@ This is useful when you want to: ## Usage with Oh My OpenCode -If you're using [Oh My OpenCode](https://github.com/code-yeongyu/oh-my-opencode), disable its built-in auto-compact hook to let supermemory handle context compaction: - -Add to `~/.config/opencode/oh-my-opencode.json`: - -```json -{ - "disabled_hooks": ["anthropic-context-window-limit-recovery"] -} -``` +Supermemory uses OpenCode's native compaction lifecycle and does not install a +competing auto-compaction trigger. It does not require changes to your +[Oh My OpenCode](https://github.com/code-yeongyu/oh-my-opencode) configuration. ## Development diff --git a/bun.lock b/bun.lock index 32a3f60..7e7dba5 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "opencode-plugin", "devDependencies": { - "@opencode-ai/plugin": "^1.0.162", + "@opencode-ai/plugin": "^1.0.191", "@types/bun": "latest", "supermemory": "^4.0.0", "typescript": "^5.7.3", diff --git a/package.json b/package.json index a4484f1..0194774 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "url": "https://github.com/supermemoryai/opencode-supermemory" }, "devDependencies": { - "@opencode-ai/plugin": "^1.0.162", + "@opencode-ai/plugin": "^1.0.191", "@types/bun": "latest", "supermemory": "^4.0.0", "typescript": "^5.7.3" @@ -40,6 +40,7 @@ "hooks": [ "chat.message", "permission.ask", + "experimental.session.compacting", "event" ] }, diff --git a/src/cli.ts b/src/cli.ts index bfad2e8..1c50fe9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,6 @@ import { getTags } from "./services/tags.js"; const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode"); const OPENCODE_COMMAND_DIR = join(OPENCODE_CONFIG_DIR, "command"); -const OH_MY_OPENCODE_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json"); const PLUGIN_NAME = "opencode-supermemory@latest"; const DEFAULT_CONFIG_FILE = CONFIG_FILE ?? join(OPENCODE_CONFIG_DIR, "supermemory.json"); @@ -344,58 +343,8 @@ function createCommands(): boolean { return true; } -function isOhMyOpencodeInstalled(): boolean { - const configPath = findOpencodeConfig(); - if (!configPath) return false; - - try { - const content = readFileSync(configPath, "utf-8"); - return content.includes("oh-my-opencode"); - } catch { - return false; - } -} - -function isAutoCompactAlreadyDisabled(): boolean { - if (!existsSync(OH_MY_OPENCODE_CONFIG)) return false; - - try { - const content = readFileSync(OH_MY_OPENCODE_CONFIG, "utf-8"); - const config = JSON.parse(content); - const disabledHooks = config.disabled_hooks as string[] | undefined; - return disabledHooks?.includes("anthropic-context-window-limit-recovery") ?? false; - } catch { - return false; - } -} - -function disableAutoCompactHook(): boolean { - try { - let config: Record = {}; - - if (existsSync(OH_MY_OPENCODE_CONFIG)) { - const content = readFileSync(OH_MY_OPENCODE_CONFIG, "utf-8"); - config = JSON.parse(content); - } - - const disabledHooks = (config.disabled_hooks as string[]) || []; - if (!disabledHooks.includes("anthropic-context-window-limit-recovery")) { - disabledHooks.push("anthropic-context-window-limit-recovery"); - } - config.disabled_hooks = disabledHooks; - - writeFileSync(OH_MY_OPENCODE_CONFIG, JSON.stringify(config, null, 2)); - console.log(`āœ“ Disabled anthropic-context-window-limit-recovery hook in oh-my-opencode.json`); - return true; - } catch (err) { - console.error("āœ— Failed to update oh-my-opencode.json:", err); - return false; - } -} - interface InstallOptions { tui: boolean; - disableAutoCompact: boolean; } async function install(options: InstallOptions): Promise { @@ -446,33 +395,9 @@ async function install(options: InstallOptions): Promise { createCommands(); } - // Step 3: Configure Oh My OpenCode (if installed) - if (isOhMyOpencodeInstalled()) { - console.log("\nStep 3: Configure Oh My OpenCode"); - console.log("Detected Oh My OpenCode plugin."); - console.log("Supermemory handles context compaction, so the built-in context-window-limit-recovery hook should be disabled."); - - if (isAutoCompactAlreadyDisabled()) { - console.log("āœ“ anthropic-context-window-limit-recovery hook already disabled"); - } else { - if (options.tui) { - const shouldDisable = await confirm(rl!, "Disable anthropic-context-window-limit-recovery hook to let Supermemory handle context?"); - if (!shouldDisable) { - console.log("Skipped."); - } else { - disableAutoCompactHook(); - } - } else if (options.disableAutoCompact) { - disableAutoCompactHook(); - } else { - console.log("Skipped. Use --disable-context-recovery to disable the hook in non-interactive mode."); - } - } - } - if (rl) rl.close(); - // Step 4: Authenticate + // Final step: Authenticate console.log("\n" + "─".repeat(50)); console.log("\nšŸ”‘ Final step: Authenticate with Supermemory\n"); @@ -654,7 +579,6 @@ opencode-supermemory - Persistent memory for OpenCode agents Commands: install Install and configure the plugin --no-tui Non-interactive mode (for LLM agents) - --disable-context-recovery Disable Oh My OpenCode's context hook login Authenticate with Supermemory (opens browser) logout Clear stored credentials status Show Supermemory connection status @@ -676,13 +600,11 @@ if (args.length === 0 || args[0] === "help" || args[0] === "--help" || args[0] = if (args[0] === "install") { const noTui = args.includes("--no-tui"); - const disableAutoCompact = args.includes("--disable-context-recovery"); - install({ tui: !noTui, disableAutoCompact }).then((code) => process.exit(code)); + install({ tui: !noTui }).then((code) => process.exit(code)); } else if (args[0] === "setup") { console.log("Note: 'setup' is deprecated. Use 'install' instead.\n"); const noTui = args.includes("--no-tui"); - const disableAutoCompact = args.includes("--disable-context-recovery"); - install({ tui: !noTui, disableAutoCompact }).then((code) => process.exit(code)); + install({ tui: !noTui }).then((code) => process.exit(code)); } else if (args[0] === "login") { login().then((code) => process.exit(code)); } else if (args[0] === "logout") { diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..9ebbf09 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; + +import { + resolveCompactionEnabled, + validateCompactionThreshold, +} from "./config.js"; + +describe("compaction configuration", () => { + test("treats zero and false as explicit legacy disable values", () => { + expect(validateCompactionThreshold(0)).toBe(0); + expect(validateCompactionThreshold(false)).toBe(0); + expect(resolveCompactionEnabled(undefined, 0)).toBe(false); + expect(resolveCompactionEnabled(undefined, false)).toBe(false); + }); + + test("prefers the explicit compactionEnabled setting", () => { + expect(resolveCompactionEnabled(false, 0.8)).toBe(false); + expect(resolveCompactionEnabled(true, 0)).toBe(true); + }); + + test("falls back safely for invalid legacy thresholds", () => { + expect(validateCompactionThreshold(-1)).toBe(0.8); + expect(validateCompactionThreshold(2)).toBe(0.8); + expect(validateCompactionThreshold(Number.NaN)).toBe(0.8); + expect(resolveCompactionEnabled(undefined, undefined)).toBe(true); + }); +}); diff --git a/src/config.ts b/src/config.ts index b100652..737240d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,7 @@ const CONFIG_FILES = [ ]; export const DEFAULT_BASE_URL = "https://api.supermemory.ai"; +const DEFAULT_COMPACTION_THRESHOLD = 0.8; interface SupermemoryConfig { apiKey?: string; @@ -26,7 +27,9 @@ interface SupermemoryConfig { projectContainerTag?: string; filterPrompt?: string; keywordPatterns?: string[]; - compactionThreshold?: number; + compactionEnabled?: boolean; + /** @deprecated OpenCode now owns the compaction trigger. Use compactionEnabled. */ + compactionThreshold?: number | false; autoRecallEveryPrompt?: boolean; captureEveryNTurns?: number; recallDirective?: string | null; @@ -60,7 +63,8 @@ const DEFAULTS: Required 1) return DEFAULTS.compactionThreshold; + if (value < 0 || value > 1) return DEFAULT_COMPACTION_THRESHOLD; return value; } +export function resolveCompactionEnabled( + enabled: boolean | undefined, + legacyThreshold: number | false | undefined, +): boolean { + if (enabled !== undefined) return enabled; + return validateCompactionThreshold(legacyThreshold) !== 0; +} + function validateCaptureEveryNTurns( value: number | undefined, fallback: number, @@ -168,6 +183,10 @@ export const CONFIG = { ...DEFAULT_KEYWORD_PATTERNS, ...(fileConfig.keywordPatterns ?? []).filter(isValidRegex), ], + compactionEnabled: resolveCompactionEnabled( + fileConfig.compactionEnabled, + fileConfig.compactionThreshold, + ), compactionThreshold: validateCompactionThreshold(fileConfig.compactionThreshold), autoRecallEveryPrompt: fileConfig.autoRecallEveryPrompt ?? diff --git a/src/index.ts b/src/index.ts index cb090e5..f10137a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,44 +73,20 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { log("Plugin disabled - SUPERMEMORY_API_KEY not set"); } - // Fetch model limits once at plugin init - const modelLimits = new Map(); - - (async () => { - try { - const response = await ctx.client.provider.list(); - if (response.data?.all) { - for (const provider of response.data.all) { - if (provider.models) { - for (const [modelId, model] of Object.entries(provider.models)) { - if (model.limit?.context) { - modelLimits.set(`${provider.id}/${modelId}`, model.limit.context); - } - } - } - } - } - log("Model limits loaded", { count: modelLimits.size }); - } catch (error) { - log("Failed to fetch model limits", { error: String(error) }); - } - })(); - - const getModelLimit = (providerID: string, modelID: string): number | undefined => { - return modelLimits.get(`${providerID}/${modelID}`); - }; - - const compactionHook = isConfigured() && ctx.client - ? createCompactionHook(ctx as CompactionContext, tags, { - threshold: CONFIG.compactionThreshold, - getModelLimit, - }) + const compactionHook = isConfigured() && ctx.client && CONFIG.compactionEnabled + ? createCompactionHook(ctx as CompactionContext, tags) : null; const captureHook = isConfigured() && ctx.client ? createCaptureHook(ctx, tags) : null; return { + "experimental.session.compacting": compactionHook + ? async (input, output) => { + await compactionHook.compacting(input, output); + } + : undefined, + "chat.message": async (input, output) => { if (!isConfigured()) return; diff --git a/src/services/compaction.test.ts b/src/services/compaction.test.ts new file mode 100644 index 0000000..0319c8a --- /dev/null +++ b/src/services/compaction.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, test } from "bun:test"; + +import { createCompactionHook, fitProjectMemories } from "./compaction.js"; +import type { ResolvedTags } from "./tags.js"; + +const tags: ResolvedTags = { + canonical: "repo_test__0123456789abcdef", + user: "repo_test__0123456789abcdef", + project: "repo_test__0123456789abcdef", + projectId: "0123456789abcdef", + projectName: "test", + personalReads: [], + projectReads: ["legacy-project"], + allReads: ["legacy-project"], +}; + +function summaryMessage(id: string, text: string) { + return { + info: { + id, + role: "assistant", + sessionID: "session-1", + summary: true, + finish: "stop", + }, + parts: [{ type: "text", text }], + }; +} + +function successfulMemoryClient(memories: Array<{ summary?: string }> = []) { + return { + listMemoriesScoped: async () => ({ + success: true, + memories, + pagination: { currentPage: 1, totalItems: memories.length, totalPages: 1 }, + }), + addMemory: async () => ({ success: true as const, id: "memory-1" }), + }; +} + +describe("native compaction integration", () => { + test("bounds and deduplicates memory context", () => { + const memories = fitProjectMemories([ + "same memory", + "same memory", + "x".repeat(20_000), + "y".repeat(20_000), + ]); + + expect(memories.filter((memory) => memory === "same memory")).toHaveLength(1); + expect(memories.every((memory) => memory.length <= 2_000)).toBe(true); + expect(memories.reduce((total, memory) => total + memory.length, 0)).toBeLessThanOrEqual(12_000); + }); + + test("adds project memory context without replacing OpenCode's prompt", async () => { + const output = { context: ["existing plugin context"], prompt: "native prompt" }; + const hook = createCompactionHook( + { + directory: "/repo", + client: { session: { messages: async () => [] } }, + }, + tags, + { memoryClient: successfulMemoryClient([{ summary: "Uses Bun" }]) }, + ); + + await hook.compacting({ sessionID: "session-1" }, output); + await hook.compacting({ sessionID: "session-1" }, output); + + expect(output.prompt).toBe("native prompt"); + expect(output.context).toHaveLength(2); + expect(output.context[1]).toContain("[SUPERMEMORY COMPACTION CONTEXT]"); + expect(output.context[1]).toContain("Uses Bun"); + }); + + test("allows native compaction to continue when memory lookup fails", async () => { + const output = { context: [] as string[] }; + const hook = createCompactionHook( + { + directory: "/repo", + client: { session: { messages: async () => [] } }, + }, + tags, + { + memoryClient: { + ...successfulMemoryClient(), + listMemoriesScoped: async () => { + throw new Error("network unavailable"); + }, + }, + }, + ); + + await expect( + hook.compacting({ sessionID: "session-1" }, output), + ).resolves.toBeUndefined(); + expect(output.context[0]).toContain("[SUPERMEMORY COMPACTION CONTEXT]"); + }); + + test("captures the exact new summary instead of an older summary", async () => { + const oldSummary = "old ".repeat(30); + const newSummary = "new ".repeat(30); + const writes: string[] = []; + const hook = createCompactionHook( + { + directory: "/repo", + client: { + session: { + messages: async () => ({ + data: [ + summaryMessage("summary-old", oldSummary), + summaryMessage("summary-new", newSummary), + ], + }), + }, + }, + }, + tags, + { + memoryClient: { + ...successfulMemoryClient(), + addMemory: async (content: string) => { + writes.push(content); + return { success: true as const, id: "memory-1" }; + }, + }, + }, + ); + + await hook.compacting({ sessionID: "session-1" }, { context: [] }); + await hook.event({ + event: { + type: "message.updated", + properties: { + info: summaryMessage("summary-new", newSummary).info, + }, + }, + }); + + expect(writes).toHaveLength(1); + expect(writes[0]).toContain(newSummary.trim()); + expect(writes[0]).not.toContain(oldSummary.trim()); + }); + + test("waits for the expected summary instead of capturing a stale one", async () => { + const oldSummary = summaryMessage("summary-old", "old ".repeat(30)); + const newSummary = summaryMessage("summary-new", "new ".repeat(30)); + let messages = [oldSummary]; + const writes: string[] = []; + const hook = createCompactionHook( + { + directory: "/repo", + client: { session: { messages: async () => ({ data: messages }) } }, + }, + tags, + { + memoryClient: { + ...successfulMemoryClient(), + addMemory: async (content: string) => { + writes.push(content); + return { success: true as const, id: "memory-1" }; + }, + }, + }, + ); + + await hook.compacting({ sessionID: "session-1" }, { context: [] }); + await hook.event({ + event: { type: "message.updated", properties: { info: newSummary.info } }, + }); + expect(writes).toHaveLength(0); + + messages = [oldSummary, newSummary]; + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-1" } }, + }); + expect(writes[0]).toContain("new ".repeat(30).trim()); + }); + + test("retries summary capture on idle after a transient write failure", async () => { + const summary = summaryMessage("summary-new", "summary ".repeat(20)); + let attempts = 0; + const hook = createCompactionHook( + { + directory: "/repo", + client: { + session: { messages: async () => ({ data: [summary] }) }, + }, + }, + tags, + { + memoryClient: { + ...successfulMemoryClient(), + addMemory: async () => { + attempts += 1; + return attempts === 1 + ? { success: false as const, error: "temporary failure" } + : { success: true as const, id: "memory-1" }; + }, + }, + }, + ); + + await hook.compacting({ sessionID: "session-1" }, { context: [] }); + await hook.event({ + event: { type: "message.updated", properties: { info: summary.info } }, + }); + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-1" } }, + }); + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-1" } }, + }); + + expect(attempts).toBe(2); + }); + + test("does not capture summaries when the native hook did not run", async () => { + let writes = 0; + const summary = summaryMessage("summary-new", "summary ".repeat(20)); + const hook = createCompactionHook( + { + directory: "/repo", + client: { session: { messages: async () => ({ data: [summary] }) } }, + }, + tags, + { + memoryClient: { + ...successfulMemoryClient(), + addMemory: async () => { + writes += 1; + return { success: true as const, id: "memory-1" }; + }, + }, + }, + ); + + await hook.event({ + event: { type: "message.updated", properties: { info: summary.info } }, + }); + + expect(writes).toBe(0); + }); + + test("does not save or retry failed compaction output", async () => { + let writes = 0; + const failedSummary = { + ...summaryMessage("summary-failed", "partial ".repeat(30)), + info: { + ...summaryMessage("summary-failed", "partial").info, + finish: "error", + error: { name: "ContextOverflowError" }, + }, + }; + const hook = createCompactionHook( + { + directory: "/repo", + client: { + session: { messages: async () => ({ data: [failedSummary] }) }, + }, + }, + tags, + { + memoryClient: { + ...successfulMemoryClient(), + addMemory: async () => { + writes += 1; + return { success: true as const, id: "memory-1" }; + }, + }, + }, + ); + + await hook.compacting({ sessionID: "session-1" }, { context: [] }); + await hook.event({ + event: { + type: "message.updated", + properties: { info: failedSummary.info }, + }, + }); + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-1" } }, + }); + + expect(writes).toBe(0); + }); +}); diff --git a/src/services/compaction.ts b/src/services/compaction.ts index f925892..4835056 100644 --- a/src/services/compaction.ts +++ b/src/services/compaction.ts @@ -1,72 +1,92 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; import { AGENT_ENTITY_CONTEXT } from "./entity-context.js"; import { supermemoryClient } from "./client.js"; import { log } from "./logger.js"; import { CONFIG } from "../config.js"; import type { ResolvedTags } from "./tags.js"; -const MESSAGE_STORAGE = join(homedir(), ".opencode", "messages"); -const PART_STORAGE = join(homedir(), ".opencode", "parts"); - -const DEFAULT_THRESHOLD = 0.80; -const MIN_TOKENS_FOR_COMPACTION = 50_000; -const COMPACTION_COOLDOWN_MS = 30_000; -const DEFAULT_CONTEXT_LIMIT = 200_000; - -interface CompactionState { - lastCompactionTime: Map; - compactionInProgress: Set; - summarizedSessions: Set; -} - -interface TokenInfo { - input: number; - output: number; - cache: { read: number; write: number }; -} +const COMPACTION_CONTEXT_MARKER = "[SUPERMEMORY COMPACTION CONTEXT]"; +const MAX_COMPACTION_MEMORY_CHARS = 12_000; +const MAX_SINGLE_MEMORY_CHARS = 2_000; interface MessageInfo { id: string; role: string; sessionID: string; - providerID?: string; - modelID?: string; - tokens?: TokenInfo; summary?: boolean; - finish?: boolean; + finish?: string | boolean; + error?: unknown; } -interface StoredMessage { - agent?: string; - model?: { providerID?: string; modelID?: string }; +interface SessionMessage { + info: MessageInfo; + parts?: Array<{ type: string; text?: string }>; } -interface SummarizeContext { - sessionID: string; - providerID: string; - modelID: string; - usageRatio: number; +interface CompactionMemoryClient { + listMemoriesScoped: ( + canonicalTag: string, + containerTags: string[], + scope: "project", + limit: number, + ) => Promise<{ + memories?: Array<{ summary?: string | null; content?: string | null }>; + }>; + addMemory: ( + content: string, + containerTag: string, + metadata?: Record, + options?: { customId?: string; entityContext?: string }, + ) => Promise<{ success: boolean; id?: string; error?: string }>; +} + +export interface CompactionContext { directory: string; - agent?: string; + client: { + session: { + messages: (params: { + path: { id: string }; + query: { directory: string }; + }) => Promise<{ data?: SessionMessage[] } | SessionMessage[]>; + }; + }; } export interface CompactionOptions { - threshold?: number; - getModelLimit?: (providerID: string, modelID: string) => number | undefined; + memoryClient?: CompactionMemoryClient; } -function createCompactionPrompt(projectMemories: string[]): string { - const memoriesSection = projectMemories.length > 0 - ? ` +export function fitProjectMemories(memories: string[]): string[] { + const result: string[] = []; + const seen = new Set(); + let remaining = MAX_COMPACTION_MEMORY_CHARS; + + for (const rawMemory of memories) { + const normalized = rawMemory.trim(); + if (!normalized || seen.has(normalized) || remaining <= 0) continue; + seen.add(normalized); + + const memory = normalized.slice( + 0, + Math.min(MAX_SINGLE_MEMORY_CHARS, remaining), + ); + result.push(memory); + remaining -= memory.length; + } + + return result; +} + +export function createCompactionPrompt(projectMemories: string[]): string { + const memoriesSection = + projectMemories.length > 0 + ? ` ## Project Knowledge (from Supermemory) The following project-specific knowledge should be preserved and referenced in the summary: -${projectMemories.map(m => `- ${m}`).join('\n')} +${projectMemories.map((memory) => `- ${memory}`).join("\n")} ` - : ''; + : ""; - return `[COMPACTION CONTEXT INJECTION] + return `${COMPACTION_CONTEXT_MARKER} When summarizing this session, you MUST include the following sections in your summary: @@ -99,213 +119,67 @@ This context is critical for maintaining continuity after compaction. `; } -function getMessageDir(sessionID: string): string | null { - if (!existsSync(MESSAGE_STORAGE)) return null; - - const directPath = join(MESSAGE_STORAGE, sessionID); - if (existsSync(directPath)) return directPath; - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID); - if (existsSync(sessionPath)) return sessionPath; - } - - return null; +function getResponseMessages( + response: { data?: SessionMessage[] } | SessionMessage[], +): SessionMessage[] { + return Array.isArray(response) ? response : response.data ?? []; } -function getOrCreateMessageDir(sessionID: string): string { - if (!existsSync(MESSAGE_STORAGE)) { - mkdirSync(MESSAGE_STORAGE, { recursive: true }); - } - - const directPath = join(MESSAGE_STORAGE, sessionID); - if (existsSync(directPath)) return directPath; - - for (const dir of readdirSync(MESSAGE_STORAGE)) { - const sessionPath = join(MESSAGE_STORAGE, dir, sessionID); - if (existsSync(sessionPath)) return sessionPath; - } - - mkdirSync(directPath, { recursive: true }); - return directPath; -} - -function findNearestMessageWithFields(messageDir: string): StoredMessage | null { - try { - const files = readdirSync(messageDir) - .filter((f) => f.endsWith(".json")) - .sort() - .reverse(); - - for (const file of files) { - try { - const content = readFileSync(join(messageDir, file), "utf-8"); - const msg = JSON.parse(content) as StoredMessage; - if (msg.agent && msg.model?.providerID && msg.model?.modelID) { - return msg; - } - } catch { - continue; - } - } - } catch { - return null; - } - return null; -} - -function generateMessageId(): string { - const timestamp = Date.now().toString(16); - const random = Math.random().toString(36).substring(2, 14); - return `msg_${timestamp}${random}`; -} - -function generatePartId(): string { - const timestamp = Date.now().toString(16); - const random = Math.random().toString(36).substring(2, 10); - return `prt_${timestamp}${random}`; -} - -function injectHookMessage( - sessionID: string, - hookContent: string, - originalMessage: { - agent?: string; - model?: { providerID?: string; modelID?: string }; - path?: { cwd?: string; root?: string }; - } -): boolean { - if (!hookContent || hookContent.trim().length === 0) { - log("[compaction] attempted to inject empty content, skipping"); - return false; - } - - const messageDir = getOrCreateMessageDir(sessionID); - const fallback = findNearestMessageWithFields(messageDir); - - const now = Date.now(); - const messageID = generateMessageId(); - const partID = generatePartId(); - - const resolvedAgent = originalMessage.agent ?? fallback?.agent ?? "general"; - const resolvedModel = - originalMessage.model?.providerID && originalMessage.model?.modelID - ? { providerID: originalMessage.model.providerID, modelID: originalMessage.model.modelID } - : fallback?.model?.providerID && fallback?.model?.modelID - ? { providerID: fallback.model.providerID, modelID: fallback.model.modelID } - : undefined; - - const messageMeta = { - id: messageID, - sessionID, - role: "user", - time: { created: now }, - agent: resolvedAgent, - model: resolvedModel, - path: originalMessage.path?.cwd - ? { cwd: originalMessage.path.cwd, root: originalMessage.path.root ?? "/" } - : undefined, - }; - - const textPart = { - id: partID, - type: "text", - text: hookContent, - synthetic: true, - time: { start: now, end: now }, - messageID, - sessionID, - }; - - try { - writeFileSync(join(messageDir, `${messageID}.json`), JSON.stringify(messageMeta, null, 2)); - - const partDir = join(PART_STORAGE, messageID); - if (!existsSync(partDir)) { - mkdirSync(partDir, { recursive: true }); - } - writeFileSync(join(partDir, `${partID}.json`), JSON.stringify(textPart, null, 2)); - - log("[compaction] hook message injected", { sessionID, messageID }); - return true; - } catch (err) { - log("[compaction] failed to inject hook message", { error: String(err) }); - return false; - } -} - -export interface CompactionContext { - directory: string; - client: { - session: { - summarize: (params: { path: { id: string }; body: { providerID: string; modelID: string }; query: { directory: string } }) => Promise; - messages: (params: { path: { id: string }; query: { directory: string } }) => Promise<{ data?: Array<{ info: MessageInfo }> }>; - promptAsync: (params: { path: { id: string }; body: { agent?: string; parts: Array<{ type: string; text: string }> }; query: { directory: string } }) => Promise; - }; - tui: { - showToast: (params: { body: { title: string; message: string; variant: string; duration: number } }) => Promise; - }; - }; +function getSummaryContent(message: SessionMessage): string { + return (message.parts ?? []) + .filter( + (part): part is { type: string; text: string } => + part.type === "text" && typeof part.text === "string", + ) + .map((part) => part.text) + .join("\n") + .trim(); } export function createCompactionHook( ctx: CompactionContext, tags: ResolvedTags, - options?: CompactionOptions + options?: CompactionOptions, ) { - const state: CompactionState = { - lastCompactionTime: new Map(), - compactionInProgress: new Set(), - summarizedSessions: new Set(), - }; + const memoryClient = options?.memoryClient ?? supermemoryClient; + const pendingSessions = new Set(); + const captureInProgress = new Set(); + const capturedSummaryIDs = new Map>(); - const threshold = options?.threshold ?? DEFAULT_THRESHOLD; - const getModelLimit = options?.getModelLimit; - - async function fetchProjectMemoriesForCompaction(): Promise { + async function fetchProjectMemories(): Promise { try { - const result = await supermemoryClient.listMemoriesScoped( + const result = await memoryClient.listMemoriesScoped( tags.canonical, tags.projectReads, "project", CONFIG.maxProjectMemories, ); - const memories = result.memories || []; - return memories.map((m: any) => m.summary || m.content || "").filter(Boolean); - } catch (err) { - log("[compaction] failed to fetch project memories", { error: String(err) }); + const memories = (result.memories ?? []) + .map((memory) => memory.summary || memory.content || "") + .filter((memory): memory is string => Boolean(memory)); + return fitProjectMemories(memories); + } catch (error) { + log("[compaction] failed to fetch project memories", { + error: String(error), + }); return []; } } - async function injectCompactionContext(summarizeCtx: SummarizeContext): Promise { - log("[compaction] injecting context", { sessionID: summarizeCtx.sessionID }); - - const projectMemories = await fetchProjectMemoriesForCompaction(); - const prompt = createCompactionPrompt(projectMemories); - - const success = injectHookMessage(summarizeCtx.sessionID, prompt, { - agent: summarizeCtx.agent, - model: { providerID: summarizeCtx.providerID, modelID: summarizeCtx.modelID }, - path: { cwd: summarizeCtx.directory }, - }); - - if (success) { - log("[compaction] context injected with project memories", { - sessionID: summarizeCtx.sessionID, - memoriesCount: projectMemories.length + async function saveSummaryAsMemory( + sessionID: string, + summaryContent: string, + ): Promise { + if (summaryContent.length < 100) { + log("[compaction] summary too short to save", { + sessionID, + length: summaryContent.length, }); - } - } - - async function saveSummaryAsMemory(sessionID: string, summaryContent: string): Promise { - if (!summaryContent || summaryContent.length < 100) { - log("[compaction] summary too short to save", { sessionID, length: summaryContent.length }); - return; + return true; } try { - const result = await supermemoryClient.addMemory( + const result = await memoryClient.addMemory( `[Session Summary]\n${summaryContent}`, tags.canonical, { @@ -316,239 +190,161 @@ export function createCompactionHook( sm_capture_mode: "compaction", sessionId: sessionID, }, - { entityContext: AGENT_ENTITY_CONTEXT } + { entityContext: AGENT_ENTITY_CONTEXT }, ); if (result.success) { - log("[compaction] summary saved as memory", { sessionID, memoryId: result.id }); - } else { - log("[compaction] failed to save summary", { error: result.error }); + log("[compaction] summary saved as memory", { + sessionID, + memoryId: result.id, + }); + return true; } - } catch (err) { - log("[compaction] failed to save summary", { error: String(err) }); - } - } - - async function checkAndTriggerCompaction(sessionID: string, lastAssistant: MessageInfo): Promise { - if (state.compactionInProgress.has(sessionID)) return; - - const lastCompaction = state.lastCompactionTime.get(sessionID) ?? 0; - if (Date.now() - lastCompaction < COMPACTION_COOLDOWN_MS) return; - - if (lastAssistant.summary === true) return; - - const tokens = lastAssistant.tokens; - if (!tokens) return; - let modelID = lastAssistant.modelID ?? ""; - let providerID = lastAssistant.providerID ?? ""; - let agent: string | undefined; - - // Fallback: find model/agent from stored messages if not available - const messageDir = getMessageDir(sessionID); - const storedMessage = messageDir ? findNearestMessageWithFields(messageDir) : null; - - if (!providerID || !modelID) { - if (storedMessage?.model?.providerID) providerID = storedMessage.model.providerID; - if (storedMessage?.model?.modelID) modelID = storedMessage.model.modelID; + log("[compaction] failed to save summary", { error: result.error }); + return false; + } catch (error) { + log("[compaction] failed to save summary", { error: String(error) }); + return false; } - agent = storedMessage?.agent; - - const configLimit = getModelLimit?.(providerID, modelID); - const contextLimit = configLimit ?? DEFAULT_CONTEXT_LIMIT; - const totalUsed = tokens.input + tokens.cache.read + tokens.output; - - if (totalUsed < MIN_TOKENS_FOR_COMPACTION) return; - - const usageRatio = totalUsed / contextLimit; - - log("[compaction] checking", { - sessionID, - totalUsed, - contextLimit, - usageRatio: usageRatio.toFixed(2), - threshold, - }); - - if (usageRatio < threshold) return; - - state.compactionInProgress.add(sessionID); - state.lastCompactionTime.set(sessionID, Date.now()); + } - if (!providerID || !modelID) { - state.compactionInProgress.delete(sessionID); + async function captureSummary( + sessionID: string, + expectedSummaryID?: string, + ): Promise { + if (!pendingSessions.has(sessionID) || captureInProgress.has(sessionID)) { return; } - await ctx.client.tui.showToast({ - body: { - title: "Preemptive Compaction", - message: `Context at ${(usageRatio * 100).toFixed(0)}% - compacting with Supermemory context...`, - variant: "warning", - duration: 3000, - }, - }).catch(() => {}); - - log("[compaction] triggering compaction", { sessionID, usageRatio }); - - try { - await injectCompactionContext({ - sessionID, - providerID, - modelID, - usageRatio, - directory: ctx.directory, - agent, - }); - - state.summarizedSessions.add(sessionID); - - await ctx.client.session.summarize({ - path: { id: sessionID }, - body: { providerID, modelID }, - query: { directory: ctx.directory }, - }); - - await ctx.client.tui.showToast({ - body: { - title: "Compaction Complete", - message: "Session compacted with Supermemory context. Resuming...", - variant: "success", - duration: 2000, - }, - }).catch(() => {}); - - state.compactionInProgress.delete(sessionID); - - setTimeout(async () => { - try { - const messageDir = getMessageDir(sessionID); - const storedMessage = messageDir ? findNearestMessageWithFields(messageDir) : null; - - await ctx.client.session.promptAsync({ - path: { id: sessionID }, - body: { - agent: storedMessage?.agent, - parts: [{ type: "text", text: "Continue" }], - }, - query: { directory: ctx.directory }, - }); - } catch {} - }, 500); - } catch (err) { - log("[compaction] compaction failed", { sessionID, error: String(err) }); - state.compactionInProgress.delete(sessionID); - } - } - - async function handleSummaryMessage(sessionID: string, _messageInfo: MessageInfo): Promise { - log("[compaction] handleSummaryMessage called", { sessionID, inSet: state.summarizedSessions.has(sessionID) }); - - if (!state.summarizedSessions.has(sessionID)) return; - - state.summarizedSessions.delete(sessionID); - log("[compaction] capturing summary for memory", { sessionID }); + const capturedForSession = capturedSummaryIDs.get(sessionID); + if (expectedSummaryID && capturedForSession?.has(expectedSummaryID)) return; + captureInProgress.add(sessionID); try { - const resp = await ctx.client.session.messages({ + const response = await ctx.client.session.messages({ path: { id: sessionID }, query: { directory: ctx.directory }, }); - - const messages = (resp.data ?? resp) as Array<{ info: MessageInfo; parts?: Array<{ type: string; text?: string }> }>; - - const summaryMessage = messages.find(m => - m.info.role === "assistant" && - m.info.summary === true + const messages = getResponseMessages(response); + const summaries = messages.filter( + (message) => + message.info.role === "assistant" && + message.info.summary === true && + Boolean(message.info.finish) && + message.info.finish !== "error" && + !message.info.error, ); + const summary = expectedSummaryID + ? summaries.find((message) => message.info.id === expectedSummaryID) + : summaries.at(-1); - log("[compaction] looking for summary message", { - sessionID, - found: !!summaryMessage, - hasParts: !!summaryMessage?.parts - }); + if (!summary) { + log("[compaction] summary message not available yet", { sessionID }); + return; + } + + const alreadyCaptured = capturedSummaryIDs + .get(sessionID) + ?.has(summary.info.id); + if (alreadyCaptured) return; - if (summaryMessage?.parts) { - const textParts = summaryMessage.parts.filter(p => p.type === "text" && p.text); - const summaryContent = textParts.map(p => p.text).join("\n"); - - log("[compaction] summary content", { - sessionID, - textPartsCount: textParts.length, - contentLength: summaryContent.length + const summaryContent = getSummaryContent(summary); + if (!summaryContent) { + log("[compaction] summary content not available yet", { + sessionID, + summaryID: summary.info.id, }); - - if (summaryContent) { - await saveSummaryAsMemory(sessionID, summaryContent); - } + return; } - } catch (err) { - log("[compaction] failed to capture summary", { error: String(err) }); + + if (!(await saveSummaryAsMemory(sessionID, summaryContent))) return; + + const captured = capturedSummaryIDs.get(sessionID) ?? new Set(); + captured.add(summary.info.id); + capturedSummaryIDs.set(sessionID, captured); + pendingSessions.delete(sessionID); + } catch (error) { + log("[compaction] failed to capture summary", { error: String(error) }); + } finally { + captureInProgress.delete(sessionID); } } return { - async event({ event }: { event: { type: string; properties?: unknown } }) { - const props = event.properties as Record | undefined; + async compacting( + input: { sessionID: string }, + output: { context: string[]; prompt?: string }, + ): Promise { + pendingSessions.add(input.sessionID); - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id) { - state.lastCompactionTime.delete(sessionInfo.id); - state.compactionInProgress.delete(sessionInfo.id); - state.summarizedSessions.delete(sessionInfo.id); + try { + const projectMemories = await fetchProjectMemories(); + const context = createCompactionPrompt(projectMemories); + if (!output.context.some((item) => item.includes(COMPACTION_CONTEXT_MARKER))) { + output.context.push(context); } - return; + log("[compaction] native context injected", { + sessionID: input.sessionID, + memoriesCount: projectMemories.length, + }); + } catch (error) { + // Compaction must never fail because optional Supermemory context failed. + log("[compaction] failed to inject native context", { + sessionID: input.sessionID, + error: String(error), + }); } + }, - if (event.type === "message.updated") { - const info = props?.info as MessageInfo | undefined; - if (!info) return; - - const sessionID = info.sessionID; - if (!sessionID) return; + async event({ event }: { event: { type: string; properties?: unknown } }) { + const properties = event.properties as + | Record + | undefined; - if (info.role === "assistant" && info.summary === true && info.finish) { - await handleSummaryMessage(sessionID, info); + if (event.type === "message.updated") { + const info = properties?.info as MessageInfo | undefined; + if ( + info?.sessionID && + info.role === "assistant" && + info.summary === true && + Boolean(info.finish) && + (info.finish === "error" || Boolean(info.error)) + ) { + pendingSessions.delete(info.sessionID); + log("[compaction] native compaction failed; summary not captured", { + sessionID: info.sessionID, + }); return; } - - if (info.role !== "assistant" || !info.finish) return; - - await checkAndTriggerCompaction(sessionID, info); + if ( + info?.sessionID && + info.role === "assistant" && + info.summary === true && + Boolean(info.finish) + ) { + await captureSummary(info.sessionID, info.id); + } return; } - if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined; - if (!sessionID) return; - - try { - const resp = await ctx.client.session.messages({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - }); - - const messages = (resp.data ?? resp) as Array<{ info: MessageInfo }>; - const assistants = messages - .filter((m) => m.info.role === "assistant") - .map((m) => m.info); - - if (assistants.length === 0) return; - - const lastAssistant = assistants[assistants.length - 1]!; - - if (!lastAssistant.providerID || !lastAssistant.modelID) { - const messageDir = getMessageDir(sessionID); - const storedMessage = messageDir ? findNearestMessageWithFields(messageDir) : null; - if (storedMessage?.model?.providerID && storedMessage?.model?.modelID) { - lastAssistant.providerID = storedMessage.model.providerID; - lastAssistant.modelID = storedMessage.model.modelID; - } - } + if ( + event.type === "session.compacted" || + event.type === "session.idle" + ) { + const sessionID = properties?.sessionID as string | undefined; + if (sessionID && pendingSessions.has(sessionID)) { + await captureSummary(sessionID); + } + return; + } - await checkAndTriggerCompaction(sessionID, lastAssistant); - } catch {} + if (event.type === "session.deleted") { + const sessionInfo = properties?.info as { id?: string } | undefined; + if (!sessionInfo?.id) return; + pendingSessions.delete(sessionInfo.id); + captureInProgress.delete(sessionInfo.id); + capturedSummaryIDs.delete(sessionInfo.id); } }, }; From 3113da6c87595eaa68d5f1e5c9b4c0ccdf7a3dd0 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Wed, 12 Aug 2026 21:04:07 +0530 Subject: [PATCH 02/10] Delete src/services/compaction.test.ts --- src/services/compaction.test.ts | 286 -------------------------------- 1 file changed, 286 deletions(-) delete mode 100644 src/services/compaction.test.ts diff --git a/src/services/compaction.test.ts b/src/services/compaction.test.ts deleted file mode 100644 index 0319c8a..0000000 --- a/src/services/compaction.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createCompactionHook, fitProjectMemories } from "./compaction.js"; -import type { ResolvedTags } from "./tags.js"; - -const tags: ResolvedTags = { - canonical: "repo_test__0123456789abcdef", - user: "repo_test__0123456789abcdef", - project: "repo_test__0123456789abcdef", - projectId: "0123456789abcdef", - projectName: "test", - personalReads: [], - projectReads: ["legacy-project"], - allReads: ["legacy-project"], -}; - -function summaryMessage(id: string, text: string) { - return { - info: { - id, - role: "assistant", - sessionID: "session-1", - summary: true, - finish: "stop", - }, - parts: [{ type: "text", text }], - }; -} - -function successfulMemoryClient(memories: Array<{ summary?: string }> = []) { - return { - listMemoriesScoped: async () => ({ - success: true, - memories, - pagination: { currentPage: 1, totalItems: memories.length, totalPages: 1 }, - }), - addMemory: async () => ({ success: true as const, id: "memory-1" }), - }; -} - -describe("native compaction integration", () => { - test("bounds and deduplicates memory context", () => { - const memories = fitProjectMemories([ - "same memory", - "same memory", - "x".repeat(20_000), - "y".repeat(20_000), - ]); - - expect(memories.filter((memory) => memory === "same memory")).toHaveLength(1); - expect(memories.every((memory) => memory.length <= 2_000)).toBe(true); - expect(memories.reduce((total, memory) => total + memory.length, 0)).toBeLessThanOrEqual(12_000); - }); - - test("adds project memory context without replacing OpenCode's prompt", async () => { - const output = { context: ["existing plugin context"], prompt: "native prompt" }; - const hook = createCompactionHook( - { - directory: "/repo", - client: { session: { messages: async () => [] } }, - }, - tags, - { memoryClient: successfulMemoryClient([{ summary: "Uses Bun" }]) }, - ); - - await hook.compacting({ sessionID: "session-1" }, output); - await hook.compacting({ sessionID: "session-1" }, output); - - expect(output.prompt).toBe("native prompt"); - expect(output.context).toHaveLength(2); - expect(output.context[1]).toContain("[SUPERMEMORY COMPACTION CONTEXT]"); - expect(output.context[1]).toContain("Uses Bun"); - }); - - test("allows native compaction to continue when memory lookup fails", async () => { - const output = { context: [] as string[] }; - const hook = createCompactionHook( - { - directory: "/repo", - client: { session: { messages: async () => [] } }, - }, - tags, - { - memoryClient: { - ...successfulMemoryClient(), - listMemoriesScoped: async () => { - throw new Error("network unavailable"); - }, - }, - }, - ); - - await expect( - hook.compacting({ sessionID: "session-1" }, output), - ).resolves.toBeUndefined(); - expect(output.context[0]).toContain("[SUPERMEMORY COMPACTION CONTEXT]"); - }); - - test("captures the exact new summary instead of an older summary", async () => { - const oldSummary = "old ".repeat(30); - const newSummary = "new ".repeat(30); - const writes: string[] = []; - const hook = createCompactionHook( - { - directory: "/repo", - client: { - session: { - messages: async () => ({ - data: [ - summaryMessage("summary-old", oldSummary), - summaryMessage("summary-new", newSummary), - ], - }), - }, - }, - }, - tags, - { - memoryClient: { - ...successfulMemoryClient(), - addMemory: async (content: string) => { - writes.push(content); - return { success: true as const, id: "memory-1" }; - }, - }, - }, - ); - - await hook.compacting({ sessionID: "session-1" }, { context: [] }); - await hook.event({ - event: { - type: "message.updated", - properties: { - info: summaryMessage("summary-new", newSummary).info, - }, - }, - }); - - expect(writes).toHaveLength(1); - expect(writes[0]).toContain(newSummary.trim()); - expect(writes[0]).not.toContain(oldSummary.trim()); - }); - - test("waits for the expected summary instead of capturing a stale one", async () => { - const oldSummary = summaryMessage("summary-old", "old ".repeat(30)); - const newSummary = summaryMessage("summary-new", "new ".repeat(30)); - let messages = [oldSummary]; - const writes: string[] = []; - const hook = createCompactionHook( - { - directory: "/repo", - client: { session: { messages: async () => ({ data: messages }) } }, - }, - tags, - { - memoryClient: { - ...successfulMemoryClient(), - addMemory: async (content: string) => { - writes.push(content); - return { success: true as const, id: "memory-1" }; - }, - }, - }, - ); - - await hook.compacting({ sessionID: "session-1" }, { context: [] }); - await hook.event({ - event: { type: "message.updated", properties: { info: newSummary.info } }, - }); - expect(writes).toHaveLength(0); - - messages = [oldSummary, newSummary]; - await hook.event({ - event: { type: "session.idle", properties: { sessionID: "session-1" } }, - }); - expect(writes[0]).toContain("new ".repeat(30).trim()); - }); - - test("retries summary capture on idle after a transient write failure", async () => { - const summary = summaryMessage("summary-new", "summary ".repeat(20)); - let attempts = 0; - const hook = createCompactionHook( - { - directory: "/repo", - client: { - session: { messages: async () => ({ data: [summary] }) }, - }, - }, - tags, - { - memoryClient: { - ...successfulMemoryClient(), - addMemory: async () => { - attempts += 1; - return attempts === 1 - ? { success: false as const, error: "temporary failure" } - : { success: true as const, id: "memory-1" }; - }, - }, - }, - ); - - await hook.compacting({ sessionID: "session-1" }, { context: [] }); - await hook.event({ - event: { type: "message.updated", properties: { info: summary.info } }, - }); - await hook.event({ - event: { type: "session.idle", properties: { sessionID: "session-1" } }, - }); - await hook.event({ - event: { type: "session.idle", properties: { sessionID: "session-1" } }, - }); - - expect(attempts).toBe(2); - }); - - test("does not capture summaries when the native hook did not run", async () => { - let writes = 0; - const summary = summaryMessage("summary-new", "summary ".repeat(20)); - const hook = createCompactionHook( - { - directory: "/repo", - client: { session: { messages: async () => ({ data: [summary] }) } }, - }, - tags, - { - memoryClient: { - ...successfulMemoryClient(), - addMemory: async () => { - writes += 1; - return { success: true as const, id: "memory-1" }; - }, - }, - }, - ); - - await hook.event({ - event: { type: "message.updated", properties: { info: summary.info } }, - }); - - expect(writes).toBe(0); - }); - - test("does not save or retry failed compaction output", async () => { - let writes = 0; - const failedSummary = { - ...summaryMessage("summary-failed", "partial ".repeat(30)), - info: { - ...summaryMessage("summary-failed", "partial").info, - finish: "error", - error: { name: "ContextOverflowError" }, - }, - }; - const hook = createCompactionHook( - { - directory: "/repo", - client: { - session: { messages: async () => ({ data: [failedSummary] }) }, - }, - }, - tags, - { - memoryClient: { - ...successfulMemoryClient(), - addMemory: async () => { - writes += 1; - return { success: true as const, id: "memory-1" }; - }, - }, - }, - ); - - await hook.compacting({ sessionID: "session-1" }, { context: [] }); - await hook.event({ - event: { - type: "message.updated", - properties: { info: failedSummary.info }, - }, - }); - await hook.event({ - event: { type: "session.idle", properties: { sessionID: "session-1" } }, - }); - - expect(writes).toBe(0); - }); -}); From c80d761e9d4b6453b4dfc8498e5484c1e20d3387 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Wed, 12 Aug 2026 21:04:26 +0530 Subject: [PATCH 03/10] Delete src/config.test.ts --- src/config.test.ts | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 src/config.test.ts diff --git a/src/config.test.ts b/src/config.test.ts deleted file mode 100644 index 9ebbf09..0000000 --- a/src/config.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - resolveCompactionEnabled, - validateCompactionThreshold, -} from "./config.js"; - -describe("compaction configuration", () => { - test("treats zero and false as explicit legacy disable values", () => { - expect(validateCompactionThreshold(0)).toBe(0); - expect(validateCompactionThreshold(false)).toBe(0); - expect(resolveCompactionEnabled(undefined, 0)).toBe(false); - expect(resolveCompactionEnabled(undefined, false)).toBe(false); - }); - - test("prefers the explicit compactionEnabled setting", () => { - expect(resolveCompactionEnabled(false, 0.8)).toBe(false); - expect(resolveCompactionEnabled(true, 0)).toBe(true); - }); - - test("falls back safely for invalid legacy thresholds", () => { - expect(validateCompactionThreshold(-1)).toBe(0.8); - expect(validateCompactionThreshold(2)).toBe(0.8); - expect(validateCompactionThreshold(Number.NaN)).toBe(0.8); - expect(resolveCompactionEnabled(undefined, undefined)).toBe(true); - }); -}); From 03a8d6ad27165c7ff2742de9ce05c51bdacc8651 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Wed, 12 Aug 2026 21:06:46 +0530 Subject: [PATCH 04/10] Remove README changes --- README.md | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 9ed7fde..a488d8f 100644 --- a/README.md +++ b/README.md @@ -185,16 +185,15 @@ Add custom triggers via `keywordPatterns` config. Run `/supermemory-init` to explore and memorize your codebase structure, patterns, and conventions. -### Native Compaction Integration +### Preemptive Compaction -When OpenCode compacts a session, Supermemory: +When context hits 80% capacity: -1. Injects project memories and continuity instructions into OpenCode's compaction context -2. Lets OpenCode select the compaction model and restore the session's original model -3. Saves the completed session summary as a memory +1. Triggers OpenCode's summarization +2. Injects project memories into summary context +3. Saves session summary as a memory -Supermemory does not run a separate token threshold, summarization request, or -synthetic continuation, so it cannot race OpenCode's native auto-compaction. +This preserves conversation context across compaction events. ### Privacy @@ -278,11 +277,8 @@ Create `~/.config/opencode/supermemory.jsonc`: // Extra keyword patterns for memory detection (regex) "keywordPatterns": ["log\\s+this", "write\\s+down"], - // Inject Supermemory context into OpenCode's native compaction lifecycle - "compactionEnabled": true, - - // Legacy disable setting; 0 disables compaction integration - // "compactionThreshold": 0, + // Context usage ratio that triggers compaction (0-1) + "compactionThreshold": 0.8, // Save completed conversation batches every N turns (0 = session end only) "captureEveryNTurns": 3, @@ -325,9 +321,15 @@ This is useful when you want to: ## Usage with Oh My OpenCode -Supermemory uses OpenCode's native compaction lifecycle and does not install a -competing auto-compaction trigger. It does not require changes to your -[Oh My OpenCode](https://github.com/code-yeongyu/oh-my-opencode) configuration. +If you're using [Oh My OpenCode](https://github.com/code-yeongyu/oh-my-opencode), disable its built-in auto-compact hook to let supermemory handle context compaction: + +Add to `~/.config/opencode/oh-my-opencode.json`: + +```json +{ + "disabled_hooks": ["anthropic-context-window-limit-recovery"] +} +``` ## Development From e732a3ab04213f4c2bfb1fc1f278af1b3aaa37f8 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Thu, 20 Aug 2026 20:40:48 +0530 Subject: [PATCH 05/10] Add OpenCode 2 plugin support --- .github/workflows/release.yml | 4 + README.md | 98 ++- bun.lock | 213 ++++- package.json | 18 +- src/cli.ts | 62 +- src/config.test.ts | 27 + src/index.ts | 320 +------- src/services/compaction.test.ts | 138 ++++ src/services/logger.ts | 20 +- src/services/memory-tool.test.ts | 377 +++++++++ src/services/memory-tool.ts | 343 ++++++++ src/services/opencode-config.test.ts | 165 ++++ src/services/opencode-config.ts | 168 ++++ src/v2/index.ts | 10 + src/v2/runtime.test.ts | 1098 +++++++++++++++++++++++++ src/v2/runtime.ts | 1100 ++++++++++++++++++++++++++ 16 files changed, 3772 insertions(+), 389 deletions(-) create mode 100644 src/config.test.ts create mode 100644 src/services/compaction.test.ts create mode 100644 src/services/memory-tool.test.ts create mode 100644 src/services/memory-tool.ts create mode 100644 src/services/opencode-config.test.ts create mode 100644 src/services/opencode-config.ts create mode 100644 src/v2/index.ts create mode 100644 src/v2/runtime.test.ts create mode 100644 src/v2/runtime.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 541c312..921af5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,10 @@ jobs: if: steps.version-check.outputs.changed == 'true' run: bun run typecheck + - name: Test + if: steps.version-check.outputs.changed == 'true' + run: bun test + - name: Build if: steps.version-check.outputs.changed == 'true' run: bun run build diff --git a/README.md b/README.md index a488d8f..69910e4 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,11 @@ OpenCode plugin for persistent memory using [Supermemory](https://supermemory.ai Your agent remembers what you tell it - across sessions, across projects. +One package supports both OpenCode generations. OpenCode V1 loads +`opencode-supermemory`; the OpenCode 2 beta loads `opencode-supermemory/v2`. +The initial V2 adapter targets `@opencode-ai/plugin` beta `0.0.0-beta-17728`. +Use the `opencode` binary for V1 and `opencode2` for the beta while testing both. + ## Installation ### For Humans @@ -45,7 +50,8 @@ bunx opencode-supermemory@latest install --no-tui This will: -- Register the plugin in `~/.config/opencode/opencode.jsonc` +- Register both the V1 and V2 entrypoints in `~/.config/opencode/opencode.jsonc` +- Allow only the V2 `supermemory_recall` helper without prompting - Create the `/supermemory-init` command #### Step 2: Verify the config @@ -58,10 +64,22 @@ Should contain: ```json { - "plugin": ["opencode-supermemory"] + "plugin": ["opencode-supermemory@latest"], + "plugins": ["opencode-supermemory/v2"], + "permissions": [ + { + "action": "supermemory_recall", + "resource": "*", + "effect": "allow" + } + ] } ``` +OpenCode V1 reads the singular `plugin` entry. OpenCode 2 reads the plural +`plugins` entry and loads the V2 adapter. Both entrypoints ship in the same npm +package and use the same Supermemory account and configuration. + If not, add it manually: **JSONC:** @@ -69,9 +87,20 @@ If not, add it manually: ```jsonc { "plugin": [ - "opencode-supermemory", + "opencode-supermemory@latest", // ... other plugins ], + "plugins": [ + "opencode-supermemory/v2", + // ... other OpenCode 2 plugins + ], + "permissions": [ + { + "action": "supermemory_recall", + "resource": "*", + "effect": "allow", + }, + ], } ``` @@ -79,10 +108,23 @@ If not, add it manually: ```json { - "plugin": ["opencode-supermemory"] + "plugin": ["opencode-supermemory@latest"], + "plugins": ["opencode-supermemory/v2"], + "permissions": [ + { + "action": "supermemory_recall", + "resource": "*", + "effect": "allow" + } + ] } ``` +The installer preserves comments, existing plugins, permissions, and unrelated +settings. Running it again is safe. If `supermemory_recall` is explicitly +denied, the installer keeps that deny and prints a warning instead of +overriding it. + #### Step 3: Authenticate Run the browser authentication flow: @@ -110,7 +152,7 @@ bunx opencode-supermemory@latest status If it is not connected, check: 1. Is the user authenticated, or is `SUPERMEMORY_API_KEY` set? -2. Is the plugin in `opencode.jsonc`? +2. Does `opencode.jsonc` contain the V1 `plugin` and V2 `plugins` entries shown above? 3. Check logs: `tail ~/.opencode-supermemory.log` #### Step 5: Initialize codebase memory (optional) @@ -119,6 +161,14 @@ Run `/supermemory-init` to have the agent explore and memorize the codebase. +### OpenCode 2 rollback + +To stop loading the beta adapter without affecting OpenCode V1, remove only +`"opencode-supermemory/v2"` from the plural `plugins` array and restart +OpenCode 2. The singular `plugin` entry continues to load the V1 adapter. The +recall permission may remain in the file; it has no effect when the V2 adapter +is not loaded. + ## Features ### Context Injection @@ -156,9 +206,12 @@ 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. -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. +On V1, recall uses the `supermemory` tool in `search` mode. On OpenCode 2, it +uses the search-only `supermemory_recall` helper, which is the only V2 action +the installer auto-allows. Add and forget operations remain behind the normal +`supermemory` permission. Customize the directive with `recallDirective`. Set +`SUPERMEMORY_DEBUG=1` to show a `[recall-decision]` line in each reply while +testing. ### Automatic Capture @@ -185,15 +238,13 @@ Add custom triggers via `keywordPatterns` config. Run `/supermemory-init` to explore and memorize your codebase structure, patterns, and conventions. -### Preemptive Compaction - -When context hits 80% capacity: - -1. Triggers OpenCode's summarization -2. Injects project memories into summary context -3. Saves session summary as a memory +### Native Compaction Lifecycle -This preserves conversation context across compaction events. +OpenCode decides when to compact, which model to use, and how execution +continues afterward. Supermemory enriches that native lifecycle by injecting +bounded project memory into compaction context and saving only successful +session summaries. It does not trigger compaction or override OpenCode's +configured compaction model. ### Privacy @@ -277,8 +328,8 @@ Create `~/.config/opencode/supermemory.jsonc`: // Extra keyword patterns for memory detection (regex) "keywordPatterns": ["log\\s+this", "write\\s+down"], - // Context usage ratio that triggers compaction (0-1) - "compactionThreshold": 0.8, + // Enrich OpenCode's native compaction lifecycle with Supermemory + "compactionEnabled": true, // Save completed conversation batches every N turns (0 = session end only) "captureEveryNTurns": 3, @@ -321,7 +372,7 @@ This is useful when you want to: ## Usage with Oh My OpenCode -If you're using [Oh My OpenCode](https://github.com/code-yeongyu/oh-my-opencode), disable its built-in auto-compact hook to let supermemory handle context compaction: +If you're using [Oh My OpenCode](https://github.com/code-yeongyu/oh-my-opencode), disable its built-in auto-compact hook so it does not compete with OpenCode's native compaction lifecycle: Add to `~/.config/opencode/oh-my-opencode.json`: @@ -339,14 +390,21 @@ bun run build bun run typecheck ``` -Local install: +Local install after building: ```jsonc { "plugin": ["file:///path/to/opencode-supermemory"], + "plugins": [ + "file:///path/to/opencode-supermemory/dist/v2/index.js", + ], } ``` +Launch `opencode` to test the V1 entry and `opencode2` to test the V2 entry. +The direct built-file URL is for local development only; the published package +uses the stable `opencode-supermemory/v2` export shown above. + ## Logs ```bash diff --git a/bun.lock b/bun.lock index 7e7dba5..a1c09a3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,30 +5,237 @@ "": { "name": "opencode-plugin", "devDependencies": { - "@opencode-ai/plugin": "^1.0.191", + "@opencode-ai/plugin": "0.0.0-beta-17728", "@types/bun": "latest", + "jsonc-parser": "3.3.1", "supermemory": "^4.0.0", "typescript": "^5.7.3", }, }, }, "packages": { - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.191", "", { "dependencies": { "@opencode-ai/sdk": "1.0.191", "zod": "4.1.8" } }, "sha512-+Z83g4uwRM+Qed5bV/HJ9KEA4FOPEOKZgTcyIvl0lVu++VYwPXydy1+YyW+/IRp17Ghz/xF7zWL+pBh2XlT9xQ=="], + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.191", "", {}, "sha512-UjbwaxdrP8XFbMcCCy4FfWbGrY1Kz/6wzdg34ASCVlXA/FxfWw7cFhM9oPKnwmR7HyGY7nq/y4Ywb0yW9TAwEA=="], + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@opencode-ai/ai": ["@opencode-ai/ai@0.0.0-beta-17728", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-beta-17728", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.0-rc.110", "google-auth-library": "10.5.0" } }, "sha512-kQMEkLIpft1QnJaDfS5y8qVDTTI3eMveriz91BSrbXR18yfehiV67gBNWjBsYdVRrxa6deEbZT2w+rZjKXKUCA=="], + + "@opencode-ai/client": ["@opencode-ai/client@0.0.0-beta-17728", "", { "dependencies": { "@opencode-ai/protocol": "0.0.0-beta-17728", "@opencode-ai/schema": "0.0.0-beta-17728" }, "peerDependencies": { "effect": "4.0.0-rc.110", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-VqoXZj064L2lpJ/On/xPJm14GWzf2ewzs4sQ2JD/jDPuzhhgWdfdGJV5LgHwNsAS7NTZQa3pt3UopVFCG8Sv0Q=="], + + "@opencode-ai/plugin": ["@opencode-ai/plugin@0.0.0-beta-17728", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/ai": "0.0.0-beta-17728", "@opencode-ai/client": "0.0.0-beta-17728", "@opencode-ai/protocol": "0.0.0-beta-17728", "@opencode-ai/schema": "0.0.0-beta-17728", "@opencode-ai/sdk": "1.18.5", "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.110", "zod": "4.1.8" }, "peerDependencies": { "@opencode-ai/theme": "0.0.0-beta-17728", "@opentui/core": ">=0.5.4", "@opentui/keymap": ">=0.5.4", "@opentui/solid": ">=0.5.4", "solid-js": ">=1.9.0" }, "optionalPeers": ["@opencode-ai/theme", "@opentui/core", "@opentui/keymap", "@opentui/solid", "solid-js"] }, "sha512-qSCDonK91UKbRvkwjRTVWvU+qim3XC5P4jnMo8nZmsTa5KaqnoV8HtjmQKvHTnwT1ygR9LRAXv7iyhvMrVKaow=="], + + "@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-beta-17728", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-beta-17728", "effect": "4.0.0-rc.110" } }, "sha512-GlnQDyFKM8JdCfFOzN0uhnxpRG4LfikBJU6vf8Ce7qGow2Mfojx8TWWJQAiI0is41Y7VSwQwKpQyLclnMxOygQ=="], + + "@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-17728", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.110" } }, "sha512-JlJuyf11RyGUIsOY3zgIeUGMUHm77CmdbhsmEAyqfQzDd7/4jZN71JKKp2zqAg+SdWsmf9s+L65P0lruSTE/Vg=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.5", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-7KgMvP5/1oxbhHj6kYBtPSTEdFKYpUeEYOzBTKdzSaRpapUpFFdn6Hkus3rr0rljO0kukWZIgRd3DrVBwTULGA=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@smithy/core": ["@smithy/core@3.33.2", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w=="], + + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@smithy/types": ["@smithy/types@4.17.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-nxu3SgmAw9JXT2CtkU0m/XNLWpP9MsaBx1zAGAypCbYj15tIFlmcYwpF+Oh18le83d+IM9PT7ENdXnE4C+d5mA=="], + + "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-iq+cW3mAb7vfcxEEpYi3zXKpDtbrIFyanWjQl4zBq4seWD4OSxXDWSfespZxenX6aEaighn+NR3u1nU1DSvs3w=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "effect": ["effect@4.0.0-rc.110", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.4" } }, "sha512-ega6FTJ8CS2of7tHZiADvgyJyV999Q6tZ9juE56V81O0jw6flRwydaPNtyfvP2a5LL9PZrse8A1jnNUD5sWVHg=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "gaxios": ["gaxios@7.3.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="], + + "gcp-metadata": ["gcp-metadata@8.1.4", "", { "dependencies": { "gaxios": "7.1.3", "google-logging-utils": "1.1.3", "json-bigint": "^1.0.0" } }, "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw=="], + + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], + + "google-logging-utils": ["google-logging-utils@1.2.0", "", {}, "sha512-WE9av4wKDZgRjBwgVUabocx8T6/7o3Ca1Fat46FXDhXVAFibzNadedcOXrdgd1Kzmk8tsk/9ZH89Wyf/SqeZ3A=="], + + "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + + "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "supermemory": ["supermemory@4.0.0", "", {}, "sha512-xMN05PQ8kTv8DuXa2qf8h/9LaRI7v1Kz3Tutt97JPq+PzhGabKLv5YVbSgqHiPX5yXcSUBVBNYPPbhAQMF6GYQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], + + "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/package.json b/package.json index 0194774..acd20e7 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,25 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./v2": { + "types": "./dist/v2/index.d.ts", + "import": "./dist/v2/index.js", + "default": "./dist/v2/index.js" + }, + "./package.json": "./package.json" + }, "bin": { "opencode-supermemory": "./dist/cli.js" }, "scripts": { "generate:version": "node scripts/sync-version.mjs", - "build": "node scripts/sync-version.mjs && bun build ./src/index.ts --outdir ./dist --target node && bun build ./src/cli.ts --outfile ./dist/cli.js --target node && tsc --emitDeclarationOnly", + "build": "node scripts/sync-version.mjs && bun build ./src/index.ts --outdir ./dist --target node && bun build ./src/v2/index.ts --outfile ./dist/v2/index.js --target node && bun build ./src/cli.ts --outfile ./dist/cli.js --target node && tsc --emitDeclarationOnly", "dev": "tsc --watch", "typecheck": "node scripts/sync-version.mjs && tsc --noEmit", "test": "node scripts/sync-version.mjs && bun test" @@ -30,8 +43,9 @@ "url": "https://github.com/supermemoryai/opencode-supermemory" }, "devDependencies": { - "@opencode-ai/plugin": "^1.0.191", + "@opencode-ai/plugin": "0.0.0-beta-17728", "@types/bun": "latest", + "jsonc-parser": "3.3.1", "supermemory": "^4.0.0", "typescript": "^5.7.3" }, diff --git a/src/cli.ts b/src/cli.ts index 1c50fe9..bd316e7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,15 +3,14 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import * as readline from "node:readline"; -import { stripJsoncComments } from "./services/jsonc.js"; import { startAuthFlow, clearCredentials, loadCredentials, CREDENTIALS_FILE } from "./services/auth.js"; import { CONFIG, CONFIG_FILE, SUPERMEMORY_API_KEY, getApiBaseUrl, isConfigured, writeInstallDefaults } from "./config.js"; import { SupermemoryClient } from "./services/client.js"; import { getTags } from "./services/tags.js"; +import { editOpenCodeConfig } from "./services/opencode-config.js"; const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode"); const OPENCODE_COMMAND_DIR = join(OPENCODE_CONFIG_DIR, "command"); -const PLUGIN_NAME = "opencode-supermemory@latest"; const DEFAULT_CONFIG_FILE = CONFIG_FILE ?? join(OPENCODE_CONFIG_DIR, "supermemory.json"); const SUPERMEMORY_INIT_COMMAND = `--- @@ -255,51 +254,18 @@ function findOpencodeConfig(): string | null { function addPluginToConfig(configPath: string): boolean { try { const content = readFileSync(configPath, "utf-8"); - - if (content.includes("opencode-supermemory")) { - console.log("āœ“ Plugin already registered in config"); - return true; - } - - const jsonContent = stripJsoncComments(content); - let config: Record; - - try { - config = JSON.parse(jsonContent); - } catch { - console.error("āœ— Failed to parse config file"); - return false; - } + const result = editOpenCodeConfig(content); - const plugins = (config.plugin as string[]) || []; - plugins.push(PLUGIN_NAME); - config.plugin = plugins; - - if (configPath.endsWith(".jsonc")) { - if (content.includes('"plugin"')) { - const newContent = content.replace( - /("plugin"\s*:\s*\[)([^\]]*?)(\])/, - (_match, start, middle, end) => { - const trimmed = middle.trim(); - if (trimmed === "") { - return `${start}\n "${PLUGIN_NAME}"\n ${end}`; - } - return `${start}${middle.trimEnd()},\n "${PLUGIN_NAME}"\n ${end}`; - } - ); - writeFileSync(configPath, newContent); - } else { - const newContent = content.replace( - /^(\s*\{)/, - `$1\n "plugin": ["${PLUGIN_NAME}"],` - ); - writeFileSync(configPath, newContent); - } + if (result.changed) { + writeFileSync(configPath, result.content); + console.log(`āœ“ Added OpenCode V1 and V2 plugin entries to ${configPath}`); } else { - writeFileSync(configPath, JSON.stringify(config, null, 2)); + console.log("āœ“ OpenCode V1 and V2 plugin entries already registered"); } - console.log(`āœ“ Added plugin to ${configPath}`); + for (const warning of result.warnings) { + console.warn(`⚠ ${warning}`); + } return true; } catch (err) { console.error("āœ— Failed to update config:", err); @@ -310,13 +276,9 @@ function addPluginToConfig(configPath: string): boolean { function createNewConfig(): boolean { const configPath = join(OPENCODE_CONFIG_DIR, "opencode.jsonc"); mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true }); - - const config = `{ - "plugin": ["${PLUGIN_NAME}"] -} -`; - - writeFileSync(configPath, config); + + const config = editOpenCodeConfig("{}\n"); + writeFileSync(configPath, config.content); console.log(`āœ“ Created ${configPath}`); return true; } diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..6480aba --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; + +import { + resolveCompactionEnabled, + validateCompactionThreshold, +} from "./config.js"; + +describe("compaction configuration", () => { + test("treats zero and false as legacy disable values", () => { + expect(validateCompactionThreshold(0)).toBe(0); + expect(validateCompactionThreshold(false)).toBe(0); + expect(resolveCompactionEnabled(undefined, 0)).toBe(false); + expect(resolveCompactionEnabled(undefined, false)).toBe(false); + }); + + test("prefers the explicit compactionEnabled setting", () => { + expect(resolveCompactionEnabled(false, 0.8)).toBe(false); + expect(resolveCompactionEnabled(true, 0)).toBe(true); + }); + + test("falls back safely for invalid legacy thresholds", () => { + expect(validateCompactionThreshold(-1)).toBe(0.8); + expect(validateCompactionThreshold(2)).toBe(0.8); + expect(validateCompactionThreshold(Number.NaN)).toBe(0.8); + expect(resolveCompactionEnabled(undefined, undefined)).toBe(true); + }); +}); diff --git a/src/index.ts b/src/index.ts index f10137a..c907be0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,20 +1,21 @@ -import type { Plugin, PluginInput } from "@opencode-ai/plugin"; +import type { Plugin, PluginInput } from "@opencode-ai/plugin/v1"; import type { Part, Permission } from "@opencode-ai/sdk"; -import { tool } from "@opencode-ai/plugin"; +import { tool } from "@opencode-ai/plugin/v1"; -import { AGENT_ENTITY_CONTEXT } from "./services/entity-context.js"; import { supermemoryClient } from "./services/client.js"; import { formatContextForPrompt } from "./services/context.js"; import { createCaptureHook } from "./services/capture.js"; import { buildRecallDirective } from "./services/recall.js"; import { getTags } from "./services/tags.js"; -import { stripPrivateContent, isFullyPrivate } from "./services/privacy.js"; import { createCompactionHook, type CompactionContext } from "./services/compaction.js"; +import { + executeSupermemoryTool, + type SupermemoryToolArgs, +} from "./services/memory-tool.js"; import { isConfigured, CONFIG, PLUGIN_VERSION } from "./config.js"; import { log } from "./services/logger.js"; import { checkNpmUpdate, formatUpdateNotice } from "./services/version-check.js"; -import type { MemoryScope, MemoryType } from "./types/index.js"; const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g; const INLINE_CODE_PATTERN = /`[^`]+`/g; @@ -255,288 +256,8 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { memoryId: tool.schema.string().optional(), limit: tool.schema.number().optional(), }, - async execute(args: { - mode?: string; - content?: string; - query?: string; - type?: MemoryType; - scope?: MemoryScope; - memoryId?: string; - limit?: number; - }) { - if (!isConfigured()) { - return JSON.stringify({ - success: false, - error: - "SUPERMEMORY_API_KEY not set. Set it in your environment to use Supermemory.", - }); - } - - const mode = args.mode || "help"; - - try { - switch (mode) { - case "help": { - return JSON.stringify({ - success: true, - message: "Supermemory Usage Guide", - commands: [ - { - command: "add", - description: "Store a new memory", - args: ["content", "type?", "scope?"], - }, - { - command: "search", - description: "Search memories", - args: ["query", "scope?"], - }, - { - command: "profile", - description: "View user profile", - args: ["query?"], - }, - { - command: "list", - description: "List recent memories", - args: ["scope?", "limit?"], - }, - { - command: "forget", - description: "Remove a memory", - args: ["memoryId", "scope?"], - }, - ], - scopes: { - user: "Personal preferences and knowledge for this project", - project: "Project-specific knowledge (default)", - }, - types: [ - "project-config", - "architecture", - "error-solution", - "preference", - "learned-pattern", - "conversation", - ], - }); - } - - case "add": { - if (!args.content) { - return JSON.stringify({ - success: false, - error: "content parameter is required for add mode", - }); - } - - const sanitizedContent = stripPrivateContent(args.content); - if (isFullyPrivate(args.content)) { - return JSON.stringify({ - success: false, - error: "Cannot store fully private content", - }); - } - - const scope = args.scope || "project"; - const internalScope = - scope === "user" ? "personal" : "project"; - - const result = await supermemoryClient.addMemory( - sanitizedContent, - tags.canonical, - { - type: args.type, - project: tags.projectName, - sm_project_id: tags.projectId, - sm_scope: internalScope, - sm_capture_mode: "tool", - }, - { entityContext: AGENT_ENTITY_CONTEXT } - ); - - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to add memory", - }); - } - - return JSON.stringify({ - success: true, - message: `Memory added to ${scope} scope`, - id: result.id, - scope, - type: args.type, - }); - } - - case "search": { - if (!args.query) { - return JSON.stringify({ - success: false, - error: "query parameter is required for search mode", - }); - } - - const scope = args.scope; - - if (scope === "user") { - const result = await supermemoryClient.searchMemoriesScoped( - args.query, - tags.canonical, - tags.personalReads, - "personal", - ); - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to search memories", - }); - } - return formatSearchResults(args.query, scope, result, args.limit); - } - - if (scope === "project") { - const result = await supermemoryClient.searchMemoriesScoped( - args.query, - tags.canonical, - tags.projectReads, - "project", - ); - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to search memories", - }); - } - return formatSearchResults(args.query, scope, result, args.limit); - } - - const result = await supermemoryClient.searchMemoriesMany( - args.query, - tags.allReads, - ); - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to search memories", - }); - } - return formatSearchResults( - args.query, - undefined, - result, - args.limit, - ); - } - - case "profile": { - const result = await supermemoryClient.getProfileScoped( - tags.canonical, - tags.personalReads, - "personal", - args.query, - ); - - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to fetch profile", - }); - } - - return JSON.stringify({ - success: true, - profile: { - static: result.profile?.static || [], - dynamic: result.profile?.dynamic || [], - }, - }); - } - - case "list": { - const scope = args.scope || "project"; - const limit = args.limit || 20; - const internalScope = - scope === "user" ? "personal" : "project"; - const readTags = - scope === "user" ? tags.personalReads : tags.projectReads; - - const result = await supermemoryClient.listMemoriesScoped( - tags.canonical, - readTags, - internalScope, - limit, - ); - - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to list memories", - }); - } - - const memories = result.memories || []; - return JSON.stringify({ - success: true, - scope, - count: memories.length, - memories: memories.map((m) => ({ - id: m.id, - content: m.summary, - createdAt: m.createdAt, - metadata: m.metadata, - })), - }); - } - - case "forget": { - if (!args.memoryId) { - return JSON.stringify({ - success: false, - error: "memoryId parameter is required for forget mode", - }); - } - - const scope = args.scope || "project"; - const readTags = - scope === "user" - ? tags.personalReads - : scope === "project" - ? tags.projectReads - : tags.allReads; - - const result = await supermemoryClient.deleteMemory( - args.memoryId, - [tags.canonical, ...readTags], - ); - - if (!result.success) { - return JSON.stringify({ - success: false, - error: result.error || "Failed to delete memory", - }); - } - - return JSON.stringify({ - success: true, - message: `Memory ${args.memoryId} removed from ${scope} scope`, - }); - } - - default: - return JSON.stringify({ - success: false, - error: `Unknown mode: ${mode}`, - }); - } - } catch (error) { - return JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : String(error), - }); - } + async execute(args: SupermemoryToolArgs) { + return executeSupermemoryTool(args, tags); }, }), }, @@ -563,28 +284,3 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { }, }; }; - -function formatSearchResults( - query: string, - scope: string | undefined, - results: { results?: Array<{ id?: string; memory?: string; chunk?: string; similarity?: number }> }, - limit?: number -): string { - const memoryResults = results.results || []; - return JSON.stringify({ - success: true, - query, - scope, - count: memoryResults.length, - results: memoryResults.slice(0, limit || 10).map((r) => { - const result = { - content: r.memory ?? r.chunk, - similarity: Math.round((r.similarity ?? 0) * 100), - }; - - return r.memory === undefined - ? { ...result, forgettable: false } - : { id: r.id, ...result, forgettable: true }; - }), - }); -} diff --git a/src/services/compaction.test.ts b/src/services/compaction.test.ts new file mode 100644 index 0000000..0857d68 --- /dev/null +++ b/src/services/compaction.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test"; + +import { createCompactionHook, fitProjectMemories } from "./compaction.js"; +import type { ResolvedTags } from "./tags.js"; + +const tags: ResolvedTags = { + canonical: "repo_test__0123456789abcdef", + user: "repo_test__0123456789abcdef", + project: "repo_test__0123456789abcdef", + projectId: "0123456789abcdef", + projectName: "test", + personalReads: [], + projectReads: ["legacy-project"], + allReads: ["legacy-project"], +}; + +function summaryMessage(id: string, text: string) { + return { + info: { + id, + role: "assistant", + sessionID: "session-1", + summary: true, + finish: "stop", + }, + parts: [{ type: "text", text }], + }; +} + +function memoryClient(memories: Array<{ summary?: string }> = []) { + return { + listMemoriesScoped: async () => ({ memories }), + addMemory: async () => ({ success: true as const, id: "memory-1" }), + }; +} + +describe("native V1 compaction integration", () => { + test("bounds and deduplicates project-memory context", () => { + const memories = fitProjectMemories([ + "same memory", + "same memory", + "x".repeat(20_000), + "y".repeat(20_000), + ]); + + expect(memories.filter((memory) => memory === "same memory")).toHaveLength(1); + expect(memories.every((memory) => memory.length <= 2_000)).toBe(true); + expect(memories.reduce((total, memory) => total + memory.length, 0)).toBeLessThanOrEqual(12_000); + }); + + test("adds context once without replacing the native prompt", async () => { + const output = { context: ["existing"], prompt: "native prompt" }; + const hook = createCompactionHook( + { directory: "/repo", client: { session: { messages: async () => [] } } }, + tags, + { memoryClient: memoryClient([{ summary: "Uses Bun" }]) }, + ); + + await hook.compacting({ sessionID: "session-1" }, output); + await hook.compacting({ sessionID: "session-1" }, output); + + expect(output.prompt).toBe("native prompt"); + expect(output.context).toHaveLength(2); + expect(output.context[1]).toContain("[SUPERMEMORY COMPACTION CONTEXT]"); + expect(output.context[1]).toContain("Uses Bun"); + }); + + test("captures the expected summary and retries transient writes", async () => { + const summary = summaryMessage("summary-new", "summary ".repeat(20)); + let attempts = 0; + const hook = createCompactionHook( + { + directory: "/repo", + client: { session: { messages: async () => ({ data: [summary] }) } }, + }, + tags, + { + memoryClient: { + ...memoryClient(), + addMemory: async () => { + attempts += 1; + return attempts === 1 + ? { success: false as const, error: "temporary" } + : { success: true as const, id: "memory-1" }; + }, + }, + }, + ); + + await hook.compacting({ sessionID: "session-1" }, { context: [] }); + await hook.event({ + event: { type: "message.updated", properties: { info: summary.info } }, + }); + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-1" } }, + }); + + expect(attempts).toBe(2); + }); + + test("does not save failed compaction output", async () => { + const failed = { + ...summaryMessage("summary-failed", "partial ".repeat(20)), + info: { + ...summaryMessage("summary-failed", "partial").info, + finish: "error", + error: { name: "ContextOverflowError" }, + }, + }; + let writes = 0; + const hook = createCompactionHook( + { + directory: "/repo", + client: { session: { messages: async () => ({ data: [failed] }) } }, + }, + tags, + { + memoryClient: { + ...memoryClient(), + addMemory: async () => { + writes += 1; + return { success: true as const, id: "memory-1" }; + }, + }, + }, + ); + + await hook.compacting({ sessionID: "session-1" }, { context: [] }); + await hook.event({ + event: { type: "message.updated", properties: { info: failed.info } }, + }); + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-1" } }, + }); + + expect(writes).toBe(0); + }); +}); diff --git a/src/services/logger.ts b/src/services/logger.ts index 5d1c44c..56483c9 100644 --- a/src/services/logger.ts +++ b/src/services/logger.ts @@ -4,12 +4,28 @@ import { join } from "path"; const LOG_FILE = join(homedir(), ".opencode-supermemory.log"); -writeFileSync(LOG_FILE, `\n--- Session started: ${new Date().toISOString()} ---\n`, { flag: "a" }); +function writeLogLine(line: string): void { + try { + appendFileSync(LOG_FILE, line); + } catch { + // Logging must never prevent either OpenCode plugin generation from loading. + } +} + +try { + writeFileSync( + LOG_FILE, + `\n--- Session started: ${new Date().toISOString()} ---\n`, + { flag: "a" }, + ); +} catch { + // A read-only home directory should disable file logging, not the plugin. +} export function log(message: string, data?: unknown) { const timestamp = new Date().toISOString(); const line = data ? `[${timestamp}] ${message}: ${JSON.stringify(data)}\n` : `[${timestamp}] ${message}\n`; - appendFileSync(LOG_FILE, line); + writeLogLine(line); } diff --git a/src/services/memory-tool.test.ts b/src/services/memory-tool.test.ts new file mode 100644 index 0000000..8ea92b2 --- /dev/null +++ b/src/services/memory-tool.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, test } from "bun:test"; + +import { AGENT_ENTITY_CONTEXT } from "./entity-context.js"; +import { + executeSupermemoryTool, + formatSearchResults, + type MemoryToolClient, + type SupermemoryToolArgs, +} from "./memory-tool.js"; +import type { ResolvedTags } from "./tags.js"; + +const tags: ResolvedTags = { + canonical: "repo_test__0123456789abcdef", + user: "repo_test__0123456789abcdef", + project: "repo_test__0123456789abcdef", + projectId: "0123456789abcdef", + projectName: "test-project", + personalReads: ["personal-legacy"], + projectReads: ["project-legacy"], + allReads: ["personal-legacy", "project-legacy"], +}; + +const successfulClient: MemoryToolClient = { + addMemory: async () => ({ + success: true as const, + id: "memory-1", + status: "queued", + }), + searchMemoriesScoped: async () => ({ success: true, results: [] }), + searchMemoriesMany: async () => ({ success: true, results: [] }), + getProfileScoped: async () => ({ + success: true, + profile: { static: [], dynamic: [] }, + }), + listMemoriesScoped: async () => ({ + success: true, + memories: [], + pagination: { currentPage: 1, totalItems: 0, totalPages: 0 }, + }), + deleteMemory: async () => ({ success: true as const }), +}; + +function createClient( + overrides: Partial = {}, +): MemoryToolClient { + return { ...successfulClient, ...overrides }; +} + +function execute( + args: SupermemoryToolArgs, + memoryClient: MemoryToolClient = successfulClient, + configured = true, +): Promise { + return executeSupermemoryTool(args, tags, { memoryClient, configured }); +} + +describe("shared supermemory tool", () => { + test("preserves the configuration gate and default help response", async () => { + expect(await execute({}, successfulClient, false)).toBe( + JSON.stringify({ + success: false, + error: + "SUPERMEMORY_API_KEY not set. Set it in your environment to use Supermemory.", + }), + ); + + expect(await execute({})).toBe( + JSON.stringify({ + success: true, + message: "Supermemory Usage Guide", + commands: [ + { + command: "add", + description: "Store a new memory", + args: ["content", "type?", "scope?"], + }, + { + command: "search", + description: "Search memories", + args: ["query", "scope?"], + }, + { + command: "profile", + description: "View user profile", + args: ["query?"], + }, + { + command: "list", + description: "List recent memories", + args: ["scope?", "limit?"], + }, + { + command: "forget", + description: "Remove a memory", + args: ["memoryId", "scope?"], + }, + ], + scopes: { + user: "Personal preferences and knowledge for this project", + project: "Project-specific knowledge (default)", + }, + types: [ + "project-config", + "architecture", + "error-solution", + "preference", + "learned-pattern", + "conversation", + ], + }), + ); + }); + + test("validates and sanitizes add requests without changing metadata", async () => { + const calls: Array> = []; + const memoryClient = createClient({ + addMemory: async (...args) => { + calls.push(args); + return { success: true as const, id: "added-1", status: "queued" }; + }, + }); + + expect(await execute({ mode: "add" }, memoryClient)).toBe( + JSON.stringify({ + success: false, + error: "content parameter is required for add mode", + }), + ); + expect( + await execute( + { mode: "add", content: "secret" }, + memoryClient, + ), + ).toBe( + JSON.stringify({ + success: false, + error: "Cannot store fully private content", + }), + ); + + expect( + await execute( + { + mode: "add", + content: "Use secret pnpm", + type: "project-config", + }, + memoryClient, + ), + ).toBe( + JSON.stringify({ + success: true, + message: "Memory added to project scope", + id: "added-1", + scope: "project", + type: "project-config", + }), + ); + + expect(calls).toEqual([ + [ + "Use [REDACTED] pnpm", + tags.canonical, + { + type: "project-config", + project: tags.projectName, + sm_project_id: tags.projectId, + sm_scope: "project", + sm_capture_mode: "tool", + }, + { entityContext: AGENT_ENTITY_CONTEXT }, + ], + ]); + }); + + test("routes searches by scope and preserves result formatting", async () => { + const scopedCalls: unknown[][] = []; + const manyCalls: unknown[][] = []; + const memoryClient = createClient({ + searchMemoriesScoped: async (...args) => { + scopedCalls.push(args); + return { + success: true, + results: [ + { id: "memory-1", memory: "remembered", similarity: 0.876 }, + { id: "chunk-1", chunk: "chunk only", similarity: 0.123 }, + ], + }; + }, + searchMemoriesMany: async (...args) => { + manyCalls.push(args); + return { success: true, results: [] }; + }, + }); + + expect( + JSON.parse( + await execute( + { mode: "search", query: "query", scope: "user", limit: 1 }, + memoryClient, + ), + ), + ).toEqual({ + success: true, + query: "query", + scope: "user", + count: 2, + results: [ + { + id: "memory-1", + content: "remembered", + similarity: 88, + forgettable: true, + }, + ], + }); + await execute( + { mode: "search", query: "project", scope: "project" }, + memoryClient, + ); + await execute({ mode: "search", query: "all" }, memoryClient); + + expect(scopedCalls).toEqual([ + ["query", tags.canonical, tags.personalReads, "personal"], + ["project", tags.canonical, tags.projectReads, "project"], + ]); + expect(manyCalls).toEqual([["all", tags.allReads]]); + expect(await execute({ mode: "search" }, memoryClient)).toBe( + JSON.stringify({ + success: false, + error: "query parameter is required for search mode", + }), + ); + }); + + test("marks chunk-only search results as non-forgettable", () => { + expect( + JSON.parse( + formatSearchResults("query", undefined, { + results: [{ id: "chunk-1", chunk: "chunk only", similarity: 0.5 }], + }), + ), + ).toEqual({ + success: true, + query: "query", + count: 1, + results: [ + { + content: "chunk only", + similarity: 50, + forgettable: false, + }, + ], + }); + }); + + test("preserves profile, list, and forget defaults and payloads", async () => { + const profileCalls: unknown[][] = []; + const listCalls: unknown[][] = []; + const deleteCalls: unknown[][] = []; + const memoryClient = createClient({ + getProfileScoped: async (...args) => { + profileCalls.push(args); + return { + success: true, + profile: { static: ["static"], dynamic: ["dynamic"] }, + }; + }, + listMemoriesScoped: async (...args) => { + listCalls.push(args); + return { + success: true, + memories: [ + { + id: "memory-1", + summary: "summary", + content: "raw content", + createdAt: "2026-08-20T00:00:00.000Z", + metadata: { type: "project-config" }, + }, + ], + pagination: { currentPage: 1, totalItems: 1, totalPages: 1 }, + }; + }, + deleteMemory: async (...args) => { + deleteCalls.push(args); + return { success: true as const }; + }, + }); + + expect( + JSON.parse( + await execute( + { mode: "profile", query: "profile query" }, + memoryClient, + ), + ), + ).toEqual({ + success: true, + profile: { static: ["static"], dynamic: ["dynamic"] }, + }); + expect(JSON.parse(await execute({ mode: "list" }, memoryClient))).toEqual({ + success: true, + scope: "project", + count: 1, + memories: [ + { + id: "memory-1", + content: "summary", + createdAt: "2026-08-20T00:00:00.000Z", + metadata: { type: "project-config" }, + }, + ], + }); + expect( + await execute( + { mode: "forget", memoryId: "memory-1", scope: "user" }, + memoryClient, + ), + ).toBe( + JSON.stringify({ + success: true, + message: "Memory memory-1 removed from user scope", + }), + ); + + expect(profileCalls).toEqual([ + [tags.canonical, tags.personalReads, "personal", "profile query"], + ]); + expect(listCalls).toEqual([ + [tags.canonical, tags.projectReads, "project", 20], + ]); + expect(deleteCalls).toEqual([ + ["memory-1", [tags.canonical, ...tags.personalReads]], + ]); + expect(await execute({ mode: "forget" }, memoryClient)).toBe( + JSON.stringify({ + success: false, + error: "memoryId parameter is required for forget mode", + }), + ); + }); + + test("preserves client failure fallbacks and thrown errors", async () => { + expect( + await execute( + { mode: "search", query: "query" }, + createClient({ + searchMemoriesMany: async () => ({ + success: false, + results: [], + }), + }), + ), + ).toBe( + JSON.stringify({ + success: false, + error: "Failed to search memories", + }), + ); + + expect( + await execute( + { mode: "list" }, + createClient({ + listMemoriesScoped: async () => { + throw new Error("network failed"); + }, + }), + ), + ).toBe(JSON.stringify({ success: false, error: "network failed" })); + + expect(await execute({ mode: "unsupported" })).toBe( + JSON.stringify({ success: false, error: "Unknown mode: unsupported" }), + ); + }); +}); diff --git a/src/services/memory-tool.ts b/src/services/memory-tool.ts new file mode 100644 index 0000000..05fa398 --- /dev/null +++ b/src/services/memory-tool.ts @@ -0,0 +1,343 @@ +import { isConfigured } from "../config.js"; +import type { MemoryScope, MemoryType } from "../types/index.js"; +import { supermemoryClient, type SupermemoryClient } from "./client.js"; +import { AGENT_ENTITY_CONTEXT } from "./entity-context.js"; +import { isFullyPrivate, stripPrivateContent } from "./privacy.js"; +import type { ResolvedTags } from "./tags.js"; + +export interface SupermemoryToolArgs { + mode?: string; + content?: string; + query?: string; + type?: MemoryType; + scope?: MemoryScope; + memoryId?: string; + limit?: number; +} + +export type MemoryToolClient = Pick< + SupermemoryClient, + | "addMemory" + | "searchMemoriesScoped" + | "searchMemoriesMany" + | "getProfileScoped" + | "listMemoriesScoped" + | "deleteMemory" +>; + +export interface MemoryToolOptions { + memoryClient?: MemoryToolClient; + configured?: boolean; +} + +export async function executeSupermemoryTool( + args: SupermemoryToolArgs, + tags: ResolvedTags, + options: MemoryToolOptions = {}, +): Promise { + const memoryClient = options.memoryClient ?? supermemoryClient; + const configured = options.configured ?? isConfigured(); + + if (!configured) { + return JSON.stringify({ + success: false, + error: + "SUPERMEMORY_API_KEY not set. Set it in your environment to use Supermemory.", + }); + } + + const mode = args.mode || "help"; + + try { + switch (mode) { + case "help": { + return JSON.stringify({ + success: true, + message: "Supermemory Usage Guide", + commands: [ + { + command: "add", + description: "Store a new memory", + args: ["content", "type?", "scope?"], + }, + { + command: "search", + description: "Search memories", + args: ["query", "scope?"], + }, + { + command: "profile", + description: "View user profile", + args: ["query?"], + }, + { + command: "list", + description: "List recent memories", + args: ["scope?", "limit?"], + }, + { + command: "forget", + description: "Remove a memory", + args: ["memoryId", "scope?"], + }, + ], + scopes: { + user: "Personal preferences and knowledge for this project", + project: "Project-specific knowledge (default)", + }, + types: [ + "project-config", + "architecture", + "error-solution", + "preference", + "learned-pattern", + "conversation", + ], + }); + } + + case "add": { + if (!args.content) { + return JSON.stringify({ + success: false, + error: "content parameter is required for add mode", + }); + } + + const sanitizedContent = stripPrivateContent(args.content); + if (isFullyPrivate(args.content)) { + return JSON.stringify({ + success: false, + error: "Cannot store fully private content", + }); + } + + const scope = args.scope || "project"; + const internalScope = scope === "user" ? "personal" : "project"; + + const result = await memoryClient.addMemory( + sanitizedContent, + tags.canonical, + { + type: args.type, + project: tags.projectName, + sm_project_id: tags.projectId, + sm_scope: internalScope, + sm_capture_mode: "tool", + }, + { entityContext: AGENT_ENTITY_CONTEXT }, + ); + + if (!result.success) { + return JSON.stringify({ + success: false, + error: result.error || "Failed to add memory", + }); + } + + return JSON.stringify({ + success: true, + message: `Memory added to ${scope} scope`, + id: result.id, + scope, + type: args.type, + }); + } + + case "search": { + if (!args.query) { + return JSON.stringify({ + success: false, + error: "query parameter is required for search mode", + }); + } + + const scope = args.scope; + + if (scope === "user") { + const result = await memoryClient.searchMemoriesScoped( + args.query, + tags.canonical, + tags.personalReads, + "personal", + ); + if (!result.success) { + return JSON.stringify({ + success: false, + error: result.error || "Failed to search memories", + }); + } + return formatSearchResults(args.query, scope, result, args.limit); + } + + if (scope === "project") { + const result = await memoryClient.searchMemoriesScoped( + args.query, + tags.canonical, + tags.projectReads, + "project", + ); + if (!result.success) { + return JSON.stringify({ + success: false, + error: result.error || "Failed to search memories", + }); + } + return formatSearchResults(args.query, scope, result, args.limit); + } + + const result = await memoryClient.searchMemoriesMany( + args.query, + tags.allReads, + ); + if (!result.success) { + return JSON.stringify({ + success: false, + error: result.error || "Failed to search memories", + }); + } + return formatSearchResults(args.query, undefined, result, args.limit); + } + + case "profile": { + const result = await memoryClient.getProfileScoped( + tags.canonical, + tags.personalReads, + "personal", + args.query, + ); + + if (!result.success) { + return JSON.stringify({ + success: false, + error: result.error || "Failed to fetch profile", + }); + } + + return JSON.stringify({ + success: true, + profile: { + static: result.profile?.static || [], + dynamic: result.profile?.dynamic || [], + }, + }); + } + + case "list": { + const scope = args.scope || "project"; + const limit = args.limit || 20; + const internalScope = scope === "user" ? "personal" : "project"; + const readTags = + scope === "user" ? tags.personalReads : tags.projectReads; + + const result = await memoryClient.listMemoriesScoped( + tags.canonical, + readTags, + internalScope, + limit, + ); + + if (!result.success) { + return JSON.stringify({ + success: false, + error: result.error || "Failed to list memories", + }); + } + + const memories = result.memories || []; + return JSON.stringify({ + success: true, + scope, + count: memories.length, + memories: memories.map((memory) => ({ + id: memory.id, + content: memory.summary, + createdAt: memory.createdAt, + metadata: memory.metadata, + })), + }); + } + + case "forget": { + if (!args.memoryId) { + return JSON.stringify({ + success: false, + error: "memoryId parameter is required for forget mode", + }); + } + + const scope = args.scope || "project"; + const readTags = + scope === "user" + ? tags.personalReads + : scope === "project" + ? tags.projectReads + : tags.allReads; + + const result = await memoryClient.deleteMemory(args.memoryId, [ + tags.canonical, + ...readTags, + ]); + + if (!result.success) { + return JSON.stringify({ + success: false, + error: result.error || "Failed to delete memory", + }); + } + + return JSON.stringify({ + success: true, + message: `Memory ${args.memoryId} removed from ${scope} scope`, + }); + } + + default: + return JSON.stringify({ + success: false, + error: `Unknown mode: ${mode}`, + }); + } + } catch (error) { + return JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +export function formatSearchResults( + query: string, + scope: string | undefined, + results: { + results?: Array<{ + id?: string; + memory?: string; + chunk?: string; + similarity?: number; + }>; + }, + limit?: number, +): string { + const memoryResults = results.results || []; + return JSON.stringify({ + success: true, + query, + scope, + count: memoryResults.length, + results: memoryResults.slice(0, limit || 10).map((result) => { + const formattedResult = { + content: result.memory ?? result.chunk, + similarity: Math.round((result.similarity ?? 0) * 100), + }; + + return result.memory === undefined + ? { ...formattedResult, forgettable: false } + : { + id: result.id, + ...formattedResult, + forgettable: true, + }; + }), + }); +} diff --git a/src/services/opencode-config.test.ts b/src/services/opencode-config.test.ts new file mode 100644 index 0000000..41f72ca --- /dev/null +++ b/src/services/opencode-config.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from "bun:test"; +import { parse } from "jsonc-parser"; +import { + RECALL_PERMISSION, + V1_PLUGIN_ENTRY, + V2_PLUGIN_ENTRY, + editOpenCodeConfig, +} from "./opencode-config.js"; + +function parseJsonc(content: string): Record { + return parse(content, undefined, { allowTrailingComma: true }) as Record< + string, + unknown + >; +} + +describe("OpenCode V1/V2 config installation", () => { + test("creates both plugin entries and the narrow recall permission", () => { + const result = editOpenCodeConfig("{}\n"); + const config = parseJsonc(result.content); + + expect(config.plugin).toEqual([V1_PLUGIN_ENTRY]); + expect(config.plugins).toEqual([V2_PLUGIN_ENTRY]); + expect(config.permissions).toEqual([RECALL_PERMISSION]); + expect(result.changed).toBe(true); + expect(result.warnings).toEqual([]); + }); + + test("preserves unrelated JSON values while extending existing arrays", () => { + const input = JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + theme: "system", + plugin: ["other-v1-plugin"], + plugins: ["other-v2-plugin"], + permissions: [ + { action: "shell", resource: "*", effect: "ask" }, + ], + }, + null, + 2, + ); + + const result = editOpenCodeConfig(input); + const config = JSON.parse(result.content) as Record; + + expect(config.$schema).toBe("https://opencode.ai/config.json"); + expect(config.theme).toBe("system"); + expect(config.plugin).toEqual(["other-v1-plugin", V1_PLUGIN_ENTRY]); + expect(config.plugins).toEqual(["other-v2-plugin", V2_PLUGIN_ENTRY]); + expect(config.permissions).toEqual([ + { action: "shell", resource: "*", effect: "ask" }, + RECALL_PERMISSION, + ]); + }); + + test("preserves JSONC comments and trailing commas", () => { + const input = `{ + // Keep the user's selected theme. + "theme": "catppuccin", // inline comment + "plugin": [ + "other-v1-plugin", // keep this plugin + ], + "permissions": [ + // Keep the shell policy. + { "action": "shell", "resource": "*", "effect": "ask" }, + ], +} +`; + + const result = editOpenCodeConfig(input); + const config = parseJsonc(result.content); + + expect(result.content).toContain("// Keep the user's selected theme."); + expect(result.content).toContain("// inline comment"); + expect(result.content).toContain("// keep this plugin"); + expect(result.content).toContain("// Keep the shell policy."); + expect(config.theme).toBe("catppuccin"); + expect(config.plugin).toEqual(["other-v1-plugin", V1_PLUGIN_ENTRY]); + expect(config.plugins).toEqual([V2_PLUGIN_ENTRY]); + expect(config.permissions).toEqual([ + { action: "shell", resource: "*", effect: "ask" }, + RECALL_PERMISSION, + ]); + }); + + test("keeps an existing V1 version and fills only missing V2 fields", () => { + const input = `{ + "plugin": ["opencode-supermemory@2.0.12"], + "plugins": ["other-v2-plugin"] +} +`; + + const result = editOpenCodeConfig(input); + const config = parseJsonc(result.content); + + expect(config.plugin).toEqual(["opencode-supermemory@2.0.12"]); + expect(config.plugins).toEqual(["other-v2-plugin", V2_PLUGIN_ENTRY]); + expect(config.permissions).toEqual([RECALL_PERMISSION]); + }); + + test("adds the V1 entry when only the V2 entry is already present", () => { + const input = JSON.stringify( + { + plugins: [V2_PLUGIN_ENTRY], + permissions: [RECALL_PERMISSION], + }, + null, + 2, + ); + + const result = editOpenCodeConfig(input); + const config = JSON.parse(result.content) as Record; + + expect(config.plugin).toEqual([V1_PLUGIN_ENTRY]); + expect(config.plugins).toEqual([V2_PLUGIN_ENTRY]); + expect(config.permissions).toEqual([RECALL_PERMISSION]); + }); + + test("preserves an object-form V2 entry without adding a duplicate", () => { + const configuredV2 = { + package: "opencode-supermemory/v2", + options: { captureEveryNTurns: 5 }, + }; + const input = JSON.stringify({ plugins: [configuredV2] }, null, 2); + + const result = editOpenCodeConfig(input); + const config = JSON.parse(result.content) as Record; + + expect(config.plugin).toEqual([V1_PLUGIN_ENTRY]); + expect(config.plugins).toEqual([configuredV2]); + expect(config.permissions).toEqual([RECALL_PERMISSION]); + }); + + test("preserves an explicit recall deny and returns a warning", () => { + const deny = { + action: "supermemory_recall", + resource: "*", + effect: "deny", + }; + const input = JSON.stringify({ permissions: [deny] }, null, 2); + + const result = editOpenCodeConfig(input); + const config = JSON.parse(result.content) as Record; + + expect(config.plugin).toEqual([V1_PLUGIN_ENTRY]); + expect(config.plugins).toEqual([V2_PLUGIN_ENTRY]); + expect(config.permissions).toEqual([deny]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("explicitly denied"); + }); + + test("is byte-for-byte idempotent after the first install", () => { + const first = editOpenCodeConfig(`{ + "plugin": ["other-v1-plugin"], + "plugins": ["other-v2-plugin"] +} +`); + const second = editOpenCodeConfig(first.content); + + expect(second.content).toBe(first.content); + expect(second.changed).toBe(false); + expect(second.warnings).toEqual([]); + }); +}); diff --git a/src/services/opencode-config.ts b/src/services/opencode-config.ts new file mode 100644 index 0000000..517e7c7 --- /dev/null +++ b/src/services/opencode-config.ts @@ -0,0 +1,168 @@ +import { + applyEdits, + modify, + parse, + type FormattingOptions, + type ParseError, +} from "jsonc-parser"; + +export const V1_PLUGIN_ENTRY = "opencode-supermemory@latest"; +export const V2_PLUGIN_ENTRY = "opencode-supermemory/v2"; + +export const RECALL_PERMISSION = { + action: "supermemory_recall", + resource: "*", + effect: "allow", +} as const; + +export interface OpenCodeConfigEditResult { + content: string; + changed: boolean; + warnings: string[]; +} + +type JsonObject = Record; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseConfig(content: string): JsonObject { + const errors: ParseError[] = []; + const value = parse(content, errors, { + allowTrailingComma: true, + disallowComments: false, + }); + + if (errors.length > 0) { + const first = errors[0]!; + throw new Error(`Invalid OpenCode JSONC config at offset ${first.offset}`); + } + + if (!isObject(value)) { + throw new Error("OpenCode config must contain a JSON object"); + } + + return value; +} + +function getFormattingOptions(content: string): FormattingOptions { + const eol = content.includes("\r\n") ? "\r\n" : "\n"; + const indent = content.match(/\r?\n([ \t]+)["}]/)?.[1]; + const usesTabs = indent?.includes("\t") ?? false; + + return { + eol, + insertSpaces: !usesTabs, + tabSize: usesTabs ? 1 : Math.max(2, indent?.length ?? 2), + }; +} + +function applyModification( + content: string, + path: Array, + value: unknown, +): string { + return applyEdits( + content, + modify(content, path, value, { + formattingOptions: getFormattingOptions(content), + }), + ); +} + +function getPluginPackage(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (isObject(value) && typeof value.package === "string") { + return value.package; + } + return undefined; +} + +function isV1Plugin(value: unknown): boolean { + const packageName = getPluginPackage(value); + return ( + packageName !== undefined && + /^(?:npm:)?opencode-supermemory(?:@[^/]+)?$/.test(packageName) + ); +} + +function isV2Plugin(value: unknown): boolean { + const packageName = getPluginPackage(value); + return ( + packageName !== undefined && + /^(?:npm:)?opencode-supermemory(?:@[^/]+)?\/v2$/.test(packageName) + ); +} + +function addArrayEntry( + content: string, + property: string, + value: unknown, + alreadyPresent: (entry: unknown) => boolean, +): string { + const config = parseConfig(content); + const current = config[property]; + + if (current === undefined) { + return applyModification(content, [property], [value]); + } + + if (!Array.isArray(current)) { + throw new Error(`OpenCode config property "${property}" must be an array`); + } + + if (current.some(alreadyPresent)) return content; + return applyModification(content, [property, -1], value); +} + +function isRecallPermission(value: unknown, effect: "allow" | "deny"): boolean { + return ( + isObject(value) && + value.action === RECALL_PERMISSION.action && + value.resource === RECALL_PERMISSION.resource && + value.effect === effect + ); +} + +/** + * Adds the OpenCode V1 and V2 plugin entries without rewriting unrelated JSONC. + * Existing package versions are kept, and an explicit recall deny is respected. + */ +export function editOpenCodeConfig(rawContent: string): OpenCodeConfigEditResult { + const original = rawContent; + let content = rawContent.trim() === "" ? "{}\n" : rawContent; + const warnings: string[] = []; + + parseConfig(content); + content = addArrayEntry(content, "plugin", V1_PLUGIN_ENTRY, isV1Plugin); + content = addArrayEntry(content, "plugins", V2_PLUGIN_ENTRY, isV2Plugin); + + const config = parseConfig(content); + const permissions = config.permissions; + if (permissions !== undefined && !Array.isArray(permissions)) { + throw new Error('OpenCode config property "permissions" must be an array'); + } + + const permissionEntries = permissions ?? []; + if (permissionEntries.some((entry) => isRecallPermission(entry, "deny"))) { + warnings.push( + 'OpenCode 2 permission "supermemory_recall" is explicitly denied; preserving the deny instead of adding an allow.', + ); + } else if ( + !permissionEntries.some((entry) => isRecallPermission(entry, "allow")) + ) { + content = addArrayEntry( + content, + "permissions", + RECALL_PERMISSION, + (entry) => isRecallPermission(entry, "allow"), + ); + } + + return { + content, + changed: content !== original, + warnings, + }; +} diff --git a/src/v2/index.ts b/src/v2/index.ts new file mode 100644 index 0000000..25f04d3 --- /dev/null +++ b/src/v2/index.ts @@ -0,0 +1,10 @@ +import { Plugin } from "@opencode-ai/plugin"; + +import { setupV2 } from "./runtime.js"; + +export default Plugin.define({ + id: "supermemory.opencode", + setup: setupV2, +}); + +export { setupV2 } from "./runtime.js"; diff --git a/src/v2/runtime.test.ts b/src/v2/runtime.test.ts new file mode 100644 index 0000000..70d313d --- /dev/null +++ b/src/v2/runtime.test.ts @@ -0,0 +1,1098 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import type { Message } from "@opencode-ai/ai"; +import type { Context as PluginContext } from "@opencode-ai/plugin/promise/plugin"; + +import plugin from "./index.js"; +import { + EventDeduper, + SUPERMEMORY_RECALL_INPUT, + V2Runtime, + buildV2RecallDirective, + detectMemoryKeyword, + setupV2, + type V2RuntimeDependencies, +} from "./runtime.js"; +import type { ResolvedTags } from "../services/tags.js"; +import type { SupermemoryToolArgs } from "../services/memory-tool.js"; +import { SupermemoryPlugin } from "../index.js"; + +const TAGS: ResolvedTags = { + canonical: "repo_test__0123456789abcdef", + user: "repo_test__0123456789abcdef", + project: "repo_test__0123456789abcdef", + projectId: "0123456789abcdef", + projectName: "test", + personalReads: ["personal"], + projectReads: ["project"], + allReads: ["personal", "project"], +}; + +const BASE_CONFIG = { + autoRecallEveryPrompt: true, + captureEveryNTurns: 2, + compactionEnabled: true, + keywordPatterns: ["remember"], + maxProjectMemories: 10, +}; + +interface AddedTool { + name: string; + input?: { + properties?: Record; + required?: readonly string[]; + }; + options?: { permission?: string; codemode?: boolean }; + execute: (input: unknown, context: { sessionID: string }) => Promise<{ + content?: string; + }>; +} + +interface FakeContext { + ctx: PluginContext; + tools: AddedTool[]; + contextHooks: Array<(input: { sessionID: string; messages: Message[] }) => unknown>; + getCalls: string[]; + subscriptions: { count: number; signal?: AbortSignal }; + disposed: { count: number }; +} + +class NeverEndingEvents implements AsyncIterable { + [Symbol.asyncIterator]() { + return { + next: () => new Promise>(() => undefined), + }; + } +} + +class PushEvents implements AsyncIterable { + #values: unknown[] = []; + #waiters: Array<(value: IteratorResult) => void> = []; + + push(value: unknown): void { + const waiter = this.#waiters.shift(); + if (waiter) waiter({ done: false, value }); + else this.#values.push(value); + } + + [Symbol.asyncIterator]() { + return { + next: async (): Promise> => { + const value = this.#values.shift(); + if (value !== undefined) return { done: false, value }; + return new Promise((resolve) => this.#waiters.push(resolve)); + }, + }; + } +} + +function fakeContext(options?: { + directory?: string | ((sessionID: string) => string); + events?: AsyncIterable; + dispose?: () => Promise; + transformGate?: Promise; +}): FakeContext { + const tools: AddedTool[] = []; + const contextHooks: FakeContext["contextHooks"] = []; + const getCalls: string[] = []; + const subscriptions: FakeContext["subscriptions"] = { count: 0 }; + const disposed = { count: 0 }; + const dispose = async () => { + disposed.count += 1; + await options?.dispose?.(); + }; + + const ctx = { + tool: { + transform: async (callback: (draft: { add: (tool: AddedTool) => void }) => void) => { + callback({ add: (tool) => tools.push(tool) }); + await options?.transformGate; + return { dispose }; + }, + }, + session: { + get: async ({ sessionID }: { sessionID: string }) => { + getCalls.push(sessionID); + const directory = + typeof options?.directory === "function" + ? options.directory(sessionID) + : options?.directory ?? "/workspace/project"; + return { + id: sessionID, + location: { directory }, + }; + }, + hook: async ( + name: string, + callback: (input: { sessionID: string; messages: Message[] }) => unknown, + ) => { + expect(name).toBe("context"); + contextHooks.push(callback); + return { dispose }; + }, + }, + event: { + subscribe: ({ signal }: { signal?: AbortSignal } = {}) => { + subscriptions.count += 1; + subscriptions.signal = signal; + return options?.events ?? new NeverEndingEvents(); + }, + }, + } as unknown as PluginContext; + + return { ctx, tools, contextHooks, getCalls, subscriptions, disposed }; +} + +function message(id: string, role: "user" | "assistant", text: string): Message { + return { + id, + role, + content: [{ type: "text", text }], + } as Message; +} + +function textParts(value: Message): string[] { + return value.content + .filter((part): part is typeof part & { type: "text"; text: string } => + part.type === "text", + ) + .map((part) => part.text); +} + +function memoryClient(overrides: Record = {}) { + return { + addMemory: async () => ({ success: true, id: "memory-1" }), + ingestConversation: async () => ({ success: true, id: "capture-1" }), + getProfileScoped: async () => ({ + success: true, + profile: { static: ["prefers tests"], dynamic: [] }, + }), + searchMemoriesScoped: async () => ({ success: true, results: [] }), + searchMemoriesMany: async () => ({ success: true, results: [] }), + listMemoriesScoped: async () => ({ + success: true, + memories: [], + pagination: { currentPage: 1, totalItems: 0, totalPages: 0 }, + }), + deleteMemory: async () => ({ success: true }), + ...overrides, + } as unknown as V2RuntimeDependencies["memoryClient"]; +} + +function dependencies( + overrides: Partial = {}, +): Partial { + return { + configured: true, + config: BASE_CONFIG, + memoryClient: memoryClient(), + executeTool: (async (args) => JSON.stringify({ success: true, args })) as V2RuntimeDependencies["executeTool"], + resolveTags: () => TAGS, + logger: () => undefined, + getUpdateNotice: async () => null, + ...overrides, + }; +} + +const cleanups: Array<() => void> = []; + +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); +}); + +describe("OpenCode V2 entrypoint and tools", () => { + test("exports the exact V2 plugin schema", () => { + expect(plugin.id).toBe("supermemory.opencode"); + expect(typeof plugin.setup).toBe("function"); + expect(Object.keys(plugin).sort()).toEqual(["id", "setup"]); + }); + + test("keeps the V1 root plugin export loadable", () => { + expect(typeof SupermemoryPlugin).toBe("function"); + }); + + test("registers both tools and confines recall to search", async () => { + const fake = fakeContext(); + const seen: SupermemoryToolArgs[] = []; + const cleanup = await setupV2( + fake.ctx, + dependencies({ + configured: false, + executeTool: (async (args) => { + seen.push(args); + return JSON.stringify({ success: true, mode: args.mode }); + }) as V2RuntimeDependencies["executeTool"], + }), + ); + cleanups.push(cleanup); + + expect(fake.tools.map((tool) => tool.name)).toEqual([ + "supermemory", + "supermemory_recall", + ]); + expect(fake.tools[0]?.options).toEqual({ + codemode: false, + permission: "supermemory", + }); + expect(fake.tools[1]?.options).toEqual({ + codemode: false, + permission: "supermemory_recall", + }); + expect(fake.tools[1]?.input).toBe(SUPERMEMORY_RECALL_INPUT); + expect(Object.keys(fake.tools[1]?.input?.properties ?? {}).sort()).toEqual([ + "limit", + "mode", + "query", + "scope", + ]); + expect(fake.tools[1]?.input?.required).toEqual(["query"]); + + const rejected = await fake.tools[1]!.execute( + { mode: "add", content: "no" }, + { sessionID: "session-1" }, + ); + expect(JSON.parse(rejected.content ?? "{}")).toMatchObject({ + success: false, + error: "supermemory_recall only supports search mode", + }); + expect(seen).toHaveLength(0); + + await fake.tools[1]!.execute( + { query: "architecture" }, + { sessionID: "session-1" }, + ); + expect(seen).toEqual([{ mode: "search", query: "architecture" }]); + expect(fake.getCalls).toEqual(["session-1"]); + }); + + test("isolates session directories and tags", async () => { + const fake = fakeContext({ + directory: (sessionID) => `/repo/${sessionID}`, + }); + const seen: Array<{ sessionTag: string; query?: string }> = []; + const runtime = new V2Runtime( + fake.ctx, + dependencies({ + resolveTags: (directory) => ({ + ...TAGS, + canonical: `tag:${directory}`, + user: `tag:${directory}`, + project: `tag:${directory}`, + }), + executeTool: (async (args, tags) => { + seen.push({ sessionTag: tags.canonical, query: args.query }); + return JSON.stringify({ success: true }); + }) as V2RuntimeDependencies["executeTool"], + }), + ); + + await runtime.executeTool({ mode: "search", query: "one" }, "one"); + await runtime.executeTool({ mode: "search", query: "two" }, "two"); + await runtime.executeTool({ mode: "search", query: "again" }, "one"); + + expect(seen).toEqual([ + { sessionTag: "tag:/repo/one", query: "one" }, + { sessionTag: "tag:/repo/two", query: "two" }, + { sessionTag: "tag:/repo/one", query: "again" }, + ]); + expect(fake.getCalls).toEqual(["one", "two"]); + expect(runtime.trackedSessionCount).toBe(2); + }); +}); + +describe("V2 context hook", () => { + test("injects initial context once and per-dispatch recall/nudges", async () => { + const fake = fakeContext({ directory: "/repo/actual" }); + const runtime = new V2Runtime( + fake.ctx, + dependencies({ getUpdateNotice: async () => "[UPDATE AVAILABLE]" }), + ); + const first = message("user-1", "user", "Please remember this preference"); + + await runtime.handleContext({ sessionID: "session-1", messages: [first] }); + const firstParts = textParts(first); + expect(firstParts[0]).toContain("[SUPERMEMORY]"); + expect(firstParts[0]).toContain("[UPDATE AVAILABLE]"); + expect(firstParts).toContain("Please remember this preference"); + expect(firstParts.some((text) => text.includes("[MEMORY TRIGGER DETECTED]"))).toBe(true); + expect(firstParts.some((text) => text.includes("`supermemory_recall` tool"))).toBe(true); + expect(firstParts.some((text) => text.includes("`supermemory` tool with `mode: \"search\"`"))).toBe(false); + expect(fake.getCalls).toEqual(["session-1"]); + + await runtime.handleContext({ sessionID: "session-1", messages: [first] }); + expect(textParts(first)).toEqual(firstParts); + + const second = message("user-2", "user", "What did we decide?"); + await runtime.handleContext({ + sessionID: "session-1", + messages: [first, second], + }); + const secondParts = textParts(second); + expect(secondParts.some((text) => text.includes("`supermemory_recall` tool"))).toBe(true); + expect(secondParts.some((text) => text.includes("[SUPERMEMORY]"))).toBe(false); + }); + + test("keeps dispatch alive when memory context lookup fails", async () => { + const fake = fakeContext(); + const runtime = new V2Runtime( + fake.ctx, + dependencies({ + getUpdateNotice: async () => "[UPDATE AVAILABLE]", + memoryClient: memoryClient({ + getProfileScoped: async () => { + throw new Error("offline"); + }, + }), + }), + ); + const user = message("user-1", "user", "hello"); + + await expect( + runtime.handleContext({ sessionID: "session-1", messages: [user] }), + ).resolves.toBeUndefined(); + expect(textParts(user).some((text) => text.includes("supermemory_recall"))).toBe(true); + expect(textParts(user).some((text) => text.includes("[UPDATE AVAILABLE]"))).toBe(false); + }); + + test("ignores memory keywords inside code", () => { + expect(detectMemoryKeyword("remember this", ["remember"])).toBe(true); + expect(detectMemoryKeyword("```ts\nremember(this)\n```", ["remember"])).toBe(false); + expect(buildV2RecallDirective()).toContain("`supermemory_recall` tool"); + expect( + buildV2RecallDirective("Call `supermemory` now, then use `supermemory` again."), + ).toBe( + "Call `supermemory_recall` now, then use `supermemory_recall` again.", + ); + }); + + test("distinguishes identical no-ID user dispatches", async () => { + const fake = fakeContext(); + const runtime = new V2Runtime(fake.ctx, dependencies()); + const first = message("", "user", "same question"); + await runtime.handleContext({ sessionID: "session-1", messages: [first] }); + const second = message("", "user", "same question"); + await runtime.handleContext({ + sessionID: "session-1", + messages: [first, second], + }); + + expect(textParts(first).filter((text) => text.includes("supermemory_recall"))).toHaveLength(1); + expect(textParts(second).filter((text) => text.includes("supermemory_recall"))).toHaveLength(1); + }); + + test("does not allocate session caches while unconfigured", async () => { + const fake = fakeContext(); + const runtime = new V2Runtime( + fake.ctx, + dependencies({ configured: false }), + ); + const user = message("user-1", "user", "remember this"); + + await runtime.handleContext({ sessionID: "session-1", messages: [user] }); + expect(runtime.trackedSessionCount).toBe(0); + expect(textParts(user)).toEqual(["remember this"]); + expect(fake.getCalls).toHaveLength(0); + }); +}); + +describe("V2 automatic capture", () => { + test("captures completed turns at cadence and the session-end remainder", async () => { + const fake = fakeContext(); + const ingests: Array<{ + messages: Array<{ role: string; content: string }>; + metadata: Record; + customId?: string; + }> = []; + const client = memoryClient({ + ingestConversation: async ( + _conversationId: string, + messages: Array<{ role: string; content: string }>, + _tags: string[], + metadata: Record, + options: { customId?: string }, + ) => { + ingests.push({ messages, metadata, customId: options.customId }); + return { success: true, id: `capture-${ingests.length}` }; + }, + }); + const runtime = new V2Runtime(fake.ctx, dependencies({ memoryClient: client })); + + const history: Message[] = []; + for (let turn = 1; turn <= 3; turn += 1) { + history.push(message(`user-${turn}`, "user", `question ${turn}`)); + await runtime.handleContext({ sessionID: "session-1", messages: history }); + await runtime.handleEvent({ + id: `text-${turn}`, + type: "session.text.ended", + data: { + sessionID: "session-1", + assistantMessageID: `assistant-${turn}`, + ordinal: 0, + text: `answer ${turn}`, + }, + }); + if (turn === 1) { + await runtime.handleEvent({ + id: "tool-called-1", + type: "session.tool.called", + data: { + sessionID: "session-1", + assistantMessageID: "assistant-1", + id: "tool-1", + input: { path: "package.json" }, + }, + }); + await runtime.handleEvent({ + id: "tool-success-1", + type: "session.tool.success", + data: { + sessionID: "session-1", + assistantMessageID: "assistant-1", + id: "tool-1", + content: [{ type: "text", text: "tool output" }], + }, + }); + await runtime.handleEvent({ + id: "text-1-second", + type: "session.text.ended", + data: { + sessionID: "session-1", + assistantMessageID: "assistant-1", + ordinal: 1, + text: "answer 1 continued", + }, + }); + } + history.push(message(`assistant-${turn}`, "assistant", `answer ${turn}`)); + await runtime.handleEvent({ + id: `success-${turn}`, + type: "session.execution.succeeded", + data: { sessionID: "session-1" }, + }); + } + + expect(ingests).toHaveLength(1); + expect(runtime.completedCaptureCount).toBe(1); + expect(ingests[0]?.messages.map((item) => item.content)).toEqual([ + "question 1", + "answer 1\nanswer 1 continued", + "question 2", + "answer 2", + ]); + expect(JSON.stringify(ingests[0]?.messages)).not.toContain("supermemory_recall"); + + await runtime.handleEvent({ + id: "delete-1", + type: "session.deleted", + data: { sessionID: "session-1" }, + }); + expect(ingests).toHaveLength(2); + expect(ingests[1]?.messages.map((item) => item.content)).toEqual([ + "question 3", + "answer 3", + ]); + expect(ingests[1]?.metadata.captureReason).toBe("session_end"); + expect(ingests[0]?.customId).not.toBe(ingests[1]?.customId); + expect(runtime.trackedSessionCount).toBe(0); + expect(runtime.completedCaptureCount).toBe(0); + + await runtime.handleEvent({ + id: "delete-1", + type: "session.deleted", + data: { sessionID: "session-1" }, + }); + expect(ingests).toHaveLength(2); + }); + + test("does not complete failed turns and redacts private spans", async () => { + const fake = fakeContext(); + const ingests: Array> = []; + const runtime = new V2Runtime( + fake.ctx, + dependencies({ + config: { ...BASE_CONFIG, captureEveryNTurns: 0 }, + memoryClient: memoryClient({ + ingestConversation: async ( + _id: string, + messages: Array<{ role: string; content: string }>, + ) => { + ingests.push(messages); + return { success: true, id: "capture" }; + }, + }), + }), + ); + + const safe = message( + "user-safe", + "user", + "keep this secret preference", + ); + await runtime.handleContext({ sessionID: "session-1", messages: [safe] }); + await runtime.handleEvent({ + id: "text-safe", + type: "session.text.ended", + data: { + sessionID: "session-1", + assistantMessageID: "assistant-safe", + ordinal: 0, + text: "done", + }, + }); + await runtime.handleEvent({ + id: "success-safe", + type: "session.execution.succeeded", + data: { sessionID: "session-1" }, + }); + + const failed = message("user-failed", "user", "do not capture this failed turn"); + await runtime.handleContext({ + sessionID: "session-1", + messages: [safe, message("assistant-safe", "assistant", "done"), failed], + }); + await runtime.handleEvent({ + id: "text-failed", + type: "session.text.ended", + data: { + sessionID: "session-1", + assistantMessageID: "assistant-failed", + ordinal: 0, + text: "partial", + }, + }); + await runtime.handleEvent({ + id: "failed", + type: "session.execution.failed", + data: { sessionID: "session-1", error: {} }, + }); + await runtime.handleEvent({ + id: "delete", + type: "session.deleted", + data: { sessionID: "session-1" }, + }); + + expect(ingests).toHaveLength(1); + expect(ingests[0]?.map((item) => item.content)).toEqual([ + "keep this [REDACTED] preference", + "done", + ]); + }); + + test("flushes only completed turns on shutdown interruption", async () => { + const fake = fakeContext(); + const ingests: Array<{ + messages: Array<{ role: string; content: string }>; + reason: unknown; + }> = []; + const runtime = new V2Runtime( + fake.ctx, + dependencies({ + config: { ...BASE_CONFIG, captureEveryNTurns: 2 }, + memoryClient: memoryClient({ + ingestConversation: async ( + _id: string, + messages: Array<{ role: string; content: string }>, + _tags: string[], + metadata: Record, + ) => { + ingests.push({ messages, reason: metadata.captureReason }); + return { success: true, id: "capture" }; + }, + }), + }), + ); + + const userOne = message("user-1", "user", "completed question"); + await runtime.handleContext({ sessionID: "session-1", messages: [userOne] }); + await runtime.handleEvent({ + id: "text-1", + type: "session.text.ended", + data: { + sessionID: "session-1", + assistantMessageID: "assistant-1", + ordinal: 0, + text: "completed answer", + }, + }); + await runtime.handleEvent({ + id: "success-1", + type: "session.execution.succeeded", + data: { sessionID: "session-1" }, + }); + + const userTwo = message("user-2", "user", "interrupted question"); + await runtime.handleContext({ + sessionID: "session-1", + messages: [ + userOne, + message("assistant-1", "assistant", "completed answer"), + userTwo, + ], + }); + await runtime.handleEvent({ + id: "text-2", + type: "session.text.ended", + data: { + sessionID: "session-1", + assistantMessageID: "assistant-2", + ordinal: 0, + text: "partial answer", + }, + }); + await runtime.handleEvent({ + id: "shutdown", + type: "session.execution.interrupted", + data: { sessionID: "session-1", reason: "shutdown" }, + }); + + expect(ingests).toHaveLength(1); + expect(ingests[0]?.reason).toBe("session_end"); + expect(ingests[0]?.messages.map((item) => item.content)).toEqual([ + "completed question", + "completed answer", + ]); + }); +}); + +describe("V2 native compaction", () => { + test("injects bounded context and saves the event summary exactly once", async () => { + const fake = fakeContext(); + const additions: Array<{ + content: string; + metadata: Record; + customId?: string; + }> = []; + const runtime = new V2Runtime( + fake.ctx, + dependencies({ + memoryClient: memoryClient({ + listMemoriesScoped: async () => ({ + success: true, + memories: [{ id: "1", summary: "Use Bun" }], + pagination: { currentPage: 1, totalItems: 1, totalPages: 1 }, + }), + addMemory: async ( + content: string, + _tag: string, + metadata: Record, + options: { customId?: string }, + ) => { + additions.push({ content, metadata, customId: options.customId }); + return { success: true, id: "summary" }; + }, + }), + }), + ); + + await runtime.handleEvent({ + id: "compact-start", + type: "session.compaction.started", + data: { sessionID: "session-1", reason: "auto" }, + }); + const user = message("user-1", "user", "compact now"); + await runtime.handleContext({ sessionID: "session-1", messages: [user] }); + expect(textParts(user).some((text) => text.includes("[SUPERMEMORY COMPACTION CONTEXT]"))).toBe(true); + expect(textParts(user).some((text) => text.includes("Use Bun"))).toBe(true); + + const ended = { + id: "compact-ended", + type: "session.compaction.ended", + data: { + sessionID: "session-1", + reason: "auto", + text: `The complete compacted session summary ${"with retained context ".repeat(5)}`, + }, + }; + await runtime.handleEvent(ended); + await runtime.handleEvent(ended); + + expect(additions).toHaveLength(1); + expect(additions[0]?.content).toBe( + `[Session Summary]\nThe complete compacted session summary ${"with retained context ".repeat(5).trimEnd()}`, + ); + expect(additions[0]?.metadata.sm_capture_mode).toBe("compaction"); + expect(additions[0]?.customId).toMatch(/^opencode:compaction:/); + }); + + test("retries failed summary writes on the next session event", async () => { + const fake = fakeContext(); + const customIds: Array = []; + const runtime = new V2Runtime( + fake.ctx, + dependencies({ + memoryClient: memoryClient({ + addMemory: async ( + _content: string, + _tag: string, + _metadata: Record, + options: { customId?: string }, + ) => { + customIds.push(options.customId); + return customIds.length === 1 + ? { success: false, error: "temporary" } + : { success: true, id: "saved" }; + }, + }), + }), + ); + + await runtime.handleEvent({ + id: "compact-ended", + type: "session.compaction.ended", + data: { sessionID: "session-1", text: "summary ".repeat(20) }, + }); + await runtime.handleEvent({ + id: "next-event", + type: "session.execution.failed", + data: { sessionID: "session-1", error: {} }, + }); + + expect(customIds).toHaveLength(2); + expect(customIds[0]).toBe(customIds[1]); + }); + + test("does not save anything for a failed compaction", async () => { + const fake = fakeContext(); + let additions = 0; + const runtime = new V2Runtime( + fake.ctx, + dependencies({ + memoryClient: memoryClient({ + addMemory: async () => { + additions += 1; + return { success: true, id: "unexpected" }; + }, + }), + }), + ); + await runtime.handleEvent({ + id: "compact-failed", + type: "session.compaction.failed", + data: { sessionID: "session-1", error: {} }, + }); + expect(additions).toBe(0); + }); + + test("preserves the V1 short-summary skip", async () => { + const fake = fakeContext(); + let additions = 0; + const runtime = new V2Runtime( + fake.ctx, + dependencies({ + memoryClient: memoryClient({ + addMemory: async () => { + additions += 1; + return { success: true, id: "unexpected" }; + }, + }), + }), + ); + await runtime.handleEvent({ + id: "compact-short", + type: "session.compaction.ended", + data: { sessionID: "session-1", text: "too short" }, + }); + expect(additions).toBe(0); + }); +}); + +describe("V2 lifecycle hardening", () => { + test("bounds event IDs while deduplicating recent events", () => { + const deduper = new EventDeduper(2); + expect(deduper.hasSeen("a")).toBe(false); + expect(deduper.hasSeen("a")).toBe(true); + expect(deduper.hasSeen("b")).toBe(false); + expect(deduper.hasSeen("c")).toBe(false); + expect(deduper.hasSeen("a")).toBe(false); + }); + + test("a duplicate setup retires only the prior generation", async () => { + const first = fakeContext(); + let staleToolCalls = 0; + const cleanupFirst = await setupV2( + first.ctx, + dependencies({ + configured: false, + executeTool: (async () => { + staleToolCalls += 1; + return "unexpected"; + }) as V2RuntimeDependencies["executeTool"], + }), + ); + const second = fakeContext(); + const cleanupSecond = await setupV2( + second.ctx, + dependencies({ configured: false }), + ); + cleanups.push(cleanupFirst, cleanupSecond); + + expect(first.disposed.count).toBe(2); + expect(second.disposed.count).toBe(0); + const staleResult = await first.tools[0]!.execute({}, { sessionID: "stale" }); + expect(JSON.parse(staleResult.content ?? "{}").success).toBe(false); + expect(staleToolCalls).toBe(0); + cleanupFirst(); + expect(second.disposed.count).toBe(0); + cleanupSecond(); + expect(second.disposed.count).toBe(2); + }); + + test("disposes a registration that resolves after a duplicate setup wins", async () => { + let releaseTransform!: () => void; + const transformGate = new Promise((resolve) => { + releaseTransform = resolve; + }); + const first = fakeContext({ transformGate }); + const firstSetup = setupV2( + first.ctx, + dependencies({ configured: false }), + ); + await Promise.resolve(); + + const second = fakeContext(); + const cleanupSecond = await setupV2( + second.ctx, + dependencies({ configured: false }), + ); + releaseTransform(); + const cleanupFirst = await firstSetup; + cleanups.push(cleanupFirst, cleanupSecond); + + expect(first.disposed.count).toBe(1); + expect(first.contextHooks).toHaveLength(0); + expect(first.subscriptions.count).toBe(0); + expect(second.disposed.count).toBe(0); + }); + + test("cleanup does not await an event stream or registration disposal", async () => { + const fake = fakeContext({ + events: new NeverEndingEvents(), + dispose: () => new Promise(() => undefined), + }); + const cleanup = await setupV2(fake.ctx, dependencies()); + const start = performance.now(); + cleanup(); + const elapsed = performance.now() - start; + + expect(elapsed).toBeLessThan(50); + expect(fake.subscriptions.count).toBe(1); + expect(fake.subscriptions.signal?.aborted).toBe(true); + }); + + test("cleanup starts a completed remainder flush without waiting for it", async () => { + const fake = fakeContext(); + let ingestStarted = 0; + let markIngestStarted!: () => void; + const ingestStart = new Promise((resolve) => { + markIngestStarted = resolve; + }); + let finishIngest!: () => void; + const ingestGate = new Promise((resolve) => { + finishIngest = resolve; + }); + const runtime = new V2Runtime( + fake.ctx, + dependencies({ + config: { ...BASE_CONFIG, captureEveryNTurns: 0 }, + memoryClient: memoryClient({ + ingestConversation: async () => { + ingestStarted += 1; + markIngestStarted(); + await ingestGate; + return { success: true, id: "capture" }; + }, + }), + }), + ); + await runtime.register(); + await runtime.handleContext({ + sessionID: "session-1", + messages: [message("user-1", "user", "completed question")], + }); + await runtime.handleEvent({ + id: "text-1", + type: "session.text.ended", + data: { + sessionID: "session-1", + assistantMessageID: "assistant-1", + ordinal: 0, + text: "completed answer", + }, + }); + await runtime.handleEvent({ + id: "success-1", + type: "session.execution.succeeded", + data: { sessionID: "session-1" }, + }); + + const start = performance.now(); + runtime.cleanup(); + expect(performance.now() - start).toBeLessThan(50); + await ingestStart; + expect(ingestStarted).toBe(1); + finishIngest(); + }); + + test("cleanup serializes behind an active cadence write and flushes the remainder", async () => { + const fake = fakeContext(); + let releaseFirstIngest!: () => void; + let activeCadenceStarted!: () => void; + const activeCadenceStart = new Promise((resolve) => { + activeCadenceStarted = resolve; + }); + let sessionEndStarted!: () => void; + const sessionEndStart = new Promise((resolve) => { + sessionEndStarted = resolve; + }); + const activeCadenceGate = new Promise((resolve) => { + releaseFirstIngest = resolve; + }); + let concurrent = 0; + let maxConcurrent = 0; + const reasons: unknown[] = []; + const runtime = new V2Runtime( + fake.ctx, + dependencies({ + config: { ...BASE_CONFIG, captureEveryNTurns: 2 }, + memoryClient: memoryClient({ + ingestConversation: async ( + _id: string, + _messages: Array<{ role: string; content: string }>, + _tags: string[], + metadata: Record, + ) => { + reasons.push(metadata.captureReason); + concurrent += 1; + maxConcurrent = Math.max(maxConcurrent, concurrent); + if (reasons.length === 2) { + activeCadenceStarted(); + await activeCadenceGate; + } + concurrent -= 1; + if (metadata.captureReason === "session_end") { + sessionEndStarted(); + } + if (reasons.length === 1) { + return { success: false, error: "retry cadence" }; + } + return { success: true, id: `capture-${reasons.length}` }; + }, + }), + }), + ); + + const history: Message[] = []; + history.push(message("user-1", "user", "question 1")); + await runtime.handleContext({ sessionID: "session-1", messages: history }); + await runtime.handleEvent({ + id: "text-1", + type: "session.text.ended", + data: { + sessionID: "session-1", + assistantMessageID: "assistant-1", + ordinal: 0, + text: "answer 1", + }, + }); + history.push(message("assistant-1", "assistant", "answer 1")); + await runtime.handleEvent({ + id: "success-1", + type: "session.execution.succeeded", + data: { sessionID: "session-1" }, + }); + + history.push(message("user-2", "user", "question 2")); + await runtime.handleContext({ sessionID: "session-1", messages: history }); + await runtime.handleEvent({ + id: "text-2", + type: "session.text.ended", + data: { + sessionID: "session-1", + assistantMessageID: "assistant-2", + ordinal: 0, + text: "answer 2", + }, + }); + history.push(message("assistant-2", "assistant", "answer 2")); + await runtime.handleEvent({ + id: "success-2", + type: "session.execution.succeeded", + data: { sessionID: "session-1" }, + }); + + history.push(message("user-3", "user", "question 3")); + await runtime.handleContext({ sessionID: "session-1", messages: history }); + await runtime.handleEvent({ + id: "text-3", + type: "session.text.ended", + data: { + sessionID: "session-1", + assistantMessageID: "assistant-3", + ordinal: 0, + text: "answer 3", + }, + }); + const thirdSuccess = runtime.handleEvent({ + id: "success-3", + type: "session.execution.succeeded", + data: { sessionID: "session-1" }, + }); + await activeCadenceStart; + + const cleanupStart = performance.now(); + runtime.cleanup(); + expect(performance.now() - cleanupStart).toBeLessThan(50); + expect(reasons).toEqual(["cadence", "cadence"]); + expect(maxConcurrent).toBe(1); + + releaseFirstIngest(); + await thirdSuccess; + await sessionEndStart; + + expect(reasons).toEqual(["cadence", "cadence", "session_end"]); + expect(maxConcurrent).toBe(1); + }); + + test("stale context callbacks do no work", async () => { + const first = fakeContext(); + const cleanupFirst = await setupV2(first.ctx, dependencies()); + const second = fakeContext(); + const cleanupSecond = await setupV2(second.ctx, dependencies()); + cleanups.push(cleanupFirst, cleanupSecond); + + const user = message("user-1", "user", "remember this"); + await first.contextHooks[0]?.({ sessionID: "stale", messages: [user] }); + expect(textParts(user)).toEqual(["remember this"]); + expect(first.getCalls).toHaveLength(0); + }); + + test("stale event subscriptions do no work", async () => { + const events = new PushEvents(); + let staleIngests = 0; + const first = fakeContext({ events }); + const cleanupFirst = await setupV2( + first.ctx, + dependencies({ + memoryClient: memoryClient({ + ingestConversation: async () => { + staleIngests += 1; + return { success: true, id: "unexpected" }; + }, + }), + }), + ); + const second = fakeContext(); + const cleanupSecond = await setupV2(second.ctx, dependencies()); + cleanups.push(cleanupFirst, cleanupSecond); + + events.push({ + id: "stale-success", + type: "session.execution.succeeded", + data: { sessionID: "stale" }, + }); + await Promise.resolve(); + await Promise.resolve(); + expect(staleIngests).toBe(0); + expect(first.getCalls).toHaveLength(0); + }); +}); diff --git a/src/v2/runtime.ts b/src/v2/runtime.ts new file mode 100644 index 0000000..6814132 --- /dev/null +++ b/src/v2/runtime.ts @@ -0,0 +1,1100 @@ +import { createHash } from "node:crypto"; + +import type { Message } from "@opencode-ai/ai"; +import type { Context as PluginContext } from "@opencode-ai/plugin/promise/plugin"; + +import { CONFIG, isConfigured, PLUGIN_VERSION } from "../config.js"; +import { + buildCadenceBatches, + buildSessionEndBatch, + getCaptureId, + type CaptureBatch, + type CaptureTurn, +} from "../services/capture.js"; +import { supermemoryClient, type SupermemoryClient } from "../services/client.js"; +import { + createCompactionPrompt, + fitProjectMemories, +} from "../services/compaction.js"; +import { formatContextForPrompt } from "../services/context.js"; +import { AGENT_ENTITY_CONTEXT } from "../services/entity-context.js"; +import { log } from "../services/logger.js"; +import { + executeSupermemoryTool, + type SupermemoryToolArgs, +} from "../services/memory-tool.js"; +import { isFullyPrivate, stripPrivateContent } from "../services/privacy.js"; +import { buildRecallDirective } from "../services/recall.js"; +import { getTags, type ResolvedTags } from "../services/tags.js"; +import { checkNpmUpdate, formatUpdateNotice } from "../services/version-check.js"; + +const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g; +const INLINE_CODE_PATTERN = /`[^`]+`/g; +const COMPACTION_CONTEXT_MARKER = "[SUPERMEMORY COMPACTION CONTEXT]"; +const SYNTHETIC_METADATA_KEY = "supermemoryV2"; +const UPDATE_COMMAND = "bunx opencode-supermemory@latest install"; + +export const MEMORY_NUDGE_MESSAGE = `[MEMORY TRIGGER DETECTED] +The user wants you to remember something. You MUST use the \`supermemory\` tool with \`mode: "add"\` to save this information. + +Extract the key information the user wants remembered and save it as a concise, searchable memory. +- Use \`scope: "project"\` for project-specific preferences (e.g., "run lint with tests") +- Use \`scope: "user"\` for personal preferences in this project (e.g., "prefers concise responses") +- Choose an appropriate \`type\`: "preference", "project-config", "learned-pattern", etc. + +DO NOT skip this step. The user explicitly asked you to remember.`; + +export const SUPERMEMORY_TOOL_INPUT = { + type: "object", + additionalProperties: false, + properties: { + mode: { + type: "string", + enum: ["add", "search", "profile", "list", "forget", "help"], + }, + content: { type: "string" }, + query: { type: "string" }, + type: { + type: "string", + enum: [ + "project-config", + "architecture", + "error-solution", + "preference", + "learned-pattern", + "conversation", + ], + }, + scope: { type: "string", enum: ["user", "project"] }, + memoryId: { type: "string" }, + limit: { type: "number" }, + }, +} as const; + +export const SUPERMEMORY_RECALL_INPUT = { + type: "object", + additionalProperties: false, + properties: { + mode: { type: "string", enum: ["search"] }, + query: { type: "string" }, + scope: { type: "string", enum: ["user", "project"] }, + limit: { type: "number" }, + }, + required: ["query"], +} as const; + +const SUPERMEMORY_DESCRIPTION = + "Manage and query the Supermemory persistent memory system. Use 'search' to find relevant memories, 'add' to store new knowledge, 'profile' to view user profile, 'list' to see recent memories, 'forget' to remove a memory."; + +const SUPERMEMORY_RECALL_DESCRIPTION = + "Search saved Supermemory context. This least-privilege helper only accepts search operations."; + +type RuntimeMemoryClient = Pick< + SupermemoryClient, + | "addMemory" + | "ingestConversation" + | "getProfileScoped" + | "searchMemoriesScoped" + | "listMemoriesScoped" + | "searchMemoriesMany" + | "deleteMemory" +>; + +type RuntimeConfig = Pick< + typeof CONFIG, + | "autoRecallEveryPrompt" + | "captureEveryNTurns" + | "compactionEnabled" + | "keywordPatterns" + | "maxProjectMemories" +>; + +export interface V2RuntimeDependencies { + configured: boolean; + config: RuntimeConfig; + memoryClient: RuntimeMemoryClient; + executeTool: typeof executeSupermemoryTool; + resolveTags: typeof getTags; + logger: typeof log; + getUpdateNotice: () => Promise; +} + +const DEFAULT_DEPENDENCIES: V2RuntimeDependencies = { + configured: isConfigured(), + config: CONFIG, + memoryClient: supermemoryClient, + executeTool: executeSupermemoryTool, + resolveTags: getTags, + logger: log, + getUpdateNotice: async () => { + const info = await checkNpmUpdate( + "opencode-supermemory", + PLUGIN_VERSION, + UPDATE_COMMAND, + ); + return info ? formatUpdateNotice(info) : null; + }, +}; + +interface V2Event { + id?: string; + type: string; + created?: number; + data?: Record; +} + +interface CachedMessage { + id: string; + role: string; + contextText: string; + streamText: Map; +} + +interface SessionState { + messages: Map; + order: string[]; + completedUsers: Set; + completedCaptureIds: Set; + injectedInitialContext: boolean; + lastInjectedDispatch?: string; + compactionNeedsContext: boolean; + directory?: string; + tags?: ResolvedTags; + resolving?: Promise; +} + +interface PendingSummary { + customId: string; + eventId: string; + sessionID: string; + text: string; +} + +interface Registration { + dispose: () => Promise; +} + +function mergeDependencies( + overrides: Partial | undefined, +): V2RuntimeDependencies { + return { + ...DEFAULT_DEPENDENCIES, + ...overrides, + config: { ...DEFAULT_DEPENDENCIES.config, ...overrides?.config }, + }; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function removeCodeBlocks(text: string): string { + return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, ""); +} + +export function detectMemoryKeyword( + text: string, + patterns: readonly string[] = CONFIG.keywordPatterns, +): boolean { + if (patterns.length === 0) return false; + return new RegExp(`\\b(${patterns.join("|")})\\b`, "i").test( + removeCodeBlocks(text), + ); +} + +export function buildV2RecallDirective( + directive: string = buildRecallDirective(), +): string { + return directive.replaceAll("`supermemory`", "`supermemory_recall`"); +} + +function isSyntheticPart(part: unknown): boolean { + if (!part || typeof part !== "object") return false; + const metadata = (part as { metadata?: Record }).metadata; + return Boolean(metadata?.[SYNTHETIC_METADATA_KEY]); +} + +function extractMessageText(message: Message): string { + return message.content + .filter( + (part): part is Message["content"][number] & { type: "text"; text: string } => + part.type === "text" && + typeof part.text === "string" && + !isSyntheticPart(part), + ) + .map((part) => part.text) + .join("\n") + .trim(); +} + +function messageKey( + message: Message, + text: string, + occurrence: number, +): string { + if (message.id) return message.id; + return `context:${message.role}:${sha256(text).slice(0, 24)}:${occurrence}`; +} + +function cachedText(message: CachedMessage): string { + if (message.streamText.size === 0) return message.contextText; + return [...message.streamText.entries()] + .sort(([left], [right]) => left - right) + .map(([, text]) => text) + .join("\n") + .trim(); +} + +function sanitizeCaptureText(text: string): string { + if (!text || isFullyPrivate(text)) return ""; + return stripPrivateContent(text).trim(); +} + +export function buildCachedCaptureTurns( + messages: Map, + order: readonly string[], + completedUsers: ReadonlySet, +): CaptureTurn[] { + const turns: CaptureTurn[] = []; + let current: + | { + id: string; + messages: CaptureTurn["messages"]; + fullyPrivate: boolean; + complete: boolean; + } + | undefined; + + const finish = () => { + if (current?.complete) { + turns.push({ + id: current.id, + messages: current.fullyPrivate ? [] : current.messages, + }); + } + current = undefined; + }; + + for (const id of order) { + const message = messages.get(id); + if (!message) continue; + const rawText = cachedText(message); + + if (message.role === "user") { + finish(); + current = { + id, + messages: sanitizeCaptureText(rawText) + ? [{ role: "user", content: sanitizeCaptureText(rawText) }] + : [], + fullyPrivate: rawText.length > 0 && isFullyPrivate(rawText), + complete: completedUsers.has(id), + }; + continue; + } + + if (!current || message.role !== "assistant" || current.fullyPrivate) { + continue; + } + + const text = sanitizeCaptureText(rawText); + if (text) current.messages.push({ role: "assistant", content: text }); + } + + finish(); + return turns; +} + +function makeSyntheticText(text: string, kind: string) { + return { + type: "text" as const, + text, + metadata: { [SYNTHETIC_METADATA_KEY]: kind }, + }; +} + +function injectIntoMessage( + message: Message, + text: string, + kind: string, + position: "start" | "end" = "end", +): void { + if (!text.trim()) return; + const mutable = message.content as Array; + const part = makeSyntheticText(text, kind); + if (position === "start") mutable.unshift(part); + else mutable.push(part); +} + +function getLatestUser(messages: Message[]): Message | undefined { + return messages.findLast((message) => message.role === "user"); +} + +export class EventDeduper { + readonly #limit: number; + readonly #seen = new Set(); + readonly #order: string[] = []; + + constructor(limit = 4_096) { + this.#limit = Math.max(1, limit); + } + + hasSeen(id: string | undefined): boolean { + if (!id) return false; + if (this.#seen.has(id)) return true; + this.#seen.add(id); + this.#order.push(id); + if (this.#order.length > this.#limit) { + const oldest = this.#order.shift(); + if (oldest) this.#seen.delete(oldest); + } + return false; + } +} + +export class V2Runtime { + readonly #ctx: PluginContext; + readonly #deps: V2RuntimeDependencies; + readonly #isOwner: () => boolean; + readonly #states = new Map(); + readonly #captureInFlight = new Map>(); + readonly #pendingSummaries = new Map(); + readonly #summaryInFlight = new Set(); + readonly #deduper = new EventDeduper(); + readonly #registrations: Registration[] = []; + readonly #abortController = new AbortController(); + #active = true; + + constructor( + ctx: PluginContext, + options?: Partial, + isOwner: () => boolean = () => true, + ) { + this.#ctx = ctx; + this.#deps = mergeDependencies(options); + this.#isOwner = isOwner; + } + + get active(): boolean { + return this.#active && this.#isOwner(); + } + + get trackedSessionCount(): number { + return this.#states.size; + } + + get completedCaptureCount(): number { + return [...this.#states.values()].reduce( + (total, state) => total + state.completedCaptureIds.size, + 0, + ); + } + + async register(): Promise { + const toolRegistration = await this.#ctx.tool.transform((draft) => { + draft.add({ + name: "supermemory", + description: SUPERMEMORY_DESCRIPTION, + input: SUPERMEMORY_TOOL_INPUT, + options: { codemode: false, permission: "supermemory" }, + execute: async (args, context) => { + if (!this.active) return { content: this.#inactiveToolResult() }; + return { + content: await this.executeTool( + args as SupermemoryToolArgs, + context.sessionID, + ), + }; + }, + }); + + draft.add({ + name: "supermemory_recall", + description: SUPERMEMORY_RECALL_DESCRIPTION, + input: SUPERMEMORY_RECALL_INPUT, + options: { codemode: false, permission: "supermemory_recall" }, + execute: async (args, context) => { + if (!this.active) return { content: this.#inactiveToolResult() }; + return { + content: await this.executeRecallTool( + args as SupermemoryToolArgs, + context.sessionID, + ), + }; + }, + }); + }); + if (!this.active) { + this.#disposeRegistration(toolRegistration); + return; + } + this.#registrations.push(toolRegistration); + + const contextRegistration = await this.#ctx.session.hook( + "context", + async (context) => { + if (!this.active) return; + await this.handleContext(context); + }, + ); + if (!this.active) { + this.#disposeRegistration(contextRegistration); + return; + } + this.#registrations.push(contextRegistration); + + if (this.active && this.#deps.configured) this.#startEventSubscription(); + } + + async executeTool(args: SupermemoryToolArgs, sessionID: string): Promise { + try { + const tags = await this.#resolveSession(sessionID); + return await this.#deps.executeTool(args, tags, { + memoryClient: this.#deps.memoryClient, + configured: this.#deps.configured, + }); + } catch (error) { + return JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + async executeRecallTool( + args: SupermemoryToolArgs, + sessionID: string, + ): Promise { + if (args.mode && args.mode !== "search") { + return JSON.stringify({ + success: false, + error: "supermemory_recall only supports search mode", + }); + } + return this.executeTool({ ...args, mode: "search" }, sessionID); + } + + async handleContext(context: { + sessionID: string; + messages: Message[]; + }): Promise { + if (!this.#deps.configured) return; + const state = this.#state(context.sessionID); + this.#cacheContextMessages(state, context.messages); + + const latestUser = getLatestUser(context.messages); + if (state.compactionNeedsContext && latestUser) { + state.compactionNeedsContext = false; + await this.#injectCompactionContext(context.sessionID, latestUser); + } + + if (!latestUser) return; + const userText = extractMessageText(latestUser); + if (!userText) return; + const dispatchKey = this.#dispatchKey(context.messages, latestUser, userText); + if (state.lastInjectedDispatch === dispatchKey) return; + state.lastInjectedDispatch = dispatchKey; + + if (detectMemoryKeyword(userText, this.#deps.config.keywordPatterns)) { + injectIntoMessage(latestUser, MEMORY_NUDGE_MESSAGE, "nudge"); + } + injectIntoMessage(latestUser, buildV2RecallDirective(), "recall"); + + if (state.injectedInitialContext) return; + state.injectedInitialContext = true; + try { + const tags = await this.#resolveSession(context.sessionID); + const [memoryContext, updateNotice] = await Promise.all([ + this.#buildInitialContext(userText, tags), + this.#deps.getUpdateNotice().catch((error) => { + this.#deps.logger("v2 update check failed", { error: String(error) }); + return null; + }), + ]); + const initialContext = [memoryContext, updateNotice] + .map((part) => part?.trim()) + .filter(Boolean) + .join("\n\n"); + injectIntoMessage(latestUser, initialContext, "initial-context", "start"); + } catch (error) { + this.#deps.logger("v2 context injection failed", { + sessionID: context.sessionID, + error: String(error), + }); + } + } + + async handleEvent(event: V2Event): Promise { + if (!this.active || this.#deduper.hasSeen(event.id)) return; + const sessionID = this.#eventSessionID(event); + + if (sessionID && event.type !== "session.compaction.ended") { + await this.#retryPendingSummaries(sessionID); + } + + switch (event.type) { + case "session.text.ended": { + if (!sessionID) return; + const assistantMessageID = String(event.data?.assistantMessageID ?? ""); + const text = String(event.data?.text ?? ""); + const ordinal = Number(event.data?.ordinal ?? 0); + if (assistantMessageID && text) { + this.#cacheAssistantText( + this.#state(sessionID), + assistantMessageID, + Number.isFinite(ordinal) ? ordinal : 0, + text, + ); + } + return; + } + + case "session.execution.succeeded": { + if (!sessionID) return; + const state = this.#state(sessionID); + this.#markLatestTurnComplete(state); + await this.#runCaptureExclusive(sessionID, () => + this.#captureCadence(sessionID, state), + ); + return; + } + + case "session.execution.interrupted": { + if (!sessionID || event.data?.reason !== "shutdown") return; + const state = this.#states.get(sessionID); + if (state) { + await this.#runCaptureExclusive(sessionID, () => + this.#captureSessionEnd(sessionID, state), + ); + } + return; + } + + case "session.deleted": { + if (!sessionID) return; + const state = this.#states.get(sessionID); + if (state) { + await this.#runCaptureExclusive(sessionID, () => + this.#captureSessionEnd(sessionID, state), + ); + } + this.#states.delete(sessionID); + return; + } + + case "session.compaction.started": { + if (sessionID && this.#deps.config.compactionEnabled) { + this.#state(sessionID).compactionNeedsContext = true; + } + return; + } + + case "session.compaction.ended": { + if (!sessionID || !this.#deps.config.compactionEnabled) return; + this.#state(sessionID).compactionNeedsContext = false; + const text = String(event.data?.text ?? "").trim(); + if (!text) return; + if (text.length < 100) { + this.#deps.logger("v2 compaction summary too short to save", { + sessionID, + length: text.length, + }); + return; + } + const eventId = event.id ?? sha256(`${sessionID}:${text}`); + const customId = `opencode:compaction:${sha256(`${sessionID}:${eventId}`)}`; + this.#pendingSummaries.set(customId, { + customId, + eventId, + sessionID, + text, + }); + await this.#retryPendingSummaries(sessionID); + return; + } + + case "session.compaction.failed": { + if (sessionID) this.#state(sessionID).compactionNeedsContext = false; + return; + } + + case "global.disposed": { + await Promise.all( + [...this.#states.entries()].map(([id, state]) => + this.#runCaptureExclusive(id, () => + this.#captureSessionEnd(id, state), + ), + ), + ); + this.#states.clear(); + return; + } + } + } + + cleanup(): void { + if (!this.#active) return; + this.#active = false; + this.#abortController.abort(); + + const snapshots = [...this.#states.entries()]; + const pendingSessions = [ + ...new Set([...this.#pendingSummaries.values()].map((item) => item.sessionID)), + ]; + for (const [sessionID, state] of snapshots) { + void this.#runCaptureExclusive(sessionID, () => + this.#captureSessionEnd(sessionID, state), + ).catch((error) => { + this.#deps.logger("v2 cleanup capture failed", { + sessionID, + error: String(error), + }); + }); + } + for (const sessionID of pendingSessions) { + void this.#retryPendingSummaries(sessionID, true); + } + + this.#states.clear(); + for (const registration of this.#registrations.splice(0)) { + this.#disposeRegistration(registration); + } + } + + #state(sessionID: string): SessionState { + const existing = this.#states.get(sessionID); + if (existing) return existing; + const state: SessionState = { + messages: new Map(), + order: [], + completedUsers: new Set(), + completedCaptureIds: new Set(), + injectedInitialContext: false, + compactionNeedsContext: false, + }; + this.#states.set(sessionID, state); + return state; + } + + #inactiveToolResult(): string { + return JSON.stringify({ + success: false, + error: "This duplicate Supermemory V2 plugin instance is inactive", + }); + } + + #disposeRegistration(registration: Registration): void { + try { + void registration.dispose().catch((error) => { + this.#deps.logger("v2 registration cleanup failed", { + error: String(error), + }); + }); + } catch (error) { + this.#deps.logger("v2 registration cleanup failed", { + error: String(error), + }); + } + } + + #dispatchKey(messages: Message[], latestUser: Message, text: string): string { + if (latestUser.id) return latestUser.id; + const index = messages.lastIndexOf(latestUser); + let occurrence = 0; + for (let cursor = 0; cursor <= index; cursor += 1) { + const candidate = messages[cursor]; + if ( + candidate?.role === "user" && + extractMessageText(candidate) === text + ) { + occurrence += 1; + } + } + return `dispatch:${index}:${occurrence}:${sha256(text)}`; + } + + async #resolveSession(sessionID: string): Promise { + const state = this.#state(sessionID); + if (state.tags) return state.tags; + if (state.resolving) return state.resolving; + + state.resolving = (async () => { + const session = await this.#ctx.session.get({ sessionID }); + const directory = session.location?.directory; + if (!directory) { + throw new Error(`Unable to resolve directory for OpenCode session ${sessionID}`); + } + state.directory = directory; + state.tags = this.#deps.resolveTags(directory); + return state.tags; + })(); + + try { + return await state.resolving; + } finally { + state.resolving = undefined; + } + } + + #cacheContextMessages(state: SessionState, messages: Message[]): void { + const occurrences = new Map(); + for (const message of messages) { + if (message.role !== "user" && message.role !== "assistant") continue; + const text = extractMessageText(message); + if (!text) continue; + const occurrenceKey = `${message.role}:${sha256(text)}`; + const occurrence = occurrences.get(occurrenceKey) ?? 0; + occurrences.set(occurrenceKey, occurrence + 1); + const id = messageKey(message, text, occurrence); + const existing = state.messages.get(id); + if (existing) { + existing.contextText = text; + continue; + } + state.messages.set(id, { + id, + role: message.role, + contextText: text, + streamText: new Map(), + }); + state.order.push(id); + } + } + + #cacheAssistantText( + state: SessionState, + id: string, + ordinal: number, + text: string, + ): void { + let message = state.messages.get(id); + if (!message) { + message = { + id, + role: "assistant", + contextText: "", + streamText: new Map(), + }; + state.messages.set(id, message); + state.order.push(id); + } + message.streamText.set(ordinal, text); + } + + #markLatestTurnComplete(state: SessionState): void { + const latestUser = state.order.findLast((id) => state.messages.get(id)?.role === "user"); + if (latestUser) state.completedUsers.add(latestUser); + } + + async #buildInitialContext( + userMessage: string, + tags: ResolvedTags, + ): Promise { + if (this.#deps.config.autoRecallEveryPrompt) { + const [profileResult, userMemoriesResult, projectMemoriesListResult] = + await Promise.all([ + this.#deps.memoryClient.getProfileScoped( + tags.canonical, + tags.personalReads, + "personal", + userMessage, + ), + this.#deps.memoryClient.searchMemoriesScoped( + userMessage, + tags.canonical, + tags.personalReads, + "personal", + ), + this.#deps.memoryClient.listMemoriesScoped( + tags.canonical, + tags.projectReads, + "project", + this.#deps.config.maxProjectMemories, + ), + ]); + + const projectMemories = { + results: (projectMemoriesListResult.memories ?? []).map((memory) => ({ + id: memory.id, + memory: memory.summary || memory.content || memory.title || "", + similarity: 1, + title: memory.title, + metadata: memory.metadata, + })), + }; + return formatContextForPrompt( + profileResult.success ? profileResult : null, + userMemoriesResult.success ? userMemoriesResult : { results: [] }, + projectMemories, + ); + } + + const profileResult = await this.#deps.memoryClient.getProfileScoped( + tags.canonical, + tags.personalReads, + "personal", + ); + return formatContextForPrompt( + profileResult.success ? profileResult : null, + { results: [] }, + { results: [] }, + ); + } + + async #injectCompactionContext( + sessionID: string, + latestUser: Message | undefined, + ): Promise { + let memories: string[] = []; + try { + const tags = await this.#resolveSession(sessionID); + const result = await this.#deps.memoryClient.listMemoriesScoped( + tags.canonical, + tags.projectReads, + "project", + this.#deps.config.maxProjectMemories, + ); + memories = fitProjectMemories( + (result.memories ?? []) + .map((memory) => memory.summary || memory.content || "") + .filter((memory): memory is string => Boolean(memory)), + ); + } catch (error) { + this.#deps.logger("v2 compaction project-memory lookup failed", { + sessionID, + error: String(error), + }); + } + + const context = createCompactionPrompt(memories); + if (latestUser && !extractMessageText(latestUser).includes(COMPACTION_CONTEXT_MARKER)) { + injectIntoMessage(latestUser, context, "compaction"); + } + this.#deps.logger("v2 compaction context injected", { + sessionID, + memoriesCount: memories.length, + }); + } + + #captureTurns(state: SessionState): CaptureTurn[] { + return buildCachedCaptureTurns( + state.messages, + state.order, + state.completedUsers, + ); + } + + async #saveCaptureBatch( + sessionID: string, + state: SessionState, + batch: CaptureBatch, + reason: "cadence" | "session_end", + ): Promise { + const captureId = getCaptureId(sessionID, batch); + if (state.completedCaptureIds.has(captureId)) return; + const messages = batch.turns.flatMap((turn) => turn.messages); + if (messages.length === 0) { + state.completedCaptureIds.add(captureId); + return; + } + + const tags = state.tags ?? (await this.#resolveSession(sessionID)); + const result = await this.#deps.memoryClient.ingestConversation( + `${sessionID}:${batch.startTurn}-${batch.endTurn}`, + messages, + [tags.canonical], + { + project: tags.projectName, + sm_project_id: tags.projectId, + sm_scope: "personal", + sm_capture_mode: "automatic", + captureReason: reason, + sessionId: sessionID, + turnStart: batch.startTurn, + turnEnd: batch.endTurn, + }, + { + defaultEntityContext: AGENT_ENTITY_CONTEXT, + customId: captureId, + }, + ); + + if (result.success) state.completedCaptureIds.add(captureId); + else { + this.#deps.logger("v2 capture failed", { + sessionID, + reason, + error: result.error, + }); + } + } + + async #captureCadence(sessionID: string, state: SessionState): Promise { + const turns = this.#captureTurns(state); + for (const batch of buildCadenceBatches( + turns, + this.#deps.config.captureEveryNTurns, + )) { + await this.#saveCaptureBatch(sessionID, state, batch, "cadence"); + } + } + + async #captureSessionEnd(sessionID: string, state: SessionState): Promise { + await this.#captureCadence(sessionID, state); + const turns = this.#captureTurns(state); + const finalBatch = buildSessionEndBatch( + turns, + this.#deps.config.captureEveryNTurns, + ); + if (finalBatch) { + await this.#saveCaptureBatch(sessionID, state, finalBatch, "session_end"); + } + } + + async #runCaptureExclusive( + sessionID: string, + task: () => Promise, + ): Promise { + const previous = this.#captureInFlight.get(sessionID) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(task); + this.#captureInFlight.set(sessionID, next); + try { + await next; + } finally { + if (this.#captureInFlight.get(sessionID) === next) { + this.#captureInFlight.delete(sessionID); + } + } + } + + async #retryPendingSummaries( + sessionID: string, + allowInactive = false, + ): Promise { + if (!allowInactive && !this.active) return; + const pending = [...this.#pendingSummaries.values()].filter( + (item) => item.sessionID === sessionID, + ); + if (pending.length === 0) return; + + let tags: ResolvedTags; + try { + tags = await this.#resolveSession(sessionID); + } catch (error) { + this.#deps.logger("v2 compaction summary retry deferred", { + sessionID, + error: String(error), + }); + return; + } + + for (const summary of pending) { + if (this.#summaryInFlight.has(summary.customId)) continue; + this.#summaryInFlight.add(summary.customId); + try { + const result = await this.#deps.memoryClient.addMemory( + `[Session Summary]\n${summary.text}`, + tags.canonical, + { + type: "conversation", + project: tags.projectName, + sm_project_id: tags.projectId, + sm_scope: "personal", + sm_capture_mode: "compaction", + sessionId: sessionID, + }, + { + customId: summary.customId, + entityContext: AGENT_ENTITY_CONTEXT, + }, + ); + if (result.success) this.#pendingSummaries.delete(summary.customId); + else { + this.#deps.logger("v2 compaction summary save failed", { + sessionID, + error: result.error, + }); + } + } catch (error) { + this.#deps.logger("v2 compaction summary save failed", { + sessionID, + error: String(error), + }); + } finally { + this.#summaryInFlight.delete(summary.customId); + } + } + } + + #eventSessionID(event: V2Event): string | undefined { + const sessionID = event.data?.sessionID; + return typeof sessionID === "string" && sessionID ? sessionID : undefined; + } + + #startEventSubscription(): void { + const events = this.#ctx.event.subscribe({ signal: this.#abortController.signal }); + void (async () => { + try { + for await (const event of events) { + if (!this.active) return; + try { + await this.handleEvent(event as V2Event); + } catch (error) { + this.#deps.logger("v2 event handling failed", { + type: (event as V2Event).type, + error: String(error), + }); + } + } + } catch (error) { + if (this.active) { + this.#deps.logger("v2 event subscription failed", { + error: String(error), + }); + } + } + })(); + } +} + +const OWNER_KEY = Symbol.for("opencode-supermemory.v2.owner"); + +interface GlobalOwner { + generation: number; + cleanup: () => void; +} + +function ownerRegistry(): Record { + return globalThis as unknown as Record; +} + +export async function setupV2( + ctx: PluginContext, + options?: Partial, +): Promise<() => void> { + const registry = ownerRegistry(); + const previous = registry[OWNER_KEY]; + previous?.cleanup(); + + const owner: GlobalOwner = { + generation: (previous?.generation ?? 0) + 1, + cleanup: () => undefined, + }; + registry[OWNER_KEY] = owner; + + const runtime = new V2Runtime(ctx, options, () => registry[OWNER_KEY] === owner); + const cleanup = () => { + runtime.cleanup(); + if (registry[OWNER_KEY] === owner) delete registry[OWNER_KEY]; + }; + owner.cleanup = cleanup; + + try { + await runtime.register(); + } catch (error) { + cleanup(); + throw error; + } + + return cleanup; +} From 635a2510ecc9ca4c04cf71c2844bc48ef617a670 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 21 Aug 2026 18:27:01 +0530 Subject: [PATCH 06/10] Delete src/services/memory-tool.test.ts --- src/services/memory-tool.test.ts | 377 ------------------------------- 1 file changed, 377 deletions(-) delete mode 100644 src/services/memory-tool.test.ts diff --git a/src/services/memory-tool.test.ts b/src/services/memory-tool.test.ts deleted file mode 100644 index 8ea92b2..0000000 --- a/src/services/memory-tool.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { AGENT_ENTITY_CONTEXT } from "./entity-context.js"; -import { - executeSupermemoryTool, - formatSearchResults, - type MemoryToolClient, - type SupermemoryToolArgs, -} from "./memory-tool.js"; -import type { ResolvedTags } from "./tags.js"; - -const tags: ResolvedTags = { - canonical: "repo_test__0123456789abcdef", - user: "repo_test__0123456789abcdef", - project: "repo_test__0123456789abcdef", - projectId: "0123456789abcdef", - projectName: "test-project", - personalReads: ["personal-legacy"], - projectReads: ["project-legacy"], - allReads: ["personal-legacy", "project-legacy"], -}; - -const successfulClient: MemoryToolClient = { - addMemory: async () => ({ - success: true as const, - id: "memory-1", - status: "queued", - }), - searchMemoriesScoped: async () => ({ success: true, results: [] }), - searchMemoriesMany: async () => ({ success: true, results: [] }), - getProfileScoped: async () => ({ - success: true, - profile: { static: [], dynamic: [] }, - }), - listMemoriesScoped: async () => ({ - success: true, - memories: [], - pagination: { currentPage: 1, totalItems: 0, totalPages: 0 }, - }), - deleteMemory: async () => ({ success: true as const }), -}; - -function createClient( - overrides: Partial = {}, -): MemoryToolClient { - return { ...successfulClient, ...overrides }; -} - -function execute( - args: SupermemoryToolArgs, - memoryClient: MemoryToolClient = successfulClient, - configured = true, -): Promise { - return executeSupermemoryTool(args, tags, { memoryClient, configured }); -} - -describe("shared supermemory tool", () => { - test("preserves the configuration gate and default help response", async () => { - expect(await execute({}, successfulClient, false)).toBe( - JSON.stringify({ - success: false, - error: - "SUPERMEMORY_API_KEY not set. Set it in your environment to use Supermemory.", - }), - ); - - expect(await execute({})).toBe( - JSON.stringify({ - success: true, - message: "Supermemory Usage Guide", - commands: [ - { - command: "add", - description: "Store a new memory", - args: ["content", "type?", "scope?"], - }, - { - command: "search", - description: "Search memories", - args: ["query", "scope?"], - }, - { - command: "profile", - description: "View user profile", - args: ["query?"], - }, - { - command: "list", - description: "List recent memories", - args: ["scope?", "limit?"], - }, - { - command: "forget", - description: "Remove a memory", - args: ["memoryId", "scope?"], - }, - ], - scopes: { - user: "Personal preferences and knowledge for this project", - project: "Project-specific knowledge (default)", - }, - types: [ - "project-config", - "architecture", - "error-solution", - "preference", - "learned-pattern", - "conversation", - ], - }), - ); - }); - - test("validates and sanitizes add requests without changing metadata", async () => { - const calls: Array> = []; - const memoryClient = createClient({ - addMemory: async (...args) => { - calls.push(args); - return { success: true as const, id: "added-1", status: "queued" }; - }, - }); - - expect(await execute({ mode: "add" }, memoryClient)).toBe( - JSON.stringify({ - success: false, - error: "content parameter is required for add mode", - }), - ); - expect( - await execute( - { mode: "add", content: "secret" }, - memoryClient, - ), - ).toBe( - JSON.stringify({ - success: false, - error: "Cannot store fully private content", - }), - ); - - expect( - await execute( - { - mode: "add", - content: "Use secret pnpm", - type: "project-config", - }, - memoryClient, - ), - ).toBe( - JSON.stringify({ - success: true, - message: "Memory added to project scope", - id: "added-1", - scope: "project", - type: "project-config", - }), - ); - - expect(calls).toEqual([ - [ - "Use [REDACTED] pnpm", - tags.canonical, - { - type: "project-config", - project: tags.projectName, - sm_project_id: tags.projectId, - sm_scope: "project", - sm_capture_mode: "tool", - }, - { entityContext: AGENT_ENTITY_CONTEXT }, - ], - ]); - }); - - test("routes searches by scope and preserves result formatting", async () => { - const scopedCalls: unknown[][] = []; - const manyCalls: unknown[][] = []; - const memoryClient = createClient({ - searchMemoriesScoped: async (...args) => { - scopedCalls.push(args); - return { - success: true, - results: [ - { id: "memory-1", memory: "remembered", similarity: 0.876 }, - { id: "chunk-1", chunk: "chunk only", similarity: 0.123 }, - ], - }; - }, - searchMemoriesMany: async (...args) => { - manyCalls.push(args); - return { success: true, results: [] }; - }, - }); - - expect( - JSON.parse( - await execute( - { mode: "search", query: "query", scope: "user", limit: 1 }, - memoryClient, - ), - ), - ).toEqual({ - success: true, - query: "query", - scope: "user", - count: 2, - results: [ - { - id: "memory-1", - content: "remembered", - similarity: 88, - forgettable: true, - }, - ], - }); - await execute( - { mode: "search", query: "project", scope: "project" }, - memoryClient, - ); - await execute({ mode: "search", query: "all" }, memoryClient); - - expect(scopedCalls).toEqual([ - ["query", tags.canonical, tags.personalReads, "personal"], - ["project", tags.canonical, tags.projectReads, "project"], - ]); - expect(manyCalls).toEqual([["all", tags.allReads]]); - expect(await execute({ mode: "search" }, memoryClient)).toBe( - JSON.stringify({ - success: false, - error: "query parameter is required for search mode", - }), - ); - }); - - test("marks chunk-only search results as non-forgettable", () => { - expect( - JSON.parse( - formatSearchResults("query", undefined, { - results: [{ id: "chunk-1", chunk: "chunk only", similarity: 0.5 }], - }), - ), - ).toEqual({ - success: true, - query: "query", - count: 1, - results: [ - { - content: "chunk only", - similarity: 50, - forgettable: false, - }, - ], - }); - }); - - test("preserves profile, list, and forget defaults and payloads", async () => { - const profileCalls: unknown[][] = []; - const listCalls: unknown[][] = []; - const deleteCalls: unknown[][] = []; - const memoryClient = createClient({ - getProfileScoped: async (...args) => { - profileCalls.push(args); - return { - success: true, - profile: { static: ["static"], dynamic: ["dynamic"] }, - }; - }, - listMemoriesScoped: async (...args) => { - listCalls.push(args); - return { - success: true, - memories: [ - { - id: "memory-1", - summary: "summary", - content: "raw content", - createdAt: "2026-08-20T00:00:00.000Z", - metadata: { type: "project-config" }, - }, - ], - pagination: { currentPage: 1, totalItems: 1, totalPages: 1 }, - }; - }, - deleteMemory: async (...args) => { - deleteCalls.push(args); - return { success: true as const }; - }, - }); - - expect( - JSON.parse( - await execute( - { mode: "profile", query: "profile query" }, - memoryClient, - ), - ), - ).toEqual({ - success: true, - profile: { static: ["static"], dynamic: ["dynamic"] }, - }); - expect(JSON.parse(await execute({ mode: "list" }, memoryClient))).toEqual({ - success: true, - scope: "project", - count: 1, - memories: [ - { - id: "memory-1", - content: "summary", - createdAt: "2026-08-20T00:00:00.000Z", - metadata: { type: "project-config" }, - }, - ], - }); - expect( - await execute( - { mode: "forget", memoryId: "memory-1", scope: "user" }, - memoryClient, - ), - ).toBe( - JSON.stringify({ - success: true, - message: "Memory memory-1 removed from user scope", - }), - ); - - expect(profileCalls).toEqual([ - [tags.canonical, tags.personalReads, "personal", "profile query"], - ]); - expect(listCalls).toEqual([ - [tags.canonical, tags.projectReads, "project", 20], - ]); - expect(deleteCalls).toEqual([ - ["memory-1", [tags.canonical, ...tags.personalReads]], - ]); - expect(await execute({ mode: "forget" }, memoryClient)).toBe( - JSON.stringify({ - success: false, - error: "memoryId parameter is required for forget mode", - }), - ); - }); - - test("preserves client failure fallbacks and thrown errors", async () => { - expect( - await execute( - { mode: "search", query: "query" }, - createClient({ - searchMemoriesMany: async () => ({ - success: false, - results: [], - }), - }), - ), - ).toBe( - JSON.stringify({ - success: false, - error: "Failed to search memories", - }), - ); - - expect( - await execute( - { mode: "list" }, - createClient({ - listMemoriesScoped: async () => { - throw new Error("network failed"); - }, - }), - ), - ).toBe(JSON.stringify({ success: false, error: "network failed" })); - - expect(await execute({ mode: "unsupported" })).toBe( - JSON.stringify({ success: false, error: "Unknown mode: unsupported" }), - ); - }); -}); From 25974fdf85a005eadd2c1e2560db358c3e677eea Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 21 Aug 2026 18:27:17 +0530 Subject: [PATCH 07/10] Delete src/services/compaction.test.ts --- src/services/compaction.test.ts | 138 -------------------------------- 1 file changed, 138 deletions(-) delete mode 100644 src/services/compaction.test.ts diff --git a/src/services/compaction.test.ts b/src/services/compaction.test.ts deleted file mode 100644 index 0857d68..0000000 --- a/src/services/compaction.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createCompactionHook, fitProjectMemories } from "./compaction.js"; -import type { ResolvedTags } from "./tags.js"; - -const tags: ResolvedTags = { - canonical: "repo_test__0123456789abcdef", - user: "repo_test__0123456789abcdef", - project: "repo_test__0123456789abcdef", - projectId: "0123456789abcdef", - projectName: "test", - personalReads: [], - projectReads: ["legacy-project"], - allReads: ["legacy-project"], -}; - -function summaryMessage(id: string, text: string) { - return { - info: { - id, - role: "assistant", - sessionID: "session-1", - summary: true, - finish: "stop", - }, - parts: [{ type: "text", text }], - }; -} - -function memoryClient(memories: Array<{ summary?: string }> = []) { - return { - listMemoriesScoped: async () => ({ memories }), - addMemory: async () => ({ success: true as const, id: "memory-1" }), - }; -} - -describe("native V1 compaction integration", () => { - test("bounds and deduplicates project-memory context", () => { - const memories = fitProjectMemories([ - "same memory", - "same memory", - "x".repeat(20_000), - "y".repeat(20_000), - ]); - - expect(memories.filter((memory) => memory === "same memory")).toHaveLength(1); - expect(memories.every((memory) => memory.length <= 2_000)).toBe(true); - expect(memories.reduce((total, memory) => total + memory.length, 0)).toBeLessThanOrEqual(12_000); - }); - - test("adds context once without replacing the native prompt", async () => { - const output = { context: ["existing"], prompt: "native prompt" }; - const hook = createCompactionHook( - { directory: "/repo", client: { session: { messages: async () => [] } } }, - tags, - { memoryClient: memoryClient([{ summary: "Uses Bun" }]) }, - ); - - await hook.compacting({ sessionID: "session-1" }, output); - await hook.compacting({ sessionID: "session-1" }, output); - - expect(output.prompt).toBe("native prompt"); - expect(output.context).toHaveLength(2); - expect(output.context[1]).toContain("[SUPERMEMORY COMPACTION CONTEXT]"); - expect(output.context[1]).toContain("Uses Bun"); - }); - - test("captures the expected summary and retries transient writes", async () => { - const summary = summaryMessage("summary-new", "summary ".repeat(20)); - let attempts = 0; - const hook = createCompactionHook( - { - directory: "/repo", - client: { session: { messages: async () => ({ data: [summary] }) } }, - }, - tags, - { - memoryClient: { - ...memoryClient(), - addMemory: async () => { - attempts += 1; - return attempts === 1 - ? { success: false as const, error: "temporary" } - : { success: true as const, id: "memory-1" }; - }, - }, - }, - ); - - await hook.compacting({ sessionID: "session-1" }, { context: [] }); - await hook.event({ - event: { type: "message.updated", properties: { info: summary.info } }, - }); - await hook.event({ - event: { type: "session.idle", properties: { sessionID: "session-1" } }, - }); - - expect(attempts).toBe(2); - }); - - test("does not save failed compaction output", async () => { - const failed = { - ...summaryMessage("summary-failed", "partial ".repeat(20)), - info: { - ...summaryMessage("summary-failed", "partial").info, - finish: "error", - error: { name: "ContextOverflowError" }, - }, - }; - let writes = 0; - const hook = createCompactionHook( - { - directory: "/repo", - client: { session: { messages: async () => ({ data: [failed] }) } }, - }, - tags, - { - memoryClient: { - ...memoryClient(), - addMemory: async () => { - writes += 1; - return { success: true as const, id: "memory-1" }; - }, - }, - }, - ); - - await hook.compacting({ sessionID: "session-1" }, { context: [] }); - await hook.event({ - event: { type: "message.updated", properties: { info: failed.info } }, - }); - await hook.event({ - event: { type: "session.idle", properties: { sessionID: "session-1" } }, - }); - - expect(writes).toBe(0); - }); -}); From 158ce5c229c4ea19e3420641f4a287b2584d589a Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 21 Aug 2026 18:27:34 +0530 Subject: [PATCH 08/10] Delete src/services/opencode-config.test.ts --- src/services/opencode-config.test.ts | 165 --------------------------- 1 file changed, 165 deletions(-) delete mode 100644 src/services/opencode-config.test.ts diff --git a/src/services/opencode-config.test.ts b/src/services/opencode-config.test.ts deleted file mode 100644 index 41f72ca..0000000 --- a/src/services/opencode-config.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { parse } from "jsonc-parser"; -import { - RECALL_PERMISSION, - V1_PLUGIN_ENTRY, - V2_PLUGIN_ENTRY, - editOpenCodeConfig, -} from "./opencode-config.js"; - -function parseJsonc(content: string): Record { - return parse(content, undefined, { allowTrailingComma: true }) as Record< - string, - unknown - >; -} - -describe("OpenCode V1/V2 config installation", () => { - test("creates both plugin entries and the narrow recall permission", () => { - const result = editOpenCodeConfig("{}\n"); - const config = parseJsonc(result.content); - - expect(config.plugin).toEqual([V1_PLUGIN_ENTRY]); - expect(config.plugins).toEqual([V2_PLUGIN_ENTRY]); - expect(config.permissions).toEqual([RECALL_PERMISSION]); - expect(result.changed).toBe(true); - expect(result.warnings).toEqual([]); - }); - - test("preserves unrelated JSON values while extending existing arrays", () => { - const input = JSON.stringify( - { - $schema: "https://opencode.ai/config.json", - theme: "system", - plugin: ["other-v1-plugin"], - plugins: ["other-v2-plugin"], - permissions: [ - { action: "shell", resource: "*", effect: "ask" }, - ], - }, - null, - 2, - ); - - const result = editOpenCodeConfig(input); - const config = JSON.parse(result.content) as Record; - - expect(config.$schema).toBe("https://opencode.ai/config.json"); - expect(config.theme).toBe("system"); - expect(config.plugin).toEqual(["other-v1-plugin", V1_PLUGIN_ENTRY]); - expect(config.plugins).toEqual(["other-v2-plugin", V2_PLUGIN_ENTRY]); - expect(config.permissions).toEqual([ - { action: "shell", resource: "*", effect: "ask" }, - RECALL_PERMISSION, - ]); - }); - - test("preserves JSONC comments and trailing commas", () => { - const input = `{ - // Keep the user's selected theme. - "theme": "catppuccin", // inline comment - "plugin": [ - "other-v1-plugin", // keep this plugin - ], - "permissions": [ - // Keep the shell policy. - { "action": "shell", "resource": "*", "effect": "ask" }, - ], -} -`; - - const result = editOpenCodeConfig(input); - const config = parseJsonc(result.content); - - expect(result.content).toContain("// Keep the user's selected theme."); - expect(result.content).toContain("// inline comment"); - expect(result.content).toContain("// keep this plugin"); - expect(result.content).toContain("// Keep the shell policy."); - expect(config.theme).toBe("catppuccin"); - expect(config.plugin).toEqual(["other-v1-plugin", V1_PLUGIN_ENTRY]); - expect(config.plugins).toEqual([V2_PLUGIN_ENTRY]); - expect(config.permissions).toEqual([ - { action: "shell", resource: "*", effect: "ask" }, - RECALL_PERMISSION, - ]); - }); - - test("keeps an existing V1 version and fills only missing V2 fields", () => { - const input = `{ - "plugin": ["opencode-supermemory@2.0.12"], - "plugins": ["other-v2-plugin"] -} -`; - - const result = editOpenCodeConfig(input); - const config = parseJsonc(result.content); - - expect(config.plugin).toEqual(["opencode-supermemory@2.0.12"]); - expect(config.plugins).toEqual(["other-v2-plugin", V2_PLUGIN_ENTRY]); - expect(config.permissions).toEqual([RECALL_PERMISSION]); - }); - - test("adds the V1 entry when only the V2 entry is already present", () => { - const input = JSON.stringify( - { - plugins: [V2_PLUGIN_ENTRY], - permissions: [RECALL_PERMISSION], - }, - null, - 2, - ); - - const result = editOpenCodeConfig(input); - const config = JSON.parse(result.content) as Record; - - expect(config.plugin).toEqual([V1_PLUGIN_ENTRY]); - expect(config.plugins).toEqual([V2_PLUGIN_ENTRY]); - expect(config.permissions).toEqual([RECALL_PERMISSION]); - }); - - test("preserves an object-form V2 entry without adding a duplicate", () => { - const configuredV2 = { - package: "opencode-supermemory/v2", - options: { captureEveryNTurns: 5 }, - }; - const input = JSON.stringify({ plugins: [configuredV2] }, null, 2); - - const result = editOpenCodeConfig(input); - const config = JSON.parse(result.content) as Record; - - expect(config.plugin).toEqual([V1_PLUGIN_ENTRY]); - expect(config.plugins).toEqual([configuredV2]); - expect(config.permissions).toEqual([RECALL_PERMISSION]); - }); - - test("preserves an explicit recall deny and returns a warning", () => { - const deny = { - action: "supermemory_recall", - resource: "*", - effect: "deny", - }; - const input = JSON.stringify({ permissions: [deny] }, null, 2); - - const result = editOpenCodeConfig(input); - const config = JSON.parse(result.content) as Record; - - expect(config.plugin).toEqual([V1_PLUGIN_ENTRY]); - expect(config.plugins).toEqual([V2_PLUGIN_ENTRY]); - expect(config.permissions).toEqual([deny]); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toContain("explicitly denied"); - }); - - test("is byte-for-byte idempotent after the first install", () => { - const first = editOpenCodeConfig(`{ - "plugin": ["other-v1-plugin"], - "plugins": ["other-v2-plugin"] -} -`); - const second = editOpenCodeConfig(first.content); - - expect(second.content).toBe(first.content); - expect(second.changed).toBe(false); - expect(second.warnings).toEqual([]); - }); -}); From fc6ed8a48e573228554227efc3dafd9f3d4a2c32 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 21 Aug 2026 18:28:19 +0530 Subject: [PATCH 09/10] Delete src/v2/runtime.test.ts --- src/v2/runtime.test.ts | 1098 ---------------------------------------- 1 file changed, 1098 deletions(-) delete mode 100644 src/v2/runtime.test.ts diff --git a/src/v2/runtime.test.ts b/src/v2/runtime.test.ts deleted file mode 100644 index 70d313d..0000000 --- a/src/v2/runtime.test.ts +++ /dev/null @@ -1,1098 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; - -import type { Message } from "@opencode-ai/ai"; -import type { Context as PluginContext } from "@opencode-ai/plugin/promise/plugin"; - -import plugin from "./index.js"; -import { - EventDeduper, - SUPERMEMORY_RECALL_INPUT, - V2Runtime, - buildV2RecallDirective, - detectMemoryKeyword, - setupV2, - type V2RuntimeDependencies, -} from "./runtime.js"; -import type { ResolvedTags } from "../services/tags.js"; -import type { SupermemoryToolArgs } from "../services/memory-tool.js"; -import { SupermemoryPlugin } from "../index.js"; - -const TAGS: ResolvedTags = { - canonical: "repo_test__0123456789abcdef", - user: "repo_test__0123456789abcdef", - project: "repo_test__0123456789abcdef", - projectId: "0123456789abcdef", - projectName: "test", - personalReads: ["personal"], - projectReads: ["project"], - allReads: ["personal", "project"], -}; - -const BASE_CONFIG = { - autoRecallEveryPrompt: true, - captureEveryNTurns: 2, - compactionEnabled: true, - keywordPatterns: ["remember"], - maxProjectMemories: 10, -}; - -interface AddedTool { - name: string; - input?: { - properties?: Record; - required?: readonly string[]; - }; - options?: { permission?: string; codemode?: boolean }; - execute: (input: unknown, context: { sessionID: string }) => Promise<{ - content?: string; - }>; -} - -interface FakeContext { - ctx: PluginContext; - tools: AddedTool[]; - contextHooks: Array<(input: { sessionID: string; messages: Message[] }) => unknown>; - getCalls: string[]; - subscriptions: { count: number; signal?: AbortSignal }; - disposed: { count: number }; -} - -class NeverEndingEvents implements AsyncIterable { - [Symbol.asyncIterator]() { - return { - next: () => new Promise>(() => undefined), - }; - } -} - -class PushEvents implements AsyncIterable { - #values: unknown[] = []; - #waiters: Array<(value: IteratorResult) => void> = []; - - push(value: unknown): void { - const waiter = this.#waiters.shift(); - if (waiter) waiter({ done: false, value }); - else this.#values.push(value); - } - - [Symbol.asyncIterator]() { - return { - next: async (): Promise> => { - const value = this.#values.shift(); - if (value !== undefined) return { done: false, value }; - return new Promise((resolve) => this.#waiters.push(resolve)); - }, - }; - } -} - -function fakeContext(options?: { - directory?: string | ((sessionID: string) => string); - events?: AsyncIterable; - dispose?: () => Promise; - transformGate?: Promise; -}): FakeContext { - const tools: AddedTool[] = []; - const contextHooks: FakeContext["contextHooks"] = []; - const getCalls: string[] = []; - const subscriptions: FakeContext["subscriptions"] = { count: 0 }; - const disposed = { count: 0 }; - const dispose = async () => { - disposed.count += 1; - await options?.dispose?.(); - }; - - const ctx = { - tool: { - transform: async (callback: (draft: { add: (tool: AddedTool) => void }) => void) => { - callback({ add: (tool) => tools.push(tool) }); - await options?.transformGate; - return { dispose }; - }, - }, - session: { - get: async ({ sessionID }: { sessionID: string }) => { - getCalls.push(sessionID); - const directory = - typeof options?.directory === "function" - ? options.directory(sessionID) - : options?.directory ?? "/workspace/project"; - return { - id: sessionID, - location: { directory }, - }; - }, - hook: async ( - name: string, - callback: (input: { sessionID: string; messages: Message[] }) => unknown, - ) => { - expect(name).toBe("context"); - contextHooks.push(callback); - return { dispose }; - }, - }, - event: { - subscribe: ({ signal }: { signal?: AbortSignal } = {}) => { - subscriptions.count += 1; - subscriptions.signal = signal; - return options?.events ?? new NeverEndingEvents(); - }, - }, - } as unknown as PluginContext; - - return { ctx, tools, contextHooks, getCalls, subscriptions, disposed }; -} - -function message(id: string, role: "user" | "assistant", text: string): Message { - return { - id, - role, - content: [{ type: "text", text }], - } as Message; -} - -function textParts(value: Message): string[] { - return value.content - .filter((part): part is typeof part & { type: "text"; text: string } => - part.type === "text", - ) - .map((part) => part.text); -} - -function memoryClient(overrides: Record = {}) { - return { - addMemory: async () => ({ success: true, id: "memory-1" }), - ingestConversation: async () => ({ success: true, id: "capture-1" }), - getProfileScoped: async () => ({ - success: true, - profile: { static: ["prefers tests"], dynamic: [] }, - }), - searchMemoriesScoped: async () => ({ success: true, results: [] }), - searchMemoriesMany: async () => ({ success: true, results: [] }), - listMemoriesScoped: async () => ({ - success: true, - memories: [], - pagination: { currentPage: 1, totalItems: 0, totalPages: 0 }, - }), - deleteMemory: async () => ({ success: true }), - ...overrides, - } as unknown as V2RuntimeDependencies["memoryClient"]; -} - -function dependencies( - overrides: Partial = {}, -): Partial { - return { - configured: true, - config: BASE_CONFIG, - memoryClient: memoryClient(), - executeTool: (async (args) => JSON.stringify({ success: true, args })) as V2RuntimeDependencies["executeTool"], - resolveTags: () => TAGS, - logger: () => undefined, - getUpdateNotice: async () => null, - ...overrides, - }; -} - -const cleanups: Array<() => void> = []; - -afterEach(() => { - for (const cleanup of cleanups.splice(0)) cleanup(); -}); - -describe("OpenCode V2 entrypoint and tools", () => { - test("exports the exact V2 plugin schema", () => { - expect(plugin.id).toBe("supermemory.opencode"); - expect(typeof plugin.setup).toBe("function"); - expect(Object.keys(plugin).sort()).toEqual(["id", "setup"]); - }); - - test("keeps the V1 root plugin export loadable", () => { - expect(typeof SupermemoryPlugin).toBe("function"); - }); - - test("registers both tools and confines recall to search", async () => { - const fake = fakeContext(); - const seen: SupermemoryToolArgs[] = []; - const cleanup = await setupV2( - fake.ctx, - dependencies({ - configured: false, - executeTool: (async (args) => { - seen.push(args); - return JSON.stringify({ success: true, mode: args.mode }); - }) as V2RuntimeDependencies["executeTool"], - }), - ); - cleanups.push(cleanup); - - expect(fake.tools.map((tool) => tool.name)).toEqual([ - "supermemory", - "supermemory_recall", - ]); - expect(fake.tools[0]?.options).toEqual({ - codemode: false, - permission: "supermemory", - }); - expect(fake.tools[1]?.options).toEqual({ - codemode: false, - permission: "supermemory_recall", - }); - expect(fake.tools[1]?.input).toBe(SUPERMEMORY_RECALL_INPUT); - expect(Object.keys(fake.tools[1]?.input?.properties ?? {}).sort()).toEqual([ - "limit", - "mode", - "query", - "scope", - ]); - expect(fake.tools[1]?.input?.required).toEqual(["query"]); - - const rejected = await fake.tools[1]!.execute( - { mode: "add", content: "no" }, - { sessionID: "session-1" }, - ); - expect(JSON.parse(rejected.content ?? "{}")).toMatchObject({ - success: false, - error: "supermemory_recall only supports search mode", - }); - expect(seen).toHaveLength(0); - - await fake.tools[1]!.execute( - { query: "architecture" }, - { sessionID: "session-1" }, - ); - expect(seen).toEqual([{ mode: "search", query: "architecture" }]); - expect(fake.getCalls).toEqual(["session-1"]); - }); - - test("isolates session directories and tags", async () => { - const fake = fakeContext({ - directory: (sessionID) => `/repo/${sessionID}`, - }); - const seen: Array<{ sessionTag: string; query?: string }> = []; - const runtime = new V2Runtime( - fake.ctx, - dependencies({ - resolveTags: (directory) => ({ - ...TAGS, - canonical: `tag:${directory}`, - user: `tag:${directory}`, - project: `tag:${directory}`, - }), - executeTool: (async (args, tags) => { - seen.push({ sessionTag: tags.canonical, query: args.query }); - return JSON.stringify({ success: true }); - }) as V2RuntimeDependencies["executeTool"], - }), - ); - - await runtime.executeTool({ mode: "search", query: "one" }, "one"); - await runtime.executeTool({ mode: "search", query: "two" }, "two"); - await runtime.executeTool({ mode: "search", query: "again" }, "one"); - - expect(seen).toEqual([ - { sessionTag: "tag:/repo/one", query: "one" }, - { sessionTag: "tag:/repo/two", query: "two" }, - { sessionTag: "tag:/repo/one", query: "again" }, - ]); - expect(fake.getCalls).toEqual(["one", "two"]); - expect(runtime.trackedSessionCount).toBe(2); - }); -}); - -describe("V2 context hook", () => { - test("injects initial context once and per-dispatch recall/nudges", async () => { - const fake = fakeContext({ directory: "/repo/actual" }); - const runtime = new V2Runtime( - fake.ctx, - dependencies({ getUpdateNotice: async () => "[UPDATE AVAILABLE]" }), - ); - const first = message("user-1", "user", "Please remember this preference"); - - await runtime.handleContext({ sessionID: "session-1", messages: [first] }); - const firstParts = textParts(first); - expect(firstParts[0]).toContain("[SUPERMEMORY]"); - expect(firstParts[0]).toContain("[UPDATE AVAILABLE]"); - expect(firstParts).toContain("Please remember this preference"); - expect(firstParts.some((text) => text.includes("[MEMORY TRIGGER DETECTED]"))).toBe(true); - expect(firstParts.some((text) => text.includes("`supermemory_recall` tool"))).toBe(true); - expect(firstParts.some((text) => text.includes("`supermemory` tool with `mode: \"search\"`"))).toBe(false); - expect(fake.getCalls).toEqual(["session-1"]); - - await runtime.handleContext({ sessionID: "session-1", messages: [first] }); - expect(textParts(first)).toEqual(firstParts); - - const second = message("user-2", "user", "What did we decide?"); - await runtime.handleContext({ - sessionID: "session-1", - messages: [first, second], - }); - const secondParts = textParts(second); - expect(secondParts.some((text) => text.includes("`supermemory_recall` tool"))).toBe(true); - expect(secondParts.some((text) => text.includes("[SUPERMEMORY]"))).toBe(false); - }); - - test("keeps dispatch alive when memory context lookup fails", async () => { - const fake = fakeContext(); - const runtime = new V2Runtime( - fake.ctx, - dependencies({ - getUpdateNotice: async () => "[UPDATE AVAILABLE]", - memoryClient: memoryClient({ - getProfileScoped: async () => { - throw new Error("offline"); - }, - }), - }), - ); - const user = message("user-1", "user", "hello"); - - await expect( - runtime.handleContext({ sessionID: "session-1", messages: [user] }), - ).resolves.toBeUndefined(); - expect(textParts(user).some((text) => text.includes("supermemory_recall"))).toBe(true); - expect(textParts(user).some((text) => text.includes("[UPDATE AVAILABLE]"))).toBe(false); - }); - - test("ignores memory keywords inside code", () => { - expect(detectMemoryKeyword("remember this", ["remember"])).toBe(true); - expect(detectMemoryKeyword("```ts\nremember(this)\n```", ["remember"])).toBe(false); - expect(buildV2RecallDirective()).toContain("`supermemory_recall` tool"); - expect( - buildV2RecallDirective("Call `supermemory` now, then use `supermemory` again."), - ).toBe( - "Call `supermemory_recall` now, then use `supermemory_recall` again.", - ); - }); - - test("distinguishes identical no-ID user dispatches", async () => { - const fake = fakeContext(); - const runtime = new V2Runtime(fake.ctx, dependencies()); - const first = message("", "user", "same question"); - await runtime.handleContext({ sessionID: "session-1", messages: [first] }); - const second = message("", "user", "same question"); - await runtime.handleContext({ - sessionID: "session-1", - messages: [first, second], - }); - - expect(textParts(first).filter((text) => text.includes("supermemory_recall"))).toHaveLength(1); - expect(textParts(second).filter((text) => text.includes("supermemory_recall"))).toHaveLength(1); - }); - - test("does not allocate session caches while unconfigured", async () => { - const fake = fakeContext(); - const runtime = new V2Runtime( - fake.ctx, - dependencies({ configured: false }), - ); - const user = message("user-1", "user", "remember this"); - - await runtime.handleContext({ sessionID: "session-1", messages: [user] }); - expect(runtime.trackedSessionCount).toBe(0); - expect(textParts(user)).toEqual(["remember this"]); - expect(fake.getCalls).toHaveLength(0); - }); -}); - -describe("V2 automatic capture", () => { - test("captures completed turns at cadence and the session-end remainder", async () => { - const fake = fakeContext(); - const ingests: Array<{ - messages: Array<{ role: string; content: string }>; - metadata: Record; - customId?: string; - }> = []; - const client = memoryClient({ - ingestConversation: async ( - _conversationId: string, - messages: Array<{ role: string; content: string }>, - _tags: string[], - metadata: Record, - options: { customId?: string }, - ) => { - ingests.push({ messages, metadata, customId: options.customId }); - return { success: true, id: `capture-${ingests.length}` }; - }, - }); - const runtime = new V2Runtime(fake.ctx, dependencies({ memoryClient: client })); - - const history: Message[] = []; - for (let turn = 1; turn <= 3; turn += 1) { - history.push(message(`user-${turn}`, "user", `question ${turn}`)); - await runtime.handleContext({ sessionID: "session-1", messages: history }); - await runtime.handleEvent({ - id: `text-${turn}`, - type: "session.text.ended", - data: { - sessionID: "session-1", - assistantMessageID: `assistant-${turn}`, - ordinal: 0, - text: `answer ${turn}`, - }, - }); - if (turn === 1) { - await runtime.handleEvent({ - id: "tool-called-1", - type: "session.tool.called", - data: { - sessionID: "session-1", - assistantMessageID: "assistant-1", - id: "tool-1", - input: { path: "package.json" }, - }, - }); - await runtime.handleEvent({ - id: "tool-success-1", - type: "session.tool.success", - data: { - sessionID: "session-1", - assistantMessageID: "assistant-1", - id: "tool-1", - content: [{ type: "text", text: "tool output" }], - }, - }); - await runtime.handleEvent({ - id: "text-1-second", - type: "session.text.ended", - data: { - sessionID: "session-1", - assistantMessageID: "assistant-1", - ordinal: 1, - text: "answer 1 continued", - }, - }); - } - history.push(message(`assistant-${turn}`, "assistant", `answer ${turn}`)); - await runtime.handleEvent({ - id: `success-${turn}`, - type: "session.execution.succeeded", - data: { sessionID: "session-1" }, - }); - } - - expect(ingests).toHaveLength(1); - expect(runtime.completedCaptureCount).toBe(1); - expect(ingests[0]?.messages.map((item) => item.content)).toEqual([ - "question 1", - "answer 1\nanswer 1 continued", - "question 2", - "answer 2", - ]); - expect(JSON.stringify(ingests[0]?.messages)).not.toContain("supermemory_recall"); - - await runtime.handleEvent({ - id: "delete-1", - type: "session.deleted", - data: { sessionID: "session-1" }, - }); - expect(ingests).toHaveLength(2); - expect(ingests[1]?.messages.map((item) => item.content)).toEqual([ - "question 3", - "answer 3", - ]); - expect(ingests[1]?.metadata.captureReason).toBe("session_end"); - expect(ingests[0]?.customId).not.toBe(ingests[1]?.customId); - expect(runtime.trackedSessionCount).toBe(0); - expect(runtime.completedCaptureCount).toBe(0); - - await runtime.handleEvent({ - id: "delete-1", - type: "session.deleted", - data: { sessionID: "session-1" }, - }); - expect(ingests).toHaveLength(2); - }); - - test("does not complete failed turns and redacts private spans", async () => { - const fake = fakeContext(); - const ingests: Array> = []; - const runtime = new V2Runtime( - fake.ctx, - dependencies({ - config: { ...BASE_CONFIG, captureEveryNTurns: 0 }, - memoryClient: memoryClient({ - ingestConversation: async ( - _id: string, - messages: Array<{ role: string; content: string }>, - ) => { - ingests.push(messages); - return { success: true, id: "capture" }; - }, - }), - }), - ); - - const safe = message( - "user-safe", - "user", - "keep this secret preference", - ); - await runtime.handleContext({ sessionID: "session-1", messages: [safe] }); - await runtime.handleEvent({ - id: "text-safe", - type: "session.text.ended", - data: { - sessionID: "session-1", - assistantMessageID: "assistant-safe", - ordinal: 0, - text: "done", - }, - }); - await runtime.handleEvent({ - id: "success-safe", - type: "session.execution.succeeded", - data: { sessionID: "session-1" }, - }); - - const failed = message("user-failed", "user", "do not capture this failed turn"); - await runtime.handleContext({ - sessionID: "session-1", - messages: [safe, message("assistant-safe", "assistant", "done"), failed], - }); - await runtime.handleEvent({ - id: "text-failed", - type: "session.text.ended", - data: { - sessionID: "session-1", - assistantMessageID: "assistant-failed", - ordinal: 0, - text: "partial", - }, - }); - await runtime.handleEvent({ - id: "failed", - type: "session.execution.failed", - data: { sessionID: "session-1", error: {} }, - }); - await runtime.handleEvent({ - id: "delete", - type: "session.deleted", - data: { sessionID: "session-1" }, - }); - - expect(ingests).toHaveLength(1); - expect(ingests[0]?.map((item) => item.content)).toEqual([ - "keep this [REDACTED] preference", - "done", - ]); - }); - - test("flushes only completed turns on shutdown interruption", async () => { - const fake = fakeContext(); - const ingests: Array<{ - messages: Array<{ role: string; content: string }>; - reason: unknown; - }> = []; - const runtime = new V2Runtime( - fake.ctx, - dependencies({ - config: { ...BASE_CONFIG, captureEveryNTurns: 2 }, - memoryClient: memoryClient({ - ingestConversation: async ( - _id: string, - messages: Array<{ role: string; content: string }>, - _tags: string[], - metadata: Record, - ) => { - ingests.push({ messages, reason: metadata.captureReason }); - return { success: true, id: "capture" }; - }, - }), - }), - ); - - const userOne = message("user-1", "user", "completed question"); - await runtime.handleContext({ sessionID: "session-1", messages: [userOne] }); - await runtime.handleEvent({ - id: "text-1", - type: "session.text.ended", - data: { - sessionID: "session-1", - assistantMessageID: "assistant-1", - ordinal: 0, - text: "completed answer", - }, - }); - await runtime.handleEvent({ - id: "success-1", - type: "session.execution.succeeded", - data: { sessionID: "session-1" }, - }); - - const userTwo = message("user-2", "user", "interrupted question"); - await runtime.handleContext({ - sessionID: "session-1", - messages: [ - userOne, - message("assistant-1", "assistant", "completed answer"), - userTwo, - ], - }); - await runtime.handleEvent({ - id: "text-2", - type: "session.text.ended", - data: { - sessionID: "session-1", - assistantMessageID: "assistant-2", - ordinal: 0, - text: "partial answer", - }, - }); - await runtime.handleEvent({ - id: "shutdown", - type: "session.execution.interrupted", - data: { sessionID: "session-1", reason: "shutdown" }, - }); - - expect(ingests).toHaveLength(1); - expect(ingests[0]?.reason).toBe("session_end"); - expect(ingests[0]?.messages.map((item) => item.content)).toEqual([ - "completed question", - "completed answer", - ]); - }); -}); - -describe("V2 native compaction", () => { - test("injects bounded context and saves the event summary exactly once", async () => { - const fake = fakeContext(); - const additions: Array<{ - content: string; - metadata: Record; - customId?: string; - }> = []; - const runtime = new V2Runtime( - fake.ctx, - dependencies({ - memoryClient: memoryClient({ - listMemoriesScoped: async () => ({ - success: true, - memories: [{ id: "1", summary: "Use Bun" }], - pagination: { currentPage: 1, totalItems: 1, totalPages: 1 }, - }), - addMemory: async ( - content: string, - _tag: string, - metadata: Record, - options: { customId?: string }, - ) => { - additions.push({ content, metadata, customId: options.customId }); - return { success: true, id: "summary" }; - }, - }), - }), - ); - - await runtime.handleEvent({ - id: "compact-start", - type: "session.compaction.started", - data: { sessionID: "session-1", reason: "auto" }, - }); - const user = message("user-1", "user", "compact now"); - await runtime.handleContext({ sessionID: "session-1", messages: [user] }); - expect(textParts(user).some((text) => text.includes("[SUPERMEMORY COMPACTION CONTEXT]"))).toBe(true); - expect(textParts(user).some((text) => text.includes("Use Bun"))).toBe(true); - - const ended = { - id: "compact-ended", - type: "session.compaction.ended", - data: { - sessionID: "session-1", - reason: "auto", - text: `The complete compacted session summary ${"with retained context ".repeat(5)}`, - }, - }; - await runtime.handleEvent(ended); - await runtime.handleEvent(ended); - - expect(additions).toHaveLength(1); - expect(additions[0]?.content).toBe( - `[Session Summary]\nThe complete compacted session summary ${"with retained context ".repeat(5).trimEnd()}`, - ); - expect(additions[0]?.metadata.sm_capture_mode).toBe("compaction"); - expect(additions[0]?.customId).toMatch(/^opencode:compaction:/); - }); - - test("retries failed summary writes on the next session event", async () => { - const fake = fakeContext(); - const customIds: Array = []; - const runtime = new V2Runtime( - fake.ctx, - dependencies({ - memoryClient: memoryClient({ - addMemory: async ( - _content: string, - _tag: string, - _metadata: Record, - options: { customId?: string }, - ) => { - customIds.push(options.customId); - return customIds.length === 1 - ? { success: false, error: "temporary" } - : { success: true, id: "saved" }; - }, - }), - }), - ); - - await runtime.handleEvent({ - id: "compact-ended", - type: "session.compaction.ended", - data: { sessionID: "session-1", text: "summary ".repeat(20) }, - }); - await runtime.handleEvent({ - id: "next-event", - type: "session.execution.failed", - data: { sessionID: "session-1", error: {} }, - }); - - expect(customIds).toHaveLength(2); - expect(customIds[0]).toBe(customIds[1]); - }); - - test("does not save anything for a failed compaction", async () => { - const fake = fakeContext(); - let additions = 0; - const runtime = new V2Runtime( - fake.ctx, - dependencies({ - memoryClient: memoryClient({ - addMemory: async () => { - additions += 1; - return { success: true, id: "unexpected" }; - }, - }), - }), - ); - await runtime.handleEvent({ - id: "compact-failed", - type: "session.compaction.failed", - data: { sessionID: "session-1", error: {} }, - }); - expect(additions).toBe(0); - }); - - test("preserves the V1 short-summary skip", async () => { - const fake = fakeContext(); - let additions = 0; - const runtime = new V2Runtime( - fake.ctx, - dependencies({ - memoryClient: memoryClient({ - addMemory: async () => { - additions += 1; - return { success: true, id: "unexpected" }; - }, - }), - }), - ); - await runtime.handleEvent({ - id: "compact-short", - type: "session.compaction.ended", - data: { sessionID: "session-1", text: "too short" }, - }); - expect(additions).toBe(0); - }); -}); - -describe("V2 lifecycle hardening", () => { - test("bounds event IDs while deduplicating recent events", () => { - const deduper = new EventDeduper(2); - expect(deduper.hasSeen("a")).toBe(false); - expect(deduper.hasSeen("a")).toBe(true); - expect(deduper.hasSeen("b")).toBe(false); - expect(deduper.hasSeen("c")).toBe(false); - expect(deduper.hasSeen("a")).toBe(false); - }); - - test("a duplicate setup retires only the prior generation", async () => { - const first = fakeContext(); - let staleToolCalls = 0; - const cleanupFirst = await setupV2( - first.ctx, - dependencies({ - configured: false, - executeTool: (async () => { - staleToolCalls += 1; - return "unexpected"; - }) as V2RuntimeDependencies["executeTool"], - }), - ); - const second = fakeContext(); - const cleanupSecond = await setupV2( - second.ctx, - dependencies({ configured: false }), - ); - cleanups.push(cleanupFirst, cleanupSecond); - - expect(first.disposed.count).toBe(2); - expect(second.disposed.count).toBe(0); - const staleResult = await first.tools[0]!.execute({}, { sessionID: "stale" }); - expect(JSON.parse(staleResult.content ?? "{}").success).toBe(false); - expect(staleToolCalls).toBe(0); - cleanupFirst(); - expect(second.disposed.count).toBe(0); - cleanupSecond(); - expect(second.disposed.count).toBe(2); - }); - - test("disposes a registration that resolves after a duplicate setup wins", async () => { - let releaseTransform!: () => void; - const transformGate = new Promise((resolve) => { - releaseTransform = resolve; - }); - const first = fakeContext({ transformGate }); - const firstSetup = setupV2( - first.ctx, - dependencies({ configured: false }), - ); - await Promise.resolve(); - - const second = fakeContext(); - const cleanupSecond = await setupV2( - second.ctx, - dependencies({ configured: false }), - ); - releaseTransform(); - const cleanupFirst = await firstSetup; - cleanups.push(cleanupFirst, cleanupSecond); - - expect(first.disposed.count).toBe(1); - expect(first.contextHooks).toHaveLength(0); - expect(first.subscriptions.count).toBe(0); - expect(second.disposed.count).toBe(0); - }); - - test("cleanup does not await an event stream or registration disposal", async () => { - const fake = fakeContext({ - events: new NeverEndingEvents(), - dispose: () => new Promise(() => undefined), - }); - const cleanup = await setupV2(fake.ctx, dependencies()); - const start = performance.now(); - cleanup(); - const elapsed = performance.now() - start; - - expect(elapsed).toBeLessThan(50); - expect(fake.subscriptions.count).toBe(1); - expect(fake.subscriptions.signal?.aborted).toBe(true); - }); - - test("cleanup starts a completed remainder flush without waiting for it", async () => { - const fake = fakeContext(); - let ingestStarted = 0; - let markIngestStarted!: () => void; - const ingestStart = new Promise((resolve) => { - markIngestStarted = resolve; - }); - let finishIngest!: () => void; - const ingestGate = new Promise((resolve) => { - finishIngest = resolve; - }); - const runtime = new V2Runtime( - fake.ctx, - dependencies({ - config: { ...BASE_CONFIG, captureEveryNTurns: 0 }, - memoryClient: memoryClient({ - ingestConversation: async () => { - ingestStarted += 1; - markIngestStarted(); - await ingestGate; - return { success: true, id: "capture" }; - }, - }), - }), - ); - await runtime.register(); - await runtime.handleContext({ - sessionID: "session-1", - messages: [message("user-1", "user", "completed question")], - }); - await runtime.handleEvent({ - id: "text-1", - type: "session.text.ended", - data: { - sessionID: "session-1", - assistantMessageID: "assistant-1", - ordinal: 0, - text: "completed answer", - }, - }); - await runtime.handleEvent({ - id: "success-1", - type: "session.execution.succeeded", - data: { sessionID: "session-1" }, - }); - - const start = performance.now(); - runtime.cleanup(); - expect(performance.now() - start).toBeLessThan(50); - await ingestStart; - expect(ingestStarted).toBe(1); - finishIngest(); - }); - - test("cleanup serializes behind an active cadence write and flushes the remainder", async () => { - const fake = fakeContext(); - let releaseFirstIngest!: () => void; - let activeCadenceStarted!: () => void; - const activeCadenceStart = new Promise((resolve) => { - activeCadenceStarted = resolve; - }); - let sessionEndStarted!: () => void; - const sessionEndStart = new Promise((resolve) => { - sessionEndStarted = resolve; - }); - const activeCadenceGate = new Promise((resolve) => { - releaseFirstIngest = resolve; - }); - let concurrent = 0; - let maxConcurrent = 0; - const reasons: unknown[] = []; - const runtime = new V2Runtime( - fake.ctx, - dependencies({ - config: { ...BASE_CONFIG, captureEveryNTurns: 2 }, - memoryClient: memoryClient({ - ingestConversation: async ( - _id: string, - _messages: Array<{ role: string; content: string }>, - _tags: string[], - metadata: Record, - ) => { - reasons.push(metadata.captureReason); - concurrent += 1; - maxConcurrent = Math.max(maxConcurrent, concurrent); - if (reasons.length === 2) { - activeCadenceStarted(); - await activeCadenceGate; - } - concurrent -= 1; - if (metadata.captureReason === "session_end") { - sessionEndStarted(); - } - if (reasons.length === 1) { - return { success: false, error: "retry cadence" }; - } - return { success: true, id: `capture-${reasons.length}` }; - }, - }), - }), - ); - - const history: Message[] = []; - history.push(message("user-1", "user", "question 1")); - await runtime.handleContext({ sessionID: "session-1", messages: history }); - await runtime.handleEvent({ - id: "text-1", - type: "session.text.ended", - data: { - sessionID: "session-1", - assistantMessageID: "assistant-1", - ordinal: 0, - text: "answer 1", - }, - }); - history.push(message("assistant-1", "assistant", "answer 1")); - await runtime.handleEvent({ - id: "success-1", - type: "session.execution.succeeded", - data: { sessionID: "session-1" }, - }); - - history.push(message("user-2", "user", "question 2")); - await runtime.handleContext({ sessionID: "session-1", messages: history }); - await runtime.handleEvent({ - id: "text-2", - type: "session.text.ended", - data: { - sessionID: "session-1", - assistantMessageID: "assistant-2", - ordinal: 0, - text: "answer 2", - }, - }); - history.push(message("assistant-2", "assistant", "answer 2")); - await runtime.handleEvent({ - id: "success-2", - type: "session.execution.succeeded", - data: { sessionID: "session-1" }, - }); - - history.push(message("user-3", "user", "question 3")); - await runtime.handleContext({ sessionID: "session-1", messages: history }); - await runtime.handleEvent({ - id: "text-3", - type: "session.text.ended", - data: { - sessionID: "session-1", - assistantMessageID: "assistant-3", - ordinal: 0, - text: "answer 3", - }, - }); - const thirdSuccess = runtime.handleEvent({ - id: "success-3", - type: "session.execution.succeeded", - data: { sessionID: "session-1" }, - }); - await activeCadenceStart; - - const cleanupStart = performance.now(); - runtime.cleanup(); - expect(performance.now() - cleanupStart).toBeLessThan(50); - expect(reasons).toEqual(["cadence", "cadence"]); - expect(maxConcurrent).toBe(1); - - releaseFirstIngest(); - await thirdSuccess; - await sessionEndStart; - - expect(reasons).toEqual(["cadence", "cadence", "session_end"]); - expect(maxConcurrent).toBe(1); - }); - - test("stale context callbacks do no work", async () => { - const first = fakeContext(); - const cleanupFirst = await setupV2(first.ctx, dependencies()); - const second = fakeContext(); - const cleanupSecond = await setupV2(second.ctx, dependencies()); - cleanups.push(cleanupFirst, cleanupSecond); - - const user = message("user-1", "user", "remember this"); - await first.contextHooks[0]?.({ sessionID: "stale", messages: [user] }); - expect(textParts(user)).toEqual(["remember this"]); - expect(first.getCalls).toHaveLength(0); - }); - - test("stale event subscriptions do no work", async () => { - const events = new PushEvents(); - let staleIngests = 0; - const first = fakeContext({ events }); - const cleanupFirst = await setupV2( - first.ctx, - dependencies({ - memoryClient: memoryClient({ - ingestConversation: async () => { - staleIngests += 1; - return { success: true, id: "unexpected" }; - }, - }), - }), - ); - const second = fakeContext(); - const cleanupSecond = await setupV2(second.ctx, dependencies()); - cleanups.push(cleanupFirst, cleanupSecond); - - events.push({ - id: "stale-success", - type: "session.execution.succeeded", - data: { sessionID: "stale" }, - }); - await Promise.resolve(); - await Promise.resolve(); - expect(staleIngests).toBe(0); - expect(first.getCalls).toHaveLength(0); - }); -}); From dd6968149863b2957ec747b9fa66a8cfa404989f Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 21 Aug 2026 18:28:33 +0530 Subject: [PATCH 10/10] Delete src/config.test.ts --- src/config.test.ts | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 src/config.test.ts diff --git a/src/config.test.ts b/src/config.test.ts deleted file mode 100644 index 6480aba..0000000 --- a/src/config.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - resolveCompactionEnabled, - validateCompactionThreshold, -} from "./config.js"; - -describe("compaction configuration", () => { - test("treats zero and false as legacy disable values", () => { - expect(validateCompactionThreshold(0)).toBe(0); - expect(validateCompactionThreshold(false)).toBe(0); - expect(resolveCompactionEnabled(undefined, 0)).toBe(false); - expect(resolveCompactionEnabled(undefined, false)).toBe(false); - }); - - test("prefers the explicit compactionEnabled setting", () => { - expect(resolveCompactionEnabled(false, 0.8)).toBe(false); - expect(resolveCompactionEnabled(true, 0)).toBe(true); - }); - - test("falls back safely for invalid legacy thresholds", () => { - expect(validateCompactionThreshold(-1)).toBe(0.8); - expect(validateCompactionThreshold(2)).toBe(0.8); - expect(validateCompactionThreshold(Number.NaN)).toBe(0.8); - expect(resolveCompactionEnabled(undefined, undefined)).toBe(true); - }); -});