fix(coding-agent): lowercase the hindsight project label

projectLabel() basenames the primary checkout root to build the Hindsight
`project:` tag, and it preserved case. Hindsight matches tags literally,
so a checkout at ~/code/General retained and recalled under
`project:General` while every other client of the same bank used
`project:general`. One repository then owns two scopes that never meet:
recall filters on the session's own tag, so neither side sees the other's
facts, and consolidation treats them as unrelated projects. A live bank
measured 581 facts stranded under a case variant on 2026-08-09.

Fold the case at the source, after the primary-root resolution added for
issue #2232, so the worktree axis and the casing axis collapse together.
Per-project bank ids lowercase for the same reason.

- hindsight/bank.ts: lowercase the resolved basename, and document why.
- test/hindsight-bank.test.ts: cover the fold on plain paths, on a
  mixed-case checkout root, and on a linked worktree of one. The non-repo
  fallback assertion now folds its own mkdtemp suffix.
- docs/memory.md: state how the project label is derived.
- CHANGELOG: Fixed entry under Unreleased.
This commit is contained in:
Álvaro Tejero Cantero
2026-08-10 07:03:53 +02:00
parent 45e12e5bb7
commit 2a0774fafd
4 changed files with 66 additions and 2 deletions
+2
View File
@@ -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.
+4
View File
@@ -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
+6 -1
View File
@@ -63,6 +63,11 @@ function baseBankId(config: HindsightConfig): string {
* worktree of one repo shares the same `project:<name>` 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;
}
/**
@@ -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",
]);
});
});