From 8f891f3d378fe64e2a97b102770be2a9b528a540 Mon Sep 17 00:00:00 2001 From: ved015 <122012786+ved015@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:49:35 +0530 Subject: [PATCH 1/2] 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 --- README.md | 29 +++ package.json | 2 + src/cli.ts | 3 +- src/config.ts | 30 ++- src/index.ts | 50 ++++- src/services/capture.test.ts | 211 +++++++++++++++++++ src/services/capture.ts | 380 +++++++++++++++++++++++++++++++++++ src/services/client.ts | 6 + src/services/recall.ts | 29 +++ 9 files changed, 734 insertions(+), 6 deletions(-) create mode 100644 src/services/capture.test.ts create mode 100644 src/services/capture.ts create mode 100644 src/services/recall.ts diff --git a/README.md b/README.md index 620d227..a488d8f 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,28 @@ Relevant Memories: The agent uses this context automatically - no manual prompting needed. +### Reasoned Recall + +On **every** turn, the agent is shown a short directive asking it to silently +decide whether recalling saved memory would improve its answer to *this* +message. The model searches only when earlier work, saved conventions, or user +preferences are likely to help; trivial and self-contained messages skip the +network call. + +Recall uses the `supermemory` tool in `search` mode and is auto-approved. +Customize the directive with `recallDirective`. Set `SUPERMEMORY_DEBUG=1` to +show a `[recall-decision]` line in each reply while testing. + +### Automatic Capture + +Completed conversations are captured automatically: + +- Every `captureEveryNTurns` completed turns, OpenCode saves the new turn batch. +- Any remaining turns are flushed when the session is deleted or the OpenCode + instance shuts down. +- Synthetic plugin context is excluded and `` content is redacted. +- Stable capture IDs make repeated lifecycle events idempotent. + ### Keyword Detection Say "remember", "save this", "don't forget" etc. and the agent auto-saves to memory. @@ -257,6 +279,13 @@ Create `~/.config/opencode/supermemory.jsonc`: // Context usage ratio that triggers compaction (0-1) "compactionThreshold": 0.8, + + // Save completed conversation batches every N turns (0 = session end only) + "captureEveryNTurns": 3, + + // Override the reasoned-recall directive shown to the agent each turn + // (null or unset = built-in default) + "recallDirective": null, } ``` diff --git a/package.json b/package.json index c8f2aba..62b3848 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "scripts": { "build": "bun build ./src/index.ts --outdir ./dist --target node && bun build ./src/cli.ts --outfile ./dist/cli.js --target node && tsc --emitDeclarationOnly", "dev": "tsc --watch", + "test": "bun test", "typecheck": "tsc --noEmit" }, "keywords": [ @@ -39,6 +40,7 @@ "type": "plugin", "hooks": [ "chat.message", + "permission.ask", "event" ] }, diff --git a/src/cli.ts b/src/cli.ts index 365a497..031a0c8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -599,7 +599,8 @@ async function status(): Promise { lines.push(`API key: ${maskKey(SUPERMEMORY_API_KEY)} (${getKeySource()})`); lines.push(`API URL: ${apiUrl}`); lines.push("Memory scope: unified project container with personal/project metadata"); - lines.push(`Recall mode: ${CONFIG.autoRecallEveryPrompt ? "auto-recall on every prompt" : "session/event based"}`); + lines.push(`Recall mode: per-turn reasoned recall${CONFIG.autoRecallEveryPrompt ? " + eager session-start dump" : ""}`); + lines.push(`Recall directive: ${CONFIG.recallDirective ? "custom" : "default"}`); lines.push(`Capture cadence: ${CONFIG.captureEveryNTurns > 0 ? `every ${CONFIG.captureEveryNTurns} turn${CONFIG.captureEveryNTurns === 1 ? "" : "s"} + session end` : "session end only"}`); lines.push(`Project container: ${tags.canonical}`); lines.push(`Personal reads: ${tags.personalReads.join(", ")}`); diff --git a/src/config.ts b/src/config.ts index 3943b3b..f628f87 100644 --- a/src/config.ts +++ b/src/config.ts @@ -29,6 +29,7 @@ interface SupermemoryConfig { compactionThreshold?: number; autoRecallEveryPrompt?: boolean; captureEveryNTurns?: number; + recallDirective?: string | null; } const DEFAULT_KEYWORD_PATTERNS = [ @@ -50,7 +51,7 @@ const DEFAULT_KEYWORD_PATTERNS = [ "always\\s+remember", ]; -const DEFAULTS: Required> = { +const DEFAULTS: Required> = { similarityThreshold: 0.6, maxMemories: 5, maxProjectMemories: 10, @@ -81,6 +82,21 @@ function validateCompactionThreshold(value: number | undefined): number { return value; } +function validateCaptureEveryNTurns( + value: number | undefined, + fallback: number, +): number { + if ( + value === undefined || + !Number.isFinite(value) || + !Number.isInteger(value) || + value < 0 + ) { + return fallback; + } + return value; +} + function loadRawConfig(): { config: SupermemoryConfig; existed: boolean } { for (const path of CONFIG_FILES) { if (existsSync(path)) { @@ -156,15 +172,21 @@ export const CONFIG = { autoRecallEveryPrompt: fileConfig.autoRecallEveryPrompt ?? (configExisted ? true : DEFAULTS.autoRecallEveryPrompt), - captureEveryNTurns: - fileConfig.captureEveryNTurns ?? - (configExisted ? 3 : DEFAULTS.captureEveryNTurns), + captureEveryNTurns: validateCaptureEveryNTurns( + fileConfig.captureEveryNTurns, + configExisted ? 3 : DEFAULTS.captureEveryNTurns, + ), + recallDirective: fileConfig.recallDirective ?? null, }; export function isConfigured(): boolean { return !!SUPERMEMORY_API_KEY; } +export function getRecallConfig(): { directive: string | null } { + return { directive: CONFIG.recallDirective ?? null }; +} + export function writeInstallDefaults(isExistingInstall: boolean): void { const current = loadRawConfig().config; const next: SupermemoryConfig = { ...current }; diff --git a/src/index.ts b/src/index.ts index bfe27a0..03ed953 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,12 @@ import type { Plugin, PluginInput } from "@opencode-ai/plugin"; -import type { Part } from "@opencode-ai/sdk"; +import type { Part, Permission } from "@opencode-ai/sdk"; import { tool } from "@opencode-ai/plugin"; import { AGENT_ENTITY_CONTEXT } from "./services/entity-context.js"; import { supermemoryClient } from "./services/client.js"; import { formatContextForPrompt } from "./services/context.js"; +import { createCaptureHook } from "./services/capture.js"; +import { buildRecallDirective } from "./services/recall.js"; import { getTags } from "./services/tags.js"; import { stripPrivateContent, isFullyPrivate } from "./services/privacy.js"; import { createCompactionHook, type CompactionContext } from "./services/compaction.js"; @@ -43,6 +45,24 @@ function combineContextParts(parts: Array): string { return parts.map((part) => part?.trim()).filter(Boolean).join("\n\n"); } +function isSupermemoryRecallSearch(input: Permission): boolean { + const type = String((input as { type?: unknown }).type ?? ""); + const title = String((input as { title?: unknown }).title ?? "").toLowerCase(); + const metadata = + ((input as { metadata?: Record }).metadata ?? {}) as Record; + + const toolName = String(metadata.tool ?? metadata.toolName ?? type); + const isSupermemory = + type === "supermemory" || toolName === "supermemory" || title.includes("supermemory"); + if (!isSupermemory) return false; + + const args = (metadata.args ?? metadata.input ?? metadata.arguments ?? metadata) as Record< + string, + unknown + >; + return String(args.mode ?? "") === "search"; +} + export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { const { directory } = ctx; const tags = getTags(directory); @@ -86,6 +106,9 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { getModelLimit, }) : null; + const captureHook = isConfigured() && ctx.client + ? createCaptureHook(ctx, tags) + : null; return { "chat.message": async (input, output) => { @@ -129,6 +152,16 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { output.parts.push(nudgePart); } + const recallPart: Part = { + id: `prt_supermemory-recall-${Date.now()}`, + sessionID: input.sessionID, + messageID: output.message.id, + type: "text", + text: buildRecallDirective(), + synthetic: true, + }; + output.parts.push(recallPart); + const isFirstMessage = !injectedSessions.has(input.sessionID); if (isFirstMessage) { @@ -525,10 +558,25 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { }), }, + "permission.ask": async (input, output) => { + if (!isConfigured()) return; + try { + if (isSupermemoryRecallSearch(input)) { + output.status = "allow"; + log("permission.ask: auto-allowing supermemory recall search"); + } + } catch (error) { + log("permission.ask: ERROR", { error: String(error) }); + } + }, + event: async (input: { event: { type: string; properties?: unknown } }) => { if (compactionHook) { await compactionHook.event(input); } + if (captureHook) { + await captureHook.event(input); + } }, }; }; diff --git a/src/services/capture.test.ts b/src/services/capture.test.ts new file mode 100644 index 0000000..ad81591 --- /dev/null +++ b/src/services/capture.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; +import type { Part } from "@opencode-ai/sdk"; + +import { + buildCadenceBatches, + buildCaptureTurns, + buildSessionEndBatch, + createCaptureHook, + getCaptureId, + type SessionMessage, +} from "./capture.js"; +import type { ResolvedTags } from "./tags.js"; + +function textPart( + messageID: string, + text: string, + synthetic = false, +): Part { + return { + id: `part-${messageID}`, + sessionID: "session-1", + messageID, + type: "text", + text, + synthetic, + }; +} + +function user(id: string, text: string): SessionMessage { + return { + info: { id, role: "user", sessionID: "session-1" }, + parts: [textPart(id, text)], + }; +} + +function assistant(id: string, text: string): SessionMessage { + return { + info: { + id, + role: "assistant", + sessionID: "session-1", + finish: "stop", + }, + parts: [textPart(id, text)], + }; +} + +function conversation(turnCount: number): SessionMessage[] { + return Array.from({ length: turnCount }, (_, index) => { + const turn = index + 1; + return [ + user(`user-${turn}`, `question ${turn}`), + assistant(`assistant-${turn}`, `answer ${turn}`), + ]; + }).flat(); +} + +describe("automatic conversation capture", () => { + test("builds fixed cadence batches and a final remainder", () => { + const turns = buildCaptureTurns(conversation(7)); + const cadence = buildCadenceBatches(turns, 3); + const sessionEnd = buildSessionEndBatch(turns, 3); + + expect(cadence.map((batch) => [batch.startTurn, batch.endTurn])).toEqual([ + [1, 3], + [4, 6], + ]); + expect(sessionEnd && [sessionEnd.startTurn, sessionEnd.endTurn]).toEqual([ + 7, 7, + ]); + }); + + test("uses a stable capture ID within the API length limit", () => { + const batch = buildCadenceBatches( + [ + { + id: `msg_${"a".repeat(48)}`, + messages: [{ role: "user", content: "test" }], + }, + ], + 1, + )[0]!; + const sessionID = `ses_${"b".repeat(48)}`; + const captureId = getCaptureId(sessionID, batch); + + expect(captureId).toBe(getCaptureId(sessionID, batch)); + expect(captureId.length).toBeLessThanOrEqual(100); + expect(captureId).not.toContain(sessionID); + }); + + test("excludes synthetic context and protects private turns", () => { + const messages = [ + { + ...user("user-1", "real prompt"), + parts: [ + textPart("user-1", "injected recall", true), + textPart("user-1", "real prompt"), + ], + }, + assistant("assistant-1", "real answer"), + user("user-2", "secret prompt"), + assistant("assistant-2", "secret-derived answer"), + user("user-3", "token secret"), + assistant("assistant-3", "safe answer"), + ]; + + const turns = buildCaptureTurns(messages); + expect(turns[0]?.messages).toEqual([ + { role: "user", content: "real prompt" }, + { role: "assistant", content: "real answer" }, + ]); + expect(turns[1]?.messages).toEqual([]); + expect(turns[2]?.messages[0]).toEqual({ + role: "user", + content: "token [REDACTED]", + }); + }); + + test("captures on the third idle and flushes the final remainder once", async () => { + let messages = conversation(1); + const writes: Array<{ + conversationId: string; + metadata?: Record; + customId?: string; + }> = []; + const ctx = { + directory: "/repo", + client: { + session: { + messages: async () => ({ data: messages }), + }, + }, + }; + const tags: ResolvedTags = { + canonical: "repo_test__0123456789abcdef", + user: "repo_test__0123456789abcdef", + project: "repo_test__0123456789abcdef", + projectId: "0123456789abcdef", + projectName: "test", + personalReads: [], + projectReads: [], + allReads: [], + }; + const hook = createCaptureHook(ctx, tags, { + captureEveryNTurns: 3, + memoryClient: { + ingestConversation: async ( + conversationId, + _conversationMessages, + _containerTags, + metadata, + options, + ) => { + writes.push({ + conversationId, + metadata, + customId: options?.customId, + }); + return { success: true }; + }, + }, + }); + + await hook.event({ + event: { + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + }); + messages = conversation(3); + await hook.event({ + event: { + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + }); + await hook.event({ + event: { + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + }); + + expect(writes).toHaveLength(1); + expect(writes[0]?.metadata?.captureReason).toBe("cadence"); + + messages = conversation(4); + await hook.event({ + event: { + type: "session.idle", + properties: { sessionID: "session-1" }, + }, + }); + await hook.event({ + event: { + type: "session.deleted", + properties: { info: { id: "session-1" } }, + }, + }); + await hook.event({ + event: { + type: "session.deleted", + properties: { info: { id: "session-1" } }, + }, + }); + + expect(writes).toHaveLength(2); + expect(writes[1]?.metadata?.captureReason).toBe("session_end"); + expect(writes[0]?.customId).not.toBe(writes[1]?.customId); + }); +}); diff --git a/src/services/capture.ts b/src/services/capture.ts new file mode 100644 index 0000000..96cef0e --- /dev/null +++ b/src/services/capture.ts @@ -0,0 +1,380 @@ +import { createHash } from "node:crypto"; +import type { Part } from "@opencode-ai/sdk"; + +import { CONFIG } from "../config.js"; +import type { ConversationMessage } from "../types/index.js"; +import { supermemoryClient } from "./client.js"; +import { AGENT_ENTITY_CONTEXT } from "./entity-context.js"; +import { log } from "./logger.js"; +import { isFullyPrivate, stripPrivateContent } from "./privacy.js"; +import type { ResolvedTags } from "./tags.js"; + +interface CaptureMessageInfo { + id: string; + role: string; + sessionID?: string; + finish?: string; + summary?: unknown; +} + +export interface SessionMessage { + info: CaptureMessageInfo; + parts?: Part[]; +} + +export interface CaptureTurn { + id: string; + messages: ConversationMessage[]; +} + +export interface CaptureBatch { + startTurn: number; + endTurn: number; + turns: CaptureTurn[]; +} + +interface CaptureContext { + directory: string; + client: { + session: { + messages: (params: { + path: { id: string }; + query: { directory: string }; + }) => Promise< + { data?: SessionMessage[]; error?: unknown } | SessionMessage[] + >; + }; + }; +} + +interface ConversationWriter { + ingestConversation: ( + conversationId: string, + messages: ConversationMessage[], + containerTags: string[], + metadata?: Record, + options?: { + defaultEntityContext?: string; + customId?: string; + }, + ) => Promise<{ success: boolean; error?: string }>; +} + +export interface CaptureOptions { + captureEveryNTurns?: number; + memoryClient?: ConversationWriter; +} + +function extractText(parts: Part[] | undefined): string { + const text = (parts ?? []) + .filter( + (part): part is Part & { type: "text"; text: string } => + part.type === "text" && + part.synthetic !== true && + part.ignored !== true && + typeof part.text === "string", + ) + .map((part) => part.text) + .join("\n") + .trim(); + + if (!text || isFullyPrivate(text)) return ""; + return stripPrivateContent(text).trim(); +} + +function isFinalAssistantMessage(info: CaptureMessageInfo): boolean { + return ( + info.role === "assistant" && + info.summary !== true && + typeof info.finish === "string" && + info.finish.length > 0 && + info.finish !== "tool-calls" + ); +} + +export function buildCaptureTurns(messages: SessionMessage[]): CaptureTurn[] { + const turns: CaptureTurn[] = []; + let current: + | { + id: string; + messages: ConversationMessage[]; + fullyPrivate: boolean; + complete: boolean; + } + | undefined; + + const finishCurrent = () => { + if (current?.complete) { + turns.push({ + id: current.id, + messages: current.fullyPrivate ? [] : current.messages, + }); + } + current = undefined; + }; + + for (const message of messages) { + const { info } = message; + + if (info.role === "user") { + finishCurrent(); + const rawText = (message.parts ?? []) + .filter( + (part): part is Part & { type: "text"; text: string } => + part.type === "text" && + part.synthetic !== true && + part.ignored !== true && + typeof part.text === "string", + ) + .map((part) => part.text) + .join("\n") + .trim(); + const text = extractText(message.parts); + current = { + id: info.id, + messages: text ? [{ role: "user", content: text }] : [], + fullyPrivate: rawText.length > 0 && isFullyPrivate(rawText), + complete: false, + }; + continue; + } + + if (!current || info.role !== "assistant" || info.summary === true) { + continue; + } + + const text = extractText(message.parts); + if (text && !current.fullyPrivate) { + current.messages.push({ role: "assistant", content: text }); + } + if (isFinalAssistantMessage(info)) { + current.complete = true; + } + } + + finishCurrent(); + return turns; +} + +export function buildCadenceBatches( + turns: CaptureTurn[], + captureEveryNTurns: number, +): CaptureBatch[] { + if (captureEveryNTurns <= 0) return []; + + const batches: CaptureBatch[] = []; + const completeBatchCount = Math.floor(turns.length / captureEveryNTurns); + for (let index = 0; index < completeBatchCount; index += 1) { + const start = index * captureEveryNTurns; + const end = start + captureEveryNTurns; + batches.push({ + startTurn: start + 1, + endTurn: end, + turns: turns.slice(start, end), + }); + } + return batches; +} + +export function buildSessionEndBatch( + turns: CaptureTurn[], + captureEveryNTurns: number, +): CaptureBatch | null { + if (turns.length === 0) return null; + + const remainder = + captureEveryNTurns > 0 ? turns.length % captureEveryNTurns : turns.length; + if (remainder === 0) return null; + + const start = turns.length - remainder; + return { + startTurn: start + 1, + endTurn: turns.length, + turns: turns.slice(start), + }; +} + +export function getCaptureId( + sessionID: string, + batch: CaptureBatch, +): string { + const firstTurn = batch.turns[0]?.id ?? String(batch.startTurn); + const lastTurn = batch.turns.at(-1)?.id ?? String(batch.endTurn); + const fingerprint = `${sessionID}:${firstTurn}:${lastTurn}`; + const digest = createHash("sha256").update(fingerprint).digest("hex"); + return `opencode:capture:${digest}`; +} + +export function createCaptureHook( + ctx: CaptureContext, + tags: ResolvedTags, + options?: CaptureOptions, +) { + const captureEveryNTurns = + options?.captureEveryNTurns ?? CONFIG.captureEveryNTurns; + const memoryClient = options?.memoryClient ?? supermemoryClient; + const snapshots = new Map(); + const activeSessions = new Set(); + const completedCaptureIds = new Set(); + const inFlight = new Map>(); + + async function refreshSnapshot(sessionID: string): Promise { + const response = await ctx.client.session.messages({ + path: { id: sessionID }, + query: { directory: ctx.directory }, + }); + if (!Array.isArray(response) && !response.data) { + throw new Error(`Unable to read OpenCode session ${sessionID}`); + } + const rawMessages = Array.isArray(response) ? response : response.data ?? []; + const turns = buildCaptureTurns(rawMessages); + snapshots.set(sessionID, turns); + activeSessions.add(sessionID); + return turns; + } + + async function saveBatch( + sessionID: string, + batch: CaptureBatch, + reason: "cadence" | "session_end", + ): Promise { + const captureId = getCaptureId(sessionID, batch); + if (completedCaptureIds.has(captureId)) return; + + const messages = batch.turns.flatMap((turn) => turn.messages); + if (messages.length === 0) { + completedCaptureIds.add(captureId); + return; + } + + const result = await memoryClient.ingestConversation( + `${sessionID}:${batch.startTurn}-${batch.endTurn}`, + messages, + [tags.canonical], + { + project: tags.projectName, + sm_project_id: tags.projectId, + sm_scope: "personal", + sm_capture_mode: "automatic", + captureReason: reason, + sessionId: sessionID, + turnStart: batch.startTurn, + turnEnd: batch.endTurn, + }, + { + defaultEntityContext: AGENT_ENTITY_CONTEXT, + customId: captureId, + }, + ); + + if (result.success) { + completedCaptureIds.add(captureId); + log("[capture] conversation batch saved", { + sessionID, + reason, + startTurn: batch.startTurn, + endTurn: batch.endTurn, + }); + return; + } + + log("[capture] failed to save conversation batch", { + sessionID, + reason, + startTurn: batch.startTurn, + endTurn: batch.endTurn, + error: result.error, + }); + } + + async function captureCadence( + sessionID: string, + turns: CaptureTurn[], + ): Promise { + for (const batch of buildCadenceBatches(turns, captureEveryNTurns)) { + await saveBatch(sessionID, batch, "cadence"); + } + } + + async function captureSessionEnd(sessionID: string): Promise { + const turns = snapshots.get(sessionID); + if (!turns) return; + + await captureCadence(sessionID, turns); + const finalBatch = buildSessionEndBatch(turns, captureEveryNTurns); + if (finalBatch) { + await saveBatch(sessionID, finalBatch, "session_end"); + } + } + + async function runExclusive( + sessionID: string, + task: () => Promise, + ): Promise { + const previous = inFlight.get(sessionID) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(task); + inFlight.set(sessionID, next); + try { + await next; + } finally { + if (inFlight.get(sessionID) === next) { + inFlight.delete(sessionID); + } + } + } + + return { + async event({ event }: { event: { type: string; properties?: unknown } }) { + const props = event.properties as Record | undefined; + + if (event.type === "message.updated") { + const info = props?.info as CaptureMessageInfo | undefined; + if (info?.sessionID) activeSessions.add(info.sessionID); + return; + } + + if (event.type === "session.idle") { + const sessionID = props?.sessionID as string | undefined; + if (!sessionID) return; + + await runExclusive(sessionID, async () => { + try { + const turns = await refreshSnapshot(sessionID); + await captureCadence(sessionID, turns); + } catch (error) { + log("[capture] failed to process idle session", { + sessionID, + error: String(error), + }); + } + }); + return; + } + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined; + const sessionID = sessionInfo?.id; + if (!sessionID) return; + + await runExclusive(sessionID, async () => { + await captureSessionEnd(sessionID); + snapshots.delete(sessionID); + activeSessions.delete(sessionID); + }); + return; + } + + if (event.type === "server.instance.disposed") { + await Promise.all( + [...activeSessions].map((sessionID) => + runExclusive(sessionID, async () => { + await captureSessionEnd(sessionID); + snapshots.delete(sessionID); + activeSessions.delete(sessionID); + }), + ), + ); + } + }, + }; +} diff --git a/src/services/client.ts b/src/services/client.ts index e46e13c..bb73f69 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -478,6 +478,7 @@ export class SupermemoryClient { options?: { defaultEntityContext?: string; entityContextByContainerTag?: Record; + customId?: string; }, ) { log("ingestConversation: start", { @@ -522,8 +523,13 @@ export class SupermemoryClient { const entityContext = options?.entityContextByContainerTag?.[tag] ?? options?.defaultEntityContext; + const customId = + options?.customId && uniqueTags.length > 1 + ? `${options.customId}:${tag}` + : options?.customId; const result = await this.addMemory(content, tag, ingestMetadata, { ...(entityContext ? { entityContext } : {}), + ...(customId ? { customId } : {}), }); if (result.success) { savedIds.push(result.id); diff --git a/src/services/recall.ts b/src/services/recall.ts new file mode 100644 index 0000000..9890d62 --- /dev/null +++ b/src/services/recall.ts @@ -0,0 +1,29 @@ +import { getRecallConfig } from "../config.js"; + +export const DEFAULT_RECALL_DIRECTIVE = ` +Before responding, silently decide whether recalling saved memory (past sessions, decisions, conventions, the user's preferences) would materially improve your answer to THIS message. Reason first — don't search reflexively, and don't narrate the decision. + +Recall — by calling the \`supermemory\` tool with \`mode: "search"\` — when the message: +- refers to earlier work or decisions ("the auth flow", "like we did", "continue", "the bug from before") +- touches an area where saved conventions, patterns, or preferences likely exist +- is ambiguous in a way past context would resolve + +Skip recall when the message is self-contained, trivial, a greeting/meta, fully answerable from the current conversation, or you already recalled the relevant context this session and the topic hasn't shifted. + +Cadence is per-message: it's fine to recall on several turns in a row, and fine to never recall in a session. When you do recall, run it before answering and fold the results into your response. +`; + +const RECALL_DEBUG_SUFFIX = ` +DEBUG MODE: Begin your reply with exactly one line, then continue normally: +[recall-decision] yes|no — +"yes" means you are recalling saved Supermemory memory (via the \`supermemory\` tool with \`mode: "search"\`) for THIS message; "no" means you are skipping it. +`; + +export function buildRecallDirective(): string { + const { directive } = getRecallConfig(); + let text = directive || DEFAULT_RECALL_DIRECTIVE; + if (process.env.SUPERMEMORY_DEBUG) { + text += `\n\n${RECALL_DEBUG_SUFFIX}`; + } + return text; +} From 6728f991a66edd9e36b5f01cfbd37eb0e1ba5c70 Mon Sep 17 00:00:00 2001 From: ved015 <122012786+ved015@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:59:27 +0530 Subject: [PATCH 2/2] Bump plugin version to 2.0.10 --- package.json | 2 +- src/config.ts | 2 +- src/services/auth.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 62b3848..31b97ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-supermemory", - "version": "2.0.9", + "version": "2.0.10", "description": "OpenCode plugin that gives coding agents persistent memory using Supermemory", "type": "module", "main": "dist/index.js", diff --git a/src/config.ts b/src/config.ts index f628f87..9589a25 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,7 +5,7 @@ import { stripJsoncComments } from "./services/jsonc.js"; import { loadCredentials } from "./services/auth.js"; const CONFIG_DIR = join(homedir(), ".config", "opencode"); -export const PLUGIN_VERSION = "2.0.9"; +export const PLUGIN_VERSION = "2.0.10"; const CONFIG_FILES = [ join(CONFIG_DIR, "supermemory.jsonc"), join(CONFIG_DIR, "supermemory.json"), diff --git a/src/services/auth.ts b/src/services/auth.ts index 90b0a4d..5e368c0 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -162,7 +162,7 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { hostname: `opencode - ${hostname()}`, os: `${platform()}-${arch()}`, cwd: process.cwd(), - cli_version: "2.0.9", + cli_version: "2.0.10", }); const authUrl = `${AUTH_BASE_URL}?${params.toString()}`;