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: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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_<project-name>__<remote-hash>` 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
Expand Down
6 changes: 3 additions & 3 deletions src/services/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
10 changes: 3 additions & 7 deletions src/services/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(promise: Promise<T>, ms: number): Promise<T> {
let id: ReturnType<typeof setTimeout>;
const timeout = new Promise<T>((_, reject) => {
Expand Down Expand Up @@ -210,7 +206,7 @@ export class SupermemoryClient {
this.getProfileWithSearch(
canonicalTag,
query,
supportsScopedCanonicalTag(canonicalTag) ? scope : undefined,
scope,
),
...legacyTags.map((containerTag) =>
this.getProfileWithSearch(containerTag, query),
Expand Down Expand Up @@ -273,7 +269,7 @@ export class SupermemoryClient {
this.searchMemories(
query,
canonicalTag,
supportsScopedCanonicalTag(canonicalTag) ? scope : undefined,
scope,
),
...legacyTags.map((containerTag) =>
this.searchMemories(query, containerTag),
Expand Down
2 changes: 1 addition & 1 deletion src/skills/add-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ async function main(): Promise<void> {
type: "manual",
project: projectName,
sm_project_id: getProjectIdentity(cwd),
sm_scope: "personal",
agent_scope: "personal",
sm_capture_mode: "explicit",
timestamp: new Date().toISOString(),
},
Expand Down
2 changes: 1 addition & 1 deletion src/skills/save-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ async function main(): Promise<void> {
source: "skill",
project: projectName,
sm_project_id: projectId,
sm_scope: "project",
agent_scope: "project",
sm_capture_mode: "explicit",
timestamp: new Date().toISOString(),
};
Expand Down
2 changes: 1 addition & 1 deletion src/skills/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ async function main(): Promise<void> {
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}`);
Expand Down
109 changes: 104 additions & 5 deletions test/unit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -357,25 +358,123 @@ 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"));
});

test("manual save writes project entity context", () => {
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";
Expand Down