vps-health-api/agent/claude-usage/aggregate.js
le king fu 7d55e30ffd 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>
2026-10-04 09:17:56 -04:00

457 lines
16 KiB
JavaScript

// 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,
};