Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/commands/backfill-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
57 changes: 46 additions & 11 deletions src/skillify/stage-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
}

Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
121 changes: 121 additions & 0 deletions tests/claude-code/stage-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down