From 559e9d065a300fe897ecb6998427a61e7d204e69 Mon Sep 17 00:00:00 2001 From: ved015 Date: Mon, 22 Jun 2026 17:57:13 +0530 Subject: [PATCH] fix: share repo tags with Claude Code --- README.md | 17 +++++--- src/hooks/recall.ts | 6 ++- src/services/client.ts | 49 ++++++++++++++++++++++ src/services/tags.ts | 18 +++++++++ src/skills/forget-memory.ts | 21 +++++----- src/skills/search-memory.ts | 11 ++--- src/skills/status.ts | 6 ++- test/unit.mjs | 81 ++++++++++++++++++++++++++++++++----- 8 files changed, 177 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 6ae4b1e..c1adf1d 100644 --- a/README.md +++ b/README.md @@ -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_`) | 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). | @@ -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_`, 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_`. +New project memories are saved only to the `repo_` tag. ### Entity context diff --git a/src/hooks/recall.ts b/src/hooks/recall.ts index bc87781..09ae88e 100644 --- a/src/hooks/recall.ts +++ b/src/hooks/recall.ts @@ -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"; @@ -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, }); @@ -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); diff --git a/src/services/client.ts b/src/services/client.ts index da26e07..0646ba6 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -191,6 +191,55 @@ export class SupermemoryClient { } } + async searchMemoriesAcrossContainers(query: string, containerTags: string[]): Promise { + 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(); + 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 { diff --git a/src/services/tags.ts b/src/services/tags.ts index 2d3acdb..312ab39 100644 --- a/src/services/tags.ts +++ b/src/services/tags.ts @@ -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(); @@ -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; diff --git a/src/skills/forget-memory.ts b/src/skills/forget-memory.ts index 301b8cc..424c00c 100644 --- a/src/skills/forget-memory.ts +++ b/src/skills/forget-memory.ts @@ -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; @@ -54,22 +54,25 @@ async function main(): Promise { 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"); diff --git a/src/skills/search-memory.ts b/src/skills/search-memory.ts index 51fd30d..b58f538 100644 --- a/src/skills/search-memory.ts +++ b/src/skills/search-memory.ts @@ -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"; @@ -58,7 +58,7 @@ async function main(): Promise { const client = new SupermemoryClient(); const userTag = getUserTag(); - const projectTag = getProjectTag(process.cwd()); + const projectSearchTags = getProjectSearchTags(process.cwd()); if (containerTag) { const validationError = validateContainerTag(containerTag); @@ -81,7 +81,7 @@ async function main(): Promise { } 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 @@ -102,8 +102,9 @@ async function main(): Promise { 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) { diff --git a/src/skills/status.ts b/src/skills/status.ts index 413feb5..b1e84d0 100644 --- a/src/skills/status.ts +++ b/src/skills/status.ts @@ -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(); @@ -94,6 +94,7 @@ async function getAccountInfo(): Promise<{ email?: string; name?: string; userId async function main(): Promise { const cwd = process.cwd(); const tags = getTags(cwd); + const legacyProjectTag = getLegacyProjectTag(cwd); const apiKey = getApiKeyValue(); const lines: string[] = []; @@ -106,6 +107,9 @@ async function main(): Promise { 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()) { diff --git a/test/unit.mjs b/test/unit.mjs index 2e6f788..8a296ea 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -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 })); @@ -111,17 +172,16 @@ 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 }); @@ -129,15 +189,18 @@ describe("container tags", () => { 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)}`, + ] ); }); });