feat(agent): aggregate Claude usage from local transcripts (#49) #51
2 changed files with 155 additions and 27 deletions
|
|
@ -431,16 +431,20 @@ describe("at most 12 models per day", () => {
|
||||||
// --- Frozen days and the scan threshold ----------------------------------------
|
// --- Frozen days and the scan threshold ----------------------------------------
|
||||||
|
|
||||||
describe("frozen days", () => {
|
describe("frozen days", () => {
|
||||||
const FROZEN = {
|
// What the agent hands over: a contiguous frozen run from the window start
|
||||||
"2026-10-01": {
|
// 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 } },
|
models: { "claude-opus-5-5": { input: 7, output: 7, cacheCreation: 7, cacheRead: 7, requests: 7 } },
|
||||||
projects: { alpha: { work: 21, 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 () => {
|
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.
|
// Threshold = oldest open day = day after the frozen run = 2026-10-02.
|
||||||
const old = writeTranscript(
|
const old = writeTranscript(
|
||||||
"a/old.jsonl",
|
"a/old.jsonl",
|
||||||
[line({ ts: "2026-10-02T15:00:00Z", output: 5 })], // would count if opened
|
[line({ ts: "2026-10-02T15:00:00Z", output: 5 })], // would count if opened
|
||||||
|
|
@ -544,10 +548,78 @@ describe("frozen days", () => {
|
||||||
expect(dayOf(r, "2026-09-30").models).toEqual({});
|
expect(dayOf(r, "2026-09-30").models).toEqual({});
|
||||||
expect(r.openDays["2026-10-01"]).toBeUndefined();
|
expect(r.openDays["2026-10-01"]).toBeUndefined();
|
||||||
expect(r.openDays["2026-09-30"]).toBeUndefined();
|
expect(r.openDays["2026-09-30"]).toBeUndefined();
|
||||||
expect(Object.keys(r.openDays)).toHaveLength(28);
|
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 }]);
|
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 () => {
|
it("re-reads frozen counters defensively (bad numbers -> 0, junk ignored)", async () => {
|
||||||
const r = await aggregate({
|
const r = await aggregate({
|
||||||
root: tmpDir,
|
root: tmpDir,
|
||||||
|
|
@ -634,3 +706,50 @@ describe("robustness", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,13 @@
|
||||||
// are deduplicated on (message.id, requestId) across ALL files, keeping the
|
// are deduplicated on (message.id, requestId) across ALL files, keeping the
|
||||||
// line with the largest output_tokens.
|
// line with the largest output_tokens.
|
||||||
// * THE SCAN THRESHOLD. Frozen days are never recomputed. A file whose mtime
|
// * 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
|
// (Toronto day) is before the oldest open day of the window cannot hold a
|
||||||
// hold a line on an open day, so it is not even opened: this is what keeps
|
// line on an open day, so it is not even opened: this is what keeps a run
|
||||||
// a run from re-reading ~210 MB of transcripts every time.
|
// 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 fs = require("node:fs");
|
||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
|
|
@ -136,6 +140,13 @@ function count(value) {
|
||||||
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
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() {
|
function emptyModel() {
|
||||||
return { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, requests: 0 };
|
return { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, requests: 0 };
|
||||||
}
|
}
|
||||||
|
|
@ -179,7 +190,7 @@ function capModels(models) {
|
||||||
(a < b ? -1 : a > b ? 1 : 0)
|
(a < b ? -1 : a > b ? 1 : 0)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const out = {};
|
const out = dict();
|
||||||
for (const name of names.slice(0, MAX_MODELS_PER_DAY - 1)) out[name] = models[name];
|
for (const name of names.slice(0, MAX_MODELS_PER_DAY - 1)) out[name] = models[name];
|
||||||
const other = emptyModel();
|
const other = emptyModel();
|
||||||
if (hasOther) addModel(other, models[OTHER_MODEL]);
|
if (hasOther) addModel(other, models[OTHER_MODEL]);
|
||||||
|
|
@ -191,8 +202,8 @@ function capModels(models) {
|
||||||
// Frozen days come from the agent's state file: re-read through the same
|
// Frozen days come from the agent's state file: re-read through the same
|
||||||
// counters, never trusted verbatim.
|
// counters, never trusted verbatim.
|
||||||
function normalizeDay(day) {
|
function normalizeDay(day) {
|
||||||
const models = {};
|
const models = dict();
|
||||||
const projects = {};
|
const projects = dict();
|
||||||
const srcModels = day && typeof day.models === "object" && day.models ? day.models : {};
|
const srcModels = day && typeof day.models === "object" && day.models ? day.models : {};
|
||||||
const srcProjects =
|
const srcProjects =
|
||||||
day && typeof day.projects === "object" && day.projects ? day.projects : {};
|
day && typeof day.projects === "object" && day.projects ? day.projects : {};
|
||||||
|
|
@ -294,7 +305,7 @@ async function aggregate({
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!isDay(today)) throw new Error("aggregate: today must be a YYYY-MM-DD date");
|
if (!isDay(today)) throw new Error("aggregate: today must be a YYYY-MM-DD date");
|
||||||
|
|
||||||
const frozen = {};
|
const frozen = dict();
|
||||||
if (frozenDays && typeof frozenDays === "object") {
|
if (frozenDays && typeof frozenDays === "object") {
|
||||||
for (const [day, value] of Object.entries(frozenDays)) {
|
for (const [day, value] of Object.entries(frozenDays)) {
|
||||||
if (isDay(day)) frozen[day] = value;
|
if (isDay(day)) frozen[day] = value;
|
||||||
|
|
@ -305,15 +316,13 @@ async function aggregate({
|
||||||
const windowStart = window[0];
|
const windowStart = window[0];
|
||||||
const openDayList = window.filter((d) => !Object.prototype.hasOwnProperty.call(frozen, d));
|
const openDayList = window.filter((d) => !Object.prototype.hasOwnProperty.call(frozen, d));
|
||||||
|
|
||||||
// Scan threshold: the day after the most recent frozen day. Floored at the
|
// Scan threshold: the oldest open day of the window. With the agent's
|
||||||
// window start — a file last written before the window cannot hold a line
|
// contiguous frozen prefix this is the day after the most recent frozen day;
|
||||||
// inside it.
|
// computed from the open days rather than from the frozen keys, so a frozen
|
||||||
const frozenList = Object.keys(frozen).sort();
|
// day in the future or a gap in the frozen run can never push the threshold
|
||||||
let threshold = windowStart;
|
// past an open day and silently empty it. A file last written before the
|
||||||
if (frozenList.length) {
|
// window cannot hold a line inside it. Every day frozen: nothing to read.
|
||||||
const afterFrozen = shiftDay(frozenList[frozenList.length - 1], 1);
|
const threshold = openDayList.length ? openDayList[0] : shiftDay(today, 1);
|
||||||
if (afterFrozen > threshold) threshold = afterFrozen;
|
|
||||||
}
|
|
||||||
|
|
||||||
// (message.id, requestId) -> best line so far.
|
// (message.id, requestId) -> best line so far.
|
||||||
const requests = new Map();
|
const requests = new Map();
|
||||||
|
|
@ -383,8 +392,8 @@ async function aggregate({
|
||||||
// Open days: every non-frozen day of the window, empty ones included, so
|
// 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
|
// the caller can freeze them all (an unfrozen empty day would hold the
|
||||||
// threshold back and every run would re-read the whole window).
|
// threshold back and every run would re-read the whole window).
|
||||||
const openDays = {};
|
const openDays = dict();
|
||||||
for (const day of openDayList) openDays[day] = { models: {}, projects: {} };
|
for (const day of openDayList) openDays[day] = { models: dict(), projects: dict() };
|
||||||
for (const r of requests.values()) {
|
for (const r of requests.values()) {
|
||||||
const bucket = openDays[r.day];
|
const bucket = openDays[r.day];
|
||||||
if (!bucket.models[r.model]) bucket.models[r.model] = emptyModel();
|
if (!bucket.models[r.model]) bucket.models[r.model] = emptyModel();
|
||||||
|
|
@ -399,7 +408,7 @@ async function aggregate({
|
||||||
|
|
||||||
// Merged 30-day series and project totals over the window.
|
// Merged 30-day series and project totals over the window.
|
||||||
const days = [];
|
const days = [];
|
||||||
const projectTotals = {};
|
const projectTotals = dict();
|
||||||
for (const date of window) {
|
for (const date of window) {
|
||||||
const source = Object.prototype.hasOwnProperty.call(frozen, date)
|
const source = Object.prototype.hasOwnProperty.call(frozen, date)
|
||||||
? normalizeDay(frozen[date])
|
? normalizeDay(frozen[date])
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue