From 57f55b86f5cfeecda34e77c121841669a6af2c28 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:59:19 +0000 Subject: [PATCH 1/2] Stop writing org settings on startup; apply filterPrompt to the project namespace Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 5 ++ README.md | 18 +++-- package.json | 2 +- src/config.ts | 12 +++- src/index.ts | 4 ++ src/services/capture.ts | 4 +- src/services/client.test.ts | 125 +++++++++++++++++++++++++++++++++ src/services/client.ts | 57 ++++++++------- src/services/compaction.ts | 4 +- src/services/entity-context.ts | 4 ++ src/services/memory-tool.ts | 4 +- src/services/v5-client.ts | 18 +++-- src/v2/runtime.test.ts | 1 + src/v2/runtime.ts | 10 ++- src/version.ts | 2 +- 15 files changed, 218 insertions(+), 52 deletions(-) create mode 100644 src/services/client.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e737a3..5ad51c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index f25982f..2174407 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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"], diff --git a/package.json b/package.json index 25110cc..a52228f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/config.ts b/src/config.ts index 98cb78c..ba02458 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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"; @@ -65,14 +66,13 @@ const DEFAULT_KEYWORD_PATTERNS = [ "always\\s+remember", ]; -const DEFAULTS: Required> = { +const DEFAULTS: Required> = { 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, @@ -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, @@ -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), diff --git a/src/index.ts b/src/index.ts index 3135005..ea796d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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; diff --git a/src/services/capture.ts b/src/services/capture.ts index aaa2af4..861cff3 100644 --- a/src/services/capture.ts +++ b/src/services/capture.ts @@ -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"; @@ -268,7 +268,7 @@ export function createCaptureHook( turnEnd: batch.endTurn, }, { - defaultEntityContext: AGENT_ENTITY_CONTEXT, + defaultEntityContext: SUPPORTING_CONTEXT, customId: captureId, timeoutMs: AUTOMATIC_CAPTURE_TIMEOUT_MS, }, diff --git a/src/services/client.test.ts b/src/services/client.test.ts new file mode 100644 index 0000000..b0d88f2 --- /dev/null +++ b/src/services/client.test.ts @@ -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, +): 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 = { ...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); +}); diff --git a/src/services/client.ts b/src/services/client.ts index d7d1534..bb1147e 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -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"; @@ -131,6 +131,7 @@ function withTimeout(promise: Promise, ms: number): Promise { export class SupermemoryClient { private client: Supermemory | V5Client | null = null; private clientKey: string | null = null; + private namespacesWithContext = new Set(); private formatConversationMessage(message: ConversationMessage): string { const content = @@ -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 { + 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, diff --git a/src/services/compaction.ts b/src/services/compaction.ts index e44353d..f226866 100644 --- a/src/services/compaction.ts +++ b/src/services/compaction.ts @@ -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"; @@ -275,7 +275,7 @@ export function createCompactionHook( sm_capture_mode: "compaction", sessionId: sessionID, }, - { entityContext: AGENT_ENTITY_CONTEXT } + { entityContext: SUPPORTING_CONTEXT } ); if (result.success) { diff --git a/src/services/entity-context.ts b/src/services/entity-context.ts index 0ddc8cf..498faaf 100644 --- a/src/services/entity-context.ts +++ b/src/services/entity-context.ts @@ -1,3 +1,5 @@ +import { CONFIG } from "../config.js"; + export const AGENT_ENTITY_CONTEXT = `Shared coding-agent memory for one software repository. RULES: @@ -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; diff --git a/src/services/memory-tool.ts b/src/services/memory-tool.ts index 1a94c1d..31cdb9d 100644 --- a/src/services/memory-tool.ts +++ b/src/services/memory-tool.ts @@ -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"; @@ -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) { diff --git a/src/services/v5-client.ts b/src/services/v5-client.ts index e7be229..1f48cf6 100644 --- a/src/services/v5-client.ts +++ b/src/services/v5-client.ts @@ -65,12 +65,6 @@ export class V5Client { constructor( apiKey: string | (() => Promise), baseUrl: string, - readonly settings: { - update( - body: { shouldLLMFilter: boolean; filterPrompt: string }, - options?: LegacyOptions, - ): Promise; - }, private readonly listBudgetMs: number, ) { this.sdk = new Supermemory({ @@ -300,6 +294,18 @@ export class V5Client { }, }; + async updateNamespaceContext( + namespace: string, + supportingContext: string, + options?: LegacyOptions, + ): Promise { + await this.sdk.namespaces.update( + namespace, + { supportingContext }, + requestOptions(options), + ); + } + async deleteDocument(id: string, namespaces: string[]): Promise { if (namespaces.length === 0) throw new Error("Document deletion requires an explicit namespace"); diff --git a/src/v2/runtime.test.ts b/src/v2/runtime.test.ts index bb05a5a..a3caaf4 100644 --- a/src/v2/runtime.test.ts +++ b/src/v2/runtime.test.ts @@ -248,6 +248,7 @@ function harness( return { success: true, id: "mem_1" }; }, deleteMemory: async () => ({ success: true }), + applyNamespaceContext: async () => undefined, }; const runtime = new V2Runtime(ctx as unknown as V2Context, { diff --git a/src/v2/runtime.ts b/src/v2/runtime.ts index f422023..809af47 100644 --- a/src/v2/runtime.ts +++ b/src/v2/runtime.ts @@ -26,7 +26,7 @@ import { formatContextForPrompt, getInjectedProfileFactTexts, } from "../services/context.js"; -import { AGENT_ENTITY_CONTEXT } from "../services/entity-context.js"; +import { SUPPORTING_CONTEXT } from "../services/entity-context.js"; import { log } from "../services/logger.js"; import { executeSupermemoryTool, @@ -105,6 +105,7 @@ export const SUPERMEMORY_RECALL_INPUT = { type RuntimeMemoryClient = Pick< SupermemoryClient, | "addMemory" + | "applyNamespaceContext" | "ingestConversation" | "getProfileScoped" | "searchMemoriesForRecall" @@ -873,6 +874,9 @@ export class V2Runtime { } state.directory = directory; state.tags = this.#deps.resolveTags(directory); + if (this.#deps.configured) { + void this.#deps.memoryClient.applyNamespaceContext(state.tags.canonical); + } return state.tags; })(); @@ -1060,7 +1064,7 @@ export class V2Runtime { turnEnd: batch.endTurn, }, { - defaultEntityContext: AGENT_ENTITY_CONTEXT, + defaultEntityContext: SUPPORTING_CONTEXT, customId: captureId, timeoutMs: AUTOMATIC_CAPTURE_TIMEOUT_MS, }, @@ -1199,7 +1203,7 @@ export class V2Runtime { }, { customId: summary.customId, - entityContext: AGENT_ENTITY_CONTEXT, + entityContext: SUPPORTING_CONTEXT, timeoutMs: AUTOMATIC_CAPTURE_TIMEOUT_MS, }, ); diff --git a/src/version.ts b/src/version.ts index 435a1f1..3b850aa 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,3 +1,3 @@ // AUTO-GENERATED by scripts/sync-version.mjs — do not edit by hand. // Sourced from package.json "version" so the two cannot drift. -export const PLUGIN_VERSION = "2.1.0"; +export const PLUGIN_VERSION = "2.1.1"; From 2a5d46951ade0bb211058dfc14f62c0a8de55346 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:52:57 +0000 Subject: [PATCH 2/2] Gate the OpenCode 2 namespace context update on the live auth state Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/v2/runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/v2/runtime.ts b/src/v2/runtime.ts index 809af47..4657801 100644 --- a/src/v2/runtime.ts +++ b/src/v2/runtime.ts @@ -874,7 +874,7 @@ export class V2Runtime { } state.directory = directory; state.tags = this.#deps.resolveTags(directory); - if (this.#deps.configured) { + if (this.#configured) { void this.#deps.memoryClient.applyNamespaceContext(state.tags.canonical); } return state.tags;