From b602504283d2794b9d1d522a4b3e9b093d196080 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Thu, 24 Sep 2026 01:23:14 +0530 Subject: [PATCH] fix(backfill): preserve each session project attribution --- src/commands/backfill-memory.ts | 2 + src/skillify/stage-memory.ts | 57 +++++++++--- tests/claude-code/stage-memory.test.ts | 121 +++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 11 deletions(-) diff --git a/src/commands/backfill-memory.ts b/src/commands/backfill-memory.ts index 8b0b8cb29..bbe53318b 100644 --- a/src/commands/backfill-memory.ts +++ b/src/commands/backfill-memory.ts @@ -244,6 +244,8 @@ export type NowFn = () => number; /** Default stager: real stage-only extraction via claude -p + local embed. */ function defaultStageFn(cwd: string, perSessionTimeoutMs: number): StageFn { const claudeBin = resolveClaudeBin(); + // Fallback only: stageSession attributes each session to the cwd recorded + // in its own transcript, since the default run spans every project. const project = projectNameFromCwd(cwd); return async (s) => { const res = await stageSession( diff --git a/src/skillify/stage-memory.ts b/src/skillify/stage-memory.ts index 81f8221de..50cd2f2b0 100644 --- a/src/skillify/stage-memory.ts +++ b/src/skillify/stage-memory.ts @@ -18,9 +18,10 @@ import { spawn } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { WIKI_PROMPT_TEMPLATE } from "../hooks/spawn-wiki-worker.js"; +import { projectNameFromCwd } from "../utils/project-name.js"; import { buildClaudeInvocation, type ClaudeGrants } from "../hooks/wiki-worker-spawn.js"; import { resolveCliBin } from "../utils/resolve-cli-bin.js"; import { EmbedClient } from "../embeddings/client.js"; @@ -39,7 +40,10 @@ export interface StageSessionInput { jsonlPath: string; /** Source agent: claude_code | codex | cursor | hermes. */ agent: string; - /** Project name for the summary header + later org/project scoping. */ + /** + * Fallback project name for the summary header + later org/project scoping, + * used only when the transcript records no usable session cwd. + */ project: string; } @@ -80,15 +84,40 @@ export interface StageResult { reason?: string; } -function countLines(path: string): number { +function readTranscript(path: string): string { try { - const buf = readFileSync(path, "utf-8"); - if (!buf) return 0; - // Trailing newline shouldn't inflate the count. - return buf.endsWith("\n") ? buf.split("\n").length - 1 : buf.split("\n").length; + return readFileSync(path, "utf-8"); } catch { - return 0; + return ""; + } +} + +function countLines(buf: string): number { + if (!buf) return 0; + // Trailing newline shouldn't inflate the count. + return buf.endsWith("\n") ? buf.split("\n").length - 1 : buf.split("\n").length; +} + +/** + * The working directory the session itself ran in, read from the transcript. + * Claude Code stamps `cwd` on each conversational record; Codex rollouts carry + * it as `payload.cwd` on `session_meta` / `turn_context`. Leading records + * without one (summaries, snapshots) and malformed lines are skipped. Returns + * null when no record yields a cwd with a usable final path segment. + */ +function sessionCwdFromTranscript(buf: string): string | null { + for (const line of buf.split("\n")) { + if (!line.trim()) continue; + let obj: unknown; + try { obj = JSON.parse(line); } catch { continue; } + if (!obj || typeof obj !== "object" || Array.isArray(obj)) continue; + const rec = obj as { type?: unknown; cwd?: unknown; payload?: { cwd?: unknown } | null }; + const payloadCwd = rec.type === "session_meta" || rec.type === "turn_context" ? rec.payload?.cwd : undefined; + const raw = typeof rec.cwd === "string" ? rec.cwd : payloadCwd; + // Check emptiness without changing a valid directory name. + if (typeof raw === "string" && raw.trim().length > 0 && basename(raw)) return raw; } + return null; } /** Spawn shape derived from a ClaudeInvocation: how to wire stdio + the prompt. */ @@ -201,13 +230,19 @@ export async function stageSession(input: StageSessionInput, opts: StageOptions) return { sessionId: key, ok: false, embedded: false, reason: "mkdir-failed" }; } - const jsonlLines = countLines(input.jsonlPath); + const transcript = readTranscript(input.jsonlPath); + const jsonlLines = countLines(transcript); + // Backfill spans every project's history, so attribute the session to the + // cwd it actually ran in (as live capture does), not the caller's cwd. + const sessionCwd = sessionCwdFromTranscript(transcript); + const project = sessionCwd ? projectNameFromCwd(sessionCwd) : input.project; // Offset 0: backfill always extracts from scratch (no prior summary on disk). const prompt = WIKI_PROMPT_TEMPLATE .replace(/__JSONL__/g, input.jsonlPath) .replace(/__SUMMARY__/g, summaryPath) .replace(/__SESSION_ID__/g, input.sessionId) - .replace(/__PROJECT__/g, input.project) + // Function replacement: a `$` in a directory name must stay literal. + .replace(/__PROJECT__/g, () => project) .replace(/__PREV_OFFSET__/g, "0") .replace(/__JSONL_LINES__/g, String(jsonlLines)) // Backfill has no server path; the source is the local session. The @@ -255,7 +290,7 @@ export async function stageSession(input: StageSessionInput, opts: StageOptions) const entry: PendingMemoryEntry = { session_id: key, source_agent: input.agent, - project: input.project, + project, source_session_path: input.jsonlPath, summary_path: summaryPath, embedded, diff --git a/tests/claude-code/stage-memory.test.ts b/tests/claude-code/stage-memory.test.ts index c07095755..c250a1875 100644 --- a/tests/claude-code/stage-memory.test.ts +++ b/tests/claude-code/stage-memory.test.ts @@ -161,6 +161,127 @@ describe("stageSession", () => { expect(existsSync(join(stagingDir, "claude_code-s1.embedding.json"))).toBe(false); }); + describe("session project attribution", () => { + // Backfill stages history from every project, so the project stamped into + // the prompt and the manifest must come from the session's own cwd; the + // caller's `project` is only a fallback. + async function stageAndCapture(lines: string[]): Promise<{ prompt: string; embedded: string[] }> { + writeFileSync(jsonlPath, lines.join("\n") + "\n"); + let prompt = ""; + const embedded: string[] = []; + const r = await stageSession(input(), opts({ + runAgent: async (_bin, p) => { + prompt = p; + const m = p.match(/SUMMARY FILE to write: (\S+)/); + if (m) writeFileSync(m[1], "# Session s1\n## What Happened\nreal content\n"); + return true; + }, + embed: async (text) => { embedded.push(text); return [0.5]; }, + })); + expect(r).toMatchObject({ ok: true, embedded: true }); + return { prompt, embedded }; + } + const manifestProject = () => readPendingMemoryManifest(manifestPath)!.entries[0].project; + + it("uses a foreign Claude session's own cwd, not the caller's project", async () => { + const { prompt, embedded } = await stageAndCapture([ + JSON.stringify({ type: "summary", summary: "earlier" }), + JSON.stringify({ type: "file-history-snapshot", snapshot: {} }), + JSON.stringify({ type: "user", cwd: "/work/other-repo", message: { content: "hi" } }), + JSON.stringify({ type: "assistant", cwd: "/work/other-repo", message: { content: [] } }), + ]); + expect(prompt).toContain("PROJECT: other-repo\n"); + expect(prompt).toContain("- **Project**: other-repo\n"); + expect(prompt).not.toContain("PROJECT: proj\n"); + expect(manifestProject()).toBe("other-repo"); + expect(embedded).toEqual(["# Session s1\n## What Happened\nreal content\n"]); + }); + + it("keeps the first recorded cwd when a later record reports another", async () => { + const { prompt } = await stageAndCapture([ + JSON.stringify({ type: "user", cwd: "/work/start-repo", message: { content: "hi" } }), + JSON.stringify({ type: "user", cwd: "/work/start-repo/sub", message: { content: "cd'd" } }), + ]); + expect(prompt).toContain("PROJECT: start-repo\n"); + expect(manifestProject()).toBe("start-repo"); + }); + + it("uses a Codex rollout's session_meta payload cwd", async () => { + const { prompt } = await stageAndCapture([ + JSON.stringify({ type: "session_meta", payload: { id: "r1", cwd: "/home/u/codex-repo" } }), + JSON.stringify({ type: "response_item", payload: { type: "message" } }), + ]); + expect(prompt).toContain("PROJECT: codex-repo\n"); + expect(manifestProject()).toBe("codex-repo"); + }); + + it("falls back to the provided project when no record carries a cwd", async () => { + const { prompt } = await stageAndCapture([ + JSON.stringify({ type: "user", message: { content: "hi" } }), + JSON.stringify({ type: "assistant", message: { content: [] } }), + JSON.stringify({ type: "response_item", payload: { cwd: "/ignored/non-meta" } }), + ]); + expect(prompt).toContain("PROJECT: proj\n"); + expect(prompt).toContain("- **Project**: proj\n"); + expect(manifestProject()).toBe("proj"); + }); + + it("skips malformed and unusable early records before the first usable cwd", async () => { + const { prompt } = await stageAndCapture([ + "{not json", + "null", + "42", + JSON.stringify(["cwd", "/array/repo"]), + JSON.stringify({ type: "user", cwd: "" }), + JSON.stringify({ type: "user", cwd: " " }), + JSON.stringify({ type: "user", cwd: 7 }), + JSON.stringify({ type: "user", cwd: "/" }), + JSON.stringify({ type: "session_meta", payload: null }), + JSON.stringify({ type: "session_meta", payload: "cwd" }), + JSON.stringify({ type: "user", cwd: "/srv/real-repo", message: { content: "hi" } }), + ]); + expect(prompt).toContain("PROJECT: real-repo\n"); + expect(manifestProject()).toBe("real-repo"); + }); + + it("preserves literal spaces and replacement-like text in the recorded project", async () => { + const { prompt } = await stageAndCapture([ + JSON.stringify({ type: "user", cwd: "/srv/repo $& ", message: { content: "hi" } }), + ]); + expect(prompt).toContain("PROJECT: repo $& \n"); + expect(prompt).toContain("- **Project**: repo $& \n"); + expect(manifestProject()).toBe("repo $& "); + }); + + it("falls back when every record is malformed or lacks a usable cwd", async () => { + const { prompt } = await stageAndCapture([ + "{not json", + "null", + JSON.stringify({ type: "user", cwd: "/" }), + JSON.stringify({ type: "user", cwd: null }), + ]); + expect(prompt).toContain("PROJECT: proj\n"); + expect(manifestProject()).toBe("proj"); + }); + + it("falls back when the transcript can't be read", async () => { + const jsonlDir = join(dir, "jsonl-as-dir"); + mkdirSync(jsonlDir); + let prompt = ""; + const r = await stageSession({ ...input(), jsonlPath: jsonlDir }, opts({ + runAgent: async (_bin, p) => { + prompt = p; + const m = p.match(/SUMMARY FILE to write: (\S+)/); + if (m) writeFileSync(m[1], "# Session s1\n## What Happened\nreal content\n"); + return true; + }, + })); + expect(r.ok).toBe(true); + expect(prompt).toContain("PROJECT: proj\n"); + expect(manifestProject()).toBe("proj"); + }); + }); + it("resolveClaudeBin returns a non-empty path", () => { expect(typeof resolveClaudeBin()).toBe("string"); expect(resolveClaudeBin().length).toBeGreaterThan(0);