feat(agent): aggregate Claude usage from local transcripts (#49) #51
4 changed files with 1235 additions and 0 deletions
755
__tests__/agent-claude-usage.test.js
Normal file
755
__tests__/agent-claude-usage.test.js
Normal file
|
|
@ -0,0 +1,755 @@
|
|||
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: <home>/claude-code/<a>/... -> <a>", () => {
|
||||
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");
|
||||
// A doubled slash never yields an empty project name.
|
||||
expect(mapProject(`${HOME}/claude-code//simpl-liste/app`, HOME)).toBe("simpl-liste");
|
||||
expect(mapProject(`${HOME}//claude-code`, HOME)).toBe("(monorepo)");
|
||||
});
|
||||
|
||||
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: <home>/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-<uid>/<home slug>-claude-code-<p>/... -> <p>", () => {
|
||||
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 <synthetic>, 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("<synthetic>");
|
||||
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", () => {
|
||||
// What the agent hands over: a contiguous frozen run from the window start
|
||||
// through 2026-10-01, empty days included.
|
||||
const FROZEN = {};
|
||||
for (const day of windowDays("2026-10-04")) {
|
||||
if (day > "2026-10-01") break;
|
||||
FROZEN[day] = { models: {}, projects: {} };
|
||||
}
|
||||
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 } },
|
||||
};
|
||||
|
||||
it("opens no file whose mtime day is before the threshold (spy on file opening)", async () => {
|
||||
// Threshold = oldest open day = day after the frozen run = 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)).toEqual(["2026-10-02", "2026-10-03", "2026-10-04"]);
|
||||
expect(r.projects).toEqual([{ name: "alpha", work: 21 + 1 + 3, cacheRead: 7, requests: 8 }]);
|
||||
});
|
||||
|
||||
it("a frozen day in the future never hides an open day's files", async () => {
|
||||
const file = writeTranscript(
|
||||
"a/open.jsonl",
|
||||
[line({ ts: "2026-10-03T15:00:00Z", output: 3 })],
|
||||
"2026-10-03T16:00:00Z",
|
||||
);
|
||||
const opened = [];
|
||||
const r = await aggregate({
|
||||
root: tmpDir,
|
||||
today: "2026-10-04",
|
||||
homedir: HOME,
|
||||
frozenDays: { "2026-11-15": { models: {}, projects: {} } },
|
||||
openFile: (f, o) => {
|
||||
opened.push(f);
|
||||
return fs.createReadStream(f, o);
|
||||
},
|
||||
});
|
||||
expect(opened).toEqual([file]);
|
||||
expect(r.openDays["2026-10-03"].models["claude-opus-5-5"].output).toBe(3);
|
||||
});
|
||||
|
||||
it("a gap in the frozen run keeps the open day before the gap scanned", async () => {
|
||||
// 10-03 open, 10-04 (today) frozen: the oldest open day sets the threshold.
|
||||
const file = writeTranscript(
|
||||
"a/open.jsonl",
|
||||
[line({ ts: "2026-10-03T15:00:00Z", output: 3 })],
|
||||
"2026-10-03T16:00:00Z",
|
||||
);
|
||||
const frozenDays = {};
|
||||
for (const day of windowDays("2026-10-04")) {
|
||||
if (day !== "2026-10-03") frozenDays[day] = { models: {}, projects: {} };
|
||||
}
|
||||
const opened = [];
|
||||
const r = await aggregate({
|
||||
root: tmpDir,
|
||||
today: "2026-10-04",
|
||||
homedir: HOME,
|
||||
frozenDays,
|
||||
openFile: (f, o) => {
|
||||
opened.push(f);
|
||||
return fs.createReadStream(f, o);
|
||||
},
|
||||
});
|
||||
expect(opened).toEqual([file]);
|
||||
expect(Object.keys(r.openDays)).toEqual(["2026-10-03"]);
|
||||
expect(r.openDays["2026-10-03"].models["claude-opus-5-5"].output).toBe(3);
|
||||
});
|
||||
|
||||
it("every day frozen: no file is opened", async () => {
|
||||
writeTranscript("a/x.jsonl", [line({ ts: "2026-10-04T15:00:00Z" })], "2026-10-04T16:00:00Z");
|
||||
const frozenDays = {};
|
||||
for (const day of windowDays("2026-10-04")) frozenDays[day] = { models: {}, projects: {} };
|
||||
const opened = [];
|
||||
const r = await aggregate({
|
||||
root: tmpDir,
|
||||
today: "2026-10-04",
|
||||
homedir: HOME,
|
||||
frozenDays,
|
||||
openFile: (f, o) => {
|
||||
opened.push(f);
|
||||
return fs.createReadStream(f, o);
|
||||
},
|
||||
});
|
||||
expect(opened).toEqual([]);
|
||||
expect(Object.keys(r.openDays)).toEqual([]);
|
||||
expect(r.days).toHaveLength(WINDOW_DAYS);
|
||||
});
|
||||
|
||||
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)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Names that collide with Object.prototype ------------------------------------
|
||||
|
||||
describe("prototype-shaped names", () => {
|
||||
const PROTO_KEYS = ["work", "cacheRead", "requests", "input", "output", "cacheCreation"];
|
||||
|
||||
function prototypeIsClean() {
|
||||
for (const k of PROTO_KEYS) {
|
||||
expect(Object.prototype.hasOwnProperty.call(Object.prototype, k)).toBe(false);
|
||||
expect(Object.prototype.hasOwnProperty.call(Object, k)).toBe(false);
|
||||
}
|
||||
}
|
||||
|
||||
it("projects named __proto__ and constructor are counted, Object.prototype untouched", async () => {
|
||||
writeTranscript(
|
||||
"a/x.jsonl",
|
||||
[
|
||||
line({ ts: "2026-10-03T15:00:00Z", cwd: `${HOME}/claude-code/__proto__`, output: 5 }),
|
||||
line({ ts: "2026-10-03T16:00:00Z", cwd: `${HOME}/claude-code/constructor`, output: 7 }),
|
||||
line({ ts: "2026-10-03T17:00:00Z", model: "constructor", output: 2 }),
|
||||
],
|
||||
"2026-10-04T12:00:00Z",
|
||||
);
|
||||
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME });
|
||||
prototypeIsClean();
|
||||
const byName = Object.fromEntries(r.projects.map((p) => [p.name, p]));
|
||||
expect(Object.keys(byName).sort()).toEqual(["__proto__", "alpha", "constructor"].sort());
|
||||
expect(Object.getOwnPropertyDescriptor(byName, "__proto__").value.work).toBe(1 + 5);
|
||||
expect(byName.constructor.work).toBe(1 + 7);
|
||||
expect(dayOf(r, "2026-10-03").models.constructor.output).toBe(2);
|
||||
expect(JSON.parse(JSON.stringify(r.days)).length).toBe(WINDOW_DAYS);
|
||||
});
|
||||
|
||||
it("a __proto__ key in a frozen day stays a plain project", async () => {
|
||||
const frozenDays = JSON.parse(
|
||||
'{"2026-10-01":{"models":{},"projects":{"__proto__":{"work":4,"cacheRead":0,"requests":1},"constructor":{"work":6,"cacheRead":0,"requests":1}}}}',
|
||||
);
|
||||
const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME, frozenDays });
|
||||
prototypeIsClean();
|
||||
expect(r.projects.map((p) => [p.name, p.work]).sort()).toEqual(
|
||||
[
|
||||
["__proto__", 4],
|
||||
["constructor", 6],
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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":"<synthetic>","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"}
|
||||
|
|
@ -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
|
||||
469
agent/claude-usage/aggregate.js
Normal file
469
agent/claude-usage/aggregate.js
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
// 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 oldest open day of the window 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. The threshold comes
|
||||
// from the open days, never from the frozen keys: a stray frozen day (in
|
||||
// the future, or after a gap) must not hide an open day's files.
|
||||
// * NO PROTOTYPE. Names read from transcripts or the state file key plain
|
||||
// maps built by dict(), so `__proto__` or `constructor` stay ordinary keys.
|
||||
|
||||
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 = "<synthetic>";
|
||||
|
||||
// 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, "\\$&");
|
||||
}
|
||||
|
||||
// Collapses repeated slashes and drops trailing ones, so "<home>/claude-code//x"
|
||||
// cannot yield an empty project name.
|
||||
function stripTrailingSlashes(p) {
|
||||
const collapsed = p.replace(/\/{2,}/g, "/");
|
||||
return collapsed.length > 1 ? collapsed.replace(/\/+$/, "") : collapsed;
|
||||
}
|
||||
|
||||
// Project name from a transcript line's `cwd`, rules applied in order:
|
||||
// 1. <home>/claude-code/<a>/... -> <a> (.spikes and .archive -> <a>/<b>)
|
||||
// 2. <home>/claude-code exactly -> (monorepo)
|
||||
// 3. /tmp/spike-defenseur-auto-* -> defenseurs
|
||||
// 4. /tmp/claude-<uid>/<home-slug>-claude-code-<p>/... -> <p>
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Every map keyed by a name read from a transcript or the state file (model,
|
||||
// project, day) has no prototype: a project named `__proto__` or
|
||||
// `constructor` is then a plain key, never a write into Object.prototype.
|
||||
function dict() {
|
||||
return Object.create(null);
|
||||
}
|
||||
|
||||
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 = dict();
|
||||
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 = dict();
|
||||
const projects = dict();
|
||||
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<string, { models: Object<string, {input, output, cacheCreation, cacheRead, requests}>,
|
||||
* projects: Object<string, {work, cacheRead, requests}> }>,
|
||||
* days: Array<{ date: string, models: Object<string, {input, output, cacheCreation, cacheRead, requests}> }>,
|
||||
* 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 = dict();
|
||||
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 oldest open day of the window. With the agent's
|
||||
// contiguous frozen prefix this is the day after the most recent frozen day;
|
||||
// computed from the open days rather than from the frozen keys, so a frozen
|
||||
// day in the future or a gap in the frozen run can never push the threshold
|
||||
// past an open day and silently empty it. A file last written before the
|
||||
// window cannot hold a line inside it. Every day frozen: nothing to read.
|
||||
const threshold = openDayList.length ? openDayList[0] : shiftDay(today, 1);
|
||||
|
||||
// (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 = dict();
|
||||
for (const day of openDayList) openDays[day] = { models: dict(), projects: dict() };
|
||||
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 = dict();
|
||||
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,
|
||||
};
|
||||
Loading…
Reference in a new issue