Skip to content

Project identity uses git-toplevel basename — collides across same-named repos, no remote-based identity #733

Description

@devon3000

Edited to correct the original report. My first version (written from the compiled dist/ bundle) claimed project identity was the full filesystem path. Reading the actual source shows it's the git-toplevel basename, which is more correct than I first stated but still has real failure modes. Corrected analysis below. (Second edit: sharpened the Impact section with the exact source-level scoping behavior — memory_smart_search is global, but mem::context and the project profile are hard-scoped by exact project string.)

Summary

Project scope is derived from the git-toplevel basename (falling back to the cwd basename). This means:

  • Collision: two unrelated repos whose top-level directory share a name (e.g. both checked out as app, server, frontend, or two different forks both named agentmemory) collapse into one project bucket.
  • No cross-checkout-name unification: the same repo checked out under different directory names — or under different absolute paths on two machines — resolves to different projects.
  • There is no use of the git remote, and no canonicalization to a globally stable identity.

(Full absolute-path project values that exist in older stores predate the basename logic — they're legacy rows from an earlier version, which is a separate migration concern.)

Where

resolveProject()src/hooks/_project.ts:

// 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();
  try {
    const top = execSync("git rev-parse --show-toplevel", { cwd: dir, /* … */ }).toString().trim();
    if (top) return basename(top);   // <-- basename only; "agentmemory", not the repo identity
  } catch {}
  return basename(dir);
}

This shared resolver is imported by all nine hooks (session-start, post-tool-use, prompt-submit, subagent-*, notification, task-completed, pre-compact, post-tool-failure). The server stores the value verbatim — api::session::start in src/triggers/api.ts persists body.project as-is and does not re-derive it. So identity is decided entirely client-side in resolveProject, which is the right single seam to fix.

Impact

The project tag's effect is path-dependent, which is what makes this subtle. After reading the search/context functions:

  • memory_smart_search / mem::smart-search — global, not affected. The hybrid observation search is called with (query, limit) only; project is never passed to the searcher (src/functions/smart-search.ts). It is forwarded solely to recallLessons. So explicit recall already spans every project tag — fragmented tags do not hide observations from search.
  • mem::context (session-start auto-injected context) — affected, and load-bearing. Related prior sessions are hard-filtered by exact string match — s.project === data.project (src/functions/context.ts). Two tags for the same repo means a session on one machine never surfaces the other machine's prior work in its startup context. (Note: injection is gated behind AGENTMEMORY_INJECT_CONTEXT=true, off by default since 0.8.10, so real-world impact depends on whether the user enabled it.)
  • Project profile — affected. Keyed by the exact project string: kv.get(KV.profiles, data.project) (src/functions/context.ts). Fragmented tags produce two separate rolling profiles instead of one unified one.
  • Lessons — soft-scoped. Global lessons still surface; project-matching ones get a 1.5× ranking boost (src/functions/context.ts). Degraded ranking, not exclusion.
  • mem::search (lower-level) — affected when scoped. It accepts a real projectFilter and drops non-matching observations (src/functions/search.ts). Any caller that passes a project gets partitioned results.
  • memory_sessions, patterns, profile views — affected. All group by exact project tag.

Net: observations are never lost (global search finds them), but the project-scoped surfaces that give a fresh session continuity — auto-context and the rolling profile — silently fragment across same-repo checkouts.

Proposed fix (implemented)

Derive a stable host/org/repo identity from the git remote when available, falling back to the existing basename behavior:

  • git config --get remote.origin.url → normalize scp-style SSH (git@host:org/repo.git), ssh://, git://, https://, and credentialed URLs → lowercase host, strip port/.githost/org/repo.
  • Fall back to git-toplevel basename, then cwd basename, when there's no remote.
  • Keep AGENTMEMORY_PROJECT_NAME as the explicit override.

To avoid changing behavior for existing users (and fragmenting their stored project keys on upgrade), I gated this behind an opt-in AGENTMEMORY_PROJECT_FROM_REMOTE flag — default behavior is unchanged. Could be promoted to default in a future major if desired.

Branch with the change (+ normalizeGitRemote unit tests and remote-mode coverage; all project-scoped suites pass): https://github.com/devon3000/agentmemory/tree/fix/git-remote-project-identity

A migration concern remains for existing stores: legacy rows (full-path and basename project values) won't unify with new remote-based identities. A one-time backfill/re-tag is needed to consolidate them. Happy to open a PR if the approach (and the opt-in gating) looks right.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions