diff --git a/docs/memory.md b/docs/memory.md index ec3253747..000e94d09 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -136,6 +136,8 @@ hindsight: By default, Hindsight uses `per-project-tagged` scoping: writes go to a shared bank with a project tag, while recall includes project-tagged and untagged global memories. `per-project` isolates each working-directory project in its own bank; `global` uses one shared bank. An explicit `hindsight.bankId` selects the bank base. Changes to the bank ID, prefix, or scoping rebuild the primary session state so later operations use the new scope. +Both project-scoped modes name the project the same way: take the repository's primary checkout root (so every linked worktree of one repository resolves to the same directory), then lowercase its basename. A checkout at `~/code/General` therefore tags `project:general`. Tags are matched literally, so this fold is what keeps one repository in one memory scope no matter how the path is capitalised. + The primary session recalls on its first model turn (`hindsight.autoRecall: true`) and automatically retains completed conversation turns every three user turns by default. `/memory enqueue` flushes queued tool retains and forces retention of the current session. At agent end, the primary state schedules cadence-based retention and flushes the retain queue; session disposal drains that queue before releasing the state. Request failures and configured timeouts are logged and leave the coding session usable. Subagents alias the parent's client, bank, and scope for explicit `recall`, `retain`, and `reflect` calls, but do not run their own automatic recall or retention. Recall is injected as background context, not instructions, and recalled memory is also available as extra context during compaction. Selecting Hindsight exposes `recall`, `retain`, and `reflect`; `memory_edit` is not available because upstream Hindsight memories are not edited through this backend. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a4d66e8d2..b9b71610b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Hindsight `per-project` and `per-project-tagged` scoping splitting one repository across two memory scopes when the checkout directory carries capitals: the project label is now lowercased, so a checkout at `~/code/General` writes and recalls under `project:general` like every other client of the same bank instead of opening a private `project:General` scope. + ## [17.2.12] - 2026-08-08 ### Fixed diff --git a/packages/coding-agent/src/hindsight/bank.ts b/packages/coding-agent/src/hindsight/bank.ts index d4752f95b..058592d6d 100644 --- a/packages/coding-agent/src/hindsight/bank.ts +++ b/packages/coding-agent/src/hindsight/bank.ts @@ -63,6 +63,11 @@ function baseBankId(config: HindsightConfig): string { * worktree of one repo shares the same `project:` tag. * Outside a repo (or when resolution fails), fall back to the cwd basename. * + * The basename is lowercased. The label becomes a tag, and Hindsight matches + * tags literally, so a checkout at `.../General` would otherwise retain into a + * `project:General` scope that never meets the `project:general` scope every + * other client of the same bank reads and writes. + * * Sync only: this runs on the hot path of `computeBankScope`, which is * exposed as a sync API to callers like `backend.ts` and must stay sync. * `git.repo.primaryRootSync` walks `.git`/`commondir` with sync file reads — @@ -71,7 +76,7 @@ function baseBankId(config: HindsightConfig): string { function projectLabel(directory: string): string { if (!directory) return UNKNOWN_PROJECT; const primary = git.repo.primaryRootSync(directory); - return path.basename(primary ?? directory) || UNKNOWN_PROJECT; + return path.basename(primary ?? directory).toLowerCase() || UNKNOWN_PROJECT; } /** diff --git a/packages/coding-agent/test/hindsight-bank.test.ts b/packages/coding-agent/test/hindsight-bank.test.ts index 7ffdfc30c..a4101c2dc 100644 --- a/packages/coding-agent/test/hindsight-bank.test.ts +++ b/packages/coding-agent/test/hindsight-bank.test.ts @@ -110,6 +110,12 @@ describe("computeBankScope", () => { }); }); + it("lowercases the project segment so one checkout maps to one bank", () => { + expect(computeBankScope(baseConfig({ scoping: "per-project" }), "/work/General")).toEqual({ + bankId: "omp-general", + }); + }); + it("composes prefix + bankId + project", () => { const scope = computeBankScope( baseConfig({ scoping: "per-project", bankId: "team", bankIdPrefix: "prod" }), @@ -146,6 +152,12 @@ describe("computeBankScope", () => { expect(scope.retainTags).toEqual(["project:unknown"]); expect(scope.recallTags).toEqual(["project:unknown"]); }); + + it("lowercases the project tag so casing cannot split one project in two", () => { + const scope = computeBankScope(baseConfig({ scoping: "per-project-tagged" }), "/work/General"); + expect(scope.retainTags).toEqual(["project:general"]); + expect(scope.recallTags).toEqual(["project:general"]); + }); }); // Regression for #2232: linked git worktrees used to silo memory into @@ -212,8 +224,49 @@ describe("computeBankScope", () => { it("falls back to the cwd basename outside any repository", () => { // The temp parent dir is not itself a repo — it just contains one. + // `mkdtemp` mixes case into the suffix, so fold it like the label does. expect(computeBankScope(baseConfig({ scoping: "per-project-tagged" }), baseDir).retainTags).toEqual([ - `project:${path.basename(baseDir)}`, + `project:${path.basename(baseDir).toLowerCase()}`, + ]); + }); + }); + + // Casing is the second fragmentation source, and it survives the #2232 + // worktree fix: the label becomes a tag, Hindsight matches tags literally, + // so `project:General` and `project:general` are two disjoint scopes over + // one repository. Fold the case after the primary root is resolved. + describe("project label case folding", () => { + let baseDir: string; + let primaryRoot: string; + let worktreeRoot: string; + + beforeAll(async () => { + baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "hindsight-bank-case-")); + primaryRoot = path.join(baseDir, "CasedRepo"); + worktreeRoot = path.join(baseDir, "CasedRepo-Feature"); + await fs.mkdir(primaryRoot, { recursive: true }); + runGit(primaryRoot, ["-c", "init.defaultBranch=main", "init"]); + runGit(primaryRoot, ["config", "user.email", "tester@example.com"]); + runGit(primaryRoot, ["config", "user.name", "Tester"]); + await fs.writeFile(path.join(primaryRoot, "README.md"), "hi\n"); + runGit(primaryRoot, ["add", "-A"]); + runGit(primaryRoot, ["commit", "-m", "base"]); + runGit(primaryRoot, ["worktree", "add", worktreeRoot, "-b", "Feature"]); + }); + + afterAll(async () => { + if (baseDir) await removeWithRetries(baseDir); + }); + + it("folds a mixed-case checkout root to a lowercase tag", () => { + const scope = computeBankScope(baseConfig({ scoping: "per-project-tagged" }), primaryRoot); + expect(scope.retainTags).toEqual(["project:casedrepo"]); + expect(scope.recallTags).toEqual(["project:casedrepo"]); + }); + + it("folds the label a linked worktree inherits from a mixed-case primary root", () => { + expect(computeBankScope(baseConfig({ scoping: "per-project-tagged" }), worktreeRoot).retainTags).toEqual([ + "project:casedrepo", ]); }); });