Skip to content
Draft
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 2.1.1

- Stop writing the organization's `filterPrompt` / `shouldLLMFilter` settings on startup, so the plugin no longer overwrites organization context.
- Apply a configured `filterPrompt` as the project container's supporting context instead: captures send it, and v5 sets it once at startup with `PATCH /ns/{namespace}`. Without one, no settings are written.

## 2.0.16

- Use the official pinned Supermemory 5.0.1 SDK for hosted content, preserving namespace strings, batch IDs, metadata, search defaults, capture cadence, and permissions.
Expand Down
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,11 +321,16 @@ keep the old content protocol on any endpoint. Failed requests never switch API
versions or reroute to hosted Supermemory, and existing container names, IDs,
credentials, capture cadence, and configuration files are not migrated or renamed.

The existing best-effort `filterPrompt` / `shouldLLMFilter` settings update remains
on the legacy settings endpoint for both protocols. v5 has no equivalent filter
toggle; the plugin does not replace organization context or request administrator
permissions. A server without that legacy endpoint cannot apply these settings;
capture and recall still work, and the failure is recorded in the existing log.
The plugin never writes organization settings. Without `filterPrompt`, captures carry
the built-in coding-agent context and the plugin makes no settings call. If you set
`filterPrompt` (up to 1500 characters; longer values are ignored), it replaces that
built-in context as the supporting context of the project container: every capture
sends it, and on v5 the plugin also applies it once at startup with
`PATCH /ns/{namespace}`. That startup update needs an admin key and an existing
container; when it fails the failure is logged and the next capture applies the
prompt instead. The project container is shared with the Claude Code, Codex and
Cursor plugins, which send the built-in context, so the most recent writer's
context applies.
Browser login and account status also retain their separate legacy auth/session
boundary.

Expand Down Expand Up @@ -377,6 +382,9 @@ scope, not from a different or malformed response.
// Optional: set exact project container tag (overrides auto-generated tag)
"projectContainerTag": "my-project-tag",

// Optional supporting context for memories in the project container (max 1500 characters)
"filterPrompt": "Focus on architecture decisions and build tooling.",

// Extra keyword patterns for memory detection (regex)
"keywordPatterns": ["log\\s+this", "write\\s+down"],

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-supermemory",
"version": "2.1.0",
"version": "2.1.1",
"description": "OpenCode plugin that gives coding agents persistent memory using Supermemory",
"type": "module",
"main": "dist/index.js",
Expand Down
12 changes: 9 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const CONFIG_FILES = [
];

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

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

Expand Down Expand Up @@ -65,14 +66,13 @@ const DEFAULT_KEYWORD_PATTERNS = [
"always\\s+remember",
];

const DEFAULTS: Required<Omit<SupermemoryConfig, "apiKey" | "baseUrl" | "apiVersion" | "userContainerTag" | "projectContainerTag" | "recallDirective">> = {
const DEFAULTS: Required<Omit<SupermemoryConfig, "apiKey" | "baseUrl" | "apiVersion" | "userContainerTag" | "projectContainerTag" | "filterPrompt" | "recallDirective">> = {
similarityThreshold: 0.55,
maxMemories: 5,
maxProjectMemories: 10,
maxProfileItems: 5,
injectProfile: true,
containerTagPrefix: "opencode",
filterPrompt: "You are a stateful coding agent. Remember all the information, including but not limited to user's coding preferences, tech stack, behaviours, workflows, and any other relevant details.",
keywordPatterns: [],
compactionThreshold: 0.80,
compactionEnabled: true,
Expand All @@ -98,6 +98,12 @@ function validateCompactionThreshold(value: number | undefined): number {
return value;
}

function resolveFilterPrompt(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const prompt = value.trim();
return prompt && prompt.length <= MAX_SUPPORTING_CONTEXT_CHARS ? prompt : undefined;
}

function validateCaptureEveryNTurns(
value: number | undefined,
fallback: number,
Expand Down Expand Up @@ -269,7 +275,7 @@ export const CONFIG = {
containerTagPrefix: fileConfig.containerTagPrefix ?? DEFAULTS.containerTagPrefix,
userContainerTag: fileConfig.userContainerTag,
projectContainerTag: fileConfig.projectContainerTag,
filterPrompt: fileConfig.filterPrompt ?? DEFAULTS.filterPrompt,
filterPrompt: resolveFilterPrompt(fileConfig.filterPrompt),
keywordPatterns: [
...DEFAULT_KEYWORD_PATTERNS,
...(fileConfig.keywordPatterns ?? []).filter(isValidRegex),
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => {
? createCaptureHook(ctx, tags, { onSaved: () => activity.saved() })
: null;

if (isConfigured()) {
void supermemoryClient.applyNamespaceContext(tags.canonical);
}

return {
"chat.message": async (input, output) => {
if (!isConfigured()) return;
Expand Down
4 changes: 2 additions & 2 deletions src/services/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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 { SUPPORTING_CONTEXT } from "./entity-context.js";
import { log } from "./logger.js";
import { isFullyPrivate, stripPrivateContent } from "./privacy.js";
import type { ResolvedTags } from "./tags.js";
Expand Down Expand Up @@ -268,7 +268,7 @@ export function createCaptureHook(
turnEnd: batch.endTurn,
},
{
defaultEntityContext: AGENT_ENTITY_CONTEXT,
defaultEntityContext: SUPPORTING_CONTEXT,
customId: captureId,
timeoutMs: AUTOMATIC_CAPTURE_TIMEOUT_MS,
},
Expand Down
125 changes: 125 additions & 0 deletions src/services/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { AGENT_ENTITY_CONTEXT } from "./entity-context.js";

const CLIENT_MODULE = join(import.meta.dir, "client.ts");
const CONTEXT_MODULE = join(import.meta.dir, "entity-context.ts");
const NAMESPACE = "repo_demo__0123456789abcdef";

interface RecordedRequest {
method: string;
path: string;
body: unknown;
}

async function runClient(
config: Record<string, unknown>,
): Promise<{ requests: RecordedRequest[]; captureContext: string }> {
const requests: RecordedRequest[] = [];
const server = Bun.serve({
port: 0,
async fetch(request) {
const text = await request.text();
requests.push({
method: request.method,
path: new URL(request.url).pathname,
body: text ? JSON.parse(text) : undefined,
});
return Response.json({ error: "not found" }, { status: 404 });
},
});
const home = mkdtempSync(join(tmpdir(), "opencode-supermemory-"));
try {
mkdirSync(join(home, ".config", "opencode"), { recursive: true });
writeFileSync(
join(home, ".config", "opencode", "supermemory.json"),
JSON.stringify({ apiKey: "sm_test", baseUrl: server.url.origin, ...config }),
);
const env: Record<string, string | undefined> = { ...process.env, HOME: home };
for (const name of [
"SUPERMEMORY_API_KEY",
"SUPERMEMORY_API_URL",
"SUPERMEMORY_BASE_URL",
"SUPERMEMORY_API_VERSION",
]) {
delete env[name];
}
const script = `
const { supermemoryClient } = await import(${JSON.stringify(CLIENT_MODULE)});
await supermemoryClient.applyNamespaceContext(${JSON.stringify(NAMESPACE)});
await supermemoryClient.applyNamespaceContext(${JSON.stringify(NAMESPACE)});
await supermemoryClient.searchMemories("stack", ${JSON.stringify(NAMESPACE)});
await Bun.sleep(100);
const { SUPPORTING_CONTEXT } = await import(${JSON.stringify(CONTEXT_MODULE)});
console.log(JSON.stringify(SUPPORTING_CONTEXT));
`;
const child = Bun.spawn([process.execPath, "-e", script], {
env,
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await child.exited;
if (exitCode !== 0) {
throw new Error(await new Response(child.stderr).text());
}
const stdout = await new Response(child.stdout).text();
return { requests, captureContext: JSON.parse(stdout.trim().split("\n").at(-1)!) };
} finally {
server.stop(true);
rmSync(home, { recursive: true, force: true });
}
}

function isOrgSettingsWrite(request: RecordedRequest): boolean {
return request.path.includes("/settings") || request.path.startsWith("/organization");
}

describe("client startup", () => {
test("v5 without a filterPrompt writes no settings", async () => {
const { requests, captureContext } = await runClient({ apiVersion: "v5" });
expect(captureContext).toBe(AGENT_ENTITY_CONTEXT);
expect(requests.some(isOrgSettingsWrite)).toBe(false);
expect(requests.some((request) => request.method === "PATCH")).toBe(false);
expect(requests.some((request) => request.path === `/ns/${NAMESPACE}/search`)).toBe(true);
}, 20_000);

test("v5 with a filterPrompt patches only the plugin namespace, once", async () => {
const { requests, captureContext } = await runClient({
apiVersion: "v5",
filterPrompt: " Focus on build tooling decisions. ",
});
expect(captureContext).toBe("Focus on build tooling decisions.");
expect(requests.some(isOrgSettingsWrite)).toBe(false);
const patches = requests.filter((request) => request.method === "PATCH");
expect(patches).toEqual([
{
method: "PATCH",
path: `/ns/${NAMESPACE}`,
body: { supportingContext: "Focus on build tooling decisions." },
},
]);
}, 20_000);

test("v5 ignores a filterPrompt over the 1500 character limit", async () => {
const { requests, captureContext } = await runClient({
apiVersion: "v5",
filterPrompt: "x".repeat(1501),
});
expect(captureContext).toBe(AGENT_ENTITY_CONTEXT);
expect(requests.some((request) => request.method === "PATCH")).toBe(false);
}, 20_000);

test("legacy protocol writes no settings even with a filterPrompt", async () => {
const { requests, captureContext } = await runClient({
apiVersion: "legacy",
filterPrompt: "Focus on build tooling decisions.",
});
expect(captureContext).toBe("Focus on build tooling decisions.");
expect(requests.some(isOrgSettingsWrite)).toBe(false);
expect(requests.some((request) => request.method === "PATCH")).toBe(false);
expect(requests.some((request) => request.path === "/v4/search")).toBe(true);
}, 20_000);
});
57 changes: 30 additions & 27 deletions src/services/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type {

const TIMEOUT_MS = 30000;
const TIMEOUT_BACKSTOP_GRACE_MS = 250;
const SETTINGS_UPDATE_TIMEOUT_MS = 3_000;
const NAMESPACE_CONTEXT_TIMEOUT_MS = 3_000;
const MAX_CONVERSATION_CHARS = 100_000;
const OPENCODE_SOURCE = "opencode";

Expand Down Expand Up @@ -131,6 +131,7 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
export class SupermemoryClient {
private client: Supermemory | V5Client | null = null;
private clientKey: string | null = null;
private namespacesWithContext = new Set<string>();

private formatConversationMessage(message: ConversationMessage): string {
const content =
Expand Down Expand Up @@ -175,44 +176,46 @@ export class SupermemoryClient {
}
if (!this.client) {
this.clientKey = clientKey;
const legacyClient = new Supermemory({
apiKey: getApiKey() || "oauth",
baseURL: baseUrl,
fetch: createAuthenticatedFetch(baseUrl),
defaultHeaders: { "x-sm-source": OPENCODE_SOURCE },
});
this.client =
apiVersion === "v5"
? new V5Client(
() => getAuthToken(undefined, baseUrl),
baseUrl,
legacyClient.settings,
TIMEOUT_MS - TIMEOUT_BACKSTOP_GRACE_MS,
)
: legacyClient;
try {
void this.client.settings
.update(
{
shouldLLMFilter: true,
filterPrompt: CONFIG.filterPrompt,
},
{ timeout: SETTINGS_UPDATE_TIMEOUT_MS, maxRetries: 0 },
)
.catch((error) => {
log("settings.update: best-effort update failed", {
error: error instanceof Error ? error.message : String(error),
: new Supermemory({
apiKey: getApiKey() || "oauth",
baseURL: baseUrl,
fetch: createAuthenticatedFetch(baseUrl),
defaultHeaders: { "x-sm-source": OPENCODE_SOURCE },
});
});
} catch (error) {
log("settings.update: best-effort update failed", {
error: error instanceof Error ? error.message : String(error),
});
}
}
return this.client;
}

// Best-effort and once per namespace; captures carry the same prompt, so a failed PATCH still converges.
async applyNamespaceContext(
namespace: string,
supportingContext = CONFIG.filterPrompt,
): Promise<void> {
if (!supportingContext || this.namespacesWithContext.has(namespace)) return;
this.namespacesWithContext.add(namespace);
try {
const client = this.getClient();
if (!(client instanceof V5Client)) return;
await client.updateNamespaceContext(namespace, supportingContext, {
timeout: NAMESPACE_CONTEXT_TIMEOUT_MS,
maxRetries: 0,
});
log("applyNamespaceContext: success", { namespace });
} catch (error) {
log("applyNamespaceContext: best-effort update failed", {
namespace,
error: error instanceof Error ? error.message : String(error),
});
}
}

async searchMemories(
query: string,
containerTag: string,
Expand Down
4 changes: 2 additions & 2 deletions src/services/compaction.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { AGENT_ENTITY_CONTEXT } from "./entity-context.js";
import { SUPPORTING_CONTEXT } from "./entity-context.js";
import { createCompactionPrompt } from "./compaction-prompt.js";
import { supermemoryClient } from "./client.js";
import { log } from "./logger.js";
Expand Down Expand Up @@ -275,7 +275,7 @@ export function createCompactionHook(
sm_capture_mode: "compaction",
sessionId: sessionID,
},
{ entityContext: AGENT_ENTITY_CONTEXT }
{ entityContext: SUPPORTING_CONTEXT }
);

if (result.success) {
Expand Down
4 changes: 4 additions & 0 deletions src/services/entity-context.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { CONFIG } from "../config.js";

export const AGENT_ENTITY_CONTEXT = `Shared coding-agent memory for one software repository.

RULES:
Expand Down Expand Up @@ -25,3 +27,5 @@ SKIP:
// every agent must send the same combined context for the shared repository tag.
export const USER_ENTITY_CONTEXT = AGENT_ENTITY_CONTEXT;
export const PROJECT_ENTITY_CONTEXT = AGENT_ENTITY_CONTEXT;

export const SUPPORTING_CONTEXT = CONFIG.filterPrompt ?? AGENT_ENTITY_CONTEXT;
4 changes: 2 additions & 2 deletions src/services/memory-tool.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isConfigured } from "../config.js";
import type { MemoryScope, MemoryType } from "../types/index.js";
import { supermemoryClient, type SupermemoryClient } from "./client.js";
import { AGENT_ENTITY_CONTEXT } from "./entity-context.js";
import { SUPPORTING_CONTEXT } from "./entity-context.js";
import { isFullyPrivate, stripPrivateContent } from "./privacy.js";
import { formatRecallHit, normalizeRecallResult } from "./recall-results.js";
import type { ResolvedTags } from "./tags.js";
Expand Down Expand Up @@ -151,7 +151,7 @@ export async function executeSupermemoryTool(
sm_scope: internalScope,
sm_capture_mode: "tool",
},
{ entityContext: AGENT_ENTITY_CONTEXT },
{ entityContext: SUPPORTING_CONTEXT },
);

if (!result.success) {
Expand Down
Loading
Loading