From 45f58bcb03c4ac6a84bc039c5f608b3b17be41de Mon Sep 17 00:00:00 2001 From: Dhravya <63950637+Dhravya@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:31:14 +0000 Subject: [PATCH] fix: migrate scoped memory metadata --- README.md | 11 ++- src/index.ts | 24 ++++-- src/services/capture.test.ts | 2 + src/services/capture.ts | 2 +- src/services/client.test.ts | 116 +++++++++++++++++++++++++++ src/services/client.ts | 26 ++---- src/services/compaction.ts | 23 ++++-- src/services/memory-metadata.test.ts | 38 +++++++++ 8 files changed, 202 insertions(+), 40 deletions(-) create mode 100644 src/services/client.test.ts create mode 100644 src/services/memory-metadata.test.ts diff --git a/README.md b/README.md index a488d8f..8eb682b 100644 --- a/README.md +++ b/README.md @@ -220,15 +220,20 @@ The `supermemory` tool is available to the agent: **Types:** `project-config`, `architecture`, `error-solution`, `preference`, `learned-pattern`, `conversation` OpenCode sends the same shared coding-agent entity context as Claude Code and -Codex. Personal and project memories are distinguished with `sm_scope` +Codex. Personal and project memories are distinguished with `agent_scope` metadata inside the shared repository container. +> **Release dependency:** Release this plugin version only after the backend +> `sm_scope` to `agent_scope` backfill has deployed and completed. Canonical +> container reads filter only on `agent_scope`; legacy containers intentionally +> remain unfiltered for backward compatibility. + ## Memory Scoping | Scope | Tag | Metadata | | ------- | ------------------------------------------- | ----------------------- | -| User | `repo_{project-name}__{repository-hash}` | `sm_scope: "personal"` | -| Project | `repo_{project-name}__{repository-hash}` | `sm_scope: "project"` | +| User | `repo_{project-name}__{repository-hash}` | `agent_scope: "personal"` | +| Project | `repo_{project-name}__{repository-hash}` | `agent_scope: "project"` | The repository hash comes from the normalized Git `origin` remote, so Claude Code, Codex, and OpenCode use the same container for the same repository. diff --git a/src/index.ts b/src/index.ts index cb090e5..11ef1a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ 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 { getTags, type ResolvedTags } from "./services/tags.js"; import { stripPrivateContent, isFullyPrivate } from "./services/privacy.js"; import { createCompactionHook, type CompactionContext } from "./services/compaction.js"; @@ -32,6 +32,20 @@ Extract the key information the user wants remembered and save it as a concise, DO NOT skip this step. The user explicitly asked you to remember.`; const UPDATE_COMMAND = "bunx opencode-supermemory@latest install"; +export function createToolMemoryMetadata( + scope: "personal" | "project", + type: MemoryType | undefined, + tags: Pick, +) { + return { + type, + project: tags.projectName, + sm_project_id: tags.projectId, + agent_scope: scope, + sm_capture_mode: "tool", + }; +} + function removeCodeBlocks(text: string): string { return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, ""); } @@ -369,13 +383,7 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { const result = await supermemoryClient.addMemory( sanitizedContent, tags.canonical, - { - type: args.type, - project: tags.projectName, - sm_project_id: tags.projectId, - sm_scope: internalScope, - sm_capture_mode: "tool", - }, + createToolMemoryMetadata(internalScope, args.type, tags), { entityContext: AGENT_ENTITY_CONTEXT } ); diff --git a/src/services/capture.test.ts b/src/services/capture.test.ts index ad81591..b58997e 100644 --- a/src/services/capture.test.ts +++ b/src/services/capture.test.ts @@ -183,6 +183,8 @@ describe("automatic conversation capture", () => { expect(writes).toHaveLength(1); expect(writes[0]?.metadata?.captureReason).toBe("cadence"); + expect(writes[0]?.metadata?.agent_scope).toBe("personal"); + expect(writes[0]?.metadata?.sm_scope).toBeUndefined(); messages = conversation(4); await hook.event({ diff --git a/src/services/capture.ts b/src/services/capture.ts index 96cef0e..764bcdb 100644 --- a/src/services/capture.ts +++ b/src/services/capture.ts @@ -254,7 +254,7 @@ export function createCaptureHook( { project: tags.projectName, sm_project_id: tags.projectId, - sm_scope: "personal", + agent_scope: "personal", sm_capture_mode: "automatic", captureReason: reason, sessionId: sessionID, diff --git a/src/services/client.test.ts b/src/services/client.test.ts new file mode 100644 index 0000000..7f85ea7 --- /dev/null +++ b/src/services/client.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; + +import { SupermemoryClient } from "./client.js"; + +const canonicalTag = "configured-shared-team-container"; +const legacyTag = "opencode_user_test"; + +const expectedScopeFilters = { + AND: [ + { + key: "agent_scope", + value: "personal", + filterType: "metadata", + }, + ], +}; + +function createClient() { + const searchCalls: unknown[] = []; + const profileCalls: unknown[] = []; + const listCalls: unknown[] = []; + const client = new SupermemoryClient(); + + (client as unknown as { client: unknown }).client = { + search: { + memories: async (request: unknown) => { + searchCalls.push(request); + return { results: [], total: 0, timing: 0 }; + }, + }, + profile: async (request: unknown) => { + profileCalls.push(request); + return { profile: { static: [], dynamic: [] } }; + }, + memories: { + list: async (request: unknown) => { + listCalls.push(request); + return { + memories: [], + pagination: { currentPage: 1, totalItems: 0, totalPages: 0 }, + }; + }, + }, + }; + + return { client, searchCalls, profileCalls, listCalls }; +} + +describe("canonical memory scope filters", () => { + test("uses agent_scope for an arbitrary configured canonical tag and leaves legacy searches unfiltered", async () => { + const { client, searchCalls } = createClient(); + + await client.searchMemoriesScoped( + "test query", + canonicalTag, + [canonicalTag, legacyTag], + "personal", + ); + + expect(searchCalls).toEqual([ + expect.objectContaining({ + containerTag: canonicalTag, + filters: expectedScopeFilters, + }), + expect.objectContaining({ + containerTag: legacyTag, + filters: undefined, + }), + ]); + }); + + test("uses agent_scope for an arbitrary configured canonical tag and leaves legacy profiles unfiltered", async () => { + const { client, profileCalls } = createClient(); + + await client.getProfileScoped( + canonicalTag, + [canonicalTag, legacyTag], + "personal", + "test query", + ); + + expect(profileCalls).toEqual([ + expect.objectContaining({ + containerTag: canonicalTag, + q: "test query", + filters: expectedScopeFilters, + }), + expect.objectContaining({ + containerTag: legacyTag, + q: "test query", + filters: undefined, + }), + ]); + }); + + test("uses agent_scope for an arbitrary configured canonical tag and leaves legacy lists unfiltered", async () => { + const { client, listCalls } = createClient(); + + await client.listMemoriesScoped( + canonicalTag, + [canonicalTag, legacyTag], + "personal", + ); + + expect(listCalls).toEqual([ + expect.objectContaining({ + containerTags: [canonicalTag], + filters: expectedScopeFilters, + }), + expect.objectContaining({ + containerTags: [legacyTag], + filters: undefined, + }), + ]); + }); +}); diff --git a/src/services/client.ts b/src/services/client.ts index d9454fc..e9a73e0 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -81,14 +81,12 @@ export interface ListResponse { function getScopeFilters(scope: MemoryScope) { return { - AND: [{ key: "sm_scope", value: scope, filterType: "metadata" as const }], + AND: [ + { key: "agent_scope", value: scope, filterType: "metadata" as const }, + ], }; } -function supportsScopedCanonicalTag(containerTag: string): boolean { - return /^repo_.+__[0-9a-f]{16}$/i.test(containerTag); -} - function isNotFoundError(error: unknown): boolean { return ( typeof error === "object" && @@ -234,11 +232,7 @@ export class SupermemoryClient { ), ]; const responses = await Promise.all([ - this.searchMemories( - query, - canonicalTag, - supportsScopedCanonicalTag(canonicalTag) ? scope : undefined, - ), + this.searchMemories(query, canonicalTag, scope), ...legacyTags.map((containerTag) => this.searchMemories(query, containerTag), ), @@ -325,11 +319,7 @@ export class SupermemoryClient { ), ]; const responses = await Promise.all([ - this.getProfile( - canonicalTag, - query, - supportsScopedCanonicalTag(canonicalTag) ? scope : undefined, - ), + this.getProfile(canonicalTag, query, scope), ...legacyTags.map((containerTag) => this.getProfile(containerTag, query), ), @@ -507,11 +497,7 @@ export class SupermemoryClient { ), ]; const responses = await Promise.all([ - this.listMemories( - canonicalTag, - limit, - supportsScopedCanonicalTag(canonicalTag) ? scope : undefined, - ), + this.listMemories(canonicalTag, limit, scope), ...legacyTags.map((containerTag) => this.listMemories(containerTag, limit), ), diff --git a/src/services/compaction.ts b/src/services/compaction.ts index f925892..4467810 100644 --- a/src/services/compaction.ts +++ b/src/services/compaction.ts @@ -57,6 +57,20 @@ export interface CompactionOptions { getModelLimit?: (providerID: string, modelID: string) => number | undefined; } +export function createCompactionMemoryMetadata( + tags: Pick, + sessionID: string, +) { + return { + type: "conversation" as const, + project: tags.projectName, + sm_project_id: tags.projectId, + agent_scope: "personal" as const, + sm_capture_mode: "compaction", + sessionId: sessionID, + }; +} + function createCompactionPrompt(projectMemories: string[]): string { const memoriesSection = projectMemories.length > 0 ? ` @@ -308,14 +322,7 @@ export function createCompactionHook( const result = await supermemoryClient.addMemory( `[Session Summary]\n${summaryContent}`, tags.canonical, - { - type: "conversation", - project: tags.projectName, - sm_project_id: tags.projectId, - sm_scope: "personal", - sm_capture_mode: "compaction", - sessionId: sessionID, - }, + createCompactionMemoryMetadata(tags, sessionID), { entityContext: AGENT_ENTITY_CONTEXT } ); diff --git a/src/services/memory-metadata.test.ts b/src/services/memory-metadata.test.ts new file mode 100644 index 0000000..bfa32ae --- /dev/null +++ b/src/services/memory-metadata.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; + +import { createToolMemoryMetadata } from "../index.js"; +import { createCompactionMemoryMetadata } from "./compaction.js"; + +const tags = { + projectName: "test-project", + projectId: "0123456789abcdef", +}; + +describe("memory write metadata", () => { + test("builds the tool write payload with agent_scope only", () => { + const metadata = createToolMemoryMetadata("project", "preference", tags); + + expect(metadata).toEqual({ + type: "preference", + project: "test-project", + sm_project_id: "0123456789abcdef", + agent_scope: "project", + sm_capture_mode: "tool", + }); + expect(metadata).not.toHaveProperty("sm_scope"); + }); + + test("builds the compaction write payload with agent_scope only", () => { + const metadata = createCompactionMemoryMetadata(tags, "session-1"); + + expect(metadata).toEqual({ + type: "conversation", + project: "test-project", + sm_project_id: "0123456789abcdef", + agent_scope: "personal", + sm_capture_mode: "compaction", + sessionId: "session-1", + }); + expect(metadata).not.toHaveProperty("sm_scope"); + }); +});