Skip to content
Merged
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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<private>` 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.
Expand Down Expand Up @@ -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,
}
```

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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": [
Expand Down Expand Up @@ -39,6 +40,7 @@
"type": "plugin",
"hooks": [
"chat.message",
"permission.ask",
"event"
]
},
Expand Down
3 changes: 2 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,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: ${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(", ")}`);
Expand Down
32 changes: 27 additions & 5 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -29,6 +29,7 @@ interface SupermemoryConfig {
compactionThreshold?: number;
autoRecallEveryPrompt?: boolean;
captureEveryNTurns?: number;
recallDirective?: string | null;
}

const DEFAULT_KEYWORD_PATTERNS = [
Expand All @@ -50,7 +51,7 @@ const DEFAULT_KEYWORD_PATTERNS = [
"always\\s+remember",
];

const DEFAULTS: Required<Omit<SupermemoryConfig, "apiKey" | "baseUrl" | "userContainerTag" | "projectContainerTag">> = {
const DEFAULTS: Required<Omit<SupermemoryConfig, "apiKey" | "baseUrl" | "userContainerTag" | "projectContainerTag" | "recallDirective">> = {
similarityThreshold: 0.6,
maxMemories: 5,
maxProjectMemories: 10,
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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 };
Expand Down
50 changes: 49 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -43,6 +45,24 @@ function combineContextParts(parts: Array<string | null | undefined>): 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<string, unknown> }).metadata ?? {}) as Record<string, unknown>;

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);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
},
};
};
Expand Down
2 changes: 1 addition & 1 deletion src/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise<AuthResult> {
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()}`;

Expand Down
Loading
Loading