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