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.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.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); } }, };