diff --git a/README.md b/README.md index 3748a23..b4029a3 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ and the lessons learned across every project — automatically. - 📦 **Custom container tags** — define custom memory containers (e.g., `work`, `personal`, `code_style`). The AI automatically picks the right container based on your instructions when saving, searching, or forgetting memories. -- 🏷️ **Personal + project routing** — `sm_scope` metadata keeps automatic/personal +- 🏷️ **Personal + project routing** — `agent_scope` metadata keeps automatic/personal memories distinguishable from explicit project knowledge in the shared container. - **Entity-aware extraction** - the shared container uses one coding-agent context covering durable preferences and project/codebase facts. @@ -78,7 +78,14 @@ anything else fails, they exit cleanly without breaking your Codex session. Codex, Claude Code, and OpenCode use one container for a repository: - `repo___` stores automatic capture and every explicit save. -- `sm_scope` metadata preserves optional personal/project filtering. +- `agent_scope` metadata preserves optional personal/project filtering. + +> **Release dependency:** Deploy and complete the backend backfill from legacy +> `sm_scope` to `agent_scope` before releasing this plugin version. Scoped +> canonical-container reads filter only on `agent_scope`; they intentionally do +> not OR on `sm_scope` because the legacy field was never vector-indexed. +> Legacy containers continue to be read without a scope filter for backward +> compatibility. The hash comes from the normalized Git remote, so clones share memory while same-named repositories do not collide. Repositories without a remote fall back to diff --git a/src/services/capture.ts b/src/services/capture.ts index b0a01c0..fc037d3 100644 --- a/src/services/capture.ts +++ b/src/services/capture.ts @@ -140,12 +140,12 @@ export async function captureEntries( sessionId, entryCount: newEntries.length, timestamp: new Date().toISOString(), - sm_scope: "personal", + agent_scope: "personal", sm_capture_mode: caller === "flush" ? "session_end" : "turn", }; - // Automatic capture and explicit saves share one project container. Scope - // metadata preserves optional personal/project filtering. + // Automatic capture and explicit saves share one project container. + // agent_scope preserves optional personal/project filtering. // Use customId so all session turns go into the same document. try { const result = await client.addMemory(content, tags.canonical, metadata, { diff --git a/src/services/client.ts b/src/services/client.ts index dfa032d..512213d 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -17,14 +17,10 @@ export type MemoryScope = "personal" | "project"; 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 withTimeout(promise: Promise, ms: number): Promise { let id: ReturnType; const timeout = new Promise((_, reject) => { @@ -210,7 +206,7 @@ export class SupermemoryClient { this.getProfileWithSearch( canonicalTag, query, - supportsScopedCanonicalTag(canonicalTag) ? scope : undefined, + scope, ), ...legacyTags.map((containerTag) => this.getProfileWithSearch(containerTag, query), @@ -273,7 +269,7 @@ export class SupermemoryClient { this.searchMemories( query, canonicalTag, - supportsScopedCanonicalTag(canonicalTag) ? scope : undefined, + scope, ), ...legacyTags.map((containerTag) => this.searchMemories(query, containerTag), diff --git a/src/skills/add-memory.ts b/src/skills/add-memory.ts index 246ebc0..4b93d11 100644 --- a/src/skills/add-memory.ts +++ b/src/skills/add-memory.ts @@ -32,7 +32,7 @@ async function main(): Promise { type: "manual", project: projectName, sm_project_id: getProjectIdentity(cwd), - sm_scope: "personal", + agent_scope: "personal", sm_capture_mode: "explicit", timestamp: new Date().toISOString(), }, diff --git a/src/skills/save-memory.ts b/src/skills/save-memory.ts index f2a96d8..115c279 100644 --- a/src/skills/save-memory.ts +++ b/src/skills/save-memory.ts @@ -76,7 +76,7 @@ async function main(): Promise { source: "skill", project: projectName, sm_project_id: projectId, - sm_scope: "project", + agent_scope: "project", sm_capture_mode: "explicit", timestamp: new Date().toISOString(), }; diff --git a/src/skills/status.ts b/src/skills/status.ts index d506921..d857450 100644 --- a/src/skills/status.ts +++ b/src/skills/status.ts @@ -93,7 +93,7 @@ async function main(): Promise { lines.push(`Connected: ${isConfigured() ? "checking..." : "no"}`); lines.push(`API key: ${maskKey(apiKey)} (${getKeySource()})`); lines.push(`API URL: ${API_URL}`); - lines.push(`Memory scope: one project container with metadata scopes`); + lines.push(`Memory scope: one project container with agent_scope metadata`); lines.push(`Auto-recall: ${getAutoRecallStatus()}`); lines.push(`Auto-capture: ${getAutoCaptureStatus()}`); lines.push(`Project container: ${tags.canonical}`); diff --git a/test/unit.mjs b/test/unit.mjs index 0e30a5f..644d3e9 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -6,11 +6,12 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from "node:fs"; +import { writeFileSync, readFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import * as TOML from "@iarna/toml"; +import { buildSync } from "esbuild"; // ─── helpers ──────────────────────────────────────────────────────────────── @@ -357,7 +358,7 @@ describe("entity context wiring", () => { test("automatic capture writes user entity context", () => { const content = readFileSync(new URL("../src/services/capture.ts", import.meta.url), "utf-8"); assert.ok(content.includes("entityContext: USER_ENTITY_CONTEXT")); - assert.ok(content.includes('sm_scope: "personal"')); + assert.ok(content.includes('agent_scope: "personal"')); assert.ok(content.includes("project: tags.projectName")); }); @@ -365,17 +366,115 @@ describe("entity context wiring", () => { const content = readFileSync(new URL("../src/skills/save-memory.ts", import.meta.url), "utf-8"); assert.ok(content.includes("PROJECT_ENTITY_CONTEXT")); assert.ok(content.includes("entityContext: getEntityContext(containerTag)")); - assert.ok(content.includes('sm_scope: "project"')); + assert.ok(content.includes('agent_scope: "project"')); }); test("personal add writes the unified personal scope", () => { const content = readFileSync(new URL("../src/skills/add-memory.ts", import.meta.url), "utf-8"); assert.ok(content.includes("getProjectTag")); - assert.ok(content.includes('sm_scope: "personal"')); + assert.ok(content.includes('agent_scope: "personal"')); assert.ok(content.includes("entityContext: USER_ENTITY_CONTEXT")); }); }); +describe("agent scope filters", () => { + const clientSource = fileURLToPath(new URL("../src/services/client.ts", import.meta.url)); + const canonicalTag = "repo_example__0123456789abcdef"; + const configuredCanonicalTag = "shared_project_memory"; + + async function createClientWithRequests(t) { + const requests = { searches: [], profiles: [] }; + const dir = mkdtempSync(join(tmpdir(), "csm-client-test-")); + const modulePath = join(dir, "client.mjs"); + t.after(() => rmSync(dir, { recursive: true, force: true })); + buildSync({ + entryPoints: [clientSource], + outfile: modulePath, + bundle: true, + platform: "node", + format: "esm", + target: "node22", + define: { __CODEX_SUPERMEMORY_VERSION__: JSON.stringify("test") }, + }); + const { SupermemoryClient } = await import(pathToFileURL(modulePath).href); + const client = new SupermemoryClient(); + client.client = { + search: { + memories: async (request) => { + requests.searches.push(request); + return { results: [], total: 0, timing: 0 }; + }, + }, + profile: async (request) => { + requests.profiles.push(request); + return { + profile: { static: [], dynamic: [] }, + searchResults: { results: [], total: 0, timing: 0 }, + }; + }, + }; + return { client, requests }; + } + + const personalAgentScope = { + AND: [{ key: "agent_scope", value: "personal", filterType: "metadata" }], + }; + + test("filters direct scoped search and profile reads by agent_scope", async (t) => { + const { client, requests } = await createClientWithRequests(t); + + await client.searchMemories("preferences", canonicalTag, "personal"); + await client.getProfile(canonicalTag, "preferences", "personal"); + + assert.deepEqual(requests.searches[0].filters, personalAgentScope); + assert.deepEqual(requests.profiles[0].filters, personalAgentScope); + }); + + test("filters only canonical scoped reads and leaves legacy containers unfiltered", async (t) => { + const { client, requests } = await createClientWithRequests(t); + + await client.searchMemoriesScoped( + "preferences", + canonicalTag, + [canonicalTag, "codex_user_legacy"], + "personal", + ); + await client.getProfileWithSearchScoped( + canonicalTag, + [canonicalTag, "codex_user_legacy"], + "personal", + "preferences", + ); + + assert.deepEqual(requests.searches[0].filters, personalAgentScope); + assert.equal(requests.searches[1].filters, undefined); + assert.deepEqual(requests.profiles[0].filters, personalAgentScope); + assert.equal(requests.profiles[1].filters, undefined); + }); + + test("filters an arbitrary configured canonical tag", async (t) => { + const { client, requests } = await createClientWithRequests(t); + + await client.searchMemoriesScoped( + "preferences", + configuredCanonicalTag, + [configuredCanonicalTag, "codex_user_legacy"], + "personal", + ); + await client.getProfileWithSearchScoped( + configuredCanonicalTag, + [configuredCanonicalTag, "codex_user_legacy"], + "personal", + "preferences", + ); + + assert.deepEqual(requests.searches[0].filters, personalAgentScope); + assert.equal(requests.searches[1].filters, undefined); + assert.deepEqual(requests.profiles[0].filters, personalAgentScope); + assert.equal(requests.profiles[1].filters, undefined); + }); +}); + describe("hooks.json format", () => { test("wrapped hooks.json shape is valid JSON", () => { const recallScript = "/home/user/.codex/supermemory/recall.js";