Skip to content

Commit c929fd4

Browse files
committed
fix: add conditioned recall and automatic capture
Port the reasoned per-turn recall flow from #52, including scoped auto-approval for Supermemory search permissions and optional debug output. Capture completed OpenCode conversations every configured N turns and flush the final remainder on session deletion or instance shutdown. Exclude synthetic plugin context, protect private content, and use stable custom IDs for idempotency. Tests: - bun run typecheck - HOME=/private/tmp bun run test - bun run build
1 parent b25ace8 commit c929fd4

9 files changed

Lines changed: 734 additions & 6 deletions

File tree

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,28 @@ Relevant Memories:
148148

149149
The agent uses this context automatically - no manual prompting needed.
150150

151+
### Reasoned Recall
152+
153+
On **every** turn, the agent is shown a short directive asking it to silently
154+
decide whether recalling saved memory would improve its answer to *this*
155+
message. The model searches only when earlier work, saved conventions, or user
156+
preferences are likely to help; trivial and self-contained messages skip the
157+
network call.
158+
159+
Recall uses the `supermemory` tool in `search` mode and is auto-approved.
160+
Customize the directive with `recallDirective`. Set `SUPERMEMORY_DEBUG=1` to
161+
show a `[recall-decision]` line in each reply while testing.
162+
163+
### Automatic Capture
164+
165+
Completed conversations are captured automatically:
166+
167+
- Every `captureEveryNTurns` completed turns, OpenCode saves the new turn batch.
168+
- Any remaining turns are flushed when the session is deleted or the OpenCode
169+
instance shuts down.
170+
- Synthetic plugin context is excluded and `<private>` content is redacted.
171+
- Stable capture IDs make repeated lifecycle events idempotent.
172+
151173
### Keyword Detection
152174

153175
Say "remember", "save this", "don't forget" etc. and the agent auto-saves to memory.
@@ -257,6 +279,13 @@ Create `~/.config/opencode/supermemory.jsonc`:
257279

258280
// Context usage ratio that triggers compaction (0-1)
259281
"compactionThreshold": 0.8,
282+
283+
// Save completed conversation batches every N turns (0 = session end only)
284+
"captureEveryNTurns": 3,
285+
286+
// Override the reasoned-recall directive shown to the agent each turn
287+
// (null or unset = built-in default)
288+
"recallDirective": null,
260289
}
261290
```
262291

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"scripts": {
1212
"build": "bun build ./src/index.ts --outdir ./dist --target node && bun build ./src/cli.ts --outfile ./dist/cli.js --target node && tsc --emitDeclarationOnly",
1313
"dev": "tsc --watch",
14+
"test": "bun test",
1415
"typecheck": "tsc --noEmit"
1516
},
1617
"keywords": [
@@ -39,6 +40,7 @@
3940
"type": "plugin",
4041
"hooks": [
4142
"chat.message",
43+
"permission.ask",
4244
"event"
4345
]
4446
},

src/cli.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,8 @@ async function status(): Promise<number> {
599599
lines.push(`API key: ${maskKey(SUPERMEMORY_API_KEY)} (${getKeySource()})`);
600600
lines.push(`API URL: ${apiUrl}`);
601601
lines.push("Memory scope: unified project container with personal/project metadata");
602-
lines.push(`Recall mode: ${CONFIG.autoRecallEveryPrompt ? "auto-recall on every prompt" : "session/event based"}`);
602+
lines.push(`Recall mode: per-turn reasoned recall${CONFIG.autoRecallEveryPrompt ? " + eager session-start dump" : ""}`);
603+
lines.push(`Recall directive: ${CONFIG.recallDirective ? "custom" : "default"}`);
603604
lines.push(`Capture cadence: ${CONFIG.captureEveryNTurns > 0 ? `every ${CONFIG.captureEveryNTurns} turn${CONFIG.captureEveryNTurns === 1 ? "" : "s"} + session end` : "session end only"}`);
604605
lines.push(`Project container: ${tags.canonical}`);
605606
lines.push(`Personal reads: ${tags.personalReads.join(", ")}`);

src/config.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ interface SupermemoryConfig {
2929
compactionThreshold?: number;
3030
autoRecallEveryPrompt?: boolean;
3131
captureEveryNTurns?: number;
32+
recallDirective?: string | null;
3233
}
3334

3435
const DEFAULT_KEYWORD_PATTERNS = [
@@ -50,7 +51,7 @@ const DEFAULT_KEYWORD_PATTERNS = [
5051
"always\\s+remember",
5152
];
5253

53-
const DEFAULTS: Required<Omit<SupermemoryConfig, "apiKey" | "baseUrl" | "userContainerTag" | "projectContainerTag">> = {
54+
const DEFAULTS: Required<Omit<SupermemoryConfig, "apiKey" | "baseUrl" | "userContainerTag" | "projectContainerTag" | "recallDirective">> = {
5455
similarityThreshold: 0.6,
5556
maxMemories: 5,
5657
maxProjectMemories: 10,
@@ -81,6 +82,21 @@ function validateCompactionThreshold(value: number | undefined): number {
8182
return value;
8283
}
8384

85+
function validateCaptureEveryNTurns(
86+
value: number | undefined,
87+
fallback: number,
88+
): number {
89+
if (
90+
value === undefined ||
91+
!Number.isFinite(value) ||
92+
!Number.isInteger(value) ||
93+
value < 0
94+
) {
95+
return fallback;
96+
}
97+
return value;
98+
}
99+
84100
function loadRawConfig(): { config: SupermemoryConfig; existed: boolean } {
85101
for (const path of CONFIG_FILES) {
86102
if (existsSync(path)) {
@@ -156,15 +172,21 @@ export const CONFIG = {
156172
autoRecallEveryPrompt:
157173
fileConfig.autoRecallEveryPrompt ??
158174
(configExisted ? true : DEFAULTS.autoRecallEveryPrompt),
159-
captureEveryNTurns:
160-
fileConfig.captureEveryNTurns ??
161-
(configExisted ? 3 : DEFAULTS.captureEveryNTurns),
175+
captureEveryNTurns: validateCaptureEveryNTurns(
176+
fileConfig.captureEveryNTurns,
177+
configExisted ? 3 : DEFAULTS.captureEveryNTurns,
178+
),
179+
recallDirective: fileConfig.recallDirective ?? null,
162180
};
163181

164182
export function isConfigured(): boolean {
165183
return !!SUPERMEMORY_API_KEY;
166184
}
167185

186+
export function getRecallConfig(): { directive: string | null } {
187+
return { directive: CONFIG.recallDirective ?? null };
188+
}
189+
168190
export function writeInstallDefaults(isExistingInstall: boolean): void {
169191
const current = loadRawConfig().config;
170192
const next: SupermemoryConfig = { ...current };

src/index.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { Plugin, PluginInput } from "@opencode-ai/plugin";
2-
import type { Part } from "@opencode-ai/sdk";
2+
import type { Part, Permission } from "@opencode-ai/sdk";
33
import { tool } from "@opencode-ai/plugin";
44

55
import { AGENT_ENTITY_CONTEXT } from "./services/entity-context.js";
66
import { supermemoryClient } from "./services/client.js";
77
import { formatContextForPrompt } from "./services/context.js";
8+
import { createCaptureHook } from "./services/capture.js";
9+
import { buildRecallDirective } from "./services/recall.js";
810
import { getTags } from "./services/tags.js";
911
import { stripPrivateContent, isFullyPrivate } from "./services/privacy.js";
1012
import { createCompactionHook, type CompactionContext } from "./services/compaction.js";
@@ -43,6 +45,24 @@ function combineContextParts(parts: Array<string | null | undefined>): string {
4345
return parts.map((part) => part?.trim()).filter(Boolean).join("\n\n");
4446
}
4547

48+
function isSupermemoryRecallSearch(input: Permission): boolean {
49+
const type = String((input as { type?: unknown }).type ?? "");
50+
const title = String((input as { title?: unknown }).title ?? "").toLowerCase();
51+
const metadata =
52+
((input as { metadata?: Record<string, unknown> }).metadata ?? {}) as Record<string, unknown>;
53+
54+
const toolName = String(metadata.tool ?? metadata.toolName ?? type);
55+
const isSupermemory =
56+
type === "supermemory" || toolName === "supermemory" || title.includes("supermemory");
57+
if (!isSupermemory) return false;
58+
59+
const args = (metadata.args ?? metadata.input ?? metadata.arguments ?? metadata) as Record<
60+
string,
61+
unknown
62+
>;
63+
return String(args.mode ?? "") === "search";
64+
}
65+
4666
export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => {
4767
const { directory } = ctx;
4868
const tags = getTags(directory);
@@ -86,6 +106,9 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => {
86106
getModelLimit,
87107
})
88108
: null;
109+
const captureHook = isConfigured() && ctx.client
110+
? createCaptureHook(ctx, tags)
111+
: null;
89112

90113
return {
91114
"chat.message": async (input, output) => {
@@ -129,6 +152,16 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => {
129152
output.parts.push(nudgePart);
130153
}
131154

155+
const recallPart: Part = {
156+
id: `prt_supermemory-recall-${Date.now()}`,
157+
sessionID: input.sessionID,
158+
messageID: output.message.id,
159+
type: "text",
160+
text: buildRecallDirective(),
161+
synthetic: true,
162+
};
163+
output.parts.push(recallPart);
164+
132165
const isFirstMessage = !injectedSessions.has(input.sessionID);
133166

134167
if (isFirstMessage) {
@@ -525,10 +558,25 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => {
525558
}),
526559
},
527560

561+
"permission.ask": async (input, output) => {
562+
if (!isConfigured()) return;
563+
try {
564+
if (isSupermemoryRecallSearch(input)) {
565+
output.status = "allow";
566+
log("permission.ask: auto-allowing supermemory recall search");
567+
}
568+
} catch (error) {
569+
log("permission.ask: ERROR", { error: String(error) });
570+
}
571+
},
572+
528573
event: async (input: { event: { type: string; properties?: unknown } }) => {
529574
if (compactionHook) {
530575
await compactionHook.event(input);
531576
}
577+
if (captureHook) {
578+
await captureHook.event(input);
579+
}
532580
},
533581
};
534582
};

0 commit comments

Comments
 (0)