diff --git a/__tests__/agent-claude-usage.test.js b/__tests__/agent-claude-usage.test.js new file mode 100644 index 0000000..636c912 --- /dev/null +++ b/__tests__/agent-claude-usage.test.js @@ -0,0 +1,633 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { + aggregate, + mapProject, + torontoDay, + shiftDay, + windowDays, + capModels, + WINDOW_DAYS, + MAX_MODELS_PER_DAY, + OTHER_MODEL, + OTHER_PROJECTS, +} = require("../agent/claude-usage/aggregate.js"); + +// Coverage for the pure aggregation of the claude-usage agent (issue #49). +// +// Every transcript here is synthetic (__tests__/fixtures/claude-usage/ or +// written inline): no real ~/.claude content ever enters the repo. Fixtures +// are copied into a temp dir and their mtimes set with utimesSync, so the +// scan threshold is exercised on explicit instants, never on the wall clock. + +const FIXTURES = path.join(__dirname, "fixtures", "claude-usage", "projects"); +const HOME = "/home/tester"; + +let tmpDir; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-usage-test-")); +}); + +afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function setMtime(file, iso) { + const t = new Date(iso); + fs.utimesSync(file, t, t); +} + +// Write a transcript under tmpDir. `lines` are objects (serialized) or raw +// strings (written verbatim, for broken lines). +function writeTranscript(rel, lines, mtimeIso) { + const file = path.join(tmpDir, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const body = lines.map((l) => (typeof l === "string" ? l : JSON.stringify(l))).join("\n"); + fs.writeFileSync(file, `${body}\n`); + if (mtimeIso) setMtime(file, mtimeIso); + return file; +} + +let seq = 0; +function line({ + ts, + model = "claude-opus-5-5", + cwd = `${HOME}/claude-code/alpha`, + id, + req, + input = 1, + output = 1, + cacheCreation = 0, + cacheRead = 0, + version = "2.1.283", + type = "assistant", +}) { + seq += 1; + return { + type, + message: { + id: id || `msg_${seq}`, + model, + usage: { + input_tokens: input, + output_tokens: output, + cache_creation_input_tokens: cacheCreation, + cache_read_input_tokens: cacheRead, + }, + }, + requestId: req || `req_${seq}`, + cwd, + timestamp: ts, + version, + }; +} + +function dayOf(result, date) { + return result.days.find((d) => d.date === date); +} + +function sumWork(projects) { + return projects.reduce((s, p) => s + p.work, 0); +} + +// --- Dates ------------------------------------------------------------------ + +describe("Toronto dates", () => { + it("23:59 and 00:01 Toronto time fall on two different days (EDT)", () => { + expect(torontoDay(Date.parse("2026-10-03T03:59:00Z"))).toBe("2026-10-02"); + expect(torontoDay(Date.parse("2026-10-03T04:01:00Z"))).toBe("2026-10-03"); + }); + + it("splits days correctly on both sides of the 2026-11-01 DST change", () => { + // Midnight Oct 31 -> Nov 1 is still EDT (UTC-4). + expect(torontoDay(Date.parse("2026-11-01T03:59:00Z"))).toBe("2026-10-31"); + expect(torontoDay(Date.parse("2026-11-01T04:01:00Z"))).toBe("2026-11-01"); + // Midnight Nov 1 -> Nov 2 is EST (UTC-5): a fixed -4 offset would be wrong. + expect(torontoDay(Date.parse("2026-11-02T04:30:00Z"))).toBe("2026-11-01"); + expect(torontoDay(Date.parse("2026-11-02T04:59:00Z"))).toBe("2026-11-01"); + expect(torontoDay(Date.parse("2026-11-02T05:01:00Z"))).toBe("2026-11-02"); + }); + + it("shifts dates as calendar strings across month, year and DST boundaries", () => { + expect(shiftDay("2026-11-02", -1)).toBe("2026-11-01"); + expect(shiftDay("2026-11-01", 1)).toBe("2026-11-02"); + expect(shiftDay("2026-03-01", -1)).toBe("2026-02-28"); + expect(shiftDay("2027-01-01", -1)).toBe("2026-12-31"); + expect(shiftDay("2027-03-14", 1)).toBe("2027-03-15"); + }); + + it.each(["2026-11-15", "2027-03-20", "2026-10-04", "2027-01-05"])( + "windowDays(%s) is 30 consecutive unique dates ending today", + (today) => { + const days = windowDays(today); + expect(days).toHaveLength(WINDOW_DAYS); + expect(new Set(days).size).toBe(WINDOW_DAYS); + expect(days[days.length - 1]).toBe(today); + for (let i = 1; i < days.length; i++) expect(shiftDay(days[i - 1], 1)).toBe(days[i]); + }, + ); + + it("aggregate puts 23:59 and 00:01 lines on two days around 2026-11-01", async () => { + writeTranscript( + "p/s.jsonl", + [ + line({ ts: "2026-11-01T03:59:00Z", output: 1 }), // Oct 31 23:59 EDT + line({ ts: "2026-11-01T04:01:00Z", output: 10 }), // Nov 1 00:01 EDT + line({ ts: "2026-11-02T04:59:00Z", output: 100 }), // Nov 1 23:59 EST + line({ ts: "2026-11-02T05:01:00Z", output: 1000 }), // Nov 2 00:01 EST + ], + "2026-11-02T12:00:00Z", + ); + const r = await aggregate({ root: tmpDir, today: "2026-11-02", homedir: HOME }); + expect(dayOf(r, "2026-10-31").models["claude-opus-5-5"].output).toBe(1); + expect(dayOf(r, "2026-11-01").models["claude-opus-5-5"].output).toBe(110); + expect(dayOf(r, "2026-11-02").models["claude-opus-5-5"].output).toBe(1000); + expect(dayOf(r, "2026-11-01").models["claude-opus-5-5"].requests).toBe(2); + }); + + it("rejects a malformed today", async () => { + await expect(aggregate({ root: tmpDir, today: "2026-13-01" })).rejects.toThrow(/today/); + await expect(aggregate({ root: tmpDir, today: "04/10/2026" })).rejects.toThrow(/today/); + }); + + it("defaults today to the current Toronto day", async () => { + const r = await aggregate({ root: tmpDir, homedir: HOME }); + expect(r.days[r.days.length - 1].date).toBe(torontoDay(Date.now())); + }); +}); + +// --- Project mapping ---------------------------------------------------------- + +describe("mapProject", () => { + it("rule 1: /claude-code//... -> ", () => { + expect(mapProject(`${HOME}/claude-code/simpl-resultat`, HOME)).toBe("simpl-resultat"); + expect(mapProject(`${HOME}/claude-code/simpl-resultat/src/lib`, HOME)).toBe( + "simpl-resultat", + ); + expect( + mapProject(`${HOME}/claude-code/vps-health-api/.claude/worktrees/agent-x`, HOME), + ).toBe("vps-health-api"); + }); + + it("rule 1: .spikes and .archive keep their second segment", () => { + expect(mapProject(`${HOME}/claude-code/.spikes/idea/sub`, HOME)).toBe(".spikes/idea"); + expect(mapProject(`${HOME}/claude-code/.archive/medic`, HOME)).toBe(".archive/medic"); + }); + + it("rule 2: /claude-code exactly -> (monorepo)", () => { + expect(mapProject(`${HOME}/claude-code`, HOME)).toBe("(monorepo)"); + expect(mapProject(`${HOME}/claude-code/`, HOME)).toBe("(monorepo)"); + }); + + it("rule 3: /tmp/spike-defenseur-auto-* -> defenseurs", () => { + expect(mapProject("/tmp/spike-defenseur-auto-20260927", HOME)).toBe("defenseurs"); + expect(mapProject("/tmp/spike-defenseur-auto-abc/repo", HOME)).toBe("defenseurs"); + }); + + it("rule 4: /tmp/claude-/-claude-code-

/... ->

", () => { + expect( + mapProject("/tmp/claude-1000/-home-tester-claude-code-vps-health-api/sess/scratchpad", HOME), + ).toBe("vps-health-api"); + expect(mapProject("/tmp/claude-1000/-home-tester-claude-code-roadtrip", HOME)).toBe( + "roadtrip", + ); + }); + + it("rule 5: anything else -> (autre)", () => { + expect(mapProject("/home/tester/other", HOME)).toBe("(autre)"); + expect(mapProject("/home/tester/claude-codex/x", HOME)).toBe("(autre)"); + expect(mapProject("/tmp/claude-1000/-home-other-claude-code-x", HOME)).toBe("(autre)"); + expect(mapProject("/tmp/spike-other", HOME)).toBe("(autre)"); + expect(mapProject(undefined, HOME)).toBe("(autre)"); + expect(mapProject("", HOME)).toBe("(autre)"); + }); + + it("uses the injected homedir, not the real one", () => { + expect(mapProject("/home/max/claude-code/alpha", "/home/max")).toBe("alpha"); + expect(mapProject("/home/max/claude-code/alpha", HOME)).toBe("(autre)"); + }); +}); + +// --- Fixture scan --------------------------------------------------------------- + +describe("aggregate on the fixture tree", () => { + async function runFixture(extra = {}) { + fs.cpSync(FIXTURES, tmpDir, { recursive: true }); + const session = path.join(tmpDir, "-home-tester-claude-code-alpha", "sess-1.jsonl"); + const sub = path.join( + tmpDir, + "-home-tester-claude-code-alpha", + "sess-1", + "subagents", + "agent-a.jsonl", + ); + setMtime(session, "2026-10-04T12:00:00Z"); + setMtime(sub, "2026-10-04T12:00:00Z"); + return aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME, ...extra }); + } + + it("counts a request duplicated across session and subagents/ once, at its largest output", async () => { + const r = await runFixture(); + expect(dayOf(r, "2026-10-03").models).toEqual({ + "claude-opus-5-5": { + input: 10, + output: 2048, + cacheCreation: 100, + cacheRead: 1000, + requests: 1, + }, + }); + }); + + it("ignores , user lines and truncated JSON without throwing", async () => { + const r = await runFixture(); + const all = r.days.flatMap((d) => Object.keys(d.models)); + expect(all).not.toContain(""); + const outputs = r.days.flatMap((d) => Object.values(d.models).map((m) => m.output)); + expect(outputs).not.toContain(7777); // synthetic + expect(outputs).not.toContain(999); // user line carrying a usage object + expect(dayOf(r, "2026-10-04").models).toEqual({ + "claude-sonnet-5": { input: 5, output: 50, cacheCreation: 0, cacheRead: 200, requests: 1 }, + }); + expect(dayOf(r, "2026-10-02").models["claude-opus-5-5"].requests).toBe(1); + }); + + it("aggregates projects with work = input + output + cacheCreation", async () => { + const r = await runFixture(); + expect(r.projects).toEqual([ + { name: "alpha", work: 10 + 2048 + 100 + 5 + 50, cacheRead: 1200, requests: 2 }, + { name: ".spikes/idea", work: 33, cacheRead: 0, requests: 1 }, + ]); + }); + + it("reports claudeCodeVersion from the most recent timestamp", async () => { + const r = await runFixture(); + expect(r.claudeCodeVersion).toBe("2.1.283"); + }); + + it("returns every window day as open when nothing is frozen", async () => { + const r = await runFixture(); + expect(Object.keys(r.openDays)).toEqual(windowDays("2026-10-04")); + expect(r.openDays["2026-10-03"].projects).toEqual({ + alpha: { work: 2158, cacheRead: 1000, requests: 1 }, + }); + expect(r.openDays["2026-09-20"]).toEqual({ models: {}, projects: {} }); + }); +}); + +// --- Series ------------------------------------------------------------------- + +describe("30-day series", () => { + it("always has 30 entries, empty days included, newest = today", async () => { + const r = await aggregate({ root: path.join(tmpDir, "missing"), today: "2026-10-04", homedir: HOME }); + expect(r.days).toHaveLength(30); + expect(r.days[29].date).toBe("2026-10-04"); + expect(r.days[0].date).toBe("2026-09-05"); + for (const d of r.days) expect(d.models).toEqual({}); + expect(r.projects).toEqual([]); + expect(r.claudeCodeVersion).toBeNull(); + }); + + it("drops lines outside the window (older than 30 days, or after today)", async () => { + writeTranscript( + "p/s.jsonl", + [ + line({ ts: "2026-09-05T04:00:00Z", output: 1 }), // Sep 5 00:00 Toronto, first day + line({ ts: "2026-09-05T03:59:00Z", output: 2 }), // Sep 4, outside + line({ ts: "2026-10-05T04:01:00Z", output: 4 }), // Oct 5, after today + line({ ts: "not a date", output: 8 }), + ], + "2026-10-04T12:00:00Z", + ); + const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME }); + const total = r.days.reduce( + (s, d) => s + Object.values(d.models).reduce((t, m) => t + m.output, 0), + 0, + ); + expect(total).toBe(1); + expect(dayOf(r, "2026-09-05").models["claude-opus-5-5"].output).toBe(1); + }); + + it("keeps 30 entries when the window straddles the 2026-11-01 DST change", async () => { + const r = await aggregate({ root: tmpDir, today: "2026-11-15", homedir: HOME }); + expect(r.days.map((d) => d.date)).toEqual(windowDays("2026-11-15")); + expect(r.days.filter((d) => d.date === "2026-11-01")).toHaveLength(1); + }); +}); + +// --- Top projects --------------------------------------------------------------- + +describe("top 8 projects + (autres)", () => { + it("keeps the top 8 by work and aggregates the rest; sums match the window total", async () => { + const lines = []; + for (let i = 1; i <= 11; i++) { + lines.push( + line({ + ts: "2026-10-03T15:00:00Z", + cwd: `${HOME}/claude-code/p${String(i).padStart(2, "0")}`, + input: i * 100, + output: i, + cacheCreation: 7, + cacheRead: 3, + }), + ); + } + writeTranscript("p/s.jsonl", lines, "2026-10-04T12:00:00Z"); + const frozenDays = { + "2026-10-01": { + models: { "claude-opus-5-5": { input: 50, output: 0, cacheCreation: 0, cacheRead: 9, requests: 1 } }, + projects: { p01: { work: 50, cacheRead: 9, requests: 1 } }, + }, + }; + const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME, frozenDays }); + + const expectedTotal = 50 + [...Array(11)].reduce((s, _, k) => s + (k + 1) * 101 + 7, 0); + expect(r.projects).toHaveLength(9); + expect(r.projects[8].name).toBe(OTHER_PROJECTS); + expect(r.projects.slice(0, 8).map((p) => p.name)).toEqual([ + "p11", "p10", "p09", "p08", "p07", "p06", "p05", "p04", + ]); + // p01 = 101 + 7 + 50 frozen, p02 = 209, p03 = 310 -> (autres) + expect(r.projects[8]).toEqual({ + name: OTHER_PROJECTS, + work: 158 + 209 + 310, + cacheRead: 3 * 3 + 9, + requests: 4, + }); + expect(sumWork(r.projects)).toBe(expectedTotal); + const seriesWork = r.days.reduce( + (s, d) => + s + Object.values(d.models).reduce((t, m) => t + m.input + m.output + m.cacheCreation, 0), + 0, + ); + expect(sumWork(r.projects)).toBe(seriesWork); + }); + + it("omits (autres) when 8 projects or fewer have volume", async () => { + const lines = []; + for (let i = 1; i <= 8; i++) { + lines.push(line({ ts: "2026-10-03T15:00:00Z", cwd: `${HOME}/claude-code/q${i}`, input: i })); + } + writeTranscript("p/s.jsonl", lines, "2026-10-04T12:00:00Z"); + const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME }); + expect(r.projects).toHaveLength(8); + expect(r.projects.map((p) => p.name)).not.toContain(OTHER_PROJECTS); + }); +}); + +// --- Model cap ------------------------------------------------------------------ + +describe("at most 12 models per day", () => { + it("merges the least consuming models into `other`, totals preserved", async () => { + const lines = []; + for (let i = 1; i <= 14; i++) { + lines.push( + line({ + ts: "2026-10-03T15:00:00Z", + model: `model-${String(i).padStart(2, "0")}`, + input: i * 10, + cacheRead: 1, + }), + ); + } + writeTranscript("p/s.jsonl", lines, "2026-10-04T12:00:00Z"); + const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME }); + const models = dayOf(r, "2026-10-03").models; + expect(Object.keys(models)).toHaveLength(MAX_MODELS_PER_DAY); + expect(models[OTHER_MODEL]).toEqual({ + input: 10 + 20 + 30, + output: 3, + cacheCreation: 0, + cacheRead: 3, + requests: 3, + }); + expect(models["model-03"]).toBeUndefined(); + expect(models["model-04"]).toBeDefined(); + // The open day keeps the raw truth: the cap is applied to the series only. + expect(Object.keys(r.openDays["2026-10-03"].models)).toHaveLength(14); + }); + + it("is idempotent and leaves a small day untouched", () => { + const small = { a: { input: 1, output: 0, cacheCreation: 0, cacheRead: 0, requests: 1 } }; + expect(capModels(small)).toBe(small); + const big = {}; + for (let i = 0; i < 20; i++) { + big[`m${i}`] = { input: i, output: 0, cacheCreation: 0, cacheRead: 0, requests: 1 }; + } + const once = capModels(big); + expect(Object.keys(once)).toHaveLength(12); + expect(capModels(once)).toBe(once); + expect(Object.values(once).reduce((s, m) => s + m.requests, 0)).toBe(20); + }); +}); + +// --- Frozen days and the scan threshold ---------------------------------------- + +describe("frozen days", () => { + const FROZEN = { + "2026-10-01": { + models: { "claude-opus-5-5": { input: 7, output: 7, cacheCreation: 7, cacheRead: 7, requests: 7 } }, + projects: { alpha: { work: 21, cacheRead: 7, requests: 7 } }, + }, + "2026-09-30": { models: {}, projects: {} }, + }; + + it("opens no file whose mtime day is before the threshold (spy on file opening)", async () => { + // Threshold = day after the most recent frozen day = 2026-10-02. + const old = writeTranscript( + "a/old.jsonl", + [line({ ts: "2026-10-02T15:00:00Z", output: 5 })], // would count if opened + "2026-09-20T12:00:00Z", + ); + const justBefore = writeTranscript( + "a/sub/just-before.jsonl", + [line({ ts: "2026-10-02T15:00:00Z", output: 5 })], + "2026-10-02T03:30:00Z", // Oct 1 23:30 Toronto + ); + const atThreshold = writeTranscript( + "a/at-threshold.jsonl", + [line({ ts: "2026-10-02T03:00:00Z", output: 1 })], // Oct 1 23:00 -> frozen, ignored + "2026-10-02T04:30:00Z", // Oct 2 00:30 Toronto + ); + const recent = writeTranscript( + "b/subagents/recent.jsonl", + [line({ ts: "2026-10-03T15:00:00Z", output: 100 })], + "2026-10-04T12:00:00Z", + ); + + const opened = []; + const openFile = (file, options) => { + opened.push(file); + return fs.createReadStream(file, options); + }; + const r = await aggregate({ + root: tmpDir, + today: "2026-10-04", + homedir: HOME, + frozenDays: FROZEN, + openFile, + }); + + expect(opened.sort()).toEqual([atThreshold, recent].sort()); + expect(opened).not.toContain(old); + expect(opened).not.toContain(justBefore); + expect(dayOf(r, "2026-10-02").models).toEqual({}); + expect(dayOf(r, "2026-10-03").models["claude-opus-5-5"].output).toBe(100); + }); + + it("the default opener goes through fs.createReadStream, and skips old files too", async () => { + const old = writeTranscript( + "a/old.jsonl", + [line({ ts: "2026-10-02T15:00:00Z" })], + "2026-09-20T12:00:00Z", + ); + const recent = writeTranscript( + "a/recent.jsonl", + [line({ ts: "2026-10-03T15:00:00Z" })], + "2026-10-04T12:00:00Z", + ); + const spy = vi.spyOn(fs, "createReadStream"); + await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME, frozenDays: FROZEN }); + const paths = spy.mock.calls.map((c) => c[0]); + expect(paths).toContain(recent); + expect(paths).not.toContain(old); + }); + + it("without frozen days, still skips files last written before the window", async () => { + const ancient = writeTranscript( + "a/ancient.jsonl", + [line({ ts: "2026-08-01T15:00:00Z" })], + "2026-08-01T16:00:00Z", + ); + const inWindow = writeTranscript( + "a/in-window.jsonl", + [line({ ts: "2026-09-05T15:00:00Z" })], + "2026-09-05T16:00:00Z", + ); + const opened = []; + await aggregate({ + root: tmpDir, + today: "2026-10-04", + homedir: HOME, + openFile: (f, o) => { + opened.push(f); + return fs.createReadStream(f, o); + }, + }); + expect(opened).toEqual([inWindow]); + expect(opened).not.toContain(ancient); + }); + + it("serves frozen days verbatim, never recomputes them, and leaves them out of openDays", async () => { + writeTranscript( + "a/recent.jsonl", + [ + line({ ts: "2026-10-01T15:00:00Z", output: 999 }), // frozen day: ignored + line({ ts: "2026-10-03T15:00:00Z", output: 3 }), + ], + "2026-10-04T12:00:00Z", + ); + const r = await aggregate({ + root: tmpDir, + today: "2026-10-04", + homedir: HOME, + frozenDays: FROZEN, + }); + expect(dayOf(r, "2026-10-01").models).toEqual(FROZEN["2026-10-01"].models); + expect(dayOf(r, "2026-09-30").models).toEqual({}); + expect(r.openDays["2026-10-01"]).toBeUndefined(); + expect(r.openDays["2026-09-30"]).toBeUndefined(); + expect(Object.keys(r.openDays)).toHaveLength(28); + expect(r.projects).toEqual([{ name: "alpha", work: 21 + 1 + 3, cacheRead: 7, requests: 8 }]); + }); + + it("re-reads frozen counters defensively (bad numbers -> 0, junk ignored)", async () => { + const r = await aggregate({ + root: tmpDir, + today: "2026-10-04", + homedir: HOME, + frozenDays: { + "2026-10-01": { + models: { + "claude-opus-5-5": { input: -1, output: 1.5, cacheCreation: "9", cacheRead: 4, requests: 1 }, + "Bad Key!": { input: 2, output: 0, cacheCreation: 0, cacheRead: 0, requests: 1 }, + junk: null, + }, + projects: { alpha: { work: Number.MAX_SAFE_INTEGER + 2, cacheRead: 1, requests: 1 } }, + }, + "not-a-day": { models: { x: { input: 1 } } }, + }, + }); + expect(dayOf(r, "2026-10-01").models).toEqual({ + "claude-opus-5-5": { input: 0, output: 0, cacheCreation: 0, cacheRead: 4, requests: 1 }, + other: { input: 2, output: 0, cacheCreation: 0, cacheRead: 0, requests: 1 }, + }); + expect(r.projects).toEqual([{ name: "alpha", work: 0, cacheRead: 1, requests: 1 }]); + expect(Object.keys(r.openDays)).toHaveLength(29); + }); +}); + +// --- Robustness ----------------------------------------------------------------- + +describe("robustness", () => { + it("counts a line missing its identifiers as its own request", async () => { + const a = line({ ts: "2026-10-03T15:00:00Z", output: 2 }); + delete a.requestId; + const b = JSON.parse(JSON.stringify(a)); + writeTranscript("p/s.jsonl", [a, b], "2026-10-04T12:00:00Z"); + const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME }); + expect(dayOf(r, "2026-10-03").models["claude-opus-5-5"].requests).toBe(2); + }); + + it("ignores non-.jsonl files and survives an unreadable transcript", async () => { + writeTranscript("p/notes.txt", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z"); + writeTranscript( + "p/good.jsonl", + [line({ ts: "2026-10-03T15:00:00Z", output: 4 })], + "2026-10-04T12:00:00Z", + ); + const bad = writeTranscript("p/bad.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z"); + // The bad transcript's stream fails with ENOENT, as when Claude Code + // removes a file between the directory walk and the open. + const r = await aggregate({ + root: tmpDir, + today: "2026-10-04", + homedir: HOME, + openFile: (f, o) => + fs.createReadStream(f === bad ? path.join(tmpDir, "does-not-exist") : f, o), + }); + expect(dayOf(r, "2026-10-03").models["claude-opus-5-5"]).toEqual({ + input: 1, + output: 4, + cacheCreation: 0, + cacheRead: 0, + requests: 1, + }); + }); + + it("folds a model name outside the server key pattern into `other`", async () => { + writeTranscript( + "p/s.jsonl", + [line({ ts: "2026-10-03T15:00:00Z", model: "Weird Model/1" })], + "2026-10-04T12:00:00Z", + ); + const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME }); + expect(Object.keys(dayOf(r, "2026-10-03").models)).toEqual([OTHER_MODEL]); + }); + + it("reads no network module and writes nothing", () => { + const src = fs.readFileSync( + path.join(__dirname, "..", "agent", "claude-usage", "aggregate.js"), + "utf-8", + ); + const requires = [...src.matchAll(/require\("([^"]+)"\)/g)].map((m) => m[1]).sort(); + expect(requires).toEqual(["node:fs", "node:os", "node:path", "node:readline"]); + expect(src).not.toMatch( + /fs\.(?:promises\.)?(?:write|append|unlink|rename|mkdir|rm|copy|cp|symlink|chmod|chown|utimes|truncate|createWriteStream)/, + ); + }); +}); diff --git a/__tests__/fixtures/claude-usage/projects/-home-tester-claude-code-alpha/sess-1.jsonl b/__tests__/fixtures/claude-usage/projects/-home-tester-claude-code-alpha/sess-1.jsonl new file mode 100644 index 0000000..7245a4a --- /dev/null +++ b/__tests__/fixtures/claude-usage/projects/-home-tester-claude-code-alpha/sess-1.jsonl @@ -0,0 +1,7 @@ +{"type":"user","message":{"role":"user","content":"synthetic fixture","usage":{"input_tokens":999,"output_tokens":999}},"cwd":"/home/tester/claude-code/alpha","timestamp":"2026-10-03T14:59:00.000Z","version":"2.1.280"} +{"type":"assistant","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":10,"output_tokens":8,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}},"requestId":"req_A","cwd":"/home/tester/claude-code/alpha","timestamp":"2026-10-03T15:00:00.000Z","version":"2.1.280"} +{"type":"assistant","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":10,"output_tokens":1024,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}},"requestId":"req_A","cwd":"/home/tester/claude-code/alpha","timestamp":"2026-10-03T15:00:01.000Z","version":"2.1.280"} +{"type":"assistant","message":{"id":"msg_S","model":"","usage":{"input_tokens":0,"output_tokens":7777,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}},"requestId":"req_S","cwd":"/home/tester/claude-code/alpha","timestamp":"2026-10-03T16:00:00.000Z","version":"2.1.280"} + +{"type":"assistant","message":{"id":"msg_T","model":"claude-opus-5-5","usage":{"input_tok +{"type":"assistant","message":{"id":"msg_B","model":"claude-sonnet-5","usage":{"input_tokens":5,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":200}},"requestId":"req_B","cwd":"/home/tester/claude-code/alpha","timestamp":"2026-10-04T12:00:00.000Z","version":"2.1.283"} diff --git a/__tests__/fixtures/claude-usage/projects/-home-tester-claude-code-alpha/sess-1/subagents/agent-a.jsonl b/__tests__/fixtures/claude-usage/projects/-home-tester-claude-code-alpha/sess-1/subagents/agent-a.jsonl new file mode 100644 index 0000000..aebd4e4 --- /dev/null +++ b/__tests__/fixtures/claude-usage/projects/-home-tester-claude-code-alpha/sess-1/subagents/agent-a.jsonl @@ -0,0 +1,4 @@ +{"type":"assistant","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":10,"output_tokens":2048,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}},"requestId":"req_A","cwd":"/home/tester/claude-code/alpha","timestamp":"2026-10-03T15:00:02.000Z","version":"2.1.280","isSidechain":true} +{"type":"assistant","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":10,"output_tokens":512,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}},"requestId":"req_A","cwd":"/home/tester/claude-code/alpha","timestamp":"2026-10-03T15:00:03.000Z","version":"2.1.280","isSidechain":true} +{"type":"assistant","message":{"id":"msg_C","model":"claude-opus-5-5","usage":{"input_tokens":3,"output_tokens":30,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}},"requestId":"req_C","cwd":"/home/tester/claude-code/.spikes/idea","timestamp":"2026-10-02T10:00:00.000Z","version":"2.1.281","isSidechain":true} +not json at all diff --git a/agent/claude-usage/aggregate.js b/agent/claude-usage/aggregate.js new file mode 100644 index 0000000..7423e23 --- /dev/null +++ b/agent/claude-usage/aggregate.js @@ -0,0 +1,457 @@ +// Pure aggregation of Claude Code usage from the local transcripts (issue #49). +// +// Reads ~/.claude/projects/**/*.jsonl (subagent transcripts included) and +// builds the `days` series and the `projects` top of the POST /claude-usage +// contract (schemaVersion 1). No network, no runtime dependency, nothing +// written: the caller (the claude-usage agent) owns the frozen-days state and +// the delivery. +// +// Properties that are load-bearing and easy to break: +// +// * DATES ARE STRINGS. A day is the America/Toronto calendar date as +// "YYYY-MM-DD"; shifting by n days goes through Date.UTC on the parts. +// Never "now - 86 400 000": DST days last 23 or 25 hours, and a +// millisecond offset lands on the wrong date around the change. +// * ONE REQUEST, ONE COUNT. Claude Code writes the same API response several +// times (one line per content block, and again in subagent transcripts), +// with identical input/cache counters and a growing output_tokens. Lines +// are deduplicated on (message.id, requestId) across ALL files, keeping the +// line with the largest output_tokens. +// * THE SCAN THRESHOLD. Frozen days are never recomputed. A file whose mtime +// (Toronto day) is before the day after the most recent frozen day cannot +// hold a line on an open day, so it is not even opened: this is what keeps +// a run from re-reading ~210 MB of transcripts every time. + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const readline = require("node:readline"); + +const TIME_ZONE = "America/Toronto"; +const WINDOW_DAYS = 30; +const TOP_PROJECTS = 8; +const MAX_MODELS_PER_DAY = 12; + +const OTHER_MODEL = "other"; +const OTHER_PROJECTS = "(autres)"; +const MONOREPO_PROJECT = "(monorepo)"; +const UNKNOWN_PROJECT = "(autre)"; +const DEFENSEUR_AUTO_PROJECT = "defenseurs"; +const SYNTHETIC_MODEL = ""; + +// Same bounds as the server's sanitizer: a model key outside this pattern +// would be dropped there, so it is folded into `other` here instead. +const MODEL_KEY_RE = /^[a-z0-9][a-z0-9.\-]{0,63}$/; +const DAY_RE = /^\d{4}-\d{2}-\d{2}$/; +const MAX_STRING = 128; + +// --- Dates ------------------------------------------------------------------ + +const dayFormatter = new Intl.DateTimeFormat("en-CA", { + timeZone: TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + +// Toronto calendar day of an instant (Date or epoch ms), as "YYYY-MM-DD". +// Built from formatToParts so the result does not depend on the en-CA +// separator chosen by the ICU build. +function torontoDay(instant) { + const parts = {}; + for (const p of dayFormatter.formatToParts(instant)) parts[p.type] = p.value; + return `${parts.year}-${parts.month}-${parts.day}`; +} + +// "YYYY-MM-DD" shifted by n calendar days (n may be negative). Pure calendar +// arithmetic on the parts: the time zone and DST never enter into it. +function shiftDay(day, n) { + const [y, m, d] = day.split("-").map(Number); + return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10); +} + +// The WINDOW_DAYS dates ending on `today`, oldest first. +function windowDays(today) { + const days = []; + for (let i = WINDOW_DAYS - 1; i >= 0; i--) days.push(shiftDay(today, -i)); + return days; +} + +function isDay(value) { + if (typeof value !== "string" || !DAY_RE.test(value)) return false; + // Rejects 2026-02-30 and friends: a real date round-trips unchanged. + return shiftDay(value, 0) === value; +} + +// --- Project mapping -------------------------------------------------------- + +function escapeRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function stripTrailingSlashes(p) { + return p.length > 1 ? p.replace(/\/+$/, "") : p; +} + +// Project name from a transcript line's `cwd`, rules applied in order: +// 1. /claude-code//... -> (.spikes and .archive -> /) +// 2. /claude-code exactly -> (monorepo) +// 3. /tmp/spike-defenseur-auto-* -> defenseurs +// 4. /tmp/claude-/-claude-code-

/... ->

+// 5. anything else -> (autre) +function mapProject(cwd, homedir) { + if (typeof cwd !== "string" || cwd === "") return UNKNOWN_PROJECT; + const dir = stripTrailingSlashes(cwd); + const home = stripTrailingSlashes(String(homedir || "")); + const monorepo = `${home}/claude-code`; + + if (home && dir.startsWith(`${monorepo}/`)) { + const parts = dir.slice(monorepo.length + 1).split("/"); + const a = parts[0]; + if ((a === ".spikes" || a === ".archive") && parts[1]) { + return `${a}/${parts[1]}`.slice(0, MAX_STRING); + } + return a.slice(0, MAX_STRING); + } + if (home && dir === monorepo) return MONOREPO_PROJECT; + if (dir.startsWith("/tmp/spike-defenseur-auto-")) return DEFENSEUR_AUTO_PROJECT; + + if (home) { + // Claude Code names its per-project temp dir after the cwd, slashes turned + // into dashes: /home/max/claude-code/foo -> -home-max-claude-code-foo. + const slug = `${home.replace(/\//g, "-")}-claude-code-`; + const re = new RegExp(`^/tmp/claude-\\d+/${escapeRe(slug)}([^/]+)(?:/|$)`); + const m = re.exec(dir); + if (m) return m[1].slice(0, MAX_STRING); + } + return UNKNOWN_PROJECT; +} + +// --- Counters --------------------------------------------------------------- + +function count(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : 0; +} + +function emptyModel() { + return { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, requests: 0 }; +} + +function emptyProject() { + return { work: 0, cacheRead: 0, requests: 0 }; +} + +function addModel(target, src) { + target.input += count(src.input); + target.output += count(src.output); + target.cacheCreation += count(src.cacheCreation); + target.cacheRead += count(src.cacheRead); + target.requests += count(src.requests); +} + +function addProject(target, src) { + target.work += count(src.work); + target.cacheRead += count(src.cacheRead); + target.requests += count(src.requests); +} + +function modelWork(m) { + return m.input + m.output + m.cacheCreation; +} + +// At most MAX_MODELS_PER_DAY keys: beyond that, the least consuming models +// (by work tokens, then cache reads, then name for a stable order) are merged +// into `other`. Idempotent: a day already capped comes back unchanged. +function capModels(models) { + const names = Object.keys(models).filter((k) => k !== OTHER_MODEL); + const hasOther = Object.prototype.hasOwnProperty.call(models, OTHER_MODEL); + if (names.length + (hasOther ? 1 : 0) <= MAX_MODELS_PER_DAY) return models; + + names.sort((a, b) => { + const ma = models[a]; + const mb = models[b]; + return ( + modelWork(mb) - modelWork(ma) || + mb.cacheRead - ma.cacheRead || + (a < b ? -1 : a > b ? 1 : 0) + ); + }); + const out = {}; + for (const name of names.slice(0, MAX_MODELS_PER_DAY - 1)) out[name] = models[name]; + const other = emptyModel(); + if (hasOther) addModel(other, models[OTHER_MODEL]); + for (const name of names.slice(MAX_MODELS_PER_DAY - 1)) addModel(other, models[name]); + out[OTHER_MODEL] = other; + return out; +} + +// Frozen days come from the agent's state file: re-read through the same +// counters, never trusted verbatim. +function normalizeDay(day) { + const models = {}; + const projects = {}; + const srcModels = day && typeof day.models === "object" && day.models ? day.models : {}; + const srcProjects = + day && typeof day.projects === "object" && day.projects ? day.projects : {}; + for (const [name, m] of Object.entries(srcModels)) { + if (!m || typeof m !== "object") continue; + const key = MODEL_KEY_RE.test(name) ? name : OTHER_MODEL; + if (!models[key]) models[key] = emptyModel(); + addModel(models[key], m); + } + for (const [name, p] of Object.entries(srcProjects)) { + if (!p || typeof p !== "object") continue; + const key = name.slice(0, MAX_STRING); + if (!projects[key]) projects[key] = emptyProject(); + addProject(projects[key], p); + } + return { models, projects }; +} + +// --- Transcript scan -------------------------------------------------------- + +// Every *.jsonl under root, recursively (subagents/ included). Symlinks are +// neither followed nor read. A missing root is an empty scan, not an error. +async function listTranscripts(root) { + const out = []; + const stack = [root]; + while (stack.length) { + const dir = stack.pop(); + let entries; + try { + entries = await fs.promises.readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) stack.push(full); + else if (e.isFile() && e.name.endsWith(".jsonl")) out.push(full); + } + } + out.sort(); + return out; +} + +// Calls onLine for each line of the file. Resolves once the file is consumed; +// a read error (file removed mid-run, permission) resolves too: one unreadable +// transcript must not sink the whole snapshot. +function readLines(file, openFile, onLine) { + return new Promise((resolve) => { + let stream; + try { + stream = openFile(file, { encoding: "utf-8" }); + } catch { + resolve(); + return; + } + let done = false; + let rl = null; + const finish = () => { + if (done) return; + done = true; + if (rl) rl.close(); + if (typeof stream.destroy === "function") stream.destroy(); + resolve(); + }; + // Listen on both: readline forwards the input's error to the interface, + // and an unlistened 'error' there is an uncaught exception that kills the + // process (and the interface then never emits 'close'). + stream.on("error", finish); + rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + rl.on("error", finish); + rl.on("line", onLine); + rl.on("close", finish); + }); +} + +/** + * Aggregate the transcripts under `root`. + * + * @param {object} opts + * @param {string} opts.root transcripts root (~/.claude/projects) + * @param {object} [opts.frozenDays] { "YYYY-MM-DD": { models, projects } } — never recomputed + * @param {string} [opts.today] Toronto date "YYYY-MM-DD" (default: now) + * @param {string} [opts.homedir] home used by the project mapping (default: os.homedir()) + * @param {Function} [opts.openFile] (path, options) => Readable — injectable for tests + * @returns {Promise<{ + * openDays: Object, + * projects: Object }>, + * days: Array<{ date: string, models: Object }>, + * projects: Array<{ name: string, work: number, cacheRead: number, requests: number }>, + * claudeCodeVersion: string | null, + * }>} + */ +async function aggregate({ + root, + frozenDays = {}, + today = torontoDay(Date.now()), + homedir = os.homedir(), + openFile = (file, options) => fs.createReadStream(file, options), +} = {}) { + if (!isDay(today)) throw new Error("aggregate: today must be a YYYY-MM-DD date"); + + const frozen = {}; + if (frozenDays && typeof frozenDays === "object") { + for (const [day, value] of Object.entries(frozenDays)) { + if (isDay(day)) frozen[day] = value; + } + } + + const window = windowDays(today); + const windowStart = window[0]; + const openDayList = window.filter((d) => !Object.prototype.hasOwnProperty.call(frozen, d)); + + // Scan threshold: the day after the most recent frozen day. Floored at the + // window start — a file last written before the window cannot hold a line + // inside it. + const frozenList = Object.keys(frozen).sort(); + let threshold = windowStart; + if (frozenList.length) { + const afterFrozen = shiftDay(frozenList[frozenList.length - 1], 1); + if (afterFrozen > threshold) threshold = afterFrozen; + } + + // (message.id, requestId) -> best line so far. + const requests = new Map(); + let anonymous = 0; + let latest = null; // { ts, version } + + const onLine = (line) => { + // Cheap prefilter: a useful line always carries "usage". Saves a + // JSON.parse on the user/tool lines, the bulk of the ~210 MB. + if (!line.includes('"usage"')) return; + let obj; + try { + obj = JSON.parse(line); + } catch { + return; // truncated or garbled line: skipped + } + if (!obj || obj.type !== "assistant") return; + const message = obj.message; + if (!message || typeof message !== "object") return; + const usage = message.usage; + if (!usage || typeof usage !== "object") return; + + const ts = typeof obj.timestamp === "string" ? Date.parse(obj.timestamp) : NaN; + if (!Number.isFinite(ts)) return; + + if (typeof obj.version === "string" && obj.version && (!latest || ts > latest.ts)) { + latest = { ts, version: obj.version.slice(0, MAX_STRING) }; + } + + const model = message.model; + if (model === SYNTHETIC_MODEL) return; + + const day = torontoDay(ts); + if (day < windowStart || day > today) return; + if (Object.prototype.hasOwnProperty.call(frozen, day)) return; + + const entry = { + day, + model: typeof model === "string" && MODEL_KEY_RE.test(model) ? model : OTHER_MODEL, + project: mapProject(obj.cwd, homedir), + input: count(usage.input_tokens), + output: count(usage.output_tokens), + cacheCreation: count(usage.cache_creation_input_tokens), + cacheRead: count(usage.cache_read_input_tokens), + }; + + const id = typeof message.id === "string" ? message.id : ""; + const reqId = typeof obj.requestId === "string" ? obj.requestId : ""; + // A line without its identifiers cannot be matched with its duplicates: + // it counts as its own request rather than being dropped. + const key = id && reqId ? `${id}\u0000${reqId}` : `\u0001${anonymous++}`; + const prev = requests.get(key); + if (!prev || entry.output > prev.output) requests.set(key, entry); + }; + + for (const file of await listTranscripts(root)) { + let stat; + try { + stat = await fs.promises.stat(file); + } catch { + continue; + } + if (torontoDay(stat.mtimeMs) < threshold) continue; + await readLines(file, openFile, onLine); + } + + // Open days: every non-frozen day of the window, empty ones included, so + // the caller can freeze them all (an unfrozen empty day would hold the + // threshold back and every run would re-read the whole window). + const openDays = {}; + for (const day of openDayList) openDays[day] = { models: {}, projects: {} }; + for (const r of requests.values()) { + const bucket = openDays[r.day]; + if (!bucket.models[r.model]) bucket.models[r.model] = emptyModel(); + addModel(bucket.models[r.model], { ...r, requests: 1 }); + if (!bucket.projects[r.project]) bucket.projects[r.project] = emptyProject(); + addProject(bucket.projects[r.project], { + work: r.input + r.output + r.cacheCreation, + cacheRead: r.cacheRead, + requests: 1, + }); + } + + // Merged 30-day series and project totals over the window. + const days = []; + const projectTotals = {}; + for (const date of window) { + const source = Object.prototype.hasOwnProperty.call(frozen, date) + ? normalizeDay(frozen[date]) + : openDays[date]; + days.push({ date, models: capModels(source.models) }); + for (const [name, p] of Object.entries(source.projects)) { + if (!projectTotals[name]) projectTotals[name] = emptyProject(); + addProject(projectTotals[name], p); + } + } + + return { + openDays, + days, + projects: topProjects(projectTotals), + claudeCodeVersion: latest ? latest.version : null, + }; +} + +// Top TOP_PROJECTS by work tokens over the window, then one "(autres)" entry +// aggregating the rest when it has any volume. The sum of `work` over the +// returned list always equals the window total. +function topProjects(totals) { + const list = Object.entries(totals) + .map(([name, p]) => ({ name, work: p.work, cacheRead: p.cacheRead, requests: p.requests })) + .filter((p) => p.work > 0 || p.cacheRead > 0 || p.requests > 0) + .sort( + (a, b) => + b.work - a.work || + b.cacheRead - a.cacheRead || + (a.name < b.name ? -1 : a.name > b.name ? 1 : 0), + ); + const top = list.slice(0, TOP_PROJECTS); + const rest = list.slice(TOP_PROJECTS); + if (rest.length) { + const other = { name: OTHER_PROJECTS, work: 0, cacheRead: 0, requests: 0 }; + for (const p of rest) addProject(other, p); + top.push(other); + } + return top; +} + +module.exports = { + aggregate, + mapProject, + torontoDay, + shiftDay, + windowDays, + capModels, + topProjects, + listTranscripts, + TIME_ZONE, + WINDOW_DAYS, + TOP_PROJECTS, + MAX_MODELS_PER_DAY, + OTHER_MODEL, + OTHER_PROJECTS, +};