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
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ Drop this file in to override defaults:
| `maxMemories` | `number` | `5` | Max memories injected per prompt. |
| `maxProfileItems` | `number` | `5` | Max profile items considered. |
| `injectProfile` | `boolean` | `true` | Whether to fetch and inject the user profile. |
| `containerTagPrefix` | `string` | `"codex"` | Prefix for auto-generated container tags. |
| `containerTagPrefix` | `string` | `"codex"` | Prefix for auto-generated user tags and legacy Codex project tags. |
| `userContainerTag` | `string` | auto | Override the user container tag. |
| `projectContainerTag` | `string` | auto (per-repo) | Override the project container tag. |
| `projectContainerTag` | `string` | auto (`repo_<repo-name>`) | Override the project container tag. |
| `filterPrompt` | `string` | (sensible) | Filter prompt used by Supermemory's stateful filter. |
| `debug` | `boolean` | `false` | Enable debug logging. |
| `autoSaveEveryTurns` | `number` | `3` | Save memories every N turns (incremental capture). |
Expand All @@ -109,10 +109,15 @@ Drop this file in to override defaults:
| `customContainers` | `array` | `[]` | Custom containers with `tag` and `description` (see below). |
| `customContainerInstructions` | `string` | `""` | Free-text instructions for the AI on how to route memories to containers.

User tags are auto-derived from your `git config user.email`. Project tags are
derived from the Git common directory when available, so linked worktrees and
Conductor workspaces for the same repository share one project container by default.
Set `SUPERMEMORY_ISOLATE_WORKTREES=true` to keep each worktree isolated.
User tags are auto-derived from your `git config user.email`. Project tags now
match Claude Code's shared repo format: `repo_<sanitized-repo-name>`, using the
git remote repo name when available and falling back to the workspace folder
name. This lets Codex and Claude Code read and write project memories in the
same Supermemory container by default.

During the transition from older Codex versions, project recall/search also
checks the legacy Codex tag: `codex_project_<hash(git-common-dir-or-root)>`.
New project memories are saved only to the `repo_<repo-name>` tag.

### Entity context

Expand Down
6 changes: 4 additions & 2 deletions src/hooks/recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { isConfigured, CONFIG, reloadApiKey, getContainerCatalog } from "../config.js";
import { SupermemoryClient } from "../services/client.js";
import { getTags } from "../services/tags.js";
import { getProjectSearchTags, getTags } from "../services/tags.js";
import { formatCombinedContext } from "../services/context.js";
import { log } from "../services/logger.js";
import { startAuthFlow, AUTH_BASE_URL } from "../services/auth.js";
Expand Down Expand Up @@ -99,11 +99,13 @@ async function main() {

const cwd = payload.cwd || process.cwd();
const tags = getTags(cwd);
const projectSearchTags = getProjectSearchTags(cwd);
const sessionId = getSessionId(payload.session_id, tags.project);

log("recall: start", {
query: query.slice(0, 100),
tags,
projectSearchTags,
sessionId,
autoRecallEveryPrompt: CONFIG.autoRecallEveryPrompt,
});
Expand All @@ -125,7 +127,7 @@ async function main() {
try {
const [profileResult, projectSearchResult] = await Promise.all([
client.getProfileWithSearch(tags.user, query),
client.searchMemories(query, tags.project),
client.searchMemoriesAcrossContainers(query, projectSearchTags),
]);

const seen = getSeenFacts(sessionId);
Expand Down
49 changes: 49 additions & 0 deletions src/services/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,55 @@ export class SupermemoryClient {
}
}

async searchMemoriesAcrossContainers(query: string, containerTags: string[]): Promise<SearchResponse> {
const uniqueTags = [...new Set(containerTags)].filter((tag) => tag.length > 0);
log("searchMemoriesAcrossContainers: start", { containerTags: uniqueTags });

if (uniqueTags.length === 0) {
return { success: false, error: "At least one containerTag is required", results: [], total: 0, timing: 0 };
}

const results = await Promise.all(
uniqueTags.map((tag) => this.searchMemories(query, tag))
);
const successes = results.filter((result) => result.success);

if (successes.length === 0) {
return {
success: false,
error: results.find((result) => result.error)?.error ?? "Failed to search memories",
results: [],
total: 0,
timing: 0,
};
}

const seen = new Set<string>();
const merged: SearchResultItem[] = [];

for (const result of successes) {
for (const item of result.results ?? []) {
const text = item.memory ?? item.chunk ?? item.content ?? String(item.context ?? "");
const key = item.id ? `id:${item.id}` : `content:${text.toLowerCase().trim()}`;
if (!key || seen.has(key)) continue;
seen.add(key);
merged.push(item);
}
}

log("searchMemoriesAcrossContainers: success", {
containerCount: uniqueTags.length,
resultCount: merged.length,
});

return {
success: true,
results: merged,
total: merged.length,
timing: successes.reduce((sum, result) => sum + (result.timing ?? 0), 0),
};
}

async getProfile(containerTag: string, query?: string) {
log("getProfile: start", { containerTag });
try {
Expand Down
18 changes: 18 additions & 0 deletions src/services/tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ function getGitRepoName(directory: string): string | null {
}
}

function sanitizeRepoName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "");
}

export function getUserTag(): string {
if (CONFIG.userContainerTag) return CONFIG.userContainerTag;
const email = getGitEmail();
Expand All @@ -92,10 +100,20 @@ export function getUserTag(): string {

export function getProjectTag(directory: string): string {
if (CONFIG.projectContainerTag) return CONFIG.projectContainerTag;
const basePath = getGitRoot(directory) || directory;
const repoName = getGitRepoName(basePath) || basename(basePath) || "unknown";
return `repo_${sanitizeRepoName(repoName)}`;
}

export function getLegacyProjectTag(directory: string): string {
const basePath = getGitRoot(directory) || directory;
return `${CONFIG.containerTagPrefix}_project_${sha256(basePath)}`;
}

export function getProjectSearchTags(directory: string): string[] {
return [...new Set([getProjectTag(directory), getLegacyProjectTag(directory)])];
}

export function getProjectName(directory: string): string {
const gitRoot = getGitRoot(directory);
const basePath = gitRoot || directory;
Expand Down
21 changes: 12 additions & 9 deletions src/skills/forget-memory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { isConfigured, validateContainerTag } from "../config.js";
import { SupermemoryClient } from "../services/client.js";
import { getProjectTag, getUserTag } from "../services/tags.js";
import { getProjectSearchTags, getUserTag } from "../services/tags.js";

function parseArgs(args: string[]): { content: string; containerTag?: string } {
let containerTag: string | undefined;
Expand Down Expand Up @@ -54,22 +54,25 @@ async function main(): Promise<void> {
console.log(`Failed to forget memory from container '${containerTag}': ${result.error}`);
}
} else {
const projectTag = getProjectTag(process.cwd());
const projectTags = getProjectSearchTags(process.cwd());
const userTag = getUserTag();

const [projectResult, userResult] = await Promise.all([
client.forgetMemory(content, projectTag),
const [projectResults, userResult] = await Promise.all([
Promise.all(projectTags.map((tag) => client.forgetMemory(content, tag))),
client.forgetMemory(content, userTag),
]);

const forgotten: string[] = [];
const errors: string[] = [];

if (projectResult.success) {
forgotten.push(projectResult.id ? `project (id: ${projectResult.id})` : "project");
} else {
errors.push(`project: ${projectResult.error}`);
}
projectResults.forEach((projectResult, index) => {
const label = index === 0 ? "project" : "legacy project";
if (projectResult.success) {
forgotten.push(projectResult.id ? `${label} (id: ${projectResult.id})` : label);
} else {
errors.push(`${label}: ${projectResult.error}`);
}
});

if (userResult.success) {
forgotten.push(userResult.id ? `user (id: ${userResult.id})` : "user");
Expand Down
11 changes: 6 additions & 5 deletions src/skills/search-memory.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CONFIG, isConfigured, validateContainerTag } from "../config.js";
import { SupermemoryClient, type SearchResponse } from "../services/client.js";
import { formatContextForPrompt } from "../services/context.js";
import { getProjectTag, getUserTag } from "../services/tags.js";
import { getProjectSearchTags, getUserTag } from "../services/tags.js";

type Scope = "user" | "project" | "both" | "custom";

Expand Down Expand Up @@ -58,7 +58,7 @@ async function main(): Promise<void> {

const client = new SupermemoryClient();
const userTag = getUserTag();
const projectTag = getProjectTag(process.cwd());
const projectSearchTags = getProjectSearchTags(process.cwd());

if (containerTag) {
const validationError = validateContainerTag(containerTag);
Expand All @@ -81,7 +81,7 @@ async function main(): Promise<void> {
} else if (scope === "both") {
const [userResult, projectResult] = await Promise.all([
client.searchMemories(query, userTag),
client.searchMemories(query, projectTag),
client.searchMemoriesAcrossContainers(query, projectSearchTags),
]);

// Surface errors when all searches fail
Expand All @@ -102,8 +102,9 @@ async function main(): Promise<void> {
timing: 0,
};
} else {
const tag = scope === "user" ? userTag : projectTag;
searchResult = await client.searchMemories(query, tag);
searchResult = scope === "user"
? await client.searchMemories(query, userTag)
: await client.searchMemoriesAcrossContainers(query, projectSearchTags);

// Surface error for single-scope search failure
if (!searchResult.success) {
Expand Down
6 changes: 5 additions & 1 deletion src/skills/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { homedir } from "node:os";
import { CONFIG, getApiBaseUrl, getApiKeyValue, isConfigured } from "../config.js";
import { CREDENTIALS_FILE, loadCredentials } from "../services/auth.js";
import { SupermemoryClient } from "../services/client.js";
import { getTags } from "../services/tags.js";
import { getLegacyProjectTag, getTags } from "../services/tags.js";

const API_URL =
getApiBaseUrl();
Expand Down Expand Up @@ -94,6 +94,7 @@ async function getAccountInfo(): Promise<{ email?: string; name?: string; userId
async function main(): Promise<void> {
const cwd = process.cwd();
const tags = getTags(cwd);
const legacyProjectTag = getLegacyProjectTag(cwd);
const apiKey = getApiKeyValue();
const lines: string[] = [];

Expand All @@ -106,6 +107,9 @@ async function main(): Promise<void> {
lines.push(`Recall mode: auto-recall on every prompt`);
lines.push(`Capture cadence: ${CONFIG.autoSaveEveryTurns > 0 ? `every ${CONFIG.autoSaveEveryTurns} turn${CONFIG.autoSaveEveryTurns === 1 ? "" : "s"} + session end` : "session end only"}`);
lines.push(`Project tag: ${tags.project}`);
if (legacyProjectTag !== tags.project) {
lines.push(`Legacy project tag searched: ${legacyProjectTag}`);
}
lines.push(`User tag: ${tags.user}`);

if (!isConfigured()) {
Expand Down
81 changes: 72 additions & 9 deletions test/unit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,68 @@ describe("container tags", () => {
return result.stdout.trim();
}

test("project tag uses the shared git common directory for worktrees", (t) => {
function getLegacyProjectTagFor(cwd, home, extraEnv = {}) {
const script = `
import { getLegacyProjectTag } from ${JSON.stringify(tagsModule)};
console.log(getLegacyProjectTag(process.argv.at(-1)));
`;
const result = spawnSync("node", ["--input-type=module", "-e", script, cwd], {
env: {
...process.env,
HOME: home,
SUPERMEMORY_CODEX_API_KEY: "sm_test",
...extraEnv,
},
encoding: "utf-8",
});
assert.equal(result.status, 0, `getLegacyProjectTag failed: ${result.stderr}`);
return result.stdout.trim();
}

function getProjectSearchTagsFor(cwd, home, extraEnv = {}) {
const script = `
import { getProjectSearchTags } from ${JSON.stringify(tagsModule)};
console.log(JSON.stringify(getProjectSearchTags(process.argv.at(-1))));
`;
const result = spawnSync("node", ["--input-type=module", "-e", script, cwd], {
env: {
...process.env,
HOME: home,
SUPERMEMORY_CODEX_API_KEY: "sm_test",
...extraEnv,
},
encoding: "utf-8",
});
assert.equal(result.status, 0, `getProjectSearchTags failed: ${result.stderr}`);
return JSON.parse(result.stdout.trim());
}

test("project tag uses the Claude-compatible git remote repo name", (t) => {
const tmpDir = makeTmpDir();
t.after(() => rmSync(tmpDir, { recursive: true, force: true }));

const repoDir = join(tmpDir, "repo");
const worktreeDir = join(tmpDir, "worktree");
const homeDir = join(tmpDir, "home");
mkdirSync(repoDir, { recursive: true });
mkdirSync(homeDir, { recursive: true });

runGit(["init"], repoDir);
runGit(["config", "user.email", "test@example.com"], repoDir);
runGit(["config", "user.name", "Test User"], repoDir);
runGit(["remote", "add", "origin", "git@github.com:supermemoryai/shared-memory-plugin.git"], repoDir);
writeFileSync(join(repoDir, "README.md"), "# test\n");
runGit(["add", "README.md"], repoDir);
runGit(["commit", "-m", "initial"], repoDir);
runGit(["worktree", "add", "--detach", worktreeDir, "HEAD"], repoDir);

assert.equal(
getProjectTagFor(worktreeDir, homeDir),
"repo_shared_memory_plugin"
);
});

test("legacy project tag preserves the old Codex git common directory hash", (t) => {
const tmpDir = makeTmpDir();
t.after(() => rmSync(tmpDir, { recursive: true, force: true }));

Expand All @@ -111,33 +172,35 @@ describe("container tags", () => {
: runGit(["rev-parse", "--show-toplevel"], worktreeDir);

assert.equal(
getProjectTagFor(worktreeDir, homeDir),
getLegacyProjectTagFor(worktreeDir, homeDir),
`codex_project_${hash16(expectedBasePath)}`
);
});

test("project tag can still isolate individual worktrees when requested", (t) => {
test("project search tags include new and legacy containers", (t) => {
const tmpDir = makeTmpDir();
t.after(() => rmSync(tmpDir, { recursive: true, force: true }));

const repoDir = join(tmpDir, "repo");
const worktreeDir = join(tmpDir, "worktree");
const homeDir = join(tmpDir, "home");
mkdirSync(repoDir, { recursive: true });
mkdirSync(homeDir, { recursive: true });

runGit(["init"], repoDir);
runGit(["config", "user.email", "test@example.com"], repoDir);
runGit(["config", "user.name", "Test User"], repoDir);
runGit(["remote", "add", "origin", "https://github.com/supermemoryai/codex-supermemory.git"], repoDir);
writeFileSync(join(repoDir, "README.md"), "# test\n");
runGit(["add", "README.md"], repoDir);
runGit(["commit", "-m", "initial"], repoDir);
runGit(["worktree", "add", "--detach", worktreeDir, "HEAD"], repoDir);
const worktreeRoot = runGit(["rev-parse", "--show-toplevel"], worktreeDir);
const gitRoot = runGit(["rev-parse", "--show-toplevel"], repoDir);

assert.equal(
getProjectTagFor(worktreeDir, homeDir, { SUPERMEMORY_ISOLATE_WORKTREES: "true" }),
`codex_project_${hash16(worktreeRoot)}`
assert.deepEqual(
getProjectSearchTagsFor(repoDir, homeDir),
[
"repo_codex_supermemory",
`codex_project_${hash16(gitRoot)}`,
]
);
});
});
Expand Down