feat(agent): aggregate Claude usage from local transcripts (#49) #51
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/vps-health-api#51
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-49-claude-usage-aggregate"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Resolves #49. Adds
agent/claude-usage/aggregate.js, the pure aggregation module of theclaude-usageagent: it reads the Claude Code transcripts (~/.claude/projects/**/*.jsonl, subagents included) and produces the 30-day per-model series and the top projects of theschemaVersion: 1contract. No network, no runtime dependency, nothing written.openDays:{ "YYYY-MM-DD": { models: {<model>: {input, output, cacheCreation, cacheRead, requests}}, projects: {<name>: {work, cacheRead, requests}} } }for every non-frozen window day, empty days included (so the agent can freeze them all).days: exactly 30{ date, models }, oldest first, newest = today; frozen days served fromfrozenDays; at most 12 models per day (rest folded intoother).projects: top 8 by work tokens (input + output + cacheCreation) over the window, then(autres)when anything remains; the sum ofworkequals the window total.claudeCodeVersion:versionof the newest line read, ornull.Also exported:
mapProject,torontoDay,shiftDay,windowDays,capModels,topProjects,listTranscriptsand the constants.Decisions
daysonly;openDayskeeps the raw per-model counts for freezing.capModels()is idempotent.^[a-z0-9][a-z0-9.\-]{0,63}$) folds intootherrather than being dropped by the server, so volume and the projects/series totals stay consistent.message.idorrequestIdcounts as its own request (measured presence is 100 %, fallback only).erroris an uncaught exception.Tests
__tests__/agent-claude-usage.test.js: 38 cases (dedup across session/subagents, synthetic/user/truncated lines, 23:59 vs 00:01 Toronto incl. both midnights around the 2026-11-01 DST change, one case per mapping rule, 30-entry series, top 8 +(autres)sums, 12-model cap, spy on file opening below the threshold, both injected andvi.spyOn(fs, "createReadStream")).__tests__/fixtures/claude-usage/; mtimes set withutimesSyncon a temp copy.npm test: 415 passed (9 files).bash test-curl.sh: 45 passed.sh scripts/boot-from-copy.sh: PASS.Generated autonomously by /autopilot run of 2026-10-04
🤖 Generated with Claude Code
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>Self-review (autopilot)
Diff
git diff main...HEADchecked against every acceptance criterion of #49:subagents/count once, at the largestoutput_tokens(fixture: 8 / 1024 in the session, 2048 / 512 in the subagent -> 2048, 1 request).<synthetic>,userlines (even with ausageobject) and truncated JSON ignored without exception..spikes/.archive, 2, 3, 4, 5), homedir injected.(autres): sum ofworkequals the window total, cross-checked against the series.vi.spyOn(fs, 'createReadStream')), boundary checked at Oct 1 23:30 vs Oct 2 00:30 Toronto.Found and fixed during review: a
cwdwith a doubled slash yielded an empty project name (d7f209f). Found while testing: readline re-emits a stream error on the interface (uncaught exception); both are now listened, an unreadable transcript is skipped.Out of scope, noted:
agent/claude-usage/is not in the DockerfileCOPY(workstation code, asagent/); docs are left to #48/#50.Verdict: ready for human review. npm test 415/415, test-curl.sh 45/45, boot-from-copy PASS.
d7f209f0cato5cb9182911Orchestrateur autopilot : branche rebasée sur
issue-48-claude-usage-routes(PR #52) et base de la PR retargetée, pour former la pile #52 → #51 → (#50). Rebase sans conflit (fichiers disjoints). Tip cumulé validé : vitest 538/538, test-curl 45/45, boot-from-copy PASS. Nouveau head :5cb9182.Verdict : REQUEST_CHANGES
Module propre et bien teste dans l'ensemble (dates en chaines, dedup inter-fichiers,
readlineprotege contre l'erreur d'entree, compteurs re-valides). Deux defauts de correction, tous deux reproduits sur une copie hors depot de la tete5cb9182.Issues
agent/claude-usage/aggregate.js:311-316: le seuil de scan suppose des jours geles contigus et anterieurs atoday. Le seuil vaut « jour apres le plus recent jour gele », calcule sur toutes les cles defrozenDays, meme hors fenetre ou dans le futur. Tout jour ouvert anterieur a ce seuil est alors rendu vide dansopenDayssans qu'aucun fichier ne soit lu, et l'appelant (#50) le gelera vide pour de bon. Reproduit :today = 2026-10-04: sans gel,openDays["2026-10-03"]compte la requete ;frozenDays = {"2026-11-15": …}(horloge du ThinkPad en avance un jour, etat d'une autre machine) :openDays["2026-10-03"].modelsvaut{};frozenDays = {"2026-10-04": …}(un trou : today gele, la veille ouverte) :{}aussi.Le seuil correct par construction est le plus ancien jour ouvert (
openDayList[0], rien a lire si la liste est vide) : un fichier dont le mtime precede le premier jour ouvert ne peut contenir aucune ligne d'un jour ouvert, quel que soit l'agencement des jours geles. Sur un etat contigu, il coincide avec la regle actuelle (meme gain de perf). Mutation faite : cette regle fait tomber 2 cas sur 38, les deux du blocfrozen days, parce que leur fixtureFROZENne gele que 09-30 et 10-01 et laisse 09-05..09-29 ouverts : le test epingle le trou (old.jsonl, mtime 09-20, doit rester ferme alors que le 09-20 est un jour ouvert rendu vide). A corriger avec le code : geler toute la fenetre jusqu'au 10-01 dans la fixture, et ajouter un cas « jour gele futur » et un cas « trou ».aggregate.js:387-410etnormalizeDay()(:194-210) : cles de projet sur objets litteraux{}. Uncwd<home>/claude-code/__proto__ou<home>/claude-code/constructor(ou une cle__proto__dans l'etat gele relu parJSON.parse) fait quebucket.projects[name]renvoieObject.prototype/Object, qui est truthy :addProjectecrit alorswork,cacheRead,requests=NaNsurObject.prototypeet la consommation du projet disparait du top (Object.entriesne la voit pas). Reproduit : apres un passage,({}).work === NaNetObject.work === NaN, les deux projets absents deprojects. Les noms de modeles sont epargnes parMODEL_KEY_RE(pas de_), maistoString/valueOfcomme nom de dossier le sont aussi pour les projets. Le CLAUDE.md du depot pose deja cette regle cote serveur (« une cle__proto__… ne doit jamais arriver la ») ; l'agent qui produit le payload doit la tenir aussi. Correctif :Object.create(null)pouropenDays[*].projects,projectTotalset les deux objets denormalizeDay()(ou desMap), plus un cas de test avec__proto__etconstructorencwdet en cle d'etat gele, qui verifie aussi({}).work === undefinedapres le passage.Suggestions (non bloquantes)
aggregate.js:349-351: le filtre « jour gele » passe avant la dedup. Une requete dont les lignes chevauchent minuit (premiere ligne 23:59:59 sur un jour gele, ligne finale 00:00:01) est comptee une seconde fois sur le jour ouvert. Marginal ; dedupliquer d'abord, puis filtrer sur le jour de la ligne retenue, le ferme.Verifications faites
npx vitest runsur la tete extraite (git archive) dans le scratchpad : 538/538 verts.output» -> 3 rouges ; seuil mtime retire -> 3 rouges. Copie restauree puis supprimee (rm -rf), aucun fichier du depot touche.Resolves #49dans les deux messages ; la timeline de #49 porte unclosesvenant de cette PR.Correctif des deux points bloquants de la revue (commit
c888f7c) :openDayList[0], ou lendemain d aujourd hui si tout est fige), plus sur la derniere cle figee. FixtureFROZENcorrigee pour ce que l agent fournit reellement (serie figee contigue depuis le debut de la fenetre). Nouveaux cas : jour fige futur, trou dans la serie figee, tout fige.dict()=Object.create(null). Nouveaux cas : projets__proto__/constructoren transcript et en jour fige, avec controle queObject.prototypeetObjectrestent sanswork/cacheRead/...Les 4 nouveaux cas echouent sur l ancien code (verifie par stash) et passent sur le nouveau. Tip de pile : vitest 543/543 sur cette branche, test-curl 45/45, boot-from-copy PASS.
Non traite (non bloquant) : double compte possible d une requete a cheval sur minuit quand le premier jour est fige.
La PR #53 a ete rebasee sur ce commit.
Verdict : APPROVE (re-revue de
c888f7c)Les deux points bloquants de la passe precedente sont corriges par construction, et chaque correctif est epingle : reintroduire l'ancien defaut fait rougir les nouveaux cas.
Points bloquants precedents
aggregate.js:325) :threshold = openDayList[0], ou le lendemain d'aujourd'hui si tout est gele. Un jour gele futur ou un trou ne peut plus vider un jour ouvert. La fixtureFROZENgele desormais une serie contigue depuis le debut de la fenetre, ce qui retire l'epingle du trou signalee. Trois cas ajoutes : jour gele futur, trou, tout gele.dict()=Object.create(null)sur toutes les maps indexees par un nom (jours geles,openDays, modeles et projets,projectTotals,normalizeDay,capModels). Des cas__proto__/constructorcouvrent le transcript et l'etat gele, avec controle deObject.prototypeetObject.Suggestions (non bloquantes)
openDays[*].models/.projectssont maintenant sans prototype : l'appelant (#50) ne doit pas y appelerobj.hasOwnProperty(...)(TypeError), seulementObject.prototype.hasOwnProperty.callouObject.hasOwn.JSON.stringifyles serialise normalement. A garder en tete en revue de #50.Verifications faites
Tete
c888f7cextraite pargit archivedans le scratchpad :npx vitest run543/543 verts.Mutations du relecteur sur la copie (fichier de test cible, 43 cas) :
dict()rendu en{}: 2 rouges ;{}dansnormalizeDayseul : 1 rouge ;projectTotals = {}: 2 rouges ;openDays[*].projects = {}: 2 rouges ;windowStart: 1 rouge ;onLine: 2 rouges.Le fichier a ete restaure apres chaque mutation (
cmpidentique), puis la copie supprimee.Commits conventionnels,
Resolves #49; la timeline de #49 porte unclosesvenant de #51 (etnonede #53).CI de la tete encore
pendingau moment de la revue ; le verdict repose sur la suite et les mutations rejouees localement.