Skip to content

Commit b448202

Browse files
fix(coding-agents): find Claude sessions for underscore paths (#3732)
* fix(coding-agents): find Claude sessions for underscore paths * fix(coding-agents): encode every non-alphanumeric in the Claude project dir Claude Code does not special-case underscores: it replaces EVERY non-alphanumeric character in the absolute path with `-`. Encoding only `/`, `.` and `_` still missed whole repositories, most commonly ones with a space in the path (`~/Documents/My Projects/...`), which reported "no past sessions found on disk" while the transcripts sat on disk under their real name. Verified against Claude Code 2.1.241 by running it in two directories: .../hs_under_test -> ...-hs-under-test .../hs+odd@repo v2 -> ...-hs-odd-repo-v2 Case is preserved and runs are not collapsed, so the encoding is a 1:1 character substitution. Widening the class cannot misattribute a session: the directory name only narrows the candidate set, and each transcript must still record a `cwd` inside the repository before it is imported. A regression test covers that collision directly. Claude-Session: https://claude.ai/code/session_01HVZ94d143NPSsZjbnvwqba --------- Co-authored-by: Nicolò Boschi <boschi1997@gmail.com>
1 parent d8a71c3 commit b448202

2 files changed

Lines changed: 53 additions & 3 deletions

File tree

hindsight-integrations/coding-agents/src/core/history.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,52 @@ describe("local history import", () => {
4747
expect(JSON.stringify(r.sessions)).not.toContain("unrelated");
4848
});
4949

50+
// Claude Code encodes EVERY non-alphanumeric character as `-`, not just `/` and `.`. Encoding
51+
// only the separators left whole repositories invisible to `--import-conversations`: the install
52+
// reported "no past sessions found on disk" while the transcripts sat there under the real name.
53+
// A space is the common case in the field (`~/Documents/My Projects/...`), not just underscores.
54+
it.each([
55+
["underscores", "/Users/x/dev/my_project", "-Users-x-dev-my-project"],
56+
["spaces", "/Users/x/My Projects/app", "-Users-x-My-Projects-app"],
57+
["mixed punctuation", "/Users/x/dev/hs+odd@repo v2", "-Users-x-dev-hs-odd-repo-v2"],
58+
["dots", "/Users/x/dev/repo.git", "-Users-x-dev-repo-git"],
59+
])("reads Claude sessions when the repository path contains %s", (_label, repo, encoded) => {
60+
const h = newHome();
61+
const dir = join(h, ".claude", "projects", encoded);
62+
mkdirSync(dir, { recursive: true });
63+
writeFileSync(join(dir, "s1.jsonl"), `${claudeLine("user", "found me", repo)}\n`);
64+
65+
const r = importLocalHistory("claude-code", repo, h);
66+
67+
expect(r.sessions).toHaveLength(1);
68+
expect(JSON.stringify(r.sessions)).toContain("found me");
69+
});
70+
71+
// Case is preserved and runs are NOT collapsed, so the encoding stays a 1:1 substitution.
72+
it("preserves case and does not collapse runs of separators", () => {
73+
const h = newHome();
74+
expect(claudeProjectDir("/tmp/a-1/-crewAI", h)).toBe(
75+
join(h, ".claude", "projects", "-tmp-a-1--crewAI")
76+
);
77+
});
78+
79+
// The lossy encoding must never be trusted on its own: `my_repo` and `my-repo` collide, so the
80+
// cwd recorded INSIDE the transcript is what actually attributes a session to a repository.
81+
it("does not import a sibling repository that encodes to the same directory name", () => {
82+
const h = newHome();
83+
const dir = join(h, ".claude", "projects", "-Users-x-dev-my-repo");
84+
mkdirSync(dir, { recursive: true });
85+
writeFileSync(
86+
join(dir, "s1.jsonl"),
87+
`${claudeLine("user", "the other one", "/Users/x/dev/my-repo")}\n`
88+
);
89+
90+
const r = importLocalHistory("claude-code", "/Users/x/dev/my_repo", h);
91+
92+
expect(r.sessions).toHaveLength(0);
93+
expect(JSON.stringify(r.sessions)).not.toContain("the other one");
94+
});
95+
5096
it("matches Codex rollouts by the cwd in their session_meta header", () => {
5197
const h = newHome();
5298
const day = join(h, ".codex", "sessions", "2026", "08", "03");

hindsight-integrations/coding-agents/src/core/history.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,13 @@ export interface HistoryImport {
5050
unattributed?: number;
5151
}
5252

53-
/** Claude encodes a project directory as its absolute path with separators replaced by `-`. */
53+
/** Claude encodes a project directory as its absolute path with EVERY non-alphanumeric character
54+
* replaced by `-` — separators, dots, underscores, spaces, `+`, `@`, all of them. Case is kept,
55+
* and runs are not collapsed (`/a-1/-b` -> `-a-1--b`), so this is a 1:1 character substitution.
56+
* Verified against Claude Code 2.1.241: `hs_under_test` -> `hs-under-test`, and
57+
* `hs+odd@repo v2` -> `hs-odd-repo-v2`. */
5458
export function claudeProjectDir(repoDir: string, home = homedir()): string {
55-
return join(home, ".claude", "projects", repoDir.replace(/[/.]/g, "-"));
59+
return join(home, ".claude", "projects", repoDir.replace(/[^a-zA-Z0-9]/g, "-"));
5660
}
5761

5862
/** Is `dir` the repo itself or somewhere inside it? */
@@ -144,7 +148,7 @@ function claudeHistory(repoDir: string, home: string): HistoryImport {
144148
let unattributed = 0;
145149
for (const file of dirs.flatMap(jsonlFiles)) {
146150
// ONLY the cwd recorded inside the session may attribute it to a repo. Falling back to the
147-
// directory name would be a guess: `/` and `.` both encode to `-`, so `repo-sub` is either the
151+
// directory name would be a guess: every non-alphanumeric encodes to `-`, so `repo-sub` is either the
148152
// subdirectory `repo/sub` or an unrelated sibling repo — and a wrong guess files someone
149153
// else's conversation into this repo's memory, which is worse than importing nothing.
150154
// (Measured: 400/400 sampled sessions record a cwd, so this skips ~nothing in practice.)

0 commit comments

Comments
 (0)