diff --git a/src/cli.ts b/src/cli.ts index d63c00d..92ecec9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -624,7 +624,7 @@ function describeOpenCodeRegistration(): string[] { async function status(): Promise { const apiUrl = getApiBaseUrl(); - const tags = getTags(process.cwd()); + const tags = await getTags(process.cwd()); const lines: string[] = []; lines.push("supermemory status"); diff --git a/src/index.ts b/src/index.ts index b062239..e9d5aac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,7 +79,7 @@ function isSupermemoryRecallSearch(input: Permission): boolean { */ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { const { directory } = ctx; - const tags = getTags(directory); + const tags = await getTags(directory); const injectedSessions = new Set(); const recallSessions = new RecallSessionCache(); const activity = createMemoryActivityReporter(ctx.client); diff --git a/src/services/tags.test.ts b/src/services/tags.test.ts index 6cf8bd7..6b1f9c4 100644 --- a/src/services/tags.test.ts +++ b/src/services/tags.test.ts @@ -1,5 +1,159 @@ -import { describe, expect, test } from "bun:test"; -import { normalizeGitRemote, sanitizeRepoName } from "./tags.js"; +import { beforeEach, afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { hostname, tmpdir, userInfo } from "node:os"; +import { join } from "node:path"; + +import { CONFIG } from "../config.js"; +import { getTags, normalizeGitRemote, sanitizeRepoName } from "./tags.js"; + +const REAL_GIT = execFileSync("which", ["git"], { encoding: "utf-8" }).trim(); + +function hash(input: string): string { + return createHash("sha256").update(input).digest("hex").slice(0, 16); +} + +function unique(tags: Array): string[] { + return [ + ...new Set( + tags.filter( + (tag): tag is string => + typeof tag === "string" && tag.trim().length > 0, + ), + ), + ]; +} + +function git(cwd: string, args: string[]): void { + execFileSync("git", args, { cwd, stdio: ["ignore", "ignore", "ignore"] }); +} + +function gitToplevel(cwd: string): string { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd, + encoding: "utf-8", + }).trim(); +} + +interface TempRoot { + root: string; + cleanup: () => void; +} + +function tempRoot(): TempRoot { + const root = mkdtempSync(join(tmpdir(), "sm-tags-test-")); + return { root, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} + +function makeRepo(root: string, name: string, remote: string | null): string { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + git(dir, ["init", "-q"]); + git(dir, ["config", "user.email", "dev@example.com"]); + git(dir, ["config", "user.name", "Dev Example"]); + if (remote) git(dir, ["remote", "add", "origin", remote]); + writeFileSync(join(dir, "README.md"), "x\n"); + git(dir, ["add", "README.md"]); + git(dir, ["commit", "-q", "-m", "init"]); + return dir; +} + +interface FakeGit { + restore: () => void; + logPath: string; + markerPath: string; +} + +function installFakeGit(root: string, script: string): FakeGit { + const bin = join(root, "fake-git-bin"); + mkdirSync(bin, { recursive: true }); + const logPath = join(root, "git-calls.log"); + const markerPath = join(root, "git-started.marker"); + const wrapper = script + .replaceAll("__GIT__", REAL_GIT) + .replaceAll("__LOG__", logPath) + .replaceAll("__MARKER__", markerPath); + const executable = join(bin, "git"); + writeFileSync(executable, wrapper); + chmodSync(executable, 0o755); + + const originalPath = process.env.PATH; + process.env.PATH = `${bin}:${originalPath ?? ""}`; + return { + restore: () => { + process.env.PATH = originalPath; + }, + logPath, + markerPath, + }; +} + +async function waitForFile(path: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (existsSync(path)) return true; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + return false; +} + +const CONFIG_KEYS = ["userContainerTag", "projectContainerTag", "containerTagPrefix"] as const; +const ENV_KEYS = [ + "USER", + "USERNAME", + "HOME", + "SUPERMEMORY_REPO_TAG", + "SUPERMEMORY_USER_TAG", + "SUPERMEMORY_PROJECT_TAG", + "CURSOR_USER_EMAIL", + "SUPERMEMORY_ISOLATE_WORKTREES", +] as const; + +let savedConfig: Record; +let savedEnv: Record; +let isolatedHome: string; + +function neutralizeEnvironment(): void { + savedConfig = {}; + for (const key of CONFIG_KEYS) savedConfig[key] = CONFIG[key]; + savedEnv = {}; + for (const key of ENV_KEYS) savedEnv[key] = process.env[key]; + + isolatedHome = mkdtempSync(join(tmpdir(), "sm-tags-home-")); + CONFIG.userContainerTag = undefined; + CONFIG.projectContainerTag = undefined; + CONFIG.containerTagPrefix = "opencode"; + process.env.USER = "testuser"; + process.env.USERNAME = "testuser"; + process.env.HOME = isolatedHome; + for (const key of ENV_KEYS) { + if (key !== "USER" && key !== "USERNAME" && key !== "HOME") delete process.env[key]; + } +} + +function restoreEnvironment(): void { + for (const key of CONFIG_KEYS) { + (CONFIG as unknown as Record)[key] = savedConfig[key]; + } + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + rmSync(isolatedHome, { recursive: true, force: true }); +} + +beforeEach(neutralizeEnvironment); +afterEach(restoreEnvironment); describe("repository identity", () => { test("normalizes equivalent HTTPS and SSH remotes", () => { @@ -17,3 +171,263 @@ describe("repository identity", () => { ); }); }); + +describe("async tag resolution", () => { + test("resolves exact canonical, identity and ordered read tags for a repo with a remote", async () => { + const temp = tempRoot(); + try { + const dir = makeRepo(temp.root, "plain-repo", "https://github.com/Example/Repo.git"); + const basePath = gitToplevel(dir); + const projectId = hash("github.com/example/repo"); + const canonical = `repo_repo__${projectId}`; + const baseHash = hash(basePath); + const emailHash = hash("dev@example.com"); + const directoryHashes = unique([hash(dir), hash(dir), hash(basePath)]); + + const tags = await getTags(dir); + + const personalReads = unique([ + canonical, + `user_project_${baseHash}`, + `claudecode_project_${baseHash}`, + `codex_user_${emailHash}`, + `opencode_user_${emailHash}`, + `cursor_user_${emailHash}`, + ]); + const projectReads = unique([ + canonical, + "repo_repo", + `codex_project_${baseHash}`, + ...directoryHashes.flatMap((value) => [ + `opencode_project_${value}`, + `opencode_project_${value}`, + ]), + `cursor_project_${baseHash}`, + ]); + + expect(tags).toEqual({ + canonical, + user: canonical, + project: canonical, + projectId, + projectName: "Repo", + personalReads, + projectReads, + allReads: unique([...personalReads, ...projectReads]), + }); + expect(tags.allReads.slice(0, 1)).toEqual([canonical]); + } finally { + temp.cleanup(); + } + }); + + test("falls back to the path identity without a remote and preserves config precedence", async () => { + const temp = tempRoot(); + try { + const dir = makeRepo(temp.root, "no-remote", null); + const basePath = gitToplevel(dir); + const identity = hash(`path:${basePath}`); + const canonical = `repo_no_remote__${identity}`; + + const tags = await getTags(dir); + expect(tags.canonical).toBe(canonical); + expect(tags.projectId).toBe(identity); + expect(tags.projectName).toBe("no-remote"); + expect(tags.personalReads[0]).toBe(canonical); + expect(tags.projectReads.slice(0, 2)).toEqual([canonical, "repo_no_remote"]); + expect(tags.allReads).toEqual( + unique([...tags.personalReads, ...tags.projectReads]), + ); + + process.env.SUPERMEMORY_REPO_TAG = "explicit_repo_tag"; + const overridden = await getTags(dir); + expect(overridden.canonical).toBe("explicit_repo_tag"); + expect(overridden.user).toBe("explicit_repo_tag"); + expect(overridden.project).toBe("explicit_repo_tag"); + expect(overridden.projectId).toBe(identity); + expect(overridden.personalReads).toContain(canonical); + } finally { + temp.cleanup(); + } + }); + + test("uses realpath identity for non-Git directories and symlinks", async () => { + const temp = tempRoot(); + try { + const nonGit = join(temp.root, "non-git"); + mkdirSync(nonGit, { recursive: true }); + const link = join(temp.root, "link-to-non-git"); + symlinkSync(nonGit, link); + + const identity = hash(`path:${realpathSync.native(nonGit)}`); + const expectedName = "testuser"; + const userHash = hash(expectedName); + const cursorHash = hash(`${hostname()}_${userInfo().username}`); + + const nonGitTags = await getTags(nonGit); + expect(nonGitTags.canonical).toBe(`repo_non_git__${identity}`); + expect(nonGitTags.projectId).toBe(identity); + expect(nonGitTags.projectName).toBe("non-git"); + expect(nonGitTags.personalReads[1]).toBe(`user_project_${hash(nonGit)}`); + expect(nonGitTags.personalReads).toContain(`codex_user_${userHash}`); + expect(nonGitTags.personalReads).toContain(`opencode_user_${userHash}`); + expect(nonGitTags.personalReads).toContain(`cursor_user_${cursorHash}`); + expect(nonGitTags.projectReads[1]).toBe("repo_non_git"); + expect(nonGitTags.allReads).toEqual( + unique([...nonGitTags.personalReads, ...nonGitTags.projectReads]), + ); + + const linkTags = await getTags(link); + expect(linkTags.canonical).toBe(`repo_link_to_non_git__${identity}`); + expect(linkTags.projectId).toBe(identity); + expect(linkTags.projectName).toBe("link-to-non-git"); + expect(linkTags.personalReads[1]).toBe(`user_project_${hash(link)}`); + } finally { + temp.cleanup(); + } + }); + + test("isolates worktrees only when explicitly requested", async () => { + const temp = tempRoot(); + try { + const repo = makeRepo(temp.root, "worktree-repo", "https://github.com/Example/Repo.git"); + const worktree = join(temp.root, "worktree"); + git(repo, ["worktree", "add", "-q", worktree, "-b", "wt-branch"]); + + const shared = await getTags(worktree); + expect(shared.canonical).toBe(`repo_repo__${hash("github.com/example/repo")}`); + expect(shared.projectId).toBe(hash("github.com/example/repo")); + + process.env.SUPERMEMORY_ISOLATE_WORKTREES = "true"; + const isolated = await getTags(worktree); + const json = realpathSync.native(worktree); + expect(isolated.projectId).toBe(hash(`path:${json}`)); + expect(isolated.canonical).toBe(`repo_repo__${hash(`path:${json}`)}`); + expect(isolated.canonical).not.toBe(shared.canonical); + expect(isolated.projectName).toBe("Repo"); + } finally { + temp.cleanup(); + } + }); + + test("honours Claude config and cursor overrides in read tag order", async () => { + const temp = tempRoot(); + try { + const claudeRepo = makeRepo(temp.root, "claude-repo", "git@github.com:Example/ClaudeRepo.git"); + mkdirSync(join(claudeRepo, ".claude", ".supermemory-claude"), { recursive: true }); + writeFileSync( + join(claudeRepo, ".claude", ".supermemory-claude", "config.json"), + JSON.stringify({ + repoContainerTag: "claude_repo_tag", + personalContainerTag: "claude_personal_tag", + }), + ); + + const claudeTags = await getTags(claudeRepo); + expect(claudeTags.canonical).toBe("claude_repo_tag"); + expect(claudeTags.personalReads.slice(0, 3)).toEqual([ + "claude_repo_tag", + `repo_clauderepo__${hash("github.com/example/clauderepo")}`, + "claude_personal_tag", + ]); + + const nonGit = join(temp.root, "cursor-dir"); + mkdirSync(nonGit, { recursive: true }); + process.env.SUPERMEMORY_USER_TAG = "explicit_user"; + process.env.SUPERMEMORY_PROJECT_TAG = "explicit_project"; + const cursorTags = await getTags(nonGit); + expect(cursorTags.personalReads).toContain(`cursor_user_${hash("explicit_user")}`); + expect(cursorTags.projectReads).toContain(`cursor_project_${hash("explicit_project")}`); + } finally { + temp.cleanup(); + } + }); + + test("probes git metadata at most once per resolution", async () => { + const temp = tempRoot(); + try { + const dir = makeRepo(temp.root, "count-repo", "https://github.com/Example/CountRepo.git"); + const fake = installFakeGit( + temp.root, + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"__LOG__\"\nexec \"__GIT__\" \"$@\"\n", + ); + try { + const tags = await getTags(dir); + expect(tags.canonical).toBe(`repo_countrepo__${hash("github.com/example/countrepo")}`); + } finally { + fake.restore(); + } + + const calls = execFileSync("cat", [fake.logPath], { encoding: "utf-8" }) + .trim() + .split("\n") + .sort(); + expect(calls).toEqual( + [ + "config user.email", + "remote get-url origin", + "rev-parse --git-common-dir", + "rev-parse --show-toplevel", + ].sort(), + ); + } finally { + temp.cleanup(); + } + }); + + test("keeps the event loop responsive while a real child Git process is awaited", async () => { + const temp = tempRoot(); + try { + const dir = makeRepo(temp.root, "slow-repo", "https://github.com/Example/SlowRepo.git"); + const fake = installFakeGit( + temp.root, + "#!/bin/sh\ntouch \"__MARKER__\"\nsleep 0.6\nexec \"__GIT__\" \"$@\"\n", + ); + try { + let resolved = false; + const pending = getTags(dir).then((tags) => { + resolved = true; + return tags; + }); + + expect(await waitForFile(fake.markerPath, 2000)).toBe(true); + expect(resolved).toBe(false); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(resolved).toBe(false); + + const tags = await pending; + expect(tags.canonical).toBe(`repo_slowrepo__${hash("github.com/example/slowrepo")}`); + } finally { + fake.restore(); + } + } finally { + temp.cleanup(); + } + }); + + test("rejects on git timeout and does not poison the remote cache", async () => { + const temp = tempRoot(); + try { + const dir = makeRepo(temp.root, "timeout-repo", "https://github.com/Example/TimeoutRepo.git"); + const expected = `repo_timeoutrepo__${hash("github.com/example/timeoutrepo")}`; + const fake = installFakeGit( + temp.root, + "#!/bin/sh\nif [ \"$1 $2\" = \"remote get-url\" ]; then exec sleep 5; fi\nexec \"__GIT__\" \"$@\"\n", + ); + try { + const started = Date.now(); + await expect(getTags(dir)).rejects.toBeInstanceOf(Error); + expect(Date.now() - started).toBeGreaterThanOrEqual(1900); + } finally { + fake.restore(); + } + + const recovered = await getTags(dir); + expect(recovered.canonical).toBe(expected); + expect(recovered.projectId).toBe(hash("github.com/example/timeoutrepo")); + } finally { + temp.cleanup(); + } + }); +}); diff --git a/src/services/tags.ts b/src/services/tags.ts index be26c57..3539ef6 100644 --- a/src/services/tags.ts +++ b/src/services/tags.ts @@ -1,10 +1,12 @@ import { createHash } from "node:crypto"; -import { execSync } from "node:child_process"; +import { execFile } from "node:child_process"; import { existsSync, readFileSync, realpathSync } from "node:fs"; import { hostname, homedir, userInfo } from "node:os"; import { basename, dirname, join, resolve, sep } from "node:path"; import { CONFIG } from "../config.js"; +const GIT_TIMEOUT_MS = 2000; + function sha256(input: string): string { return createHash("sha256").update(input).digest("hex").slice(0, 16); } @@ -14,31 +16,73 @@ const repoInfoCache = new Map< { name: string | null; normalizedRemote: string | null } >(); -function getGitRoot(directory: string): string | null { - const isolateWorktrees = process.env.SUPERMEMORY_ISOLATE_WORKTREES === "true"; +interface GitMetadata { + basePath: string; + repoName: string | null; + normalizedRemote: string | null; + email: string | null; + isolateWorktrees: boolean; +} + +function isGitCancellation(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const gitError = error as Error & { + killed?: boolean; + signal?: NodeJS.Signals | null; + code?: string | null; + }; + return ( + gitError.killed === true || + gitError.signal != null || + gitError.name === "AbortError" || + gitError.code === "ETIMEDOUT" + ); +} + +function isIsolateWorktrees(): boolean { + return process.env.SUPERMEMORY_ISOLATE_WORKTREES === "true"; +} + +function runGit(args: string[], cwd: string): Promise { + return new Promise((resolvePromise, reject) => { + execFile( + "git", + args, + { + cwd, + encoding: "utf-8", + timeout: GIT_TIMEOUT_MS, + killSignal: "SIGKILL", + windowsHide: true, + }, + (error, stdout) => { + if (error) { + reject(error); + return; + } + resolvePromise(String(stdout).trim()); + }, + ); + }); +} +async function resolveGitRoot( + directory: string, + isolateWorktrees: boolean, +): Promise { try { if (isolateWorktrees) { - const gitRoot = execSync("git rev-parse --show-toplevel", { - cwd: directory, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); + const gitRoot = await runGit(["rev-parse", "--show-toplevel"], directory); return gitRoot || null; } - const gitCommonDir = execSync("git rev-parse --git-common-dir", { - cwd: directory, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); + const gitCommonDir = await runGit( + ["rev-parse", "--git-common-dir"], + directory, + ); if (gitCommonDir === ".git") { - const gitRoot = execSync("git rev-parse --show-toplevel", { - cwd: directory, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); + const gitRoot = await runGit(["rev-parse", "--show-toplevel"], directory); return gitRoot || null; } @@ -47,30 +91,10 @@ function getGitRoot(directory: string): string | null { return dirname(resolved); } - const gitRoot = execSync("git rev-parse --show-toplevel", { - cwd: directory, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); + const gitRoot = await runGit(["rev-parse", "--show-toplevel"], directory); return gitRoot || null; - } catch { - return null; - } -} - -function getProjectBasePath(directory: string): string { - return getGitRoot(directory) || resolve(directory); -} - -function getGitEmail(directory: string): string | null { - try { - const email = execSync("git config user.email", { - cwd: getProjectBasePath(directory), - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - return email || null; - } catch { + } catch (error) { + if (isGitCancellation(error)) throw error; return null; } } @@ -108,19 +132,18 @@ export function normalizeGitRemote(remoteUrl: string): string | null { .toLowerCase(); } -function getGitRepoInfo(directory: string): { +async function resolveRepoInfo(directory: string): Promise<{ name: string | null; normalizedRemote: string | null; -} { +}> { const cached = repoInfoCache.get(directory); if (cached) return cached; try { - const remoteUrl = execSync("git remote get-url origin", { - cwd: directory, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); + const remoteUrl = await runGit( + ["remote", "get-url", "origin"], + directory, + ); const normalizedRemote = normalizeGitRemote(remoteUrl); const displayRemote = remoteUrl.replace(/\/+$/, "").replace(/\.git$/i, ""); const separator = Math.max( @@ -133,24 +156,46 @@ function getGitRepoInfo(directory: string): { }; repoInfoCache.set(directory, result); return result; - } catch { + } catch (error) { + if (isGitCancellation(error)) throw error; const result = { name: null, normalizedRemote: null }; repoInfoCache.set(directory, result); return result; } } -function getGitRepoName(directory: string): string | null { - return getGitRepoInfo(directory).name; +async function resolveGitEmail(directory: string): Promise { + try { + const email = await runGit(["config", "user.email"], directory); + return email || null; + } catch (error) { + if (isGitCancellation(error)) throw error; + return null; + } +} + +async function resolveGitMetadata(directory: string): Promise { + const isolateWorktrees = isIsolateWorktrees(); + const basePath = + (await resolveGitRoot(directory, isolateWorktrees)) || resolve(directory); + const repoInfo = await resolveRepoInfo(basePath); + const email = await resolveGitEmail(basePath); + return { + basePath, + repoName: repoInfo.name, + normalizedRemote: repoInfo.normalizedRemote, + email, + isolateWorktrees, + }; } -function loadClaudeProjectConfig(directory: string): { +function loadClaudeProjectConfig(basePath: string): { personalContainerTag?: string; repoContainerTag?: string; } | null { try { const configPath = join( - getProjectBasePath(directory), + basePath, ".claude", ".supermemory-claude", "config.json", @@ -249,57 +294,50 @@ export function sanitizeRepoName(name: string): string { return sanitized.slice(0, 95).replace(/_+$/g, "") || "unknown"; } -export function getProjectIdentity(directory: string): string { - const basePath = getProjectBasePath(directory); - const { normalizedRemote } = getGitRepoInfo(basePath); - const isolateWorktrees = process.env.SUPERMEMORY_ISOLATE_WORKTREES === "true"; - let localIdentity = basePath; +function repoName(meta: GitMetadata): string { + return meta.repoName || basename(meta.basePath) || "unknown"; +} + +function projectIdentity(meta: GitMetadata): string { + let localIdentity = meta.basePath; try { - localIdentity = realpathSync.native(basePath); + localIdentity = realpathSync.native(meta.basePath); } catch {} return sha256( - !isolateWorktrees && normalizedRemote - ? normalizedRemote + !meta.isolateWorktrees && meta.normalizedRemote + ? meta.normalizedRemote : `path:${localIdentity}`, ); } -export function getGeneratedProjectTag(directory: string): string { - const basePath = getProjectBasePath(directory); - const repoName = getGitRepoName(basePath) || basename(basePath) || "unknown"; - const shortName = sanitizeRepoName(repoName).slice(0, 72).replace(/_+$/g, ""); - return `repo_${shortName || "unknown"}__${getProjectIdentity(directory)}`; +function generatedProjectTag(meta: GitMetadata): string { + const shortName = sanitizeRepoName(repoName(meta)) + .slice(0, 72) + .replace(/_+$/g, ""); + return `repo_${shortName || "unknown"}__${projectIdentity(meta)}`; } -export function getLegacyGeneratedProjectTag(directory: string): string { - const basePath = getProjectBasePath(directory); - const repoName = getGitRepoName(basePath) || basename(basePath) || "unknown"; - return `repo_${sanitizeRepoName(repoName)}`; +function legacyGeneratedProjectTag(meta: GitMetadata): string { + return `repo_${sanitizeRepoName(repoName(meta))}`; } -export function getProjectTag(directory: string): string { +function projectTag(directory: string, meta: GitMetadata): string { return ( - loadClaudeProjectConfig(directory)?.repoContainerTag || + loadClaudeProjectConfig(meta.basePath)?.repoContainerTag || process.env.SUPERMEMORY_REPO_TAG || loadLegacyCursorConfig(directory).repoContainerTag || CONFIG.projectContainerTag || loadCodexConfig()?.projectContainerTag || - getGeneratedProjectTag(directory) + generatedProjectTag(meta) ); } -export function getUserTag(directory = process.cwd()): string { - return getProjectTag(directory); -} - -export function getProjectName(directory: string): string { - const basePath = getProjectBasePath(directory); - return getGitRepoName(basePath) || basename(basePath) || "unknown"; -} - -function getLegacyClaudePersonalTags(directory: string): string[] { - const projectHash = sha256(getProjectBasePath(directory)); - const claudeConfig = loadClaudeProjectConfig(directory); +function legacyClaudePersonalTags( + directory: string, + meta: GitMetadata, +): string[] { + const projectHash = sha256(meta.basePath); + const claudeConfig = loadClaudeProjectConfig(meta.basePath); return uniqueTags([ claudeConfig?.personalContainerTag, `user_project_${projectHash}`, @@ -307,13 +345,10 @@ function getLegacyClaudePersonalTags(directory: string): string[] { ]); } -function getLegacyCodexUserTags(directory: string): string[] { +function legacyCodexUserTags(meta: GitMetadata): string[] { const config = loadCodexConfig(); const identity = - getGitEmail(directory) || - process.env.USER || - process.env.USERNAME || - hostname(); + meta.email || process.env.USER || process.env.USERNAME || hostname(); const hash = sha256(identity); return uniqueTags([ config?.userContainerTag, @@ -322,9 +357,9 @@ function getLegacyCodexUserTags(directory: string): string[] { ]); } -function getLegacyCodexProjectTags(directory: string): string[] { +function legacyCodexProjectTags(meta: GitMetadata): string[] { const config = loadCodexConfig(); - const projectHash = sha256(getProjectBasePath(directory)); + const projectHash = sha256(meta.basePath); return uniqueTags([ config?.projectContainerTag, `${config?.containerTagPrefix || "codex"}_project_${projectHash}`, @@ -332,9 +367,9 @@ function getLegacyCodexProjectTags(directory: string): string[] { ]); } -export function getLegacyOpenCodeUserTags(directory: string): string[] { +function legacyOpenCodeUserTags(meta: GitMetadata): string[] { const identity = - getGitEmail(directory) || + meta.email || process.env.USER || process.env.USERNAME || "anonymous"; @@ -346,11 +381,14 @@ export function getLegacyOpenCodeUserTags(directory: string): string[] { ]); } -export function getLegacyOpenCodeProjectTags(directory: string): string[] { +function legacyOpenCodeProjectTags( + directory: string, + meta: GitMetadata, +): string[] { const directoryHashes = uniqueTags([ sha256(directory), sha256(resolve(directory)), - sha256(getProjectBasePath(directory)), + sha256(meta.basePath), ]); return uniqueTags([ CONFIG.projectContainerTag, @@ -361,23 +399,29 @@ export function getLegacyOpenCodeProjectTags(directory: string): string[] { ]); } -export function getLegacyCursorUserTags(directory: string): string[] { +function legacyCursorUserTags( + directory: string, + meta: GitMetadata, +): string[] { const config = loadLegacyCursorConfig(directory); const identity = config.userContainerTag || process.env.SUPERMEMORY_USER_TAG || process.env.CURSOR_USER_EMAIL || - getGitEmail(directory) || + meta.email || `${hostname()}_${userInfo().username}`; return [`cursor_user_${sha256(identity)}`]; } -export function getLegacyCursorProjectTags(directory: string): string[] { +function legacyCursorProjectTags( + directory: string, + meta: GitMetadata, +): string[] { const config = loadLegacyCursorConfig(directory); const identity = config.projectContainerTag || process.env.SUPERMEMORY_PROJECT_TAG || - getProjectBasePath(directory); + meta.basePath; return [`cursor_project_${sha256(identity)}`]; } @@ -392,32 +436,35 @@ function uniqueTags(tags: Array): string[] { ]; } -export function getPersonalReadTags(directory: string): string[] { +function personalReadTags( + directory: string, + meta: GitMetadata, + canonical: string, + generated: string, +): string[] { return uniqueTags([ - getProjectTag(directory), - getGeneratedProjectTag(directory), - ...getLegacyClaudePersonalTags(directory), - ...getLegacyCodexUserTags(directory), - ...getLegacyOpenCodeUserTags(directory), - ...getLegacyCursorUserTags(directory), - ]); -} - -export function getProjectReadTags(directory: string): string[] { - return uniqueTags([ - getProjectTag(directory), - getGeneratedProjectTag(directory), - getLegacyGeneratedProjectTag(directory), - ...getLegacyCodexProjectTags(directory), - ...getLegacyOpenCodeProjectTags(directory), - ...getLegacyCursorProjectTags(directory), + canonical, + generated, + ...legacyClaudePersonalTags(directory, meta), + ...legacyCodexUserTags(meta), + ...legacyOpenCodeUserTags(meta), + ...legacyCursorUserTags(directory, meta), ]); } -export function getAllReadTags(directory: string): string[] { +function projectReadTags( + directory: string, + meta: GitMetadata, + canonical: string, + generated: string, +): string[] { return uniqueTags([ - ...getPersonalReadTags(directory), - ...getProjectReadTags(directory), + canonical, + generated, + legacyGeneratedProjectTag(meta), + ...legacyCodexProjectTags(meta), + ...legacyOpenCodeProjectTags(directory, meta), + ...legacyCursorProjectTags(directory, meta), ]); } @@ -432,16 +479,20 @@ export interface ResolvedTags { allReads: string[]; } -export function getTags(directory: string): ResolvedTags { - const canonical = getProjectTag(directory); +export async function getTags(directory: string): Promise { + const meta = await resolveGitMetadata(directory); + const canonical = projectTag(directory, meta); + const generated = generatedProjectTag(meta); + const personalReads = personalReadTags(directory, meta, canonical, generated); + const projectReads = projectReadTags(directory, meta, canonical, generated); return { canonical, user: canonical, project: canonical, - projectId: getProjectIdentity(directory), - projectName: getProjectName(directory), - personalReads: getPersonalReadTags(directory), - projectReads: getProjectReadTags(directory), - allReads: getAllReadTags(directory), + projectId: projectIdentity(meta), + projectName: repoName(meta), + personalReads, + projectReads, + allReads: uniqueTags([...personalReads, ...projectReads]), }; } diff --git a/src/v2/runtime.test.ts b/src/v2/runtime.test.ts index bb05a5a..34f6a46 100644 --- a/src/v2/runtime.test.ts +++ b/src/v2/runtime.test.ts @@ -138,6 +138,7 @@ interface Harness { function harness( config: Partial = {}, + resolveTags: V2RuntimeDependencies["resolveTags"] = async () => tags, ): Harness { const tools = new Map(); const hooks: Harness["hooks"] = {}; @@ -263,7 +264,7 @@ function harness( }, memoryClient: memoryClient as unknown as V2RuntimeDependencies["memoryClient"], executeTool: executeSupermemoryTool, - resolveTags: () => tags, + resolveTags, logger: () => undefined, checkUpdate: async () => ({ currentVersion: "2.0.15", @@ -463,4 +464,66 @@ describe("OpenCode 2 runtime", () => { expect(h.emitted.map((event) => event.kind)).toEqual(["recalling", "recalled"]); h.runtime.cleanup(); }); + + test("shares one tag resolution per session and caches success", async () => { + let calls = 0; + let release: (() => void) | null = null; + const h = harness({}, async () => { + calls++; + await new Promise((resolve) => { + release = resolve; + }); + return tags; + }); + await h.runtime.register(); + + const first = h.runtime.handleContext({ + sessionID: "s1", + messages: [request("a", "alpha prompt about caching")], + }); + const second = h.runtime.handleContext({ + sessionID: "s1", + messages: [request("b", "beta prompt about deploy")], + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(calls).toBe(1); + + release!(); + await Promise.all([first, second]); + expect(h.queries.length).toBeGreaterThan(0); + + await h.runtime.handleContext({ + sessionID: "s1", + messages: [request("c", "gamma prompt about testing")], + }); + expect(calls).toBe(1); + h.runtime.cleanup(); + }); + + test("clears in-flight tag resolution after rejection so the next attempt retries", async () => { + let calls = 0; + let fail = true; + const h = harness({}, async () => { + calls++; + if (fail) throw new Error("tag resolution failed"); + return tags; + }); + await h.runtime.register(); + + await h.runtime.handleContext({ + sessionID: "s1", + messages: [request("a", "alpha prompt about caching")], + }); + expect(calls).toBe(1); + expect(h.queries).toHaveLength(0); + + fail = false; + await h.runtime.handleContext({ + sessionID: "s1", + messages: [request("b", "beta prompt about deploy")], + }); + expect(calls).toBe(2); + expect(h.queries.length).toBeGreaterThan(0); + h.runtime.cleanup(); + }); }); diff --git a/src/v2/runtime.ts b/src/v2/runtime.ts index 2fee56b..c602815 100644 --- a/src/v2/runtime.ts +++ b/src/v2/runtime.ts @@ -129,7 +129,7 @@ export interface V2RuntimeDependencies { config: RuntimeConfig; memoryClient: RuntimeMemoryClient; executeTool: typeof executeSupermemoryTool; - resolveTags: typeof getTags; + resolveTags: (directory: string) => Promise; logger: typeof log; checkUpdate: () => Promise; } @@ -867,7 +867,7 @@ export class V2Runtime { throw new Error(`Unable to resolve directory for OpenCode session ${sessionID}`); } state.directory = directory; - state.tags = this.#deps.resolveTags(directory); + state.tags = await this.#deps.resolveTags(directory); return state.tags; })();