Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,17 +148,17 @@ Relevant Memories:

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

### Reasoned Recall
### Direct 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.
On every substantive prompt, Supermemory directly searches the current project
and injects up to five strong, fresh matches. Short prompts and commands are
skipped, repeat results are suppressed per session, and recall fails open after
three seconds so it never blocks the agent indefinitely.

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.
Set `recallMode` to `"advisory"` to retain model-decided tool recall, or to
`"off"` to disable automatic recall. `recallDirective` customizes advisory mode.
Legacy `autoRecallEveryPrompt: true` maps to direct mode and `false` maps to
advisory mode when `recallMode` is unset.

### Automatic Capture

Expand Down Expand Up @@ -283,8 +283,10 @@ Create `~/.config/opencode/supermemory.jsonc`:
// 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)
// "direct" (default for new installs), "advisory", or "off"
"recallMode": "direct",

// Override the directive used in advisory mode
"recallDirective": null,
}
```
Expand Down
4 changes: 2 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,8 +581,8 @@ async function status(): Promise<number> {
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: per-turn reasoned recall${CONFIG.autoRecallEveryPrompt ? " + eager session-start dump" : ""}`);
lines.push(`Recall directive: ${CONFIG.recallDirective ? "custom" : "default"}`);
lines.push(`Recall mode: ${CONFIG.recallMode}`);
lines.push(`Recall directive: ${CONFIG.recallMode === "advisory" && 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(", ")}`);
Expand Down
32 changes: 28 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const CONFIG_FILES = [

export const DEFAULT_BASE_URL = "https://api.supermemory.ai";

export type RecallMode = "direct" | "advisory" | "off";

interface SupermemoryConfig {
apiKey?: string;
baseUrl?: string;
Expand All @@ -30,6 +32,7 @@ interface SupermemoryConfig {
autoRecallEveryPrompt?: boolean;
captureEveryNTurns?: number;
recallDirective?: string | null;
recallMode?: RecallMode;
}

const DEFAULT_KEYWORD_PATTERNS = [
Expand Down Expand Up @@ -63,6 +66,7 @@ const DEFAULTS: Required<Omit<SupermemoryConfig, "apiKey" | "baseUrl" | "userCon
compactionThreshold: 0.80,
autoRecallEveryPrompt: false,
captureEveryNTurns: 0,
recallMode: "direct",
};

function isValidRegex(pattern: string): boolean {
Expand Down Expand Up @@ -97,6 +101,20 @@ function validateCaptureEveryNTurns(
return value;
}

function resolveRecallMode(): RecallMode {
if (
fileConfig.recallMode === "direct" ||
fileConfig.recallMode === "advisory" ||
fileConfig.recallMode === "off"
) {
return fileConfig.recallMode;
}
if (fileConfig.recallDirective?.trim()) return "advisory";
if (fileConfig.autoRecallEveryPrompt === true) return "direct";
if (fileConfig.autoRecallEveryPrompt === false) return "advisory";
return DEFAULTS.recallMode;
}

function loadRawConfig(): { config: SupermemoryConfig; existed: boolean } {
for (const path of CONFIG_FILES) {
if (existsSync(path)) {
Expand Down Expand Up @@ -172,6 +190,7 @@ export const CONFIG = {
autoRecallEveryPrompt:
fileConfig.autoRecallEveryPrompt ??
(configExisted ? true : DEFAULTS.autoRecallEveryPrompt),
recallMode: resolveRecallMode(),
captureEveryNTurns: validateCaptureEveryNTurns(
fileConfig.captureEveryNTurns,
configExisted ? 3 : DEFAULTS.captureEveryNTurns,
Expand All @@ -183,18 +202,23 @@ export function isConfigured(): boolean {
return !!SUPERMEMORY_API_KEY;
}

export function getRecallConfig(): { directive: string | null } {
return { directive: CONFIG.recallDirective ?? null };
export function getRecallConfig(): {
directive: string | null;
mode: RecallMode;
} {
return {
directive: CONFIG.recallDirective ?? null,
mode: CONFIG.recallMode,
};
}

export function writeInstallDefaults(isExistingInstall: boolean): void {
const current = loadRawConfig().config;
const next: SupermemoryConfig = { ...current };
if (isExistingInstall) {
if (next.autoRecallEveryPrompt === undefined) next.autoRecallEveryPrompt = true;
if (next.captureEveryNTurns === undefined) next.captureEveryNTurns = 3;
} else {
next.autoRecallEveryPrompt = false;
next.recallMode = "direct";
next.captureEveryNTurns = 0;
}
writeFileSync(DEFAULT_CONFIG_FILE, JSON.stringify(next, null, 2));
Expand Down
207 changes: 118 additions & 89 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ 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 {
buildDirectRecallContext,
buildRecallDirective,
DIRECT_RECALL_TIMEOUT_MS,
RecallSessionCache,
} from "./services/recall.js";
import {
formatRecallHit,
normalizeRecallResult,
Expand Down Expand Up @@ -71,6 +76,7 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => {
const { directory } = ctx;
const tags = getTags(directory);
const injectedSessions = new Set<string>();
const recallSessions = new RecallSessionCache();
log("Plugin init", { directory, tags, configured: isConfigured() });

if (!isConfigured()) {
Expand Down Expand Up @@ -156,104 +162,115 @@ 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) injectedSessions.add(input.sessionID);

if (isFirstMessage) {
injectedSessions.add(input.sessionID);

let memoryContext = "";
const updateCheck = checkNpmUpdate(
"opencode-supermemory",
PLUGIN_VERSION,
UPDATE_COMMAND
).then((info) => (info ? formatUpdateNotice(info) : null));
if (CONFIG.recallMode === "advisory") {
output.parts.push({
id: `prt_supermemory-recall-${Date.now()}`,
sessionID: input.sessionID,
messageID: output.message.id,
type: "text",
text: buildRecallDirective(),
synthetic: true,
});
}

if (CONFIG.autoRecallEveryPrompt) {
const [profileResult, userMemoriesResult, projectMemoriesListResult] = await Promise.all([
supermemoryClient.getProfileScoped(
const profileRequest =
isFirstMessage && CONFIG.recallMode !== "off" && CONFIG.injectProfile
? supermemoryClient.getProfileScoped(
tags.canonical,
tags.personalReads,
"personal",
userMessage,
),
supermemoryClient.searchMemoriesScoped(
userMessage,
tags.canonical,
tags.personalReads,
"personal",
),
supermemoryClient.listMemoriesScoped(
tags.canonical,
tags.projectReads,
"project",
CONFIG.maxProjectMemories,
undefined,
{ timeoutMs: DIRECT_RECALL_TIMEOUT_MS },
)
: Promise.resolve(null);

const directRecall =
CONFIG.recallMode === "direct"
? buildDirectRecallContext({
prompt: userMessage,
sessionID: input.sessionID,
cache: recallSessions,
search: (query) =>
supermemoryClient.searchMemoriesForRecall(
query,
tags.canonical,
tags.personalReads,
tags.projectReads,
{ timeoutMs: DIRECT_RECALL_TIMEOUT_MS },
),
suppressTexts: isFirstMessage
? profileRequest.then((result) =>
result?.success && result.profile
? [
...result.profile.static,
...result.profile.dynamic,
]
: [],
)
: undefined,
})
: Promise.resolve("");

const firstMessage = isFirstMessage
? Promise.all([
profileRequest,
checkNpmUpdate(
"opencode-supermemory",
PLUGIN_VERSION,
UPDATE_COMMAND,
),
]);

const profile = profileResult.success ? profileResult : null;
const userMemories = userMemoriesResult.success ? userMemoriesResult : { results: [] };
const projectMemoriesList = projectMemoriesListResult.success ? projectMemoriesListResult : { memories: [] };

const projectMemories = {
results: (projectMemoriesList.memories || []).map((m: any) => ({
id: m.id,
memory: m.summary || m.content || m.title || "",
similarity: 1,
title: m.title,
metadata: m.metadata,
})),
total: projectMemoriesList.memories?.length || 0,
timing: 0,
};

memoryContext = formatContextForPrompt(
profile,
userMemories,
projectMemories
);
} else {
const profileResult = await supermemoryClient.getProfileScoped(
tags.canonical,
tags.personalReads,
"personal",
);
const profile = profileResult.success ? profileResult : null;
memoryContext = formatContextForPrompt(profile, { results: [] }, { results: [] });
}
]).then(([profileResult, updateInfo]) => {
const profile = profileResult?.success ? profileResult : null;
const memoryContext = profile
? formatContextForPrompt(
profile,
{ results: [] },
{ results: [] },
)
: "";
return combineContextParts([
memoryContext,
updateInfo ? formatUpdateNotice(updateInfo) : null,
]);
})
: Promise.resolve("");

const [directRecallContext, firstMessageContext] = await Promise.all([
directRecall,
firstMessage,
]);

if (firstMessageContext) {
output.parts.unshift({
id: `prt_supermemory-context-${Date.now()}`,
sessionID: input.sessionID,
messageID: output.message.id,
type: "text",
text: firstMessageContext,
synthetic: true,
});
}

const updateNotice = await updateCheck;
const firstMessageContext = combineContextParts([memoryContext, updateNotice]);

if (firstMessageContext) {
const contextPart: Part = {
id: `prt_supermemory-context-${Date.now()}`,
sessionID: input.sessionID,
messageID: output.message.id,
type: "text",
text: firstMessageContext,
synthetic: true,
};

output.parts.unshift(contextPart);

const duration = Date.now() - start;
log("chat.message: context injected", {
duration,
contextLength: firstMessageContext.length,
});
}
if (directRecallContext) {
output.parts.push({
id: `prt_supermemory-direct-recall-${Date.now()}`,
sessionID: input.sessionID,
messageID: output.message.id,
type: "text",
text: directRecallContext,
synthetic: true,
});
}

log("chat.message: context processed", {
duration: Date.now() - start,
firstMessageContextLength: firstMessageContext.length,
directRecallContextLength: directRecallContext.length,
});

} catch (error) {
log("chat.message: ERROR", { error: String(error) });
}
Expand Down Expand Up @@ -582,6 +599,18 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => {
},

event: async (input: { event: { type: string; properties?: unknown } }) => {
const props = input.event.properties as Record<string, unknown> | undefined;
if (input.event.type === "session.deleted") {
const sessionID = (props?.info as { id?: string } | undefined)?.id;
if (sessionID) {
injectedSessions.delete(sessionID);
recallSessions.delete(sessionID);
}
} else if (input.event.type === "server.instance.disposed") {
injectedSessions.clear();
recallSessions.clear();
}

if (compactionHook) {
await compactionHook.event(input);
}
Expand Down
Loading
Loading