feat(agent): aggregate Claude usage from local transcripts
Pure, dependency-free module for the claude-usage agent. It walks
~/.claude/projects/**/*.jsonl (subagent transcripts included), keeps
assistant lines with message.usage, drops <synthetic> and truncated
lines, deduplicates on (message.id, requestId) across all files at the
largest output_tokens, and buckets by America/Toronto day computed as
strings only (DST-safe).
Returns { openDays, days, projects, claudeCodeVersion }: the recomputed
non-frozen days, the merged 30-day series (at most 12 models per day,
the rest folded into "other"), the top 8 projects by work tokens plus
"(autres)", and the version of the newest line. Files whose mtime day
is before the day after the most recent frozen day are never opened.
Resolves #49
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ef8558e90e
commit
7d55e30ffd
4 changed files with 1101 additions and 0 deletions
633
__tests__/agent-claude-usage.test.js
Normal file
633
__tests__/agent-claude-usage.test.js
Normal file
|
|
@ -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: <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");
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
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)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
457
agent/claude-usage/aggregate.js
Normal file
457
agent/claude-usage/aggregate.js
Normal file
|
|
@ -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 = "<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, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTrailingSlashes(p) {
|
||||||
|
return p.length > 1 ? p.replace(/\/+$/, "") : p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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 = {};
|
||||||
|
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,
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue