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
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 16 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<ResolvedTags, "projectName" | "projectId">,
) {
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, "");
}
Expand Down Expand Up @@ -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 }
);

Expand Down
2 changes: 2 additions & 0 deletions src/services/capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion src/services/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
116 changes: 116 additions & 0 deletions src/services/client.test.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
]);
});
});
26 changes: 6 additions & 20 deletions src/services/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" &&
Expand Down Expand Up @@ -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),
),
Expand Down Expand Up @@ -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),
),
Expand Down Expand Up @@ -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),
),
Expand Down
23 changes: 15 additions & 8 deletions src/services/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ export interface CompactionOptions {
getModelLimit?: (providerID: string, modelID: string) => number | undefined;
}

export function createCompactionMemoryMetadata(
tags: Pick<ResolvedTags, "projectName" | "projectId">,
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
? `
Expand Down Expand Up @@ -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 }
);

Expand Down
38 changes: 38 additions & 0 deletions src/services/memory-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading