diff --git a/plugin/scripts/notification.mjs b/plugin/scripts/notification.mjs index dcf2e3931..4ccb26060 100755 --- a/plugin/scripts/notification.mjs +++ b/plugin/scripts/notification.mjs @@ -2,10 +2,7 @@ import { execSync } from "node:child_process"; import { basename } 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 gitToplevelBasename(dir) { try { const top = execSync("git rev-parse --show-toplevel", { cwd: dir, @@ -16,9 +13,60 @@ function resolveProject(cwd) { ], timeout: 500 }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return top ? basename(top) : null; + } catch { + return null; + } +} +function normalizeGitRemote(url) { + const raw = (url ?? "").trim(); + if (!raw) return null; + let host = ""; + let path = ""; + const scp = raw.match(/^[^@/]+@([^:/]+):(.+)$/); + if (scp) { + host = scp[1]; + path = scp[2]; + } else { + const noCreds = raw.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "").replace(/^[^@/]*@/, ""); + const slash = noCreds.indexOf("/"); + if (slash === -1) return null; + host = noCreds.slice(0, slash); + path = noCreds.slice(slash + 1); + } + host = host.toLowerCase().replace(/:\d+$/, ""); + path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase(); + if (!host || !path) return null; + return `${host}/${path}`; +} +function gitRemoteIdentity(dir) { + try { + return normalizeGitRemote(execSync("git config --get remote.origin.url", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim()); + } catch { + return null; + } +} +function remoteIdentityEnabled() { + const flag = process.env["AGENTMEMORY_PROJECT_FROM_REMOTE"]; + return flag === "1" || flag === "true"; +} +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(); + if (remoteIdentityEnabled()) { + const id = gitRemoteIdentity(dir); + if (id) return id; + } + return gitToplevelBasename(dir) ?? basename(dir); } function hookCwd(data) { if (!data || typeof data !== "object") return void 0; diff --git a/plugin/scripts/post-tool-failure.mjs b/plugin/scripts/post-tool-failure.mjs index 0af94e7cd..3ba9d97d4 100755 --- a/plugin/scripts/post-tool-failure.mjs +++ b/plugin/scripts/post-tool-failure.mjs @@ -2,10 +2,7 @@ import { execSync } from "node:child_process"; import { basename } 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 gitToplevelBasename(dir) { try { const top = execSync("git rev-parse --show-toplevel", { cwd: dir, @@ -16,9 +13,60 @@ function resolveProject(cwd) { ], timeout: 500 }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return top ? basename(top) : null; + } catch { + return null; + } +} +function normalizeGitRemote(url) { + const raw = (url ?? "").trim(); + if (!raw) return null; + let host = ""; + let path = ""; + const scp = raw.match(/^[^@/]+@([^:/]+):(.+)$/); + if (scp) { + host = scp[1]; + path = scp[2]; + } else { + const noCreds = raw.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "").replace(/^[^@/]*@/, ""); + const slash = noCreds.indexOf("/"); + if (slash === -1) return null; + host = noCreds.slice(0, slash); + path = noCreds.slice(slash + 1); + } + host = host.toLowerCase().replace(/:\d+$/, ""); + path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase(); + if (!host || !path) return null; + return `${host}/${path}`; +} +function gitRemoteIdentity(dir) { + try { + return normalizeGitRemote(execSync("git config --get remote.origin.url", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim()); + } catch { + return null; + } +} +function remoteIdentityEnabled() { + const flag = process.env["AGENTMEMORY_PROJECT_FROM_REMOTE"]; + return flag === "1" || flag === "true"; +} +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(); + if (remoteIdentityEnabled()) { + const id = gitRemoteIdentity(dir); + if (id) return id; + } + return gitToplevelBasename(dir) ?? basename(dir); } function hookCwd(data) { if (!data || typeof data !== "object") return void 0; diff --git a/plugin/scripts/post-tool-use.mjs b/plugin/scripts/post-tool-use.mjs index 1189e35fe..3a385d225 100755 --- a/plugin/scripts/post-tool-use.mjs +++ b/plugin/scripts/post-tool-use.mjs @@ -2,10 +2,7 @@ import { execSync } from "node:child_process"; import { basename } 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 gitToplevelBasename(dir) { try { const top = execSync("git rev-parse --show-toplevel", { cwd: dir, @@ -16,9 +13,60 @@ function resolveProject(cwd) { ], timeout: 500 }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return top ? basename(top) : null; + } catch { + return null; + } +} +function normalizeGitRemote(url) { + const raw = (url ?? "").trim(); + if (!raw) return null; + let host = ""; + let path = ""; + const scp = raw.match(/^[^@/]+@([^:/]+):(.+)$/); + if (scp) { + host = scp[1]; + path = scp[2]; + } else { + const noCreds = raw.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "").replace(/^[^@/]*@/, ""); + const slash = noCreds.indexOf("/"); + if (slash === -1) return null; + host = noCreds.slice(0, slash); + path = noCreds.slice(slash + 1); + } + host = host.toLowerCase().replace(/:\d+$/, ""); + path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase(); + if (!host || !path) return null; + return `${host}/${path}`; +} +function gitRemoteIdentity(dir) { + try { + return normalizeGitRemote(execSync("git config --get remote.origin.url", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim()); + } catch { + return null; + } +} +function remoteIdentityEnabled() { + const flag = process.env["AGENTMEMORY_PROJECT_FROM_REMOTE"]; + return flag === "1" || flag === "true"; +} +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(); + if (remoteIdentityEnabled()) { + const id = gitRemoteIdentity(dir); + if (id) return id; + } + return gitToplevelBasename(dir) ?? basename(dir); } function hookCwd(data) { if (!data || typeof data !== "object") return void 0; diff --git a/plugin/scripts/pre-compact.mjs b/plugin/scripts/pre-compact.mjs index 753094359..b15f3bdcd 100755 --- a/plugin/scripts/pre-compact.mjs +++ b/plugin/scripts/pre-compact.mjs @@ -2,10 +2,7 @@ import { execSync } from "node:child_process"; import { basename } 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 gitToplevelBasename(dir) { try { const top = execSync("git rev-parse --show-toplevel", { cwd: dir, @@ -16,9 +13,60 @@ function resolveProject(cwd) { ], timeout: 500 }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return top ? basename(top) : null; + } catch { + return null; + } +} +function normalizeGitRemote(url) { + const raw = (url ?? "").trim(); + if (!raw) return null; + let host = ""; + let path = ""; + const scp = raw.match(/^[^@/]+@([^:/]+):(.+)$/); + if (scp) { + host = scp[1]; + path = scp[2]; + } else { + const noCreds = raw.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "").replace(/^[^@/]*@/, ""); + const slash = noCreds.indexOf("/"); + if (slash === -1) return null; + host = noCreds.slice(0, slash); + path = noCreds.slice(slash + 1); + } + host = host.toLowerCase().replace(/:\d+$/, ""); + path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase(); + if (!host || !path) return null; + return `${host}/${path}`; +} +function gitRemoteIdentity(dir) { + try { + return normalizeGitRemote(execSync("git config --get remote.origin.url", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim()); + } catch { + return null; + } +} +function remoteIdentityEnabled() { + const flag = process.env["AGENTMEMORY_PROJECT_FROM_REMOTE"]; + return flag === "1" || flag === "true"; +} +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(); + if (remoteIdentityEnabled()) { + const id = gitRemoteIdentity(dir); + if (id) return id; + } + return gitToplevelBasename(dir) ?? basename(dir); } function hookCwd(data) { if (!data || typeof data !== "object") return void 0; diff --git a/plugin/scripts/prompt-submit.mjs b/plugin/scripts/prompt-submit.mjs index 53daba26c..531288b39 100755 --- a/plugin/scripts/prompt-submit.mjs +++ b/plugin/scripts/prompt-submit.mjs @@ -2,10 +2,7 @@ import { execSync } from "node:child_process"; import { basename } 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 gitToplevelBasename(dir) { try { const top = execSync("git rev-parse --show-toplevel", { cwd: dir, @@ -16,9 +13,60 @@ function resolveProject(cwd) { ], timeout: 500 }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return top ? basename(top) : null; + } catch { + return null; + } +} +function normalizeGitRemote(url) { + const raw = (url ?? "").trim(); + if (!raw) return null; + let host = ""; + let path = ""; + const scp = raw.match(/^[^@/]+@([^:/]+):(.+)$/); + if (scp) { + host = scp[1]; + path = scp[2]; + } else { + const noCreds = raw.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "").replace(/^[^@/]*@/, ""); + const slash = noCreds.indexOf("/"); + if (slash === -1) return null; + host = noCreds.slice(0, slash); + path = noCreds.slice(slash + 1); + } + host = host.toLowerCase().replace(/:\d+$/, ""); + path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase(); + if (!host || !path) return null; + return `${host}/${path}`; +} +function gitRemoteIdentity(dir) { + try { + return normalizeGitRemote(execSync("git config --get remote.origin.url", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim()); + } catch { + return null; + } +} +function remoteIdentityEnabled() { + const flag = process.env["AGENTMEMORY_PROJECT_FROM_REMOTE"]; + return flag === "1" || flag === "true"; +} +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(); + if (remoteIdentityEnabled()) { + const id = gitRemoteIdentity(dir); + if (id) return id; + } + return gitToplevelBasename(dir) ?? basename(dir); } function hookCwd(data) { if (!data || typeof data !== "object") return void 0; diff --git a/plugin/scripts/session-end.mjs b/plugin/scripts/session-end.mjs index f2d8f79b1..1caa236c3 100755 --- a/plugin/scripts/session-end.mjs +++ b/plugin/scripts/session-end.mjs @@ -3,10 +3,7 @@ import { readFileSync } from "node:fs"; import { execSync } from "node:child_process"; import { basename } 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 gitToplevelBasename(dir) { try { const top = execSync("git rev-parse --show-toplevel", { cwd: dir, @@ -17,9 +14,60 @@ function resolveProject(cwd) { ], timeout: 500 }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return top ? basename(top) : null; + } catch { + return null; + } +} +function normalizeGitRemote(url) { + const raw = (url ?? "").trim(); + if (!raw) return null; + let host = ""; + let path = ""; + const scp = raw.match(/^[^@/]+@([^:/]+):(.+)$/); + if (scp) { + host = scp[1]; + path = scp[2]; + } else { + const noCreds = raw.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "").replace(/^[^@/]*@/, ""); + const slash = noCreds.indexOf("/"); + if (slash === -1) return null; + host = noCreds.slice(0, slash); + path = noCreds.slice(slash + 1); + } + host = host.toLowerCase().replace(/:\d+$/, ""); + path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase(); + if (!host || !path) return null; + return `${host}/${path}`; +} +function gitRemoteIdentity(dir) { + try { + return normalizeGitRemote(execSync("git config --get remote.origin.url", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim()); + } catch { + return null; + } +} +function remoteIdentityEnabled() { + const flag = process.env["AGENTMEMORY_PROJECT_FROM_REMOTE"]; + return flag === "1" || flag === "true"; +} +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(); + if (remoteIdentityEnabled()) { + const id = gitRemoteIdentity(dir); + if (id) return id; + } + return gitToplevelBasename(dir) ?? basename(dir); } function hookCwd(data) { if (!data || typeof data !== "object") return void 0; diff --git a/plugin/scripts/session-start.mjs b/plugin/scripts/session-start.mjs index 7c112250c..fed186961 100755 --- a/plugin/scripts/session-start.mjs +++ b/plugin/scripts/session-start.mjs @@ -2,10 +2,7 @@ import { execSync } from "node:child_process"; import { basename } 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 gitToplevelBasename(dir) { try { const top = execSync("git rev-parse --show-toplevel", { cwd: dir, @@ -16,9 +13,60 @@ function resolveProject(cwd) { ], timeout: 500 }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return top ? basename(top) : null; + } catch { + return null; + } +} +function normalizeGitRemote(url) { + const raw = (url ?? "").trim(); + if (!raw) return null; + let host = ""; + let path = ""; + const scp = raw.match(/^[^@/]+@([^:/]+):(.+)$/); + if (scp) { + host = scp[1]; + path = scp[2]; + } else { + const noCreds = raw.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "").replace(/^[^@/]*@/, ""); + const slash = noCreds.indexOf("/"); + if (slash === -1) return null; + host = noCreds.slice(0, slash); + path = noCreds.slice(slash + 1); + } + host = host.toLowerCase().replace(/:\d+$/, ""); + path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase(); + if (!host || !path) return null; + return `${host}/${path}`; +} +function gitRemoteIdentity(dir) { + try { + return normalizeGitRemote(execSync("git config --get remote.origin.url", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim()); + } catch { + return null; + } +} +function remoteIdentityEnabled() { + const flag = process.env["AGENTMEMORY_PROJECT_FROM_REMOTE"]; + return flag === "1" || flag === "true"; +} +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(); + if (remoteIdentityEnabled()) { + const id = gitRemoteIdentity(dir); + if (id) return id; + } + return gitToplevelBasename(dir) ?? basename(dir); } function hookCwd(data) { if (!data || typeof data !== "object") return void 0; diff --git a/plugin/scripts/subagent-start.mjs b/plugin/scripts/subagent-start.mjs index 722f0c7f0..471b2c27d 100755 --- a/plugin/scripts/subagent-start.mjs +++ b/plugin/scripts/subagent-start.mjs @@ -2,10 +2,7 @@ import { execSync } from "node:child_process"; import { basename } 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 gitToplevelBasename(dir) { try { const top = execSync("git rev-parse --show-toplevel", { cwd: dir, @@ -16,9 +13,60 @@ function resolveProject(cwd) { ], timeout: 500 }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return top ? basename(top) : null; + } catch { + return null; + } +} +function normalizeGitRemote(url) { + const raw = (url ?? "").trim(); + if (!raw) return null; + let host = ""; + let path = ""; + const scp = raw.match(/^[^@/]+@([^:/]+):(.+)$/); + if (scp) { + host = scp[1]; + path = scp[2]; + } else { + const noCreds = raw.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "").replace(/^[^@/]*@/, ""); + const slash = noCreds.indexOf("/"); + if (slash === -1) return null; + host = noCreds.slice(0, slash); + path = noCreds.slice(slash + 1); + } + host = host.toLowerCase().replace(/:\d+$/, ""); + path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase(); + if (!host || !path) return null; + return `${host}/${path}`; +} +function gitRemoteIdentity(dir) { + try { + return normalizeGitRemote(execSync("git config --get remote.origin.url", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim()); + } catch { + return null; + } +} +function remoteIdentityEnabled() { + const flag = process.env["AGENTMEMORY_PROJECT_FROM_REMOTE"]; + return flag === "1" || flag === "true"; +} +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(); + if (remoteIdentityEnabled()) { + const id = gitRemoteIdentity(dir); + if (id) return id; + } + return gitToplevelBasename(dir) ?? basename(dir); } function hookCwd(data) { if (!data || typeof data !== "object") return void 0; diff --git a/plugin/scripts/subagent-stop.mjs b/plugin/scripts/subagent-stop.mjs index 8927c1af6..98df71587 100755 --- a/plugin/scripts/subagent-stop.mjs +++ b/plugin/scripts/subagent-stop.mjs @@ -2,10 +2,7 @@ import { execSync } from "node:child_process"; import { basename } 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 gitToplevelBasename(dir) { try { const top = execSync("git rev-parse --show-toplevel", { cwd: dir, @@ -16,9 +13,60 @@ function resolveProject(cwd) { ], timeout: 500 }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return top ? basename(top) : null; + } catch { + return null; + } +} +function normalizeGitRemote(url) { + const raw = (url ?? "").trim(); + if (!raw) return null; + let host = ""; + let path = ""; + const scp = raw.match(/^[^@/]+@([^:/]+):(.+)$/); + if (scp) { + host = scp[1]; + path = scp[2]; + } else { + const noCreds = raw.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "").replace(/^[^@/]*@/, ""); + const slash = noCreds.indexOf("/"); + if (slash === -1) return null; + host = noCreds.slice(0, slash); + path = noCreds.slice(slash + 1); + } + host = host.toLowerCase().replace(/:\d+$/, ""); + path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase(); + if (!host || !path) return null; + return `${host}/${path}`; +} +function gitRemoteIdentity(dir) { + try { + return normalizeGitRemote(execSync("git config --get remote.origin.url", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim()); + } catch { + return null; + } +} +function remoteIdentityEnabled() { + const flag = process.env["AGENTMEMORY_PROJECT_FROM_REMOTE"]; + return flag === "1" || flag === "true"; +} +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(); + if (remoteIdentityEnabled()) { + const id = gitRemoteIdentity(dir); + if (id) return id; + } + return gitToplevelBasename(dir) ?? basename(dir); } function hookCwd(data) { if (!data || typeof data !== "object") return void 0; diff --git a/plugin/scripts/task-completed.mjs b/plugin/scripts/task-completed.mjs index 613a9cd9b..229a63498 100755 --- a/plugin/scripts/task-completed.mjs +++ b/plugin/scripts/task-completed.mjs @@ -2,10 +2,7 @@ import { execSync } from "node:child_process"; import { basename } 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 gitToplevelBasename(dir) { try { const top = execSync("git rev-parse --show-toplevel", { cwd: dir, @@ -16,9 +13,60 @@ function resolveProject(cwd) { ], timeout: 500 }).toString().trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return top ? basename(top) : null; + } catch { + return null; + } +} +function normalizeGitRemote(url) { + const raw = (url ?? "").trim(); + if (!raw) return null; + let host = ""; + let path = ""; + const scp = raw.match(/^[^@/]+@([^:/]+):(.+)$/); + if (scp) { + host = scp[1]; + path = scp[2]; + } else { + const noCreds = raw.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "").replace(/^[^@/]*@/, ""); + const slash = noCreds.indexOf("/"); + if (slash === -1) return null; + host = noCreds.slice(0, slash); + path = noCreds.slice(slash + 1); + } + host = host.toLowerCase().replace(/:\d+$/, ""); + path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase(); + if (!host || !path) return null; + return `${host}/${path}`; +} +function gitRemoteIdentity(dir) { + try { + return normalizeGitRemote(execSync("git config --get remote.origin.url", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim()); + } catch { + return null; + } +} +function remoteIdentityEnabled() { + const flag = process.env["AGENTMEMORY_PROJECT_FROM_REMOTE"]; + return flag === "1" || flag === "true"; +} +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(); + if (remoteIdentityEnabled()) { + const id = gitRemoteIdentity(dir); + if (id) return id; + } + return gitToplevelBasename(dir) ?? basename(dir); } function hookCwd(data) { if (!data || typeof data !== "object") return void 0; diff --git a/scripts/backfill-project-identity.mjs b/scripts/backfill-project-identity.mjs new file mode 100755 index 000000000..0a212bb1e --- /dev/null +++ b/scripts/backfill-project-identity.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +// One-time backfill: consolidate fragmented `project` tags in an agentmemory +// store onto a single canonical identity (e.g. the git-remote id produced by +// AGENTMEMORY_PROJECT_FROM_REMOTE — "github.com/org/repo"). +// +// Why this exists: project identity was historically the git-toplevel basename +// (and, in older versions, the full cwd path). The same repo checked out under +// different paths/machines therefore fragments across several `project` values. +// Observations are never lost (recall is global), but project-scoped surfaces — +// session lists, the rolling project profile, and session-start auto-context — +// silo. This re-tags the legacy rows so those surfaces unify too. See issue #733. +// +// Operates on the JSON "standalone" store (the default backend at +// ~/.agentmemory/standalone.json). Run it ON THE MACHINE/STORE that holds the +// fragmented data (typically the server). +// +// Usage: +// node scripts/backfill-project-identity.mjs --canonical github.com/devon3000/chessboard \ +// --match '(^|/)chessboard$' [--store /path/to/standalone.json] [--apply] +// +// --canonical Target identity all matched values collapse onto. (required) +// --match JS regex; any `project` value matching it is re-tagged. Repeatable. +// --map old=new Explicit value mapping. Repeatable. Takes priority over --match. +// --store Store file. Default: $AGENTMEMORY_DATA/standalone.json or ~/.agentmemory/standalone.json +// --apply Write changes. Without it, runs dry (default) and only reports. +// +// Safety: dry-run by default; --apply writes a timestamped .bak beside the store first. + +import { readFileSync, writeFileSync, copyFileSync, existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +// --- scopes that carry a `.project` value field on each entry --- +const VALUE_SCOPES = ["mem:sessions", "mem:summaries", "mem:memories", "mem:lessons", "mem:actions"]; +// --- scope keyed BY the project string (the KEY is the project) --- +const PROFILE_SCOPE = "mem:profiles"; + +function parseArgs(argv) { + const out = { match: [], map: new Map(), apply: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--apply") out.apply = true; + else if (a === "--canonical") out.canonical = argv[++i]; + else if (a === "--store") out.store = argv[++i]; + else if (a === "--match") out.match.push(new RegExp(argv[++i])); + else if (a === "--map") { + const eq = argv[++i] ?? ""; + const idx = eq.indexOf("="); + if (idx === -1) fail(`--map expects old=new, got "${eq}"`); + out.map.set(eq.slice(0, idx), eq.slice(idx + 1)); + } else fail(`unknown arg: ${a}`); + } + return out; +} + +function fail(msg) { + console.error(`error: ${msg}`); + process.exit(1); +} + +function resolveStorePath(explicit) { + if (explicit) return explicit; + const dataDir = process.env.AGENTMEMORY_DATA || join(homedir(), ".agentmemory"); + return join(dataDir, "standalone.json"); +} + +// Map one project value -> canonical, or null if it should stay as-is. +// Explicit --map wins; otherwise --match regexes; the canonical value itself +// always maps to itself (no-op) so it's never reported as "changed". +function targetFor(value, args) { + if (value === args.canonical) return null; + if (args.map.has(value)) return args.map.get(value); + if (args.match.some((re) => re.test(value))) return args.canonical; + return null; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.canonical) fail("--canonical is required"); + if (args.match.length === 0 && args.map.size === 0) + fail("provide at least one --match or --map old=new"); + + const storePath = resolveStorePath(args.store); + if (!existsSync(storePath)) fail(`store not found: ${storePath}`); + + const store = JSON.parse(readFileSync(storePath, "utf8")); + + // 1) Pre-flight tally so the operator sees every distinct project value. + const tally = {}; + const bump = (v) => { const k = v ?? "(none)"; tally[k] = (tally[k] || 0) + 1; }; + for (const scope of VALUE_SCOPES) { + const map = store[scope]; + if (!map || typeof map !== "object") continue; + for (const k of Object.keys(map)) bump(map[k]?.project); + } + const profiles = store[PROFILE_SCOPE] && typeof store[PROFILE_SCOPE] === "object" ? store[PROFILE_SCOPE] : {}; + const profileKeys = Object.keys(profiles); + + console.log(`store: ${storePath}`); + console.log(`canonical: ${args.canonical}`); + console.log(`mode: ${args.apply ? "APPLY" : "dry-run (no writes)"}\n`); + console.log("current project values (across sessions/summaries/memories/lessons/actions):"); + for (const [v, n] of Object.entries(tally).sort((a, b) => b[1] - a[1])) { + const dst = v === "(none)" ? null : targetFor(v, args); + console.log(` ${String(n).padStart(5)} ${v}${dst ? ` -> ${dst}` : ""}`); + } + console.log(`\nprofiles (${profileKeys.length}): keyed by project string`); + for (const k of profileKeys) { + const dst = targetFor(k, args); + console.log(` ${k}${dst ? ` -> ${dst}` : ""}`); + } + + // 2) Apply value-field rewrites. + const changes = { ...Object.fromEntries(VALUE_SCOPES.map((s) => [s, 0])), profilesMoved: 0, profilesMerged: 0 }; + for (const scope of VALUE_SCOPES) { + const map = store[scope]; + if (!map || typeof map !== "object") continue; + for (const k of Object.keys(map)) { + const entry = map[k]; + if (!entry || typeof entry !== "object") continue; + const dst = entry.project == null ? null : targetFor(entry.project, args); + if (dst) { entry.project = dst; changes[scope]++; } + } + } + + // 3) Profiles: rename key old -> canonical. If canonical already exists, + // keep whichever has the later updatedAt/generatedAt and drop the other. + const ts = (p) => new Date(p?.updatedAt || p?.generatedAt || p?.createdAt || 0).getTime(); + for (const k of profileKeys) { + const dst = targetFor(k, args); + if (!dst || dst === k) continue; + if (profiles[dst]) { + const winner = ts(profiles[k]) > ts(profiles[dst]) ? profiles[k] : profiles[dst]; + profiles[dst] = winner; + delete profiles[k]; + changes.profilesMerged++; + } else { + profiles[dst] = profiles[k]; + delete profiles[k]; + changes.profilesMoved++; + } + } + + console.log("\nplanned changes:"); + for (const scope of VALUE_SCOPES) console.log(` ${scope}: ${changes[scope]} re-tagged`); + console.log(` ${PROFILE_SCOPE}: ${changes.profilesMoved} moved, ${changes.profilesMerged} merged`); + + const total = VALUE_SCOPES.reduce((n, s) => n + changes[s], 0) + changes.profilesMoved + changes.profilesMerged; + if (total === 0) { console.log("\nnothing to do."); return; } + + if (!args.apply) { + console.log("\ndry-run only — re-run with --apply to write (a .bak is created first)."); + return; + } + + const bak = `${storePath}.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`; + copyFileSync(storePath, bak); + writeFileSync(storePath, JSON.stringify(store, null, 2)); + console.log(`\napplied. backup: ${bak}`); + console.log("note: BM25/vector indexes are unaffected (project isn't indexed); profiles regenerate on the next session."); +} + +main(); diff --git a/src/hooks/_project.ts b/src/hooks/_project.ts index 9f0320c5c..69ca55654 100644 --- a/src/hooks/_project.ts +++ b/src/hooks/_project.ts @@ -1,11 +1,7 @@ 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(); +function gitToplevelBasename(dir: string): string | null { try { const top = execSync("git rev-parse --show-toplevel", { cwd: dir, @@ -14,9 +10,90 @@ export function resolveProject(cwd?: string): string { }) .toString() .trim(); - if (top) return basename(top); - } catch {} - return basename(dir); + return top ? basename(top) : null; + } catch { + return null; + } +} + +// Normalize any git remote URL to a stable "host/org/repo" identity. +// Handles scp-style SSH (git@host:org/repo.git), ssh://, https://, git://, +// and URLs carrying credentials. Returns null when it can't parse one. +export function normalizeGitRemote(url: string | null | undefined): string | null { + const raw = (url ?? "").trim(); + if (!raw) return null; + + let host = ""; + let path = ""; + + // scp-style: git@github.com:org/repo.git (no scheme, host and path split by ':') + const scp = raw.match(/^[^@/]+@([^:/]+):(.+)$/); + if (scp) { + host = scp[1]; + path = scp[2]; + } else { + const schemeStripped = raw.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, ""); + const noCreds = schemeStripped.replace(/^[^@/]*@/, ""); + const slash = noCreds.indexOf("/"); + if (slash === -1) return null; + host = noCreds.slice(0, slash); + path = noCreds.slice(slash + 1); + } + + host = host.toLowerCase().replace(/:\d+$/, ""); // drop optional port + // Lowercased end-to-end, path included: hosting providers treat owner/repo + // case-insensitively, so `github.com/Acme/Widgets` and + // `github.com/acme/widgets` are the same repo. Preserving path case would + // re-fragment same-repo clones into separate projects — the exact failure + // this identity is meant to remove. + path = path + .replace(/^\/+/, "") + .replace(/\/+$/, "") + .replace(/\.git$/i, "") + .toLowerCase(); + + if (!host || !path) return null; + return `${host}/${path}`; +} + +function gitRemoteIdentity(dir: string): string | null { + try { + const url = execSync("git config --get remote.origin.url", { + cwd: dir, + stdio: ["ignore", "pipe", "ignore"], + timeout: 500, + }) + .toString() + .trim(); + return normalizeGitRemote(url); + } catch { + return null; + } +} + +function remoteIdentityEnabled(): boolean { + const flag = process.env["AGENTMEMORY_PROJECT_FROM_REMOTE"]; + return flag === "1" || flag === "true"; +} + +// Resolution order: +// AGENTMEMORY_PROJECT_NAME env (explicit override) +// → git remote identity "host/org/repo" — only when AGENTMEMORY_PROJECT_FROM_REMOTE is set. +// Stable across machines and differently-named checkouts of the same repo. +// → 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(); + + if (remoteIdentityEnabled()) { + const id = gitRemoteIdentity(dir); + if (id) return id; + } + + return gitToplevelBasename(dir) ?? basename(dir); } export function hookCwd(data: Record | null | undefined): string | undefined { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ef26427aa..97a703826 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -11,6 +11,7 @@ import type { import { getVisibleTools } from "./tools-registry.js"; import { timingSafeCompare } from "../auth.js"; import { getAgentId, isAgentScopeIsolated } from "../config.js"; +import { resolveProject } from "../hooks/_project.js"; type McpResponse = { status_code: number; @@ -182,7 +183,13 @@ export function registerMcpEndpoints( ? args.files.split(",").map((f: string) => f.trim()).filter(Boolean) : []; - const project = + // Project resolution: an explicit project always wins. Otherwise, + // scope === "global" stores the memory unscoped (shared across all + // projects), and the default is to scope to the current project so + // remembered facts unify with the session's project surfaces + // instead of silently landing unscoped. Mirrors the session-start + // hook's resolveProject so both paths agree on identity. + const explicitProject = typeof args.project === "string" && args.project.trim().length > 0 ? args.project.trim() : undefined; @@ -190,6 +197,9 @@ export function registerMcpEndpoints( typeof args.agentId === "string" && args.agentId.trim().length > 0 ? (args.agentId as string).trim() : undefined; + const isGlobal = + typeof args.scope === "string" && args.scope.trim().toLowerCase() === "global"; + const project = explicitProject ?? (isGlobal ? undefined : resolveProject()); const result = await sdk.trigger({ function_id: "mem::remember", payload: { content: args.content, diff --git a/src/mcp/standalone.ts b/src/mcp/standalone.ts index f425b159c..cc471f70b 100644 --- a/src/mcp/standalone.ts +++ b/src/mcp/standalone.ts @@ -6,6 +6,7 @@ import { getAllTools } from "./tools-registry.js"; import { getStandalonePersistPath } from "../config.js"; import { VERSION } from "../version.js"; import { generateId } from "../state/schema.js"; +import { resolveProject } from "../hooks/_project.js"; import { resolveHandle, invalidateHandle, @@ -113,6 +114,7 @@ interface Validated { tokenBudget?: number; memoryIds?: string[]; reason?: string; + project?: string; } function validate(toolName: string, args: Record): Validated { @@ -130,12 +132,19 @@ function validate(toolName: string, args: Record): Validated { v.type = (args["type"] as string) || "fact"; v.concepts = normalizeList(args["concepts"]); v.files = normalizeList(args["files"]); - // The tool schema exposes project (and now agentId); dropping them - // here silently broke project/agent scoping through the stdio - // package specifically. - if (typeof args["project"] === "string" && args["project"].trim()) { - v.project = args["project"].trim(); - } + // The tool schema exposes project, scope and agentId; dropping them here + // silently broke project/agent scoping through the stdio package + // specifically. For project: explicit wins; scope:"global" stores + // unscoped; otherwise default to the current project so remembered facts + // unify with the session's project surfaces instead of landing unscoped. + const explicitProject = + typeof args["project"] === "string" && (args["project"] as string).trim() + ? (args["project"] as string).trim() + : undefined; + const isGlobal = + typeof args["scope"] === "string" && + (args["scope"] as string).trim().toLowerCase() === "global"; + v.project = explicitProject ?? (isGlobal ? undefined : resolveProject()); if (typeof args["agentId"] === "string" && args["agentId"].trim()) { v.agentId = args["agentId"].trim(); } @@ -271,6 +280,7 @@ async function handleLocal( content: v.content, concepts: v.concepts, files: v.files, + ...(v.project !== undefined && { project: v.project }), createdAt: isoNow, updatedAt: isoNow, strength: 7, diff --git a/src/mcp/tools-registry.ts b/src/mcp/tools-registry.ts index 1225b4ce7..912938479 100644 --- a/src/mcp/tools-registry.ts +++ b/src/mcp/tools-registry.ts @@ -81,7 +81,17 @@ export const CORE_TOOLS: McpToolDef[] = [ "Stable canonical project identifier this memory belongs to (e.g. a slug, " + "UUID, or registry key). Must match the value used when the session was " + "started. Do not use filesystem paths or ad-hoc display names — those " + - "change across machines and will silently break project scoping.", + "change across machines and will silently break project scoping. " + + "If omitted, defaults to the current project (resolved the same way as " + + "the session). Pass scope:'global' to store unscoped instead.", + }, + scope: { + type: "string", + description: + "'project' (default) scopes the memory to the current project; " + + "'global' stores it unscoped (shared across all projects) for facts " + + "that aren't project-specific, like user preferences or conventions. " + + "Ignored when an explicit project is given.", }, agentId: { type: "string", diff --git a/test/hook-project.test.ts b/test/hook-project.test.ts index 37d0e424d..7dde84349 100644 --- a/test/hook-project.test.ts +++ b/test/hook-project.test.ts @@ -3,19 +3,29 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; -import { resolveProject } from "../src/hooks/_project.js"; +import { resolveProject, normalizeGitRemote } from "../src/hooks/_project.js"; // The checkout directory is not necessarily named "agentmemory" — contributors clone // into forks, worktrees and arbitrary paths — so the git-toplevel assertions run against // a throwaway repo whose name we control instead of against process.cwd(). const REPO_NAME = "amem-fixture-repo"; +// Fixture for the opt-in git-remote identity mode: a repo whose remote we set +// ourselves, so the expected identity is fixed rather than inherited from the +// contributor's own checkout. +const REMOTE_REPO_NAME = "amem-remote-repo"; +const FIXTURE_REMOTE_URL = "https://github.com/devon3000/amem-remote-repo.git"; +const FIXTURE_REMOTE_IDENTITY = "github.com/devon3000/amem-remote-repo"; describe("resolveProject — hook project basename resolver", () => { const originalEnv = process.env.AGENTMEMORY_PROJECT_NAME; + const originalRemoteFlag = process.env.AGENTMEMORY_PROJECT_FROM_REMOTE; let tmpRoot: string; let repoDir: string; let nestedDir: string; + // A repo with a known remote, so remote-mode assertions are deterministic + // instead of reading whatever remote the current checkout happens to have. + let remoteRepoDir: string; beforeAll(() => { tmpRoot = mkdtempSync(join(tmpdir(), "amem-project-")); @@ -23,6 +33,14 @@ describe("resolveProject — hook project basename resolver", () => { nestedDir = join(repoDir, "src", "hooks"); mkdirSync(nestedDir, { recursive: true }); execFileSync("git", ["init", "--quiet"], { cwd: repoDir, stdio: "ignore" }); + + remoteRepoDir = join(tmpRoot, REMOTE_REPO_NAME); + mkdirSync(remoteRepoDir, { recursive: true }); + execFileSync("git", ["init", "--quiet"], { cwd: remoteRepoDir, stdio: "ignore" }); + execFileSync("git", ["remote", "add", "origin", FIXTURE_REMOTE_URL], { + cwd: remoteRepoDir, + stdio: "ignore", + }); }); afterAll(() => { @@ -31,6 +49,7 @@ describe("resolveProject — hook project basename resolver", () => { beforeEach(() => { delete process.env.AGENTMEMORY_PROJECT_NAME; + delete process.env.AGENTMEMORY_PROJECT_FROM_REMOTE; }); afterEach(() => { @@ -40,6 +59,11 @@ describe("resolveProject — hook project basename resolver", () => { } else { process.env.AGENTMEMORY_PROJECT_NAME = originalEnv; } + if (originalRemoteFlag === undefined) { + delete process.env.AGENTMEMORY_PROJECT_FROM_REMOTE; + } else { + process.env.AGENTMEMORY_PROJECT_FROM_REMOTE = originalRemoteFlag; + } }); it("AGENTMEMORY_PROJECT_NAME env wins over everything", () => { @@ -97,4 +121,104 @@ describe("resolveProject — hook project basename resolver", () => { expect(resolveProject("")).toBe(REPO_NAME); expect(resolveProject(" ")).toBe(REPO_NAME); }); + + it("ignores the remote flag by default (basename behavior unchanged)", () => { + // Flag unset -> still basename even though this repo has a remote. + expect(resolveProject(remoteRepoDir)).toBe(REMOTE_REPO_NAME); + }); + + it("uses git remote identity when AGENTMEMORY_PROJECT_FROM_REMOTE is set", () => { + process.env.AGENTMEMORY_PROJECT_FROM_REMOTE = "1"; + expect(resolveProject(remoteRepoDir)).toBe(FIXTURE_REMOTE_IDENTITY); + }); + + it("remote mode falls back to basename for a repo with no remote", () => { + process.env.AGENTMEMORY_PROJECT_FROM_REMOTE = "1"; + expect(resolveProject(repoDir)).toBe(REPO_NAME); + }); + + it("env override still wins over remote mode", () => { + process.env.AGENTMEMORY_PROJECT_FROM_REMOTE = "1"; + process.env.AGENTMEMORY_PROJECT_NAME = "explicit"; + expect(resolveProject(remoteRepoDir)).toBe("explicit"); + }); +}); + +describe("normalizeGitRemote — git URL -> host/org/repo", () => { + it("https with .git", () => { + expect(normalizeGitRemote("https://github.com/devon3000/chessboard.git")).toBe( + "github.com/devon3000/chessboard", + ); + }); + + it("https without .git", () => { + expect(normalizeGitRemote("https://github.com/devon3000/chessboard")).toBe( + "github.com/devon3000/chessboard", + ); + }); + + it("scp-style ssh", () => { + expect(normalizeGitRemote("git@github.com:devon3000/chessboard.git")).toBe( + "github.com/devon3000/chessboard", + ); + }); + + it("ssh:// url", () => { + expect(normalizeGitRemote("ssh://git@github.com/devon3000/chessboard.git")).toBe( + "github.com/devon3000/chessboard", + ); + }); + + it("git:// url", () => { + expect(normalizeGitRemote("git://github.com/devon3000/chessboard.git")).toBe( + "github.com/devon3000/chessboard", + ); + }); + + it("strips embedded credentials", () => { + expect( + normalizeGitRemote("https://user:token@github.com/devon3000/chessboard.git"), + ).toBe("github.com/devon3000/chessboard"); + }); + + it("lowercases host and drops port", () => { + expect(normalizeGitRemote("https://GitHub.com:443/Org/Repo.git")).toBe( + "github.com/org/repo", + ); + }); + + // #716 specifies the identity is lowercased end-to-end. Providers treat + // owner/repo case-insensitively, so two clones of one repo whose remotes + // differ only in case must land on the same project key. + it("lowercases the owner/repo path, not just the host", () => { + expect(normalizeGitRemote("git@github.com:Acme/Widgets.git")).toBe( + "github.com/acme/widgets", + ); + expect(normalizeGitRemote("https://github.com/Devon3000/Chessboard")).toBe( + "github.com/devon3000/chessboard", + ); + }); + + it("case-variant remotes of the same repo resolve to one identity", () => { + const variants = [ + "https://github.com/acme/widgets.git", + "https://github.com/Acme/Widgets.git", + "git@github.com:ACME/WIDGETS.git", + "ssh://git@GitHub.com/Acme/widgets", + ].map((u) => normalizeGitRemote(u)); + expect(new Set(variants).size).toBe(1); + expect(variants[0]).toBe("github.com/acme/widgets"); + }); + + it("handles nested groups (gitlab subgroups)", () => { + expect( + normalizeGitRemote("git@gitlab.com:group/subgroup/proj.git"), + ).toBe("gitlab.com/group/subgroup/proj"); + }); + + it("returns null for empty / unparseable input", () => { + expect(normalizeGitRemote("")).toBeNull(); + expect(normalizeGitRemote(null)).toBeNull(); + expect(normalizeGitRemote("not-a-url")).toBeNull(); + }); }); diff --git a/test/mcp-standalone.test.ts b/test/mcp-standalone.test.ts index 3dcc80ae1..1cf57e860 100644 --- a/test/mcp-standalone.test.ts +++ b/test/mcp-standalone.test.ts @@ -170,6 +170,46 @@ describe("handleToolCall", () => { expect(fetchTrap).not.toHaveBeenCalled(); }); + describe("memory_save project scoping", () => { + const original = process.env.AGENTMEMORY_PROJECT_NAME; + beforeEach(() => { + process.env.AGENTMEMORY_PROJECT_NAME = "test-proj"; + }); + afterEach(() => { + if (original === undefined) delete process.env.AGENTMEMORY_PROJECT_NAME; + else process.env.AGENTMEMORY_PROJECT_NAME = original; + }); + + it("defaults project to the current project when none is given", async () => { + const kv = new InMemoryKV(); + await handleToolCall("memory_save", { content: "scoped by default" }, kv); + const [mem] = await kv.list>("mem:memories"); + expect(mem.project).toBe("test-proj"); + }); + + it("stores unscoped (no project) when scope is global", async () => { + const kv = new InMemoryKV(); + await handleToolCall( + "memory_save", + { content: "cross-project fact", scope: "global" }, + kv, + ); + const [mem] = await kv.list>("mem:memories"); + expect(mem.project).toBeUndefined(); + }); + + it("an explicit project always wins over the default and scope", async () => { + const kv = new InMemoryKV(); + await handleToolCall( + "memory_save", + { content: "explicitly scoped", project: "other-proj", scope: "global" }, + kv, + ); + const [mem] = await kv.list>("mem:memories"); + expect(mem.project).toBe("other-proj"); + }); + }); + it("memory_save persists to disk immediately after saving", async () => { const kv = new InMemoryKV("/tmp/test-handle.json"); const result = await handleToolCall(