diff --git a/README.md b/README.md index 19da222..6b436b0 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,7 @@ bunx opencode-supermemory@latest install --no-tui ``` This registers the plugin in `~/.config/opencode/opencode.jsonc` for both OpenCode V1 -and OpenCode 2, and creates the `/supermemory-index` command. Add -`--disable-context-recovery` if the user also has -[Oh My OpenCode](#usage-with-oh-my-opencode) installed, to avoid its auto-compact hook -fighting with this plugin's V1 compaction. +and OpenCode 2, and creates the `/supermemory-index` command. - Register the plugin in `~/.config/opencode/opencode.jsonc` (`plugin` for V1, `plugins` for OpenCode 2, plus an `allow` rule for the read-only `supermemory_recall` tool) @@ -127,7 +124,7 @@ Run `/supermemory-index` to have the agent explore and memorize the codebase. `/ | --- | --- | | 🧠 **Context injection**
On a session's first message, the agent silently receives your profile, all project knowledge, and (if `autoRecallEveryPrompt` is on) a semantic search over personal memories. | šŸ”Ž **Reasoned recall**
Every turn, the agent is shown a directive asking it to decide whether recalling memory would help before answering. It searches via the `supermemory` tool only when it decides to; the search itself is auto-approved. | | šŸ’¾ **Automatic capture**
Completed turns are saved every `captureEveryNTurns` turns, with any remainder flushed when the session ends or OpenCode shuts down. Synthetic plugin context is excluded and `` content is redacted. | šŸ—£ļø **Keyword detection**
Saying "remember", "save this", "don't forget", or a custom pattern nudges the agent to save to memory. | -| 🧭 **Codebase indexing**
`/supermemory-init` has the agent explore and memorize the codebase's structure, patterns, and conventions. | šŸ—œļø **Compaction memory**
On OpenCode V1, triggers summarization at 80% context capacity. On OpenCode 2, enriches the native compaction request. Both inject project memories into the summary and save the summary itself as a memory. | +| 🧭 **Codebase indexing**
`/supermemory-init` has the agent explore and memorize the codebase's structure, patterns, and conventions. | šŸ—œļø **Compaction memory**
When OpenCode compacts a long session, project memories are added to the summary and the summary itself is saved as a memory. OpenCode decides when to compact. | | šŸ”’ **Privacy**
Content wrapped in `...` is never stored. | šŸ”” **Update notices**
Checks npm for a newer release on session start and surfaces a one-line notice. | ``` @@ -186,13 +183,13 @@ Run `/supermemory-index` to explore and memorize your codebase structure, patter ### Compaction Memory -On OpenCode V1, when context hits 80% capacity (`compactionThreshold`) the plugin triggers -OpenCode's summarization, injects project memories into the summary prompt, and saves the -resulting summary as a memory. +OpenCode owns compaction on both V1 and OpenCode 2: it decides when to compact, which model +writes the summary, and how the session continues. The plugin adds bounded project memories +to OpenCode's own compaction prompt, then saves each successful summary as a memory. It never +starts a compaction or sends a continuation itself. -On OpenCode 2, OpenCode owns the compaction trigger and model. The plugin hooks the native -compaction request to add the same project memories, then saves each successful summary as -a memory. Set `compactionEnabled: false` to opt out on OpenCode 2. +Set `compactionEnabled: false` to turn this off. The older `compactionThreshold` setting is +no longer used to trigger compaction; a value of `0` or `false` still turns it off. ### Activity Notices and Status Footer @@ -244,8 +241,6 @@ What differs on OpenCode 2: - Recall runs through the read-only `supermemory_recall` tool (search only). The installer allows it without prompting; `add`, `forget`, and the rest stay behind the normal `supermemory` permission. An explicit `deny` for `supermemory_recall` is preserved. -- Compaction is native: the plugin enriches OpenCode's compaction request and saves the - summary instead of triggering compaction itself (`compactionEnabled`). - Recalled context is attached to the outgoing model request for the current prompt rather than persisted into the transcript. @@ -374,10 +369,7 @@ scope, not from a different or malformed response. // Extra keyword patterns for memory detection (regex) "keywordPatterns": ["log\\s+this", "write\\s+down"], - // OpenCode V1: context usage ratio that triggers compaction (0-1) - "compactionThreshold": 0.8, - - // OpenCode 2: enrich native compaction with project memories and save summaries + // Add project memories to OpenCode's compaction summaries and save the summaries "compactionEnabled": true, // Save completed conversation batches every N turns (0 = session end only) @@ -418,20 +410,6 @@ This is useful to preserve a legacy personal memory container, sync memories bet machines for the same project, organize memories with your own naming scheme, or integrate with existing Supermemory container tags from other tools. -## 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 -(or pass `--disable-context-recovery` to `install`): - -```json -{ - "disabled_hooks": ["anthropic-context-window-limit-recovery"] -} -``` - -Add that to `~/.config/opencode/oh-my-opencode.json`. -
Development
diff --git a/package.json b/package.json index f9f3c4e..cb4874f 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "hooks": [ "chat.message", "permission.ask", + "experimental.session.compacting", "event" ] }, diff --git a/src/cli.ts b/src/cli.ts index d63c00d..d990d77 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,7 +25,6 @@ const OPENCODE_TUI_CONFIGS = [ join(OPENCODE_CONFIG_DIR, "tui.jsonc"), join(OPENCODE_CONFIG_DIR, "tui.json"), ]; -const OH_MY_OPENCODE_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json"); const DEFAULT_CONFIG_FILE = CONFIG_FILE ?? join(OPENCODE_CONFIG_DIR, "supermemory.json"); const SUPERMEMORY_INDEX_COMMAND = `--- @@ -359,58 +358,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 { @@ -464,33 +413,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"); @@ -707,7 +632,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 @@ -729,13 +653,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.ts b/src/config.ts index 2946693..1dfb796 100644 --- a/src/config.ts +++ b/src/config.ts @@ -29,9 +29,13 @@ interface SupermemoryConfig { projectContainerTag?: string; filterPrompt?: string; keywordPatterns?: string[]; - compactionThreshold?: number; - /** OpenCode 2 only: enrich native compaction with project memories and save summaries. */ + /** Enrich OpenCode's native compaction with project memories and save summaries. */ compactionEnabled?: boolean; + /** + * @deprecated OpenCode now decides when to compact. Only `0` or `false` is + * still read, as a way to turn compaction memory off; use compactionEnabled. + */ + compactionThreshold?: number | false; autoRecallEveryPrompt?: boolean; captureEveryNTurns?: number; recallDirective?: string | null; @@ -57,6 +61,8 @@ const DEFAULT_KEYWORD_PATTERNS = [ "always\\s+remember", ]; +const DEFAULT_COMPACTION_THRESHOLD = 0.8; + const DEFAULTS: Required> = { similarityThreshold: 0.55, maxMemories: 5, @@ -66,7 +72,7 @@ const DEFAULTS: Required 1) return DEFAULTS.compactionThreshold; + if (value < 0 || value > 1) return DEFAULT_COMPACTION_THRESHOLD; return value; } +/** `compactionEnabled` wins; otherwise a legacy threshold of 0 or false turns it off. */ +export function resolveCompactionEnabled( + enabled: unknown, + legacyThreshold: number | false | undefined, +): boolean { + if (typeof enabled === "boolean") return enabled; + return validateCompactionThreshold(legacyThreshold) !== 0; +} + function validateCaptureEveryNTurns( value: number | undefined, fallback: number, @@ -200,10 +218,10 @@ export const CONFIG = { ...(fileConfig.keywordPatterns ?? []).filter(isValidRegex), ], compactionThreshold: validateCompactionThreshold(fileConfig.compactionThreshold), - compactionEnabled: - typeof fileConfig.compactionEnabled === "boolean" - ? fileConfig.compactionEnabled - : DEFAULTS.compactionEnabled, + compactionEnabled: resolveCompactionEnabled( + fileConfig.compactionEnabled, + fileConfig.compactionThreshold, + ), autoRecallEveryPrompt: fileConfig.autoRecallEveryPrompt ?? (configExisted ? true : DEFAULTS.autoRecallEveryPrompt), diff --git a/src/index.ts b/src/index.ts index b062239..79a5c11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -89,37 +89,9 @@ 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 + const compactionHook = isConfigured() && ctx.client && CONFIG.compactionEnabled ? createCompactionHook(ctx as CompactionContext, tags, { - threshold: CONFIG.compactionThreshold, - getModelLimit, + onSaved: () => activity.saved(), }) : null; const captureHook = isConfigured() && ctx.client @@ -127,6 +99,12 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { : 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.ts b/src/services/compaction.ts index e44353d..a0d983e 100644 --- a/src/services/compaction.ts +++ b/src/services/compaction.ts @@ -1,270 +1,149 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; +import { createHash } from "node:crypto"; + import { AGENT_ENTITY_CONTEXT } from "./entity-context.js"; -import { createCompactionPrompt } from "./compaction-prompt.js"; +import { AUTOMATIC_CAPTURE_TIMEOUT_MS } from "./capture.js"; +import { + COMPACTION_CONTEXT_MARKER, + createCompactionPrompt, + fitProjectMemories, +} from "./compaction-prompt.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 MIN_SUMMARY_CHARS = 100; interface MessageInfo { id: string; role: string; sessionID: string; - providerID?: string; - modelID?: string; - tokens?: TokenInfo; - summary?: boolean; - finish?: boolean; + summary?: unknown; + 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; - directory: string; - agent?: string; -} - -export interface CompactionOptions { - threshold?: number; - getModelLimit?: (providerID: string, modelID: string) => number | undefined; -} - -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; +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; timeoutMs?: number }, + ) => Promise<{ success: boolean; id?: string; error?: string }>; } -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; +export interface CompactionContext { + directory: string; + client: { + session: { + messages: (params: { + path: { id: string }; + query: { directory: string }; + }) => Promise<{ data?: SessionMessage[] } | SessionMessage[]>; + }; + }; } -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; +export interface CompactionOptions { + memoryClient?: CompactionMemoryClient; + /** Called after a compaction summary is saved as a memory. */ + onSaved?: () => void; } -function generateMessageId(): string { - const timestamp = Date.now().toString(16); - const random = Math.random().toString(36).substring(2, 14); - return `msg_${timestamp}${random}`; +function getResponseMessages( + response: { data?: SessionMessage[] } | SessionMessage[], +): SessionMessage[] { + return Array.isArray(response) ? response : response.data ?? []; } -function generatePartId(): string { - const timestamp = Date.now().toString(16); - const random = Math.random().toString(36).substring(2, 10); - return `prt_${timestamp}${random}`; +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(); } -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; - } +function isFinishedSummary(info: MessageInfo | undefined): info is MessageInfo { + return Boolean( + info?.sessionID && + info.role === "assistant" && + info.summary === true && + info.finish, + ); } -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 isFailedSummary(info: MessageInfo): boolean { + return info.finish === "error" || Boolean(info.error); } +/** + * OpenCode V1 compaction support. OpenCode decides when to compact, which + * model summarizes, and how the session continues. Supermemory adds bounded + * project memories to OpenCode's own compaction prompt and saves each + * successful summary as a memory. + */ export function createCompactionHook( ctx: CompactionContext, tags: ResolvedTags, - options?: CompactionOptions + options?: CompactionOptions, ) { - const state: CompactionState = { - lastCompactionTime: new Map(), - compactionInProgress: new Set(), - summarizedSessions: new Set(), - }; - - const threshold = options?.threshold ?? DEFAULT_THRESHOLD; - const getModelLimit = options?.getModelLimit; + const memoryClient = options?.memoryClient ?? supermemoryClient; + const pendingSessions = new Set(); + const captureInProgress = new Set(); + const capturedSummaryIDs = new Map>(); - 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, + summaryID: string, + summaryContent: string, + ): Promise { + if (summaryContent.length < MIN_SUMMARY_CHARS) { + 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, { @@ -275,239 +154,141 @@ export function createCompactionHook( sm_capture_mode: "compaction", sessionId: sessionID, }, - { entityContext: AGENT_ENTITY_CONTEXT } + { + // A stable id per summary makes a repeated save of the same summary a no-op. + customId: `opencode:compaction:${createHash("sha256").update(`${sessionID}:${summaryID}`).digest("hex")}`, + entityContext: AGENT_ENTITY_CONTEXT, + timeoutMs: AUTOMATIC_CAPTURE_TIMEOUT_MS, + }, ); 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, + }); + options?.onSaved?.(); + 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 }); + const capturedForSession = capturedSummaryIDs.get(sessionID); + if (expectedSummaryID && capturedForSession?.has(expectedSummaryID)) return; + captureInProgress.add(sessionID); try { - await injectCompactionContext({ - sessionID, - providerID, - modelID, - usageRatio, - directory: ctx.directory, - agent, - }); - - state.summarizedSessions.add(sessionID); - - await ctx.client.session.summarize({ + const response = await ctx.client.session.messages({ path: { id: sessionID }, - body: { providerID, modelID }, query: { directory: ctx.directory }, }); + const summaries = getResponseMessages(response).filter( + (message) => isFinishedSummary(message.info) && !isFailedSummary(message.info), + ); + const summary = expectedSummaryID + ? summaries.find((message) => message.info.id === expectedSummaryID) + : summaries.at(-1); - await ctx.client.tui.showToast({ - body: { - title: "Compaction Complete", - message: "Session compacted with Supermemory context. Resuming...", - variant: "success", - duration: 2000, - }, - }).catch(() => {}); + if (!summary) { + log("[compaction] summary message not available yet", { sessionID }); + return; + } + if (capturedSummaryIDs.get(sessionID)?.has(summary.info.id)) return; - state.compactionInProgress.delete(sessionID); + const summaryContent = getSummaryContent(summary); + if (!summaryContent) { + log("[compaction] summary content not available yet", { + sessionID, + summaryID: summary.info.id, + }); + return; + } - setTimeout(async () => { - try { - const messageDir = getMessageDir(sessionID); - const storedMessage = messageDir ? findNearestMessageWithFields(messageDir) : null; + if (!(await saveSummaryAsMemory(sessionID, summary.info.id, summaryContent))) { + return; + } - 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); + 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); } } - 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 }); - - try { - const resp = 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 - ); - - log("[compaction] looking for summary message", { - sessionID, - found: !!summaryMessage, - hasParts: !!summaryMessage?.parts - }); + return { + async compacting( + input: { sessionID: string }, + output: { context: string[]; prompt?: string }, + ): Promise { + pendingSessions.add(input.sessionID); - 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 - }); - - if (summaryContent) { - await saveSummaryAsMemory(sessionID, summaryContent); + try { + const projectMemories = await fetchProjectMemories(); + if (!output.context.some((item) => item.includes(COMPACTION_CONTEXT_MARKER))) { + output.context.push(createCompactionPrompt(projectMemories)); } + 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), + }); } - } catch (err) { - log("[compaction] failed to capture summary", { error: String(err) }); - } - } + }, - return { async event({ event }: { event: { type: string; properties?: unknown } }) { - const props = event.properties as Record | undefined; + const properties = event.properties as Record | undefined; - 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); + if (event.type === "message.updated") { + const info = properties?.info as MessageInfo | undefined; + if (!isFinishedSummary(info)) return; + if (isFailedSummary(info)) { + pendingSessions.delete(info.sessionID); + log("[compaction] native compaction failed; summary not captured", { + sessionID: info.sessionID, + }); + return; } + await captureSummary(info.sessionID, info.id); return; } - if (event.type === "message.updated") { - const info = props?.info as MessageInfo | undefined; - if (!info) return; - - const sessionID = info.sessionID; - if (!sessionID) return; - - if (info.role === "assistant" && info.summary === true && info.finish) { - await handleSummaryMessage(sessionID, info); - return; + if (event.type === "session.compacted" || event.type === "session.idle") { + const sessionID = properties?.sessionID as string | undefined; + if (sessionID && pendingSessions.has(sessionID)) { + await captureSummary(sessionID); } - - if (info.role !== "assistant" || !info.finish) return; - - await checkAndTriggerCompaction(sessionID, info); 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; - } - } - - 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); } }, };