diff --git a/plugin/scripts/notification.mjs b/plugin/scripts/notification.mjs index 3967158c9..5fda72e64 100755 --- a/plugin/scripts/notification.mjs +++ b/plugin/scripts/notification.mjs @@ -1,27 +1,67 @@ #!/usr/bin/env node -import { execSync } from "node:child_process"; -import { basename } from "node:path"; - +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, resolve } from "node:path"; //#region src/hooks/_project.ts -function resolveProject(cwd) { - const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; - if (explicit && explicit.trim()) return explicit.trim(); - const dir = cwd && cwd.trim() ? cwd : process.cwd(); +function cleanEnv(name) { + const value = process.env[name]; + if (!value) return void 0; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : void 0; +} +function gitOutput(cwd, args) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).trim(); +} +function realPath(path) { try { - const top = execSync("git rev-parse --show-toplevel", { - cwd: dir, - stdio: [ - "ignore", - "pipe", - "ignore" - ], - timeout: 500 - }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return realpathSync(path); + } catch { + return resolve(path); + } +} +function gitCommonDir(cwd) { + try { + return gitOutput(cwd, [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir" + ]); + } catch { + const relativeOrAbsolute = gitOutput(cwd, ["rev-parse", "--git-common-dir"]); + return isAbsolute(relativeOrAbsolute) ? relativeOrAbsolute : resolve(cwd, relativeOrAbsolute); + } +} +function canonicalGitProject(cwd) { + try { + const common = realPath(gitCommonDir(cwd)); + const root = basename(common) === ".git" ? realPath(dirname(common)) : common; + return `git:${createHash("sha256").update(root).digest("hex").slice(0, 32)}`; + } catch { + return; + } +} +function resolveCwd(cwd) { + if (typeof cwd !== "string") return process.cwd(); + return cwd.trim().length > 0 ? cwd : process.cwd(); +} +function resolveProject(cwd) { + const explicitId = cleanEnv("AGENTMEMORY_PROJECT_ID"); + if (explicitId) return explicitId; + const explicitName = cleanEnv("AGENTMEMORY_PROJECT_NAME"); + if (explicitName) return explicitName; + const dir = resolveCwd(cwd); + return canonicalGitProject(dir) ?? basename(dir); } - //#endregion //#region src/hooks/notification.ts function isSdkChildContext(payload) { @@ -57,7 +97,7 @@ async function main() { hookType: "notification", sessionId, project: resolveProject(data.cwd), - cwd: data.cwd || process.cwd(), + cwd: resolveCwd(data.cwd), timestamp: (/* @__PURE__ */ new Date()).toISOString(), data: { notification_type: notificationType, @@ -70,7 +110,7 @@ async function main() { setTimeout(() => process.exit(0), 500).unref(); } main(); - //#endregion -export { }; +export {}; + //# sourceMappingURL=notification.mjs.map \ No newline at end of file diff --git a/plugin/scripts/post-commit.mjs b/plugin/scripts/post-commit.mjs index 8552cd614..5318184f8 100755 --- a/plugin/scripts/post-commit.mjs +++ b/plugin/scripts/post-commit.mjs @@ -1,7 +1,6 @@ #!/usr/bin/env node import { execFile } from "node:child_process"; import { promisify } from "node:util"; - //#region src/hooks/post-commit.ts const exec = promisify(execFile); function isSdkChildContext(payload) { @@ -96,7 +95,7 @@ async function main() { } catch {} } main(); - //#endregion -export { }; +export {}; + //# sourceMappingURL=post-commit.mjs.map \ No newline at end of file diff --git a/plugin/scripts/post-tool-failure.mjs b/plugin/scripts/post-tool-failure.mjs index 6fdad8d9d..1133afcdf 100755 --- a/plugin/scripts/post-tool-failure.mjs +++ b/plugin/scripts/post-tool-failure.mjs @@ -1,27 +1,67 @@ #!/usr/bin/env node -import { execSync } from "node:child_process"; -import { basename } from "node:path"; - +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, resolve } from "node:path"; //#region src/hooks/_project.ts -function resolveProject(cwd) { - const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; - if (explicit && explicit.trim()) return explicit.trim(); - const dir = cwd && cwd.trim() ? cwd : process.cwd(); +function cleanEnv(name) { + const value = process.env[name]; + if (!value) return void 0; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : void 0; +} +function gitOutput(cwd, args) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).trim(); +} +function realPath(path) { try { - const top = execSync("git rev-parse --show-toplevel", { - cwd: dir, - stdio: [ - "ignore", - "pipe", - "ignore" - ], - timeout: 500 - }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return realpathSync(path); + } catch { + return resolve(path); + } +} +function gitCommonDir(cwd) { + try { + return gitOutput(cwd, [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir" + ]); + } catch { + const relativeOrAbsolute = gitOutput(cwd, ["rev-parse", "--git-common-dir"]); + return isAbsolute(relativeOrAbsolute) ? relativeOrAbsolute : resolve(cwd, relativeOrAbsolute); + } +} +function canonicalGitProject(cwd) { + try { + const common = realPath(gitCommonDir(cwd)); + const root = basename(common) === ".git" ? realPath(dirname(common)) : common; + return `git:${createHash("sha256").update(root).digest("hex").slice(0, 32)}`; + } catch { + return; + } +} +function resolveCwd(cwd) { + if (typeof cwd !== "string") return process.cwd(); + return cwd.trim().length > 0 ? cwd : process.cwd(); +} +function resolveProject(cwd) { + const explicitId = cleanEnv("AGENTMEMORY_PROJECT_ID"); + if (explicitId) return explicitId; + const explicitName = cleanEnv("AGENTMEMORY_PROJECT_NAME"); + if (explicitName) return explicitName; + const dir = resolveCwd(cwd); + return canonicalGitProject(dir) ?? basename(dir); } - //#endregion //#region src/hooks/post-tool-failure.ts function isSdkChildContext(payload) { @@ -58,7 +98,7 @@ async function main() { hookType: "post_tool_failure", sessionId, project: resolveProject(data.cwd), - cwd: data.cwd || process.cwd(), + cwd: resolveCwd(data.cwd), timestamp: (/* @__PURE__ */ new Date()).toISOString(), data: { tool_name: toolName, @@ -71,7 +111,7 @@ async function main() { setTimeout(() => process.exit(0), 500).unref(); } main(); - //#endregion -export { }; +export {}; + //# sourceMappingURL=post-tool-failure.mjs.map \ No newline at end of file diff --git a/plugin/scripts/post-tool-use.mjs b/plugin/scripts/post-tool-use.mjs index b4aef9c94..d525df4ab 100755 --- a/plugin/scripts/post-tool-use.mjs +++ b/plugin/scripts/post-tool-use.mjs @@ -1,27 +1,67 @@ #!/usr/bin/env node -import { execSync } from "node:child_process"; -import { basename } from "node:path"; - +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, resolve } from "node:path"; //#region src/hooks/_project.ts -function resolveProject(cwd) { - const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; - if (explicit && explicit.trim()) return explicit.trim(); - const dir = cwd && cwd.trim() ? cwd : process.cwd(); +function cleanEnv(name) { + const value = process.env[name]; + if (!value) return void 0; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : void 0; +} +function gitOutput(cwd, args) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).trim(); +} +function realPath(path) { try { - const top = execSync("git rev-parse --show-toplevel", { - cwd: dir, - stdio: [ - "ignore", - "pipe", - "ignore" - ], - timeout: 500 - }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return realpathSync(path); + } catch { + return resolve(path); + } +} +function gitCommonDir(cwd) { + try { + return gitOutput(cwd, [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir" + ]); + } catch { + const relativeOrAbsolute = gitOutput(cwd, ["rev-parse", "--git-common-dir"]); + return isAbsolute(relativeOrAbsolute) ? relativeOrAbsolute : resolve(cwd, relativeOrAbsolute); + } +} +function canonicalGitProject(cwd) { + try { + const common = realPath(gitCommonDir(cwd)); + const root = basename(common) === ".git" ? realPath(dirname(common)) : common; + return `git:${createHash("sha256").update(root).digest("hex").slice(0, 32)}`; + } catch { + return; + } +} +function resolveCwd(cwd) { + if (typeof cwd !== "string") return process.cwd(); + return cwd.trim().length > 0 ? cwd : process.cwd(); +} +function resolveProject(cwd) { + const explicitId = cleanEnv("AGENTMEMORY_PROJECT_ID"); + if (explicitId) return explicitId; + const explicitName = cleanEnv("AGENTMEMORY_PROJECT_NAME"); + if (explicitName) return explicitName; + const dir = resolveCwd(cwd); + return canonicalGitProject(dir) ?? basename(dir); } - //#endregion //#region src/hooks/post-tool-use.ts function isSdkChildContext(payload) { @@ -57,7 +97,7 @@ async function main() { hookType: "post_tool_use", sessionId, project: resolveProject(data.cwd), - cwd: data.cwd || process.cwd(), + cwd: resolveCwd(data.cwd), timestamp: (/* @__PURE__ */ new Date()).toISOString(), data: { tool_name: toolName, @@ -116,7 +156,7 @@ function truncate(value, max) { return value; } main(); - //#endregion -export { }; +export {}; + //# sourceMappingURL=post-tool-use.mjs.map \ No newline at end of file diff --git a/plugin/scripts/pre-compact.mjs b/plugin/scripts/pre-compact.mjs index 0afdcb0b4..701ff210a 100755 --- a/plugin/scripts/pre-compact.mjs +++ b/plugin/scripts/pre-compact.mjs @@ -1,27 +1,67 @@ #!/usr/bin/env node -import { execSync } from "node:child_process"; -import { basename } from "node:path"; - +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, resolve } from "node:path"; //#region src/hooks/_project.ts -function resolveProject(cwd) { - const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; - if (explicit && explicit.trim()) return explicit.trim(); - const dir = cwd && cwd.trim() ? cwd : process.cwd(); +function cleanEnv(name) { + const value = process.env[name]; + if (!value) return void 0; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : void 0; +} +function gitOutput(cwd, args) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).trim(); +} +function realPath(path) { try { - const top = execSync("git rev-parse --show-toplevel", { - cwd: dir, - stdio: [ - "ignore", - "pipe", - "ignore" - ], - timeout: 500 - }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return realpathSync(path); + } catch { + return resolve(path); + } +} +function gitCommonDir(cwd) { + try { + return gitOutput(cwd, [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir" + ]); + } catch { + const relativeOrAbsolute = gitOutput(cwd, ["rev-parse", "--git-common-dir"]); + return isAbsolute(relativeOrAbsolute) ? relativeOrAbsolute : resolve(cwd, relativeOrAbsolute); + } +} +function canonicalGitProject(cwd) { + try { + const common = realPath(gitCommonDir(cwd)); + const root = basename(common) === ".git" ? realPath(dirname(common)) : common; + return `git:${createHash("sha256").update(root).digest("hex").slice(0, 32)}`; + } catch { + return; + } +} +function resolveCwd(cwd) { + if (typeof cwd !== "string") return process.cwd(); + return cwd.trim().length > 0 ? cwd : process.cwd(); +} +function resolveProject(cwd) { + const explicitId = cleanEnv("AGENTMEMORY_PROJECT_ID"); + if (explicitId) return explicitId; + const explicitName = cleanEnv("AGENTMEMORY_PROJECT_NAME"); + if (explicitName) return explicitName; + const dir = resolveCwd(cwd); + return canonicalGitProject(dir) ?? basename(dir); } - //#endregion //#region src/hooks/pre-compact.ts function isSdkChildContext(payload) { @@ -74,7 +114,7 @@ async function main() { } catch {} } main(); - //#endregion -export { }; +export {}; + //# sourceMappingURL=pre-compact.mjs.map \ No newline at end of file diff --git a/plugin/scripts/pre-tool-use.mjs b/plugin/scripts/pre-tool-use.mjs index d70c166ed..739f21130 100755 --- a/plugin/scripts/pre-tool-use.mjs +++ b/plugin/scripts/pre-tool-use.mjs @@ -1,4 +1,68 @@ #!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, resolve } from "node:path"; +//#region src/hooks/_project.ts +function cleanEnv(name) { + const value = process.env[name]; + if (!value) return void 0; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : void 0; +} +function gitOutput(cwd, args) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).trim(); +} +function realPath(path) { + try { + return realpathSync(path); + } catch { + return resolve(path); + } +} +function gitCommonDir(cwd) { + try { + return gitOutput(cwd, [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir" + ]); + } catch { + const relativeOrAbsolute = gitOutput(cwd, ["rev-parse", "--git-common-dir"]); + return isAbsolute(relativeOrAbsolute) ? relativeOrAbsolute : resolve(cwd, relativeOrAbsolute); + } +} +function canonicalGitProject(cwd) { + try { + const common = realPath(gitCommonDir(cwd)); + const root = basename(common) === ".git" ? realPath(dirname(common)) : common; + return `git:${createHash("sha256").update(root).digest("hex").slice(0, 32)}`; + } catch { + return; + } +} +function resolveCwd(cwd) { + if (typeof cwd !== "string") return process.cwd(); + return cwd.trim().length > 0 ? cwd : process.cwd(); +} +function resolveProject(cwd) { + const explicitId = cleanEnv("AGENTMEMORY_PROJECT_ID"); + if (explicitId) return explicitId; + const explicitName = cleanEnv("AGENTMEMORY_PROJECT_NAME"); + if (explicitName) return explicitName; + const dir = resolveCwd(cwd); + return canonicalGitProject(dir) ?? basename(dir); +} +//#endregion //#region src/hooks/pre-tool-use.ts function isSdkChildContext(payload) { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; @@ -57,7 +121,7 @@ async function main() { } const rawSessionId = data.session_id || data.sessionId; const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : "unknown"; - const project = typeof data.project === "string" && data.project.trim().length > 0 ? data.project.trim() : void 0; + const project = resolveProject(data.cwd); try { const res = await fetch(`${REST_URL}/agentmemory/enrich`, { method: "POST", @@ -78,7 +142,7 @@ async function main() { } catch {} } main(); - //#endregion -export { }; +export {}; + //# sourceMappingURL=pre-tool-use.mjs.map \ No newline at end of file diff --git a/plugin/scripts/prompt-submit.mjs b/plugin/scripts/prompt-submit.mjs index 1a4147e6a..6169d9157 100755 --- a/plugin/scripts/prompt-submit.mjs +++ b/plugin/scripts/prompt-submit.mjs @@ -1,27 +1,67 @@ #!/usr/bin/env node -import { execSync } from "node:child_process"; -import { basename } from "node:path"; - +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, resolve } from "node:path"; //#region src/hooks/_project.ts -function resolveProject(cwd) { - const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; - if (explicit && explicit.trim()) return explicit.trim(); - const dir = cwd && cwd.trim() ? cwd : process.cwd(); +function cleanEnv(name) { + const value = process.env[name]; + if (!value) return void 0; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : void 0; +} +function gitOutput(cwd, args) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).trim(); +} +function realPath(path) { try { - const top = execSync("git rev-parse --show-toplevel", { - cwd: dir, - stdio: [ - "ignore", - "pipe", - "ignore" - ], - timeout: 500 - }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return realpathSync(path); + } catch { + return resolve(path); + } +} +function gitCommonDir(cwd) { + try { + return gitOutput(cwd, [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir" + ]); + } catch { + const relativeOrAbsolute = gitOutput(cwd, ["rev-parse", "--git-common-dir"]); + return isAbsolute(relativeOrAbsolute) ? relativeOrAbsolute : resolve(cwd, relativeOrAbsolute); + } +} +function canonicalGitProject(cwd) { + try { + const common = realPath(gitCommonDir(cwd)); + const root = basename(common) === ".git" ? realPath(dirname(common)) : common; + return `git:${createHash("sha256").update(root).digest("hex").slice(0, 32)}`; + } catch { + return; + } +} +function resolveCwd(cwd) { + if (typeof cwd !== "string") return process.cwd(); + return cwd.trim().length > 0 ? cwd : process.cwd(); +} +function resolveProject(cwd) { + const explicitId = cleanEnv("AGENTMEMORY_PROJECT_ID"); + if (explicitId) return explicitId; + const explicitName = cleanEnv("AGENTMEMORY_PROJECT_NAME"); + if (explicitName) return explicitName; + const dir = resolveCwd(cwd); + return canonicalGitProject(dir) ?? basename(dir); } - //#endregion //#region src/hooks/prompt-submit.ts function isSdkChildContext(payload) { @@ -54,7 +94,7 @@ async function main() { hookType: "prompt_submit", sessionId, project: resolveProject(data.cwd), - cwd: data.cwd || process.cwd(), + cwd: resolveCwd(data.cwd), timestamp: (/* @__PURE__ */ new Date()).toISOString(), data: { prompt: data.prompt ?? data.userPrompt } }), @@ -63,7 +103,7 @@ async function main() { setTimeout(() => process.exit(0), 500).unref(); } main(); - //#endregion -export { }; +export {}; + //# sourceMappingURL=prompt-submit.mjs.map \ No newline at end of file diff --git a/plugin/scripts/session-end.mjs b/plugin/scripts/session-end.mjs index 019149c30..0ba564be5 100755 --- a/plugin/scripts/session-end.mjs +++ b/plugin/scripts/session-end.mjs @@ -54,7 +54,7 @@ async function main() { setTimeout(() => process.exit(0), 1500).unref(); } main(); - //#endregion -export { }; +export {}; + //# sourceMappingURL=session-end.mjs.map \ No newline at end of file diff --git a/plugin/scripts/session-start.mjs b/plugin/scripts/session-start.mjs index 51b70eb4c..a250e18e7 100755 --- a/plugin/scripts/session-start.mjs +++ b/plugin/scripts/session-start.mjs @@ -1,27 +1,67 @@ #!/usr/bin/env node -import { execSync } from "node:child_process"; -import { basename } from "node:path"; - +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, resolve } from "node:path"; //#region src/hooks/_project.ts -function resolveProject(cwd) { - const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; - if (explicit && explicit.trim()) return explicit.trim(); - const dir = cwd && cwd.trim() ? cwd : process.cwd(); +function cleanEnv(name) { + const value = process.env[name]; + if (!value) return void 0; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : void 0; +} +function gitOutput(cwd, args) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).trim(); +} +function realPath(path) { try { - const top = execSync("git rev-parse --show-toplevel", { - cwd: dir, - stdio: [ - "ignore", - "pipe", - "ignore" - ], - timeout: 500 - }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return realpathSync(path); + } catch { + return resolve(path); + } +} +function gitCommonDir(cwd) { + try { + return gitOutput(cwd, [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir" + ]); + } catch { + const relativeOrAbsolute = gitOutput(cwd, ["rev-parse", "--git-common-dir"]); + return isAbsolute(relativeOrAbsolute) ? relativeOrAbsolute : resolve(cwd, relativeOrAbsolute); + } +} +function canonicalGitProject(cwd) { + try { + const common = realPath(gitCommonDir(cwd)); + const root = basename(common) === ".git" ? realPath(dirname(common)) : common; + return `git:${createHash("sha256").update(root).digest("hex").slice(0, 32)}`; + } catch { + return; + } +} +function resolveCwd(cwd) { + if (typeof cwd !== "string") return process.cwd(); + return cwd.trim().length > 0 ? cwd : process.cwd(); +} +function resolveProject(cwd) { + const explicitId = cleanEnv("AGENTMEMORY_PROJECT_ID"); + if (explicitId) return explicitId; + const explicitName = cleanEnv("AGENTMEMORY_PROJECT_NAME"); + if (explicitName) return explicitName; + const dir = resolveCwd(cwd); + return canonicalGitProject(dir) ?? basename(dir); } - //#endregion //#region src/hooks/session-start.ts function isSdkChildContext(payload) { @@ -50,7 +90,7 @@ async function main() { } if (isSdkChildContext(data)) return; const sessionId = data.session_id || data.sessionId || `ses_${Date.now().toString(36)}`; - const cwd = data.cwd || process.cwd(); + const cwd = resolveCwd(data.cwd); const project = resolveProject(data.cwd); const url = `${REST_URL}/agentmemory/session/start`; const init = { @@ -81,7 +121,7 @@ async function main() { } catch {} } main(); - //#endregion -export { }; +export {}; + //# sourceMappingURL=session-start.mjs.map \ No newline at end of file diff --git a/plugin/scripts/stop.mjs b/plugin/scripts/stop.mjs index 03d30c64f..63051c830 100755 --- a/plugin/scripts/stop.mjs +++ b/plugin/scripts/stop.mjs @@ -38,7 +38,7 @@ async function main() { setTimeout(() => process.exit(0), 1500).unref(); } main(); - //#endregion -export { }; +export {}; + //# sourceMappingURL=stop.mjs.map \ No newline at end of file diff --git a/plugin/scripts/subagent-start.mjs b/plugin/scripts/subagent-start.mjs index 2359a1c62..a1e1ae370 100755 --- a/plugin/scripts/subagent-start.mjs +++ b/plugin/scripts/subagent-start.mjs @@ -1,27 +1,67 @@ #!/usr/bin/env node -import { execSync } from "node:child_process"; -import { basename } from "node:path"; - +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, resolve } from "node:path"; //#region src/hooks/_project.ts -function resolveProject(cwd) { - const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; - if (explicit && explicit.trim()) return explicit.trim(); - const dir = cwd && cwd.trim() ? cwd : process.cwd(); +function cleanEnv(name) { + const value = process.env[name]; + if (!value) return void 0; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : void 0; +} +function gitOutput(cwd, args) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).trim(); +} +function realPath(path) { try { - const top = execSync("git rev-parse --show-toplevel", { - cwd: dir, - stdio: [ - "ignore", - "pipe", - "ignore" - ], - timeout: 500 - }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return realpathSync(path); + } catch { + return resolve(path); + } +} +function gitCommonDir(cwd) { + try { + return gitOutput(cwd, [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir" + ]); + } catch { + const relativeOrAbsolute = gitOutput(cwd, ["rev-parse", "--git-common-dir"]); + return isAbsolute(relativeOrAbsolute) ? relativeOrAbsolute : resolve(cwd, relativeOrAbsolute); + } +} +function canonicalGitProject(cwd) { + try { + const common = realPath(gitCommonDir(cwd)); + const root = basename(common) === ".git" ? realPath(dirname(common)) : common; + return `git:${createHash("sha256").update(root).digest("hex").slice(0, 32)}`; + } catch { + return; + } +} +function resolveCwd(cwd) { + if (typeof cwd !== "string") return process.cwd(); + return cwd.trim().length > 0 ? cwd : process.cwd(); +} +function resolveProject(cwd) { + const explicitId = cleanEnv("AGENTMEMORY_PROJECT_ID"); + if (explicitId) return explicitId; + const explicitName = cleanEnv("AGENTMEMORY_PROJECT_NAME"); + if (explicitName) return explicitName; + const dir = resolveCwd(cwd); + return canonicalGitProject(dir) ?? basename(dir); } - //#endregion //#region src/hooks/subagent-start.ts function isSdkChildContext(payload) { @@ -57,7 +97,7 @@ async function main() { hookType: "subagent_start", sessionId, project: resolveProject(data.cwd), - cwd: data.cwd || process.cwd(), + cwd: resolveCwd(data.cwd), timestamp: (/* @__PURE__ */ new Date()).toISOString(), data: { agent_id: agentId, @@ -69,7 +109,7 @@ async function main() { setTimeout(() => process.exit(0), 500).unref(); } main(); - //#endregion -export { }; +export {}; + //# sourceMappingURL=subagent-start.mjs.map \ No newline at end of file diff --git a/plugin/scripts/subagent-stop.mjs b/plugin/scripts/subagent-stop.mjs index 2ba1b002c..a331166fd 100755 --- a/plugin/scripts/subagent-stop.mjs +++ b/plugin/scripts/subagent-stop.mjs @@ -1,27 +1,67 @@ #!/usr/bin/env node -import { execSync } from "node:child_process"; -import { basename } from "node:path"; - +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, resolve } from "node:path"; //#region src/hooks/_project.ts -function resolveProject(cwd) { - const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; - if (explicit && explicit.trim()) return explicit.trim(); - const dir = cwd && cwd.trim() ? cwd : process.cwd(); +function cleanEnv(name) { + const value = process.env[name]; + if (!value) return void 0; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : void 0; +} +function gitOutput(cwd, args) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).trim(); +} +function realPath(path) { try { - const top = execSync("git rev-parse --show-toplevel", { - cwd: dir, - stdio: [ - "ignore", - "pipe", - "ignore" - ], - timeout: 500 - }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return realpathSync(path); + } catch { + return resolve(path); + } +} +function gitCommonDir(cwd) { + try { + return gitOutput(cwd, [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir" + ]); + } catch { + const relativeOrAbsolute = gitOutput(cwd, ["rev-parse", "--git-common-dir"]); + return isAbsolute(relativeOrAbsolute) ? relativeOrAbsolute : resolve(cwd, relativeOrAbsolute); + } +} +function canonicalGitProject(cwd) { + try { + const common = realPath(gitCommonDir(cwd)); + const root = basename(common) === ".git" ? realPath(dirname(common)) : common; + return `git:${createHash("sha256").update(root).digest("hex").slice(0, 32)}`; + } catch { + return; + } +} +function resolveCwd(cwd) { + if (typeof cwd !== "string") return process.cwd(); + return cwd.trim().length > 0 ? cwd : process.cwd(); +} +function resolveProject(cwd) { + const explicitId = cleanEnv("AGENTMEMORY_PROJECT_ID"); + if (explicitId) return explicitId; + const explicitName = cleanEnv("AGENTMEMORY_PROJECT_NAME"); + if (explicitName) return explicitName; + const dir = resolveCwd(cwd); + return canonicalGitProject(dir) ?? basename(dir); } - //#endregion //#region src/hooks/subagent-stop.ts function isSdkChildContext(payload) { @@ -57,7 +97,7 @@ async function main() { hookType: "subagent_stop", sessionId, project: resolveProject(data.cwd), - cwd: data.cwd || process.cwd(), + cwd: resolveCwd(data.cwd), timestamp: (/* @__PURE__ */ new Date()).toISOString(), data: { agent_id: agentId, @@ -70,7 +110,7 @@ async function main() { setTimeout(() => process.exit(0), 500).unref(); } main(); - //#endregion -export { }; +export {}; + //# sourceMappingURL=subagent-stop.mjs.map \ No newline at end of file diff --git a/plugin/scripts/task-completed.mjs b/plugin/scripts/task-completed.mjs index 478f0688e..3c4bb2ecd 100755 --- a/plugin/scripts/task-completed.mjs +++ b/plugin/scripts/task-completed.mjs @@ -1,27 +1,67 @@ #!/usr/bin/env node -import { execSync } from "node:child_process"; -import { basename } from "node:path"; - +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, resolve } from "node:path"; //#region src/hooks/_project.ts -function resolveProject(cwd) { - const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; - if (explicit && explicit.trim()) return explicit.trim(); - const dir = cwd && cwd.trim() ? cwd : process.cwd(); +function cleanEnv(name) { + const value = process.env[name]; + if (!value) return void 0; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : void 0; +} +function gitOutput(cwd, args) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).trim(); +} +function realPath(path) { try { - const top = execSync("git rev-parse --show-toplevel", { - cwd: dir, - stdio: [ - "ignore", - "pipe", - "ignore" - ], - timeout: 500 - }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return realpathSync(path); + } catch { + return resolve(path); + } +} +function gitCommonDir(cwd) { + try { + return gitOutput(cwd, [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir" + ]); + } catch { + const relativeOrAbsolute = gitOutput(cwd, ["rev-parse", "--git-common-dir"]); + return isAbsolute(relativeOrAbsolute) ? relativeOrAbsolute : resolve(cwd, relativeOrAbsolute); + } +} +function canonicalGitProject(cwd) { + try { + const common = realPath(gitCommonDir(cwd)); + const root = basename(common) === ".git" ? realPath(dirname(common)) : common; + return `git:${createHash("sha256").update(root).digest("hex").slice(0, 32)}`; + } catch { + return; + } +} +function resolveCwd(cwd) { + if (typeof cwd !== "string") return process.cwd(); + return cwd.trim().length > 0 ? cwd : process.cwd(); +} +function resolveProject(cwd) { + const explicitId = cleanEnv("AGENTMEMORY_PROJECT_ID"); + if (explicitId) return explicitId; + const explicitName = cleanEnv("AGENTMEMORY_PROJECT_NAME"); + if (explicitName) return explicitName; + const dir = resolveCwd(cwd); + return canonicalGitProject(dir) ?? basename(dir); } - //#endregion //#region src/hooks/task-completed.ts function isSdkChildContext(payload) { @@ -54,7 +94,7 @@ async function main() { hookType: "task_completed", sessionId, project: resolveProject(data.cwd), - cwd: data.cwd || process.cwd(), + cwd: resolveCwd(data.cwd), timestamp: (/* @__PURE__ */ new Date()).toISOString(), data: { task_id: data.task_id, @@ -69,7 +109,7 @@ async function main() { setTimeout(() => process.exit(0), 500).unref(); } main(); - //#endregion -export { }; +export {}; + //# sourceMappingURL=task-completed.mjs.map \ No newline at end of file diff --git a/plugin/skills/agentmemory-config/REFERENCE.md b/plugin/skills/agentmemory-config/REFERENCE.md index 08c873a44..0dce83c77 100644 --- a/plugin/skills/agentmemory-config/REFERENCE.md +++ b/plugin/skills/agentmemory-config/REFERENCE.md @@ -3,7 +3,7 @@ Generated by scanning `src/` for `AGENTMEMORY_*` usage. Do not edit the block below by hand; run `npm run skills:gen` after adding or removing a variable. Internal markers ending in two underscores are excluded. -Configuration is read from the environment and from `~/.agentmemory/.env` (no `export` prefix). 34 recognized variables: +Configuration is read from the environment and from `~/.agentmemory/.env` (no `export` prefix). 35 recognized variables: - `AGENTMEMORY_AGENT_SCOPE` - `AGENTMEMORY_ALLOW_AGENT_SDK` @@ -25,6 +25,7 @@ Configuration is read from the environment and from `~/.agentmemory/.env` (no `e - `AGENTMEMORY_LLM_TIMEOUT_MS` - `AGENTMEMORY_MCP_BLOCK` - `AGENTMEMORY_PROBE_TIMEOUT_MS` +- `AGENTMEMORY_PROJECT_ID` - `AGENTMEMORY_PROJECT_NAME` - `AGENTMEMORY_PROVIDER` - `AGENTMEMORY_REFLECT` diff --git a/plugin/skills/agentmemory-mcp-tools/REFERENCE.md b/plugin/skills/agentmemory-mcp-tools/REFERENCE.md index 0c556fc77..56fe2b888 100644 --- a/plugin/skills/agentmemory-mcp-tools/REFERENCE.md +++ b/plugin/skills/agentmemory-mcp-tools/REFERENCE.md @@ -35,7 +35,7 @@ agentmemory exposes 53 MCP tools. 8 are in the lean core set (`--tools core` or | `memory_obsidian_export` | | `vaultDir`: string, `types`: string | Export memories, lessons, and crystals as Obsidian-compatible Markdown files with YAML frontmatter and wikilinks for graph view. | | `memory_patterns` | | `project`: string | Detect recurring patterns across sessions. | | `memory_profile` | | `project`*: string, `refresh`: string | User/project profile with top concepts and file patterns. | -| `memory_recall` | yes | `query`*: string, `limit`: number, `format`: string, `token_budget`: number | Search past session observations for relevant context. Use when you need to recall what happened in previous sessions, find past decisions, or look up how a file was modified before. | +| `memory_recall` | yes | `query`*: string, `limit`: number, `format`: string, `token_budget`: number, `project`: string | Search past session observations for relevant context. Use when you need to recall what happened in previous sessions, find past decisions, or look up how a file was modified before. | | `memory_reflect` | yes | `project`: string, `maxClusters`: number | Traverse the knowledge graph, group related memories by concept clusters, and synthesize higher-order insights via LLM. Returns new and reinforced insights. | | `memory_relations` | | `memoryId`*: string, `maxHops`: number, `minConfidence`: number | Query the memory relationship graph. | | `memory_routine_run` | | `routineId`*: string, `project`: string, `initiatedBy`: string | Instantiate a frozen workflow routine, creating actions for each step with proper dependencies. | diff --git a/src/functions/remember.ts b/src/functions/remember.ts index 5735b4f23..f178d8915 100644 --- a/src/functions/remember.ts +++ b/src/functions/remember.ts @@ -160,7 +160,7 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { logger.info("Memory saved", { memId: memory.id, type: memory.type, - project: memory.project, + hasProject: memory.project !== undefined, }); return { success: true, memory }; }); diff --git a/src/hooks/_project.ts b/src/hooks/_project.ts index 35364ea3b..f4c8dab5f 100644 --- a/src/hooks/_project.ts +++ b/src/hooks/_project.ts @@ -1,20 +1,72 @@ -import { execSync } from "node:child_process"; -import { basename } from "node:path"; - -// Resolution order: AGENTMEMORY_PROJECT_NAME env → git toplevel basename → cwd basename. -export function resolveProject(cwd?: string): string { - const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; - if (explicit && explicit.trim()) return explicit.trim(); - const dir = cwd && cwd.trim() ? cwd : process.cwd(); +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, resolve } from "node:path"; + +function cleanEnv(name: string): string | undefined { + const value = process.env[name]; + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function gitOutput(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 500, + }).trim(); +} + +function realPath(path: string): string { try { - const top = execSync("git rev-parse --show-toplevel", { - cwd: dir, - stdio: ["ignore", "pipe", "ignore"], - timeout: 500, - }) - .toString() - .trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return realpathSync(path); + } catch { + return resolve(path); + } +} + +function gitCommonDir(cwd: string): string { + try { + return gitOutput(cwd, [ + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ]); + } catch { + const relativeOrAbsolute = gitOutput(cwd, [ + "rev-parse", + "--git-common-dir", + ]); + return isAbsolute(relativeOrAbsolute) + ? relativeOrAbsolute + : resolve(cwd, relativeOrAbsolute); + } +} + +function canonicalGitProject(cwd: string): string | undefined { + try { + const common = realPath(gitCommonDir(cwd)); + const root = basename(common) === ".git" ? realPath(dirname(common)) : common; + return `git:${createHash("sha256").update(root).digest("hex").slice(0, 32)}`; + } catch { + return undefined; + } +} + +export function resolveCwd(cwd?: unknown): string { + if (typeof cwd !== "string") return process.cwd(); + return cwd.trim().length > 0 ? cwd : process.cwd(); +} + +export function resolveProject(cwd?: unknown): string { + const explicitId = cleanEnv("AGENTMEMORY_PROJECT_ID"); + if (explicitId) return explicitId; + + const explicitName = cleanEnv("AGENTMEMORY_PROJECT_NAME"); + if (explicitName) return explicitName; + + const dir = resolveCwd(cwd); + return canonicalGitProject(dir) ?? basename(dir); } diff --git a/src/hooks/notification.ts b/src/hooks/notification.ts index 9ad770423..d84646c73 100644 --- a/src/hooks/notification.ts +++ b/src/hooks/notification.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { resolveProject } from "./_project.js"; +import { resolveCwd, resolveProject } from "./_project.js"; function isSdkChildContext(payload: unknown): boolean { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; @@ -45,8 +45,8 @@ async function main() { body: JSON.stringify({ hookType: "notification", sessionId, - project: resolveProject(data.cwd as string | undefined), - cwd: (data.cwd as string | undefined) || process.cwd(), + project: resolveProject(data.cwd), + cwd: resolveCwd(data.cwd), timestamp: new Date().toISOString(), data: { notification_type: notificationType, diff --git a/src/hooks/post-tool-failure.ts b/src/hooks/post-tool-failure.ts index f0295b3e6..07300bd18 100644 --- a/src/hooks/post-tool-failure.ts +++ b/src/hooks/post-tool-failure.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { resolveProject } from "./_project.js"; +import { resolveCwd, resolveProject } from "./_project.js"; function isSdkChildContext(payload: unknown): boolean { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; @@ -43,8 +43,8 @@ async function main() { body: JSON.stringify({ hookType: "post_tool_failure", sessionId, - project: resolveProject(data.cwd as string | undefined), - cwd: (data.cwd as string | undefined) || process.cwd(), + project: resolveProject(data.cwd), + cwd: resolveCwd(data.cwd), timestamp: new Date().toISOString(), data: { tool_name: toolName, diff --git a/src/hooks/post-tool-use.ts b/src/hooks/post-tool-use.ts index c68a7731d..e8c70fb69 100644 --- a/src/hooks/post-tool-use.ts +++ b/src/hooks/post-tool-use.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { resolveProject } from "./_project.js"; +import { resolveCwd, resolveProject } from "./_project.js"; function isSdkChildContext(payload: unknown): boolean { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; @@ -43,8 +43,8 @@ async function main() { body: JSON.stringify({ hookType: "post_tool_use", sessionId, - project: resolveProject(data.cwd as string | undefined), - cwd: (data.cwd as string | undefined) || process.cwd(), + project: resolveProject(data.cwd), + cwd: resolveCwd(data.cwd), timestamp: new Date().toISOString(), data: { tool_name: toolName, diff --git a/src/hooks/pre-compact.ts b/src/hooks/pre-compact.ts index 8bb2660c4..24fec624b 100644 --- a/src/hooks/pre-compact.ts +++ b/src/hooks/pre-compact.ts @@ -32,7 +32,7 @@ async function main() { if (isSdkChildContext(data)) return; const sessionId = ((data.session_id || data.sessionId) as string) || "unknown"; - const project = resolveProject(data.cwd as string | undefined); + const project = resolveProject(data.cwd); if (process.env["CLAUDE_MEMORY_BRIDGE"] === "true") { try { diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts index 277d7799b..7c306ad22 100644 --- a/src/hooks/pre-tool-use.ts +++ b/src/hooks/pre-tool-use.ts @@ -1,5 +1,7 @@ #!/usr/bin/env node +import { resolveProject } from "./_project.js"; + function isSdkChildContext(payload: unknown): boolean { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; if (!payload || typeof payload !== "object") return false; @@ -93,10 +95,7 @@ async function main() { typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : "unknown"; - const project = - typeof data.project === "string" && data.project.trim().length > 0 - ? data.project.trim() - : undefined; + const project = resolveProject(data.cwd); try { const res = await fetch(`${REST_URL}/agentmemory/enrich`, { diff --git a/src/hooks/prompt-submit.ts b/src/hooks/prompt-submit.ts index 2da8ea6a4..b9bb1b5c0 100644 --- a/src/hooks/prompt-submit.ts +++ b/src/hooks/prompt-submit.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { resolveProject } from "./_project.js"; +import { resolveCwd, resolveProject } from "./_project.js"; function isSdkChildContext(payload: unknown): boolean { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; @@ -39,8 +39,8 @@ async function main() { body: JSON.stringify({ hookType: "prompt_submit", sessionId, - project: resolveProject(data.cwd as string | undefined), - cwd: (data.cwd as string | undefined) || process.cwd(), + project: resolveProject(data.cwd), + cwd: resolveCwd(data.cwd), timestamp: new Date().toISOString(), data: { prompt: data.prompt ?? data.userPrompt }, }), diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index 573c092b1..731b9d303 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { resolveProject } from "./_project.js"; +import { resolveCwd, resolveProject } from "./_project.js"; // Inlined from ./sdk-guard so each hook bundles to a single self-contained // .mjs (matches the pattern used by every other hook entry in tsdown.config). @@ -52,8 +52,8 @@ async function main() { const sessionId = ((data.session_id || data.sessionId) as string) || `ses_${Date.now().toString(36)}`; - const cwd = (data.cwd as string) || process.cwd(); - const project = resolveProject(data.cwd as string | undefined); + const cwd = resolveCwd(data.cwd); + const project = resolveProject(data.cwd); const url = `${REST_URL}/agentmemory/session/start`; const init: RequestInit = { diff --git a/src/hooks/subagent-start.ts b/src/hooks/subagent-start.ts index 44c87c85e..2cfdce5c8 100644 --- a/src/hooks/subagent-start.ts +++ b/src/hooks/subagent-start.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { resolveProject } from "./_project.js"; +import { resolveCwd, resolveProject } from "./_project.js"; // Inlined from ./sdk-guard so each hook bundles to a single self-contained // .mjs (matches the pattern used by every other hook entry in tsdown.config). @@ -49,8 +49,8 @@ async function main() { body: JSON.stringify({ hookType: "subagent_start", sessionId, - project: resolveProject(data.cwd as string | undefined), - cwd: (data.cwd as string | undefined) || process.cwd(), + project: resolveProject(data.cwd), + cwd: resolveCwd(data.cwd), timestamp: new Date().toISOString(), data: { agent_id: agentId, diff --git a/src/hooks/subagent-stop.ts b/src/hooks/subagent-stop.ts index 5a437c9fd..15d98d63a 100644 --- a/src/hooks/subagent-stop.ts +++ b/src/hooks/subagent-stop.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { resolveProject } from "./_project.js"; +import { resolveCwd, resolveProject } from "./_project.js"; function isSdkChildContext(payload: unknown): boolean { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; @@ -45,8 +45,8 @@ async function main() { body: JSON.stringify({ hookType: "subagent_stop", sessionId, - project: resolveProject(data.cwd as string | undefined), - cwd: (data.cwd as string | undefined) || process.cwd(), + project: resolveProject(data.cwd), + cwd: resolveCwd(data.cwd), timestamp: new Date().toISOString(), data: { agent_id: agentId, diff --git a/src/hooks/task-completed.ts b/src/hooks/task-completed.ts index dbdf815ac..ab7f39dff 100644 --- a/src/hooks/task-completed.ts +++ b/src/hooks/task-completed.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { resolveProject } from "./_project.js"; +import { resolveCwd, resolveProject } from "./_project.js"; function isSdkChildContext(payload: unknown): boolean { if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; @@ -39,8 +39,8 @@ async function main() { body: JSON.stringify({ hookType: "task_completed", sessionId, - project: resolveProject(data.cwd as string | undefined), - cwd: (data.cwd as string | undefined) || process.cwd(), + project: resolveProject(data.cwd), + cwd: resolveCwd(data.cwd), timestamp: new Date().toISOString(), data: { task_id: data.task_id, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index dbca07d9b..9f7b69000 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -121,12 +121,17 @@ export function registerMcpEndpoints( typeof args.agentId === "string" && args.agentId.trim().length > 0 ? (args.agentId as string).trim() : undefined; + const project = + typeof args.project === "string" && args.project.trim().length > 0 + ? args.project.trim() + : undefined; const result = await sdk.trigger({ function_id: "mem::search", payload: { query: args.query, limit: typeof args.limit === "number" ? args.limit : 10, format, token_budget: tokenBudget, agentId: recallAgentId, + ...(project !== undefined && { project }), } }); const text = format === "narrative" && diff --git a/src/mcp/standalone.ts b/src/mcp/standalone.ts index dd66ecb1d..d56b83483 100644 --- a/src/mcp/standalone.ts +++ b/src/mcp/standalone.ts @@ -103,6 +103,7 @@ interface Validated { limit?: number; format?: string; tokenBudget?: number; + project?: string; memoryIds?: string[]; reason?: string; } @@ -122,6 +123,10 @@ function validate(toolName: string, args: Record): Validated { v.type = (args["type"] as string) || "fact"; v.concepts = normalizeList(args["concepts"]); v.files = normalizeList(args["files"]); + const saveProject = args["project"]; + if (typeof saveProject === "string" && saveProject.trim()) { + v.project = saveProject.trim(); + } return v; } case "memory_recall": @@ -143,6 +148,10 @@ function validate(toolName: string, args: Record): Validated { const n = Number(budget); if (Number.isFinite(n) && n > 0) v.tokenBudget = Math.floor(n); } + const project = args["project"]; + if (typeof project === "string" && project.trim()) { + v.project = project.trim(); + } return v; } case "memory_sessions": { @@ -173,14 +182,16 @@ async function handleProxy( ): Promise<{ content: Array<{ type: string; text: string }> }> { switch (v.tool) { case "memory_save": { + const body: Record = { + content: v.content, + type: v.type, + concepts: v.concepts, + files: v.files, + }; + if (v.project != null) body["project"] = v.project; const result = await handle.call("/agentmemory/remember", { method: "POST", - body: JSON.stringify({ - content: v.content, - type: v.type, - concepts: v.concepts, - files: v.files, - }), + body: JSON.stringify(body), }); return textResponse(result); } @@ -191,6 +202,7 @@ async function handleProxy( format: v.format ?? "full", }; if (v.tokenBudget != null) body["token_budget"] = v.tokenBudget; + if (v.project != null) body["project"] = v.project; const result = await handle.call("/agentmemory/search", { method: "POST", body: JSON.stringify(body), @@ -201,6 +213,7 @@ async function handleProxy( const body: Record = { query: v.query, limit: v.limit }; if (v.format != null) body["format"] = v.format; if (v.tokenBudget != null) body["token_budget"] = v.tokenBudget; + if (v.project != null) body["project"] = v.project; const result = await handle.call("/agentmemory/smart-search", { method: "POST", body: JSON.stringify(body), @@ -258,6 +271,7 @@ async function handleLocal( version: 1, isLatest: true, sessionIds: [], + ...(v.project !== undefined && { project: v.project }), }); kvInstance.persist(); return textResponse({ saved: id }); @@ -270,6 +284,11 @@ async function handleLocal( const all = await kvInstance.list>("mem:memories"); const results = all + .filter((m) => { + if (v.project === undefined) return true; + const project = typeof m["project"] === "string" ? m["project"] : undefined; + return project === v.project; + }) .filter((m) => { const text = [ typeof m["title"] === "string" ? m["title"] : "", diff --git a/src/mcp/tools-registry.ts b/src/mcp/tools-registry.ts index c4df3499c..c894b4a51 100644 --- a/src/mcp/tools-registry.ts +++ b/src/mcp/tools-registry.ts @@ -32,6 +32,12 @@ export const CORE_TOOLS: McpToolDef[] = [ type: "number", description: "Optional token budget to trim returned results", }, + project: { + type: "string", + description: + "Optional opaque canonical project identifier to restrict recall. Use the same value " + + "that session hooks store as project; linked Git worktrees share one git: value.", + }, }, required: ["query"], }, diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 7b6c2bf2b..d9676aefc 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -1831,6 +1831,13 @@ export function registerApiTriggers( if (authErr) return authErr; const memories = await kv.list(KV.memories); const latest = req.query_params?.["latest"] === "true"; + const projectFilter = + typeof req.query_params?.["project"] === "string" && + req.query_params["project"].trim().length > 0 + ? req.query_params["project"].trim() + : undefined; + const includeUnscoped = + req.query_params?.["includeUnscoped"] === "true"; // agentId filter. Request param wins, env AGENT_ID (when // scope=isolated) is the fallback. Shared mode keeps the tag but // does not restrict the list endpoint. Pass agentId=* to opt out @@ -1849,6 +1856,13 @@ export function registerApiTriggers( ? undefined : explicitAgentId ?? (isAgentScopeIsolated() ? getAgentId() : undefined); let filtered = latest ? memories.filter((m) => m.isLatest) : memories; + if (projectFilter) { + filtered = filtered.filter( + (m) => + m.project === projectFilter || + (includeUnscoped && m.project === undefined), + ); + } if (filterAgentId) { filtered = filtered.filter( (m) => diff --git a/test/api-memories-project.test.ts b/test/api-memories-project.test.ts new file mode 100644 index 000000000..9cc3fe27e --- /dev/null +++ b/test/api-memories-project.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/auth.js", () => ({ + timingSafeCompare: (a: string, b: string) => a === b, +})); + +vi.mock("../src/config.js", async () => { + const actual = await vi.importActual("../src/config.js"); + return { + ...actual, + getAgentId: () => undefined, + isAgentScopeIsolated: () => false, + detectEmbeddingProvider: () => false, + detectLlmProviderKind: () => "none", + }; +}); + +import { registerApiTriggers } from "../src/triggers/api.js"; +import { KV } from "../src/state/schema.js"; +import type { Memory } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + getFunction: (id: string) => functions.get(id), + }; +} + +function makeReq(query_params: Record = {}) { + return { body: {}, headers: {}, query_params }; +} + +function memory(id: string, content: string, project?: string): Memory { + return { + id, + createdAt: "2026-06-13T00:00:00.000Z", + updatedAt: "2026-06-13T00:00:00.000Z", + type: "fact", + title: content, + content, + concepts: [], + files: [], + sessionIds: [], + strength: 7, + version: 1, + isLatest: true, + ...(project !== undefined && { project }), + }; +} + +describe("GET /agentmemory/memories project filter", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(async () => { + sdk = mockSdk(); + kv = mockKV(); + registerApiTriggers(sdk as never, kv as never); + + await kv.set(KV.memories, "mem_main", memory("mem_main", "main repo", "git:repo-main")); + await kv.set(KV.memories, "mem_other", memory("mem_other", "other repo", "git:repo-other")); + await kv.set(KV.memories, "mem_legacy", memory("mem_legacy", "legacy unscoped")); + }); + + it("filters memories by project", async () => { + const fn = sdk.getFunction("api::memories")!; + const result = await fn(makeReq({ project: "git:repo-main" })) as { + status_code: number; + body: { memories: Memory[]; total: number }; + }; + + expect(result.status_code).toBe(200); + expect(result.body.total).toBe(1); + expect(result.body.memories.map((m) => m.id)).toEqual(["mem_main"]); + }); + + it("applies project filter to count mode", async () => { + const fn = sdk.getFunction("api::memories")!; + const result = await fn(makeReq({ project: "git:repo-main", count: "true" })) as { + status_code: number; + body: { total: number; latestCount: number }; + }; + + expect(result.status_code).toBe(200); + expect(result.body.total).toBe(1); + expect(result.body.latestCount).toBe(1); + }); + + it("applies project filter before pagination", async () => { + const fn = sdk.getFunction("api::memories")!; + const result = await fn(makeReq({ project: "git:repo-main", limit: "1", offset: "0" })) as { + status_code: number; + body: { memories: Memory[]; total: number; limit: number; offset: number }; + }; + + expect(result.status_code).toBe(200); + expect(result.body.total).toBe(1); + expect(result.body.limit).toBe(1); + expect(result.body.offset).toBe(0); + expect(result.body.memories.map((m) => m.id)).toEqual(["mem_main"]); + }); + + it("can include unscoped legacy memories when explicitly requested", async () => { + const fn = sdk.getFunction("api::memories")!; + const result = await fn(makeReq({ + project: "git:repo-main", + includeUnscoped: "true", + })) as { + status_code: number; + body: { memories: Memory[]; total: number }; + }; + + expect(result.status_code).toBe(200); + expect(result.body.total).toBe(2); + expect(result.body.memories.map((m) => m.id).sort()).toEqual([ + "mem_legacy", + "mem_main", + ]); + }); +}); diff --git a/test/hook-project.test.ts b/test/hook-project.test.ts index 66e25f181..29bb1140b 100644 --- a/test/hook-project.test.ts +++ b/test/hook-project.test.ts @@ -1,66 +1,178 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, rmSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { resolveProject } from "../src/hooks/_project.js"; +import { basename, dirname, join } from "node:path"; +import { resolveCwd, resolveProject } from "../src/hooks/_project.js"; -describe("resolveProject — hook project basename resolver", () => { - const originalEnv = process.env.AGENTMEMORY_PROJECT_NAME; +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); +} + +function initRepo(dir: string): void { + git(dir, ["init"]); + git(dir, ["config", "user.email", "agentmemory-test@example.com"]); + git(dir, ["config", "user.name", "agentmemory test"]); + writeFileSync(join(dir, "README.md"), "# test\n"); + git(dir, ["add", "README.md"]); + git(dir, ["commit", "-m", "initial"]); +} + +function canonicalProjectForGitCwd(cwd: string): string { + const commonDir = git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]); + const realCommonDir = realpathSync(commonDir); + const root = basename(realCommonDir) === ".git" + ? realpathSync(dirname(realCommonDir)) + : realCommonDir; + return `git:${createHash("sha256").update(root).digest("hex").slice(0, 32)}`; +} + +describe("resolveProject — canonical project resolver", () => { + const originalProjectId = process.env.AGENTMEMORY_PROJECT_ID; + const originalProjectName = process.env.AGENTMEMORY_PROJECT_NAME; beforeEach(() => { + delete process.env.AGENTMEMORY_PROJECT_ID; delete process.env.AGENTMEMORY_PROJECT_NAME; }); afterEach(() => { - if (originalEnv === undefined) { + if (originalProjectId === undefined) { + delete process.env.AGENTMEMORY_PROJECT_ID; + } else { + process.env.AGENTMEMORY_PROJECT_ID = originalProjectId; + } + if (originalProjectName === undefined) { delete process.env.AGENTMEMORY_PROJECT_NAME; } else { - process.env.AGENTMEMORY_PROJECT_NAME = originalEnv; + process.env.AGENTMEMORY_PROJECT_NAME = originalProjectName; } }); - it("AGENTMEMORY_PROJECT_NAME env wins over everything", () => { + it("AGENTMEMORY_PROJECT_ID env wins over everything", () => { + process.env.AGENTMEMORY_PROJECT_ID = "knowledge-work"; + process.env.AGENTMEMORY_PROJECT_NAME = "legacy-name"; + + expect(resolveProject("/var/log")).toBe("knowledge-work"); + expect(resolveProject(process.cwd())).toBe("knowledge-work"); + }); + + it("AGENTMEMORY_PROJECT_NAME remains a backward-compatible override", () => { process.env.AGENTMEMORY_PROJECT_NAME = "my-override"; + expect(resolveProject("/var/log")).toBe("my-override"); expect(resolveProject(process.cwd())).toBe("my-override"); }); - it("trims whitespace on env override", () => { - process.env.AGENTMEMORY_PROJECT_NAME = " spaced "; - expect(resolveProject("/var/log")).toBe("spaced"); + it("trims whitespace on env overrides", () => { + process.env.AGENTMEMORY_PROJECT_ID = " scoped-project "; + expect(resolveProject("/var/log")).toBe("scoped-project"); + + delete process.env.AGENTMEMORY_PROJECT_ID; + process.env.AGENTMEMORY_PROJECT_NAME = " legacy-scope "; + expect(resolveProject("/var/log")).toBe("legacy-scope"); }); - it("ignores empty env override", () => { + it("ignores empty env overrides", () => { + process.env.AGENTMEMORY_PROJECT_ID = " "; process.env.AGENTMEMORY_PROJECT_NAME = " "; - const repoBasename = "agentmemory"; - expect(resolveProject(process.cwd())).toBe(repoBasename); + + expect(resolveProject(process.cwd())).toBe(canonicalProjectForGitCwd(process.cwd())); }); - it("returns git toplevel basename when cwd is inside a repo", () => { - const top = resolveProject(process.cwd()); - expect(top).toBe("agentmemory"); + it("returns canonical git common-dir parent when cwd is inside a repo", () => { + const project = resolveProject(process.cwd()); + expect(project).toBe(canonicalProjectForGitCwd(process.cwd())); + expect(project).toMatch(/^git:[a-f0-9]{32}$/); + expect(project).not.toContain(process.cwd()); }); - it("returns git toplevel basename from a nested subdir", () => { + it("returns the same canonical project from a nested subdir", () => { const nested = join(process.cwd(), "src", "hooks"); - expect(resolveProject(nested)).toBe("agentmemory"); + + expect(resolveProject(nested)).toBe(canonicalProjectForGitCwd(process.cwd())); + }); + + it("linked worktrees share the parent repository project id", () => { + const parent = mkdtempSync(join(tmpdir(), "amem-parent-")); + const linkedParent = mkdtempSync(join(tmpdir(), "amem-linked-container-")); + const linked = join(linkedParent, "same-name"); + + try { + initRepo(parent); + git(parent, ["worktree", "add", "-b", "feature/test", linked]); + + const expected = canonicalProjectForGitCwd(parent); + expect(resolveProject(parent)).toBe(expected); + expect(resolveProject(linked)).toBe(expected); + } finally { + rmSync(linkedParent, { recursive: true, force: true }); + rmSync(parent, { recursive: true, force: true }); + } + }); + + it("unrelated repos with the same basename do not share a project id", () => { + const rootA = mkdtempSync(join(tmpdir(), "amem-a-")); + const rootB = mkdtempSync(join(tmpdir(), "amem-b-")); + const repoA = join(rootA, "same-name"); + const repoB = join(rootB, "same-name"); + + try { + mkdirSync(repoA); + mkdirSync(repoB); + initRepo(repoA); + initRepo(repoB); + + expect(resolveProject(repoA)).toBe(canonicalProjectForGitCwd(repoA)); + expect(resolveProject(repoB)).toBe(canonicalProjectForGitCwd(repoB)); + expect(resolveProject(repoA)).not.toBe(resolveProject(repoB)); + } finally { + rmSync(rootA, { recursive: true, force: true }); + rmSync(rootB, { recursive: true, force: true }); + } }); it("falls back to basename(cwd) when not in a git repo", () => { const dir = mkdtempSync(join(tmpdir(), "amem-noproj-")); try { - expect(resolveProject(dir)).toBe(dir.split("/").pop()); + expect(resolveProject(dir)).toBe(basename(dir)); } finally { rmSync(dir, { recursive: true, force: true }); } }); it("defaults to process.cwd() when no cwd argument given", () => { - expect(resolveProject()).toBe("agentmemory"); + expect(resolveProject()).toBe(canonicalProjectForGitCwd(process.cwd())); }); it("defaults to process.cwd() when cwd argument is empty", () => { - expect(resolveProject("")).toBe("agentmemory"); - expect(resolveProject(" ")).toBe("agentmemory"); + expect(resolveProject("")).toBe(canonicalProjectForGitCwd(process.cwd())); + expect(resolveProject(" ")).toBe(canonicalProjectForGitCwd(process.cwd())); + }); + + it("preserves non-empty cwd strings exactly", () => { + const cwd = join(tmpdir(), " amem-spaced-project "); + + expect(resolveCwd(cwd)).toBe(cwd); + expect(resolveCwd(" ")).toBe(process.cwd()); + }); + + it("defaults to process.cwd() when cwd argument is not a string", () => { + const expected = canonicalProjectForGitCwd(process.cwd()); + + expect(resolveProject({ path: process.cwd() })).toBe(expected); + expect(resolveProject(42)).toBe(expected); + }); + + it("does not require the fallback directory to exist", () => { + const missing = join(tmpdir(), "amem-missing-project-dir"); + if (existsSync(missing)) rmSync(missing, { recursive: true, force: true }); + + expect(resolveProject(missing)).toBe("amem-missing-project-dir"); }); }); diff --git a/test/mcp-project-scope.test.ts b/test/mcp-project-scope.test.ts new file mode 100644 index 000000000..a528f770b --- /dev/null +++ b/test/mcp-project-scope.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/config.js", () => ({ + getAgentId: () => undefined, + isAgentScopeIsolated: () => false, +})); + +import { registerMcpEndpoints } from "../src/mcp/server.js"; + +function mockKV() { + return { + get: async () => null, + set: async (_scope: string, _key: string, data: T): Promise => data, + delete: async () => {}, + list: async () => [], + }; +} + +function mockSdk() { + const functions = new Map(); + const triggerOverrides = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + if (triggerOverrides.has(id)) return triggerOverrides.get(id)!(payload); + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + overrideTrigger: (id: string, handler: Function) => { + triggerOverrides.set(id, handler); + }, + getFunction: (id: string) => functions.get(id), + }; +} + +function makeReq(body?: unknown) { + return { + body, + headers: {}, + query_params: {}, + }; +} + +describe("MCP project scoping", () => { + let sdk: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + registerMcpEndpoints(sdk as never, mockKV() as never); + }); + + it("memory_recall forwards project to mem::search", async () => { + let receivedPayload: Record | undefined; + sdk.overrideTrigger("mem::search", async (payload: Record) => { + receivedPayload = payload; + return { format: "compact", results: [] }; + }); + + const call = sdk.getFunction("mcp::tools::call")!; + const result = await call(makeReq({ + name: "memory_recall", + arguments: { + query: "worktree auth decision", + limit: 5, + format: "compact", + project: "git:repo-main", + }, + })) as { status_code: number }; + + expect(result.status_code).toBe(200); + expect(receivedPayload).toMatchObject({ + query: "worktree auth decision", + limit: 5, + format: "compact", + project: "git:repo-main", + }); + }); +}); diff --git a/test/mcp-standalone-proxy.test.ts b/test/mcp-standalone-proxy.test.ts index dc08a024e..3d725ba79 100644 --- a/test/mcp-standalone-proxy.test.ts +++ b/test/mcp-standalone-proxy.test.ts @@ -100,6 +100,7 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { limit: 5, format: "full", token_budget: 800, + project: "git:repo-main", }); const body = JSON.parse(res.content[0].text); expect(body.mode).toBe("full"); @@ -111,6 +112,7 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { limit: 5, format: "full", token_budget: 800, + project: "git:repo-main", }); expect(calls.find((c) => c.url.endsWith("/agentmemory/smart-search"))).toBeUndefined(); }); diff --git a/test/mcp-standalone.test.ts b/test/mcp-standalone.test.ts index b48eade96..da27ea719 100644 --- a/test/mcp-standalone.test.ts +++ b/test/mcp-standalone.test.ts @@ -222,6 +222,41 @@ describe("handleToolCall", () => { expect(parsed.results[0].content).toBe("TypeScript is great"); }); + it("local fallback stores and filters memories by project", async () => { + const kv = new InMemoryKV(); + + await handleToolCall( + "memory_save", + { + content: "main worktree uses jose for auth", + project: "git:repo-main", + }, + kv, + ); + await handleToolCall( + "memory_save", + { + content: "other repo uses nextauth cookies", + project: "git:repo-other", + }, + kv, + ); + + const result = await handleToolCall( + "memory_recall", + { + query: "auth", + project: "git:repo-main", + }, + kv, + ); + + const parsed = JSON.parse(result.content[0].text); + const text = JSON.stringify(parsed); + expect(text).toContain("jose"); + expect(text).not.toContain("nextauth"); + }); + it("memory_save accepts concepts/files as arrays (plugin skill format, #139)", async () => { const kv = new InMemoryKV(); const result = await handleToolCall( diff --git a/test/pre-tool-use-project.test.ts b/test/pre-tool-use-project.test.ts new file mode 100644 index 000000000..6fb91301c --- /dev/null +++ b/test/pre-tool-use-project.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, it, expect } from "vitest"; +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { resolveProject } from "../src/hooks/_project.js"; + +function readRequestBody(req: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => resolve(body)); + req.on("error", reject); + }); +} + +function childEnv(url: string): NodeJS.ProcessEnv { + const env = { + ...process.env, + AGENTMEMORY_INJECT_CONTEXT: "true", + AGENTMEMORY_URL: url, + }; + delete env.AGENTMEMORY_PROJECT_ID; + delete env.AGENTMEMORY_PROJECT_NAME; + return env; +} + +function withoutProjectEnv(callback: () => T): T { + const projectId = process.env.AGENTMEMORY_PROJECT_ID; + const projectName = process.env.AGENTMEMORY_PROJECT_NAME; + delete process.env.AGENTMEMORY_PROJECT_ID; + delete process.env.AGENTMEMORY_PROJECT_NAME; + try { + return callback(); + } finally { + if (projectId === undefined) { + delete process.env.AGENTMEMORY_PROJECT_ID; + } else { + process.env.AGENTMEMORY_PROJECT_ID = projectId; + } + if (projectName === undefined) { + delete process.env.AGENTMEMORY_PROJECT_NAME; + } else { + process.env.AGENTMEMORY_PROJECT_NAME = projectName; + } + } +} + +function runHook(payload: unknown, url: string): Promise<{ code: number | null; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ["--import", "tsx", "src/hooks/pre-tool-use.ts"], + { + cwd: process.cwd(), + env: childEnv(url), + stdio: ["pipe", "pipe", "pipe"], + }, + ); + + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => resolve({ code, stderr })); + child.stdin.end(JSON.stringify(payload)); + }); +} + +describe("pre-tool-use project resolution", () => { + const originalProjectId = process.env.AGENTMEMORY_PROJECT_ID; + const originalProjectName = process.env.AGENTMEMORY_PROJECT_NAME; + + afterEach(() => { + if (originalProjectId === undefined) { + delete process.env.AGENTMEMORY_PROJECT_ID; + } else { + process.env.AGENTMEMORY_PROJECT_ID = originalProjectId; + } + if (originalProjectName === undefined) { + delete process.env.AGENTMEMORY_PROJECT_NAME; + } else { + process.env.AGENTMEMORY_PROJECT_NAME = originalProjectName; + } + }); + + it("sends the resolved project when payload has cwd but no project", async () => { + let capturedBody: Record | undefined; + const server = createServer(async (req, res) => { + if (req.method === "POST" && req.url === "/agentmemory/enrich") { + capturedBody = JSON.parse(await readRequestBody(req)) as Record; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ context: "" })); + return; + } + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + + try { + const cwd = process.cwd(); + const result = await runHook( + { + session_id: "session-1", + cwd, + tool_name: "Read", + tool_input: { file_path: "src/hooks/_project.ts" }, + }, + `http://127.0.0.1:${port}`, + ); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(capturedBody).toMatchObject({ + sessionId: "session-1", + files: ["src/hooks/_project.ts"], + terms: [], + toolName: "Read", + project: withoutProjectEnv(() => resolveProject(cwd)), + }); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + }); + + it("ignores non-string payload cwd instead of crashing", async () => { + let capturedBody: Record | undefined; + const server = createServer(async (req, res) => { + if (req.method === "POST" && req.url === "/agentmemory/enrich") { + capturedBody = JSON.parse(await readRequestBody(req)) as Record; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ context: "" })); + return; + } + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + + try { + const result = await runHook( + { + session_id: "session-1", + cwd: { path: process.cwd() }, + tool_name: "Read", + tool_input: { file_path: "src/hooks/_project.ts" }, + }, + `http://127.0.0.1:${port}`, + ); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(capturedBody).toMatchObject({ + sessionId: "session-1", + files: ["src/hooks/_project.ts"], + terms: [], + toolName: "Read", + project: withoutProjectEnv(() => resolveProject()), + }); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + }); + + it("does not inherit project env overrides from the test runner", async () => { + let capturedBody: Record | undefined; + const server = createServer(async (req, res) => { + if (req.method === "POST" && req.url === "/agentmemory/enrich") { + capturedBody = JSON.parse(await readRequestBody(req)) as Record; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ context: "" })); + return; + } + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + + process.env.AGENTMEMORY_PROJECT_ID = "host-runner-project"; + process.env.AGENTMEMORY_PROJECT_NAME = "host-runner-name"; + + try { + const cwd = process.cwd(); + const result = await runHook( + { + session_id: "session-1", + cwd, + tool_name: "Read", + tool_input: { file_path: "src/hooks/_project.ts" }, + }, + `http://127.0.0.1:${port}`, + ); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(capturedBody?.project).toBe(withoutProjectEnv(() => resolveProject(cwd))); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + } + }); +}); diff --git a/test/remember-project-scope.test.ts b/test/remember-project-scope.test.ts index bda699f5a..e4df952f1 100644 --- a/test/remember-project-scope.test.ts +++ b/test/remember-project-scope.test.ts @@ -22,6 +22,7 @@ vi.mock("iii-sdk", async (importOriginal) => { import { vi } from "vitest"; import { registerRememberFunction } from "../src/functions/remember.js"; import { getSearchIndex, setIndexPersistence } from "../src/functions/search.js"; +import { logger } from "../src/logger.js"; function mockKV() { const store = new Map>(); @@ -129,6 +130,27 @@ describe("mem::remember — project field stamping", () => { expect(result.memory.project).toBeUndefined(); }); + + it("redacts raw project values from memory-save logs", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + const info = vi.mocked(logger.info); + info.mockClear(); + registerRememberFunction(sdk as never, kv as never); + + await sdk.trigger({ + function_id: "mem::remember", + payload: { + content: "path-derived project values stay out of logs", + project: "/repo/main", + }, + }); + + expect(info).toHaveBeenCalledWith("Memory saved", expect.objectContaining({ + hasProject: true, + })); + expect(JSON.stringify(info.mock.calls)).not.toContain("/repo/main"); + }); }); describe("mem::remember — cross-project dedup isolation", () => { diff --git a/test/worktree-project-scope.test.ts b/test/worktree-project-scope.test.ts new file mode 100644 index 000000000..2a7eb54dc --- /dev/null +++ b/test/worktree-project-scope.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/state/keyed-mutex.js", () => ({ + withKeyedLock: (_key: string, fn: () => Promise) => fn(), +})); + +vi.mock("../src/functions/audit.js", () => ({ + recordAudit: vi.fn(), +})); + +vi.mock("../src/functions/access-tracker.js", () => ({ + recordAccessBatch: vi.fn(), + deleteAccessLog: vi.fn(), +})); + +vi.mock("../src/config.js", () => ({ + getAgentId: () => undefined, + isAgentScopeIsolated: () => false, +})); + +import { resolveProject } from "../src/hooks/_project.js"; +import { registerRememberFunction } from "../src/functions/remember.js"; +import { registerSearchFunction, getSearchIndex, setIndexPersistence } from "../src/functions/search.js"; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); +} + +function initRepo(dir: string): void { + git(dir, ["init"]); + git(dir, ["config", "user.email", "agentmemory-test@example.com"]); + git(dir, ["config", "user.name", "agentmemory test"]); + writeFileSync(join(dir, "README.md"), "# test\n"); + git(dir, ["add", "README.md"]); + git(dir, ["commit", "-m", "initial"]); +} + +function makeMockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function makeMockSdk() { + const functions = new Map(); + return { + registerFunction: (id: string, handler: Function) => { + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function registered: ${id}`); + return fn(payload); + }, + }; +} + +function registerMemorySearch() { + const sdk = makeMockSdk(); + const kv = makeMockKV(); + registerRememberFunction(sdk as never, kv as never); + registerSearchFunction(sdk as never, kv as never); + return sdk; +} + +describe("worktree project scoping", () => { + const originalProjectId = process.env.AGENTMEMORY_PROJECT_ID; + const originalProjectName = process.env.AGENTMEMORY_PROJECT_NAME; + + beforeEach(() => { + delete process.env.AGENTMEMORY_PROJECT_ID; + delete process.env.AGENTMEMORY_PROJECT_NAME; + setIndexPersistence(null); + getSearchIndex().clear(); + }); + + afterEach(() => { + setIndexPersistence(null); + getSearchIndex().clear(); + if (originalProjectId === undefined) { + delete process.env.AGENTMEMORY_PROJECT_ID; + } else { + process.env.AGENTMEMORY_PROJECT_ID = originalProjectId; + } + if (originalProjectName === undefined) { + delete process.env.AGENTMEMORY_PROJECT_NAME; + } else { + process.env.AGENTMEMORY_PROJECT_NAME = originalProjectName; + } + }); + + it("memory written in one linked worktree is recalled from another linked worktree", async () => { + const parent = mkdtempSync(join(tmpdir(), "amem-e2e-parent-")); + const linkedParent = mkdtempSync(join(tmpdir(), "amem-e2e-linked-")); + const linked = join(linkedParent, "same-name"); + + try { + initRepo(parent); + git(parent, ["worktree", "add", "-b", "feature/e2e", linked]); + + const projectFromParent = resolveProject(parent); + const projectFromLinked = resolveProject(linked); + expect(projectFromLinked).toBe(projectFromParent); + expect(projectFromParent).toMatch(/^git:[a-f0-9]{32}$/); + expect(projectFromParent).not.toContain(parent); + expect(projectFromParent).not.toContain(linked); + + const sdk = registerMemorySearch(); + + await sdk.trigger("mem::remember", { + content: "linked worktrees must share repository memory scope", + type: "fact", + project: projectFromParent, + }); + + getSearchIndex().clear(); + + const result = await sdk.trigger("mem::search", { + query: "repository memory scope", + project: projectFromLinked, + }) as { results: Array<{ observation: { title: string; narrative?: string } }> }; + + const combined = result.results + .map((r) => `${r.observation.title} ${r.observation.narrative ?? ""}`) + .join(" "); + expect(combined).toContain("linked worktrees"); + } finally { + rmSync(linkedParent, { recursive: true, force: true }); + rmSync(parent, { recursive: true, force: true }); + } + }); + + it("same-basename unrelated repositories do not share recall", async () => { + const rootA = mkdtempSync(join(tmpdir(), "amem-same-a-")); + const rootB = mkdtempSync(join(tmpdir(), "amem-same-b-")); + const repoA = join(rootA, "same-name"); + const repoB = join(rootB, "same-name"); + + try { + mkdirSync(repoA); + mkdirSync(repoB); + initRepo(repoA); + initRepo(repoB); + + const projectA = resolveProject(repoA); + const projectB = resolveProject(repoB); + expect(projectA).not.toBe(projectB); + expect(projectA).toMatch(/^git:[a-f0-9]{32}$/); + expect(projectB).toMatch(/^git:[a-f0-9]{32}$/); + expect(projectA).not.toContain(repoA); + expect(projectB).not.toContain(repoB); + + const sdk = registerMemorySearch(); + + await sdk.trigger("mem::remember", { + content: "repo A has a private architecture decision", + type: "architecture", + project: projectA, + }); + + getSearchIndex().clear(); + + const result = await sdk.trigger("mem::search", { + query: "private architecture decision", + project: projectB, + }) as { results: Array<{ observation: { title: string; narrative?: string } }> }; + + expect(result.results).toHaveLength(0); + } finally { + rmSync(rootA, { recursive: true, force: true }); + rmSync(rootB, { recursive: true, force: true }); + } + }); + + it("keeps historical basename scope separate unless AGENTMEMORY_PROJECT_NAME is set", async () => { + const root = mkdtempSync(join(tmpdir(), "amem-legacy-")); + const repo = join(root, "same-name"); + + try { + mkdirSync(repo); + initRepo(repo); + + const historicalProject = basename(repo); + const opaqueProject = resolveProject(repo); + expect(opaqueProject).toMatch(/^git:[a-f0-9]{32}$/); + expect(opaqueProject).not.toBe(historicalProject); + expect(opaqueProject).not.toContain(repo); + + const sdk = registerMemorySearch(); + + await sdk.trigger("mem::remember", { + content: "legacy basename scope keeps release checklist", + type: "workflow", + project: historicalProject, + }); + + getSearchIndex().clear(); + + const opaqueResult = await sdk.trigger("mem::search", { + query: "release checklist", + project: opaqueProject, + }) as { results: Array<{ observation: { title: string; narrative?: string } }> }; + expect(opaqueResult.results).toHaveLength(0); + + process.env.AGENTMEMORY_PROJECT_NAME = historicalProject; + const overrideProject = resolveProject(repo); + expect(overrideProject).toBe(historicalProject); + + const legacyResult = await sdk.trigger("mem::search", { + query: "release checklist", + project: overrideProject, + }) as { results: Array<{ observation: { title: string; narrative?: string } }> }; + const combined = legacyResult.results + .map((r) => `${r.observation.title} ${r.observation.narrative ?? ""}`) + .join(" "); + expect(combined).toContain("legacy basename scope"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +});