Consommation : tokens et coût équivalent par jour, heure, projet, modèle, session, type d'agent, surface, skill et MCP ; prévision des limites 5 h et semaine…

Now shown inside Cockpit (cards Qui consomme and Quota, Voir → /conso); /conso keeps working.
Read-only pane: what your agents burn (tokens and list-price cost equivalent) and when the plan's 5-hour and weekly windows run out. /conso [aujourdhui|semaine|prevision|scenarios|plein|codex] [refresh]. UI in French.
entrypoint du transcript : onglet Code, CLI, SDK, cloud), effort, skill (attributionSkill) et serveur MCP (attributionMcpServer), top 10 des sessions de la semaine.scenarios à partir de l'historique mesuré (tokens par heure active, par type d'agent et famille de modèle), et lequel tient dans le quota restant avant le reset.min_left_pct % au reset à moins de max_hours_left h de celui-ci, suggère S5 « Plein régime » (Opus high/xhigh partout, parallélisme au plafond du PC) et montre le texte à copier. Le mod ne lance rien./conso classement) : qui consomme le plus. Fenêtres en boutons (1 h · 4 h · aujourd'hui · 2 j · 7 j · 30 j, réglables dans classement.presets), regroupement en boutons (conversation · projet · modèle · type d'agent · fonction · Claude / Codex). Tableau des 20 premiers + « autres » : tokens (entrée, sortie, cache lu, cache écrit), équivalent prix public, part, tendance contre la fenêtre de même longueur juste avant, petite barre. Les sous-agents et agents de Workflow sont comptés dans leur conversation parente, avec la part « sous-agents ». Une ligne de conversation montre son titre et son id avec « Copier l'id » et, si le Fil de veille la connaît, « Ouvrir » (lien claude:// de l'app, même règle que le Fil ; non vérifié). Le total « aujourd'hui » est celui de l'onglet Aujourd'hui (même cache, testé). Les lignes sont par heure d'horloge : « 1 h » couvre les tranches qui touchent la dernière heure. Non disponible : la famille d'outil et les commandes de mod (les transcripts donnent les tokens par requête). Codex : par jour entier, hors total. En ligne de commande : node scanner/usage_scan.mjs classement --preset today --group conversation --top 3 [--json]. Le scanner écrit classement.json (31 jours, ~0,5 Mo) à chaque scan./conso l'ajoute à sa réponse. Quand rien n'est écrit : « non disponible », jamais une estimation. Option codex (config | on | off) et codex.enabled (auto par défaut : actif si le dossier existe)./conso historique) : un enregistrement par jour (tokens et coût, par modèle, projet et type) dans ~/.claude/usage-mod/history.json, écrit à chaque scan et gardé quand les transcripts disparaissent (Claude Code les efface après cleanupPeriodDays, 30 j par défaut ; le scanner n'en garde que retention_days). Fenêtres 30 j · 90 j · 1 an · tout (history.presets), barres par jour, semaine ou mois selon la longueur. Un jour est réécrit tant que le scanner le voit en entier ; un jour ancien qui rétrécit (transcripts effacés) garde son chiffre. Claude seulement./conso debit) : tokens par seconde. Tableau Modèle · Tokens · Temps · TPS pondéré · médiane · plage (p10–p90, tps.range_pct), par modèle ou par modèle et effort, puis les derniers flux « opus-5-5 medium : 87 TPS (669 tokens en 7 s) ». Un flux = une réponse de l'API : de la dernière ligne qui l'a envoyée (prompt ou résultat d'outil) à son dernier bloc ; le temps compte l'attente du premier token, la sortie compte la réflexion. Codex : de task_started ou de la dernière sortie d'outil au token_usage_record de la réponse (tokens de sortie de cette réponse), quand Codex est affiché. Flux de moins de 50 tokens de sortie ou de plus de 30 min exclus. Médiane et plage lues dans des paliers x1,15 (±7 %)./conso, Débit et Classement ne montrent que Claude ; le bouton « + Codex » ajoute Codex (onglet Codex compris), « − Codex » le retire. Défaut réglable : codex.show_default (false)./conso) : « Claude 5 h 24 % · semaine 14 % · épuisé ven 14:00 | Codex 5 h ? · semaine 18 % », lue par lib/core/usage.mjs (node scanner/usage_scan.mjs quotas) : le même lecteur que band et Cockpit, donc les mêmes chiffres partout.| Chiffre | Source | Statut |
|---|---|---|
| Tokens par requête (input, output, cache read, cache write 5 min / 1 h), modèle, heure, cwd, session, effort, skill, MCP | ~/.claude/projects/**/*.jsonl, sous-agents (<session>/subagents/agent-*.jsonl) et agents de Workflow (<session>/subagents/workflows/wf_*/agent-*.jsonl) ; une réponse = message.id + requestId, comptée une fois même quand elle est écrite en plusieurs lignes (une par bloc), copiée dans un fork, une reprise ou un fichier de sous-agent (le uuid de ligne ne sert que s'il n'y a pas de message.id) ; un fichier réécrit est recompté depuis le début | lu sur ce PC |
| % 5 h et semaine, reset | $.session.usage().rateLimits et l'événement session.measure du mod (kind five_hour / seven_day, percentUsed, resetsAt) ; ce sont les champs rate_limits.five_hour / seven_day de la ligne de statut (abonnés Pro et Max, après la première réponse de la session). claude-hud les lit de la même façon (stdin de la ligne de statut). /usage les montre aussi (costs, aide) | officiel |
| Tokens par 1 % | Anthropic ne publie aucun volume de tokens pour Max. Chaque lecture (heure, %, reset) est gardée dans ~/.claude/usage-mod/samples.jsonl ; tokens par 1 % = tokens comptés localement depuis le début de la fenêtre (reset − 7 j) / %, médiane des échantillons | non vérifié : seuls les transcripts de ce PC comptent (une session cloud ou un autre PC fait monter le % sans tokens locaux), et le poids réel de chaque type de token et de chaque modèle dans la limite n'est pas publié. basis = cost (défaut, pondère par le prix public) ou tokens |
| Coût | prix publics claude.com/pricing dans lib/prices.json (écriture cache 1 h = 2 × input, non vérifié pour les modèles 5.x) | estimation, un plan Max ne facture pas au token |
| Sous-conversation | transcript principal dont le premier prompt vient d'une autre session (origin.kind = peer) | non vérifié |
| Débit d'un scénario | débit mesuré par heure active × max_parallel × occupation × facteur d'effort (scenario_estimate) | non vérifié (occupation et facteurs sont des estimations réglables) |
| Tokens Codex (par jour et modèle) | ~/.codex/sessions/AAAA/MM/JJ/rollout-*.jsonl (codex.sessions_dir) : event_msg / token_count donne info.total_token_usage cumulé par session (différence entre deux événements = tokens de l'événement, rangés au jour et au modèle courant) ; turn_context.payload.model donne le modèle. Même source que le plugin codex-hud (src/local-logs.ts) | lu sur ce PC ; format interne de Codex, pas documenté publiquement (non vérifié) | | % de limite Codex | token_count.rate_limits (primary / secondary : used_percent, window_minutes 300 = 5 h, 10080 = semaine, resets_at), lecture la plus récente | lu sur ce PC ; une fenêtre absente est affichée « non disponible ». Codex 0.142+ lancé par l'app-server n'écrirait plus ces % sur disque d'après codex-hud (non vérifié) : le mod ne lance pas Codex et n'appelle pas son RPC | | Coût Codex | non calculé (pas de tarif fiable par abonnement) | — |
Aucun fichier d'identifiants n'est lu ; seuls les *.jsonl sous projects_dir, les rollout-*.jsonl sous codex.sessions_dir et le dossier du mod (jamais ~/.codex/auth.json, config.toml, .env ni les bases sqlite).
usage.config.json (seuils, plan max-20x, projections, libellés, surfaces, plein_regime, scenario_estimate, prix par id de modèle dans prices), surchargeable dans ~/.claude/usage-mod/usage.config.json. Option status_line (config | on | off). S5 se règle dans mods/scenarios/scenarios.json.
lib/)lib/transcripts.mjs (pur, sans I/O) : usageOf(ligne) → requête (tokens, modèle, heure, attributions) ; feedLine(état, ligne, { prices, claim }) pour un parseur incrémental par fichier ; kindOfPath, projectOf, familyOf, aggregate.lib/prices.json + priceFor(model, table) / costOf(vecteur, model, table) : coût au prix public (palier Haiku > 100 K compris).lib/scan-node.mjs (Node, bibliothèque standard) : scanProjects({ root, cache, prices }) relit seulement ce qui a été ajouté depuis le dernier passage (offset par fichier, dédoublonnage entre fichiers).[input, output, cache_read, cache_write_5m, cache_write_1h, requêtes, secondes actives, coût $] (V donne les index, tokensOf la somme des tokens).import { usageOf, costOf } from '../usage/lib/transcripts.mjs' puis costOf(usageOf(JSON.parse(line)).v, model, prices).Scanner : node mods/usage/scanner/usage_scan.mjs report affiche les totaux, Codex et la prévision dans le terminal (scan met à jour ~/.claude/usage-mod/aggregate.json, codex.json et debit.json (lignes TPS par heure, modèle et effort, et tous les flux de la dernière heure, lus par la section Débit du Panneau de cockpit) ; codex ne fait que la partie Codex, lecture incrémentale par fichier).
Le parseur incrémental et la table de prix reprennent des idées de claude-goblin (MIT) : clé message.id + requestId, état par fichier, prix publics.
hooks/register.tsx 485 lines1// usage mod ("Consommation"): read-only view of what the agents burn and when the plan's windows run out.
2// /conso [aujourdhui|semaine|prevision|scenarios|plein|classement] [refresh] opens the pane. Tokens come from the local
3// transcripts (scanner/usage_scan.mjs, incremental, run with Node); the official 5-hour and weekly % come from
4// $.session.usage().rateLimits and the session.measure event, saved as samples to calibrate tokens per 1 %.
5// Nothing here blocks or rewrites anything: session.measure passes on untouched, the scenarios are only suggested.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import { tokensOf } from '../lib/transcripts.mjs'
10import { CV, codexView } from '../lib/codex.mjs'
11import { bar, codexTokens, isAppLink, linksFromFil, rank, trendText } from '../lib/classement.mjs'
12import { deepMerge, expandHome, fmtPct, fmtTokens, fmtUsd, heatRow, model, outcome, promptLine, when } from './logic.mjs'
13import { historyView } from '../lib/history.mjs'
14import { fmtDur, fmtTps, recentStreams, shortModel, tpsTable } from '../lib/tps.mjs'
15
16const TABS = ['today', 'week', 'classement', 'history', 'tps', 'forecast', 'scenarios', 'full', 'codex'] as const
17const ALIASES: Record<string, string> = { aujourdhui: 'today', today: 'today', semaine: 'week', week: 'week', prevision: 'forecast', forecast: 'forecast', scenarios: 'scenarios', plein: 'full', full: 'full', codex: 'codex', classement: 'classement', qui: 'classement', ranking: 'classement', historique: 'history', history: 'history', debit: 'tps', tps: 'tps' }
18const tab = atom({ plugin: 'usage', key: 'tab' } as const, 'today')
19const copied = atom({ plugin: 'usage', key: 'copied' } as const, '')
20const cPreset = atom({ plugin: 'usage', key: 'preset' } as const, '')
21const cGroup = atom({ plugin: 'usage', key: 'group' } as const, '')
22const hPreset = atom({ plugin: 'usage', key: 'history' } as const, '')
23const tBy = atom({ plugin: 'usage', key: 'tps_by' } as const, '')
24// '' = codex.show_default of the config (Claude only by default); 'on' / 'off' once the Codex button was pressed
25const showCx = atom({ plugin: 'usage', key: 'show_codex' } as const, '')
26
27type Options = { status_line?: string; codex?: string }
28const S: { busy: boolean; error: string; at: number; last: any; aggr: any; cls: any; quotas: string } = { busy: false, error: '', at: 0, last: undefined, aggr: undefined, cls: undefined, quotas: '' }
29
30// Hands a desktop-app conversation link to the OS (same rule and call as veille's Fil). Windows: url.dll, no shell.
31async function openUrl($: EngineInterface, url: string) {
32 if (!isAppLink(url)) return false
33 let win = false
34 try { win = (await $.env.get('OS')) === 'Windows_NT' } catch {}
35 try {
36 const r = await $.process.run(win ? ['rundll32.exe', 'url.dll,FileProtocolHandler', url] : ['xdg-open', url], { timeoutMs: 10000 })
37 return r.exitCode === 0
38 } catch {
39 return false
40 }
41}
42
43const home = async ($: EngineInterface) => {
44 let v: string | undefined
45 try {
46 v = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
47 } catch {}
48 return (v ?? '').replace(/\\/g, '/')
49}
50const readJson = async ($: EngineInterface, path: string): Promise<any> => {
51 try {
52 return JSON.parse((await $.fs.read(path, { as: 'text' })) as string)
53 } catch {
54 return undefined
55 }
56}
57
58async function context($: EngineInterface, options: Options) {
59 const root = $.plugin.root.replace(/\\/g, '/')
60 const h = await home($)
61 const base = (await readJson($, `${root}/usage.config.json`)) || {}
62 const dataDir = expandHome(base.data_dir || '~/.claude/usage-mod', h)
63 const cfg = deepMerge(base, (await readJson($, `${dataDir}/usage.config.json`)) || {})
64 if (options.status_line === 'on' || options.status_line === 'off') cfg.status_line = options.status_line
65 if (options.codex === 'on' || options.codex === 'off') cfg.codex = { ...(cfg.codex || {}), enabled: options.codex }
66 return { root, dataDir, cfg }
67}
68
69async function samples($: EngineInterface, dataDir: string) {
70 try {
71 const text = (await $.fs.read(`${dataDir}/samples.jsonl`, { as: 'text' })) as string
72 return text.split('\n').filter(Boolean).map(l => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
73 } catch {
74 return []
75 }
76}
77
78async function liveLimits($: EngineInterface) {
79 try {
80 return (await $.session.usage()).rateLimits ?? []
81 } catch {
82 return []
83 }
84}
85
86/** Runs the scanner (incremental) and redraws; never awaited by a hook that the person waits on. */
87async function rescan($: EngineInterface, options: Options) {
88 if (S.busy) return
89 S.busy = true
90 try {
91 const { root, cfg } = await context($, options)
92 const r = await $.process.run([cfg.node || 'node', `${root}/scanner/usage_scan.mjs`, 'scan', '--codex-enabled', String(cfg.codex?.enabled ?? 'auto')], { timeoutMs: Math.min((cfg.scan_timeout_s ?? 600) * 1000, 600000) })
93 if (r.exitCode !== 0) throw new Error((r.stderr || '').trim().split('\n').pop() || `scanner exit ${r.exitCode}`)
94 S.error = ''
95 S.at = await $.clock.now()
96 await loadQuotas($, options)
97 S.aggr = undefined
98 S.cls = undefined
99 } catch (err) {
100 S.error = String((err as Error)?.message ?? err).slice(0, 300)
101 } finally {
102 S.busy = false
103 }
104 await refreshStatus($, options)
105 $.ui.invalidate('ui.render')
106}
107
108/** The 5 h / week line of lib/core/usage.mjs (scanner/usage_scan.mjs quotas): the numbers band and cockpit print. */
109async function loadQuotas($: EngineInterface, options: Options) {
110 try {
111 const { root, cfg } = await context($, options)
112 const r = await $.process.run([cfg.node || 'node', `${root}/scanner/usage_scan.mjs`, 'quotas'], { timeoutMs: 30000 })
113 if (r.exitCode === 0) S.quotas = String(JSON.parse(r.stdout).line || '')
114 } catch {}
115}
116
117async function compute($: EngineInterface, options: Options, current = '') {
118 const { root, dataDir, cfg } = await context($, options)
119 const aggr = S.aggr ?? (S.aggr = await readJson($, `${dataDir}/aggregate.json`))
120 const sc = await readJson($, `${root}/${cfg.scenarios_file || '../scenarios/scenarios.json'}`)
121 const now = await $.clock.now()
122 const codexOff = cfg.codex?.enabled === 'off'
123 const pick = String((await read($, showCx)) || '')
124 // Claude only unless the Codex button was pressed or codex.show_default is true
125 const showCodex = !codexOff && (pick ? pick === 'on' : cfg.codex?.show_default === true)
126 const cxFile = codexOff ? undefined : await readJson($, `${dataDir}/codex.json`)
127 const cx = codexOff ? { available: false, reason: 'off' } : codexView(cxFile, cfg, { now, tzMin: aggr?.tzMin ?? 0 })
128 const m = aggr ? model(aggr, cfg, { now, live: await liveLimits($), samples: await samples($, dataDir), scenarios: sc?.scenarios ?? [] }) : undefined
129 const cls = current === 'classement' ? S.cls ?? (S.cls = await readJson($, `${dataDir}/classement.json`)) : undefined
130 const cxRaw = (current === 'classement' || current === 'tps') && showCodex ? cxFile : undefined
131 const hist = current === 'history' ? await readJson($, `${dataDir}/history.json`) : undefined
132 return { cfg, m, sc, cx, cxRaw, cls, hist, aggr, now, showCodex, codexOff, stale: !aggr || now - S.at > (cfg.refresh_min ?? 10) * 60000 }
133}
134
135async function refreshStatus($: EngineInterface, options: Options) {
136 const { cfg, m } = await compute($, options)
137 if (cfg.status_line !== 'on') return
138 $.ui.status(m?.status)
139}
140
141export const register: Register = (on, raw) => {
142 const options = (raw ?? {}) as Options
143
144 on('session.start', async ($, e, next) => {
145 await $.command.register({
146 name: 'conso',
147 description: 'Consommation : tokens, coût équivalent, prévision des limites 5 h et semaine, scénarios (args: [aujourdhui|semaine|prevision|scenarios|plein] [refresh])',
148 argumentHint: '[aujourdhui|semaine|prevision|scenarios|plein] [refresh] | close',
149 })
150 const { cfg } = await context($, options)
151 if (cfg.status_line === 'on') $.clock.after(2000, () => rescan($, options))
152 return next(e)
153 })
154
155 on('command.run', { command: 'conso' }, async ($, e) => {
156 if ((e.args ?? '').trim().toLowerCase() === 'close') {
157 await $.ui.close({ id: 'usage' })
158 return {}
159 }
160 const words = String(e.args ?? '').toLowerCase().split(/\s+/).filter(Boolean)
161 const want = words.map(w => ALIASES[w.normalize('NFD').replace(/[̀-ͯ']/g, '')]).find(Boolean)
162 if (want) await update($, tab, () => want)
163 const { cfg, m, cx, stale, showCodex } = await compute($, options)
164 if (!S.quotas) await loadQuotas($, options)
165 if (stale || words.includes('refresh')) $.clock.after(0, () => rescan($, options))
166 await $.ui.open({ id: 'usage', title: cfg.labels?.pane_title ?? 'usage' })
167 const l = cfg.labels ?? {}
168 if (!m) return { text: `conso: premier scan en cours (${cfg.projects_dir})` }
169 const week = m.week ? `${l.week_label} ${fmtPct(m.week.reading.pct)} · ${outcome(m.week.current, m.tz, l)} ${l.pace_now}` : l.no_reading
170 const cxl = cfg.codex?.labels ?? {}
171 const codex = !showCodex ? '' : ` · ${cxl.codex ?? 'Codex'} ${cx.available && cx.hasData ? `${fmtTokens(cx.today.total[CV.total])} tokens${cx.windows[0] ? ` · ${cx.windows[0].label} ${fmtPct(cx.windows[0].pct)}` : ''}` : cxl.unavailable ?? 'non disponible'}`
172 return { text: `conso: aujourd'hui ${fmtTokens(tokensOf(m.today.total))} tokens · ${fmtUsd(m.today.total[7])} · ${week}${codex}${S.quotas ? `\nquotas : ${S.quotas}` : ''}` }
173 })
174
175 // Observe only: save a sample when a rate-limit window moved, refresh the status line. The event passes on unchanged.
176 on('session.measure', async ($, e, next) => {
177 const result = await next(e)
178 if ((e.changed ?? []).includes('rateLimits') && (e.rateLimits ?? []).length) {
179 const sample: Record<string, unknown> = {}
180 for (const r of e.rateLimits) {
181 if (r.kind === 'seven_day' || r.kind === 'five_hour') sample[r.kind] = { pct: r.percentUsed, reset: r.resetsAt ? Date.parse(r.resetsAt) : undefined }
182 }
183 $.clock.after(0, async () => {
184 const { root, cfg } = await context($, options)
185 await $.process.run([cfg.node || 'node', `${root}/scanner/usage_scan.mjs`, 'sample', JSON.stringify(sample)], { timeoutMs: 30000 })
186 await refreshStatus($, options)
187 })
188 }
189 return result
190 }).catch(($, e, next) => next(e)) // fail open: a failing sample never stops the measurement
191
192 on('ui.render', { component: 'Pane', requestId: 'usage' }, async ($, e) => {
193 const { Box, Text, Button, Code } = $.ui.resolve(e)
194 const current = ((await read($, tab)) as string) || 'today'
195 const { cfg, m, sc, cx, cxRaw, cls, hist, aggr, now, showCodex, codexOff } = await compute($, options, current)
196 const l = cfg.labels ?? {}
197 const cxl = cfg.codex?.labels ?? {}
198 const fill = (t: string, v: Record<string, string>) => String(t ?? '').replace(/\{(\w+)\}/g, (_, k) => v[k] ?? `{${k}}`)
199 const dir = String(cfg.codex?.sessions_dir ?? '~/.codex/sessions')
200 const cxTokens = (v: number[]) => `${fmtTokens(v[CV.total])} tokens (entrée ${fmtTokens(v[CV.input])} dont cache ${fmtTokens(v[CV.cached])} · sortie ${fmtTokens(v[CV.output])} dont raisonnement ${fmtTokens(v[CV.reasoning])})`
201 const cxOk = cx.available && cx.hasData
202 const last = await read($, copied)
203 const unit = m?.basis === 'tokens' ? 'tokens' : '$'
204 const amount = (v: number[]) => `${fmtTokens(tokensOf(v))} tokens · ${fmtUsd(v[7])}`
205 const rows = (title: string, xs: { key: string; v: number[] }[], n = 10, label = (k: string) => k) => (
206 <Box flexDirection="column">
207 <Text bold>{title}</Text>
208 {xs.length === 0 ? <Text dimColor> —</Text> : xs.slice(0, n).map(x => <Text>{` ${label(x.key).slice(0, 48).padEnd(48)} ${amount(x.v)}`}</Text>)}
209 </Box>
210 )
211 const header = (
212 <Box flexDirection="column">
213 <Text dimColor>{`${cfg.plan_label ?? cfg.plan} · lecture seule · coûts : ${l.estimate}${S.busy ? ' · scan en cours…' : ''}`}</Text>
214 {S.error ? <Text color="error">{`scan: ${S.error}`}</Text> : null}
215 {S.quotas ? <Text key="quotas">{`${l.quotas_label ?? 'Quotas'} : ${S.quotas}`}</Text> : null}
216 {m && !showCodex ? <Text>{`${cxl.claude ?? 'Claude'} aujourd'hui ${fmtTokens(tokensOf(m.today.total))} tokens · 7 j ${fmtTokens(tokensOf(m.weekView.total))}`}</Text> : null}
217 {m && showCodex ? (
218 <Text>{`${cxl.claude ?? 'Claude'} aujourd'hui ${fmtTokens(tokensOf(m.today.total))} tokens · 7 j ${fmtTokens(tokensOf(m.weekView.total))} | ${cxl.codex ?? 'Codex'} ${cxOk ? `aujourd'hui ${fmtTokens(cx.today.total[CV.total])} tokens · 7 j ${fmtTokens(cx.week.total[CV.total])}${cx.windows.map((w: any) => ` · ${w.label} ${fmtPct(w.pct)}`).join('')}` : cxl.unavailable ?? 'non disponible'}`}</Text>
219 ) : null}
220 <Box flexDirection="row" gap={1} flexWrap="wrap">
221 {TABS.filter(t => t !== 'codex' || showCodex).map(t => (
222 <Button key={`tab-${t}`} {...(t !== current ? { plain: true as const } : {})} onPress={() => update($, tab, () => t)}>
223 {l.tabs?.[t] ?? (t === 'classement' ? 'Classement' : t)}
224 </Button>
225 ))}
226 {codexOff ? null : (
227 <Button key="codex-toggle" {...(showCodex ? {} : { plain: true as const })} onPress={async () => {
228 await update($, showCx, () => (showCodex ? 'off' : 'on'))
229 if (showCodex && current === 'codex') await update($, tab, () => 'today')
230 }}>{showCodex ? cxl.hide ?? '− Codex' : cxl.show ?? '+ Codex'}</Button>
231 )}
232 <Button key="refresh" plain onPress={() => { $.clock.after(0, () => rescan($, options)) }}>↻</Button>
233 </Box>
234 </Box>
235 )
236 if (!m) return <Box flexDirection="column" gap={1}>{header}<Text>Pas encore de données : le premier scan lit les transcripts des derniers jours.</Text></Box>
237
238 const kindLabel = (k: string) => l.kinds?.[k] ?? k
239 const sessionLabel = (sid: string) => {
240 const s = S.aggr?.sessions?.[sid]
241 return s ? `${s.title || sid.slice(0, 8)} (${s.project})` : sid.slice(0, 8)
242 }
243 const period = (p: any, week: boolean) => (
244 <Box flexDirection="column" gap={1}>
245 <Text bold>{`Total : ${amount(p.total)}`}</Text>
246 {week ? rows('Par jour', p.perDay.map((d: any) => ({ key: d.day, v: d.v })), 7) : null}
247 <Box flexDirection="column">
248 <Text bold>Par heure (carte de chaleur, 7 jours)</Text>
249 {(() => {
250 const max = Math.max(...m.heat.grid.flat(), 0)
251 return m.heat.grid.map((row: number[], i: number) => (
252 <Text>{` ${when(m.heat.start + i * 86400000 + 43200000, m.tz, l, true).split(' ')[0]} |${heatRow(row, max, cfg.heat)}|`}</Text>
253 ))
254 })()}
255 <Text dimColor>{' 0h 6h 12h 18h 23h'}</Text>
256 </Box>
257 {rows('Par projet', p.project)}
258 {rows('Par modèle', p.model)}
259 {rows('Par type', p.kind, 10, kindLabel)}
260 {rows("Par surface de l'app", p.surface)}
261 {rows('Par effort', p.effort)}
262 {rows('Par skill', p.skill)}
263 {rows('Par serveur MCP', p.mcp)}
264 {rows(week ? 'Top 10 des sessions de la semaine' : 'Sessions', p.session, 10, sessionLabel)}
265 </Box>
266 )
267
268 const fc = (title: string, w: any) =>
269 !w ? <Text>{`${title} : ${l.no_reading}`}</Text> : (
270 <Box flexDirection="column">
271 <Text bold>{`${title} : ${fmtPct(w.reading.pct)} utilisés · reset ${when(w.reading.reset, m.tz, l)}${w.reading.live ? '' : ' (dernier échantillon)'}`}</Text>
272 <Text>{` ${l.pace_now} : ${outcome(w.current, m.tz, l) || 'pas assez de mesures'}`}</Text>
273 {w.byRate ? Object.entries(w.byRate).map(([k, f]: any) => <Text dimColor>{` rythme ${k === 'recent' ? '2 dernières h' : k === 'today' ? "aujourd'hui" : 'moyenne 7 j'} (${fmtPct(f?.pctPerHour)}/h) : ${outcome(f, m.tz, l)}`}</Text>) : null}
274 {w.projections ? w.projections.map((p: any) => <Text>{` ${p.label} : ${outcome(p.result, m.tz, l) || '?'}`}</Text>) : null}
275 </Box>
276 )
277
278 const codexPane = () => {
279 if (cx.reason === 'off') return <Text>{cxl.off}</Text>
280 if (!cx.available) return <Text>{fill(cx.reason === 'no_dir' ? cxl.no_dir : `${cxl.unavailable} : {dir}`, { dir })}</Text>
281 if (!cx.hasData) return <Text>{fill(cxl.no_data, { dir })}</Text>
282 const byModel = (title: string, xs: { key: string; v: number[] }[]) => (
283 <Box flexDirection="column">
284 <Text bold>{title}</Text>
285 {xs.length === 0 ? <Text dimColor> —</Text> : xs.map(x => <Text>{` ${x.key.slice(0, 32).padEnd(32)} ${fmtTokens(x.v[CV.total])} tokens`}</Text>)}
286 </Box>
287 )
288 return (
289 <Box flexDirection="column" gap={1}>
290 <Box flexDirection="column">
291 <Text bold>{`Aujourd'hui : ${cxTokens(cx.today.total)}`}</Text>
292 <Text bold>{`7 jours : ${cxTokens(cx.week.total)}`}</Text>
293 <Text dimColor>{`${cx.sessions} session(s) lues${cx.model ? ` · dernier modèle ${cx.model}` : ''}${cx.plan ? ` · plan ${cx.plan}` : ''}`}</Text>
294 </Box>
295 <Box flexDirection="column">
296 <Text bold>Limites Codex</Text>
297 {cx.windows.length === 0 ? <Text>{` ${cxl.no_limits}`}</Text> : cx.windows.map((w: any) => (
298 <Text>{` ${w.label} : ${fmtPct(w.pct)} utilisés${w.reset ? ` · reset ${when(w.reset, m.tz, l)}` : ''} · lu ${when(w.at, m.tz, l)}${w.past ? ` (${cxl.past})` : w.stale ? ` (${cxl.stale})` : ''}`}</Text>
299 ))}
300 {cx.missing.map((x: string) => <Text dimColor>{` ${x} : ${cxl.unavailable}`}</Text>)}
301 </Box>
302 {byModel("Par modèle aujourd'hui", cx.today.model)}
303 {byModel('Par modèle, 7 jours', cx.week.model)}
304 <Box flexDirection="column">
305 <Text bold>Par jour</Text>
306 {cx.week.perDay.map((d: any) => <Text>{` ${d.key} ${fmtTokens(d.v[CV.total])} tokens`}</Text>)}
307 </Box>
308 <Text dimColor>{fill(cxl.source, { dir })}</Text>
309 </Box>
310 )
311 }
312 const classementPane = async () => {
313 const c = cfg.classement ?? {}
314 const cl = c.labels ?? {}
315 const presets: any[] = c.presets ?? []
316 const groups: any[] = c.groups ?? []
317 const pid = ((await read($, cPreset)) as string) || c.default_preset || 'today'
318 const g0 = ((await read($, cGroup)) as string) || c.default_group || 'conversation'
319 const gid = g0 === 'source' && !showCodex ? (c.default_group && c.default_group !== 'source' ? c.default_group : 'conversation') : g0
320 const preset = presets.find(p => p.id === pid) ?? presets[0] ?? { id: 'today', since: 'midnight' }
321 if (!cls) return <Text>Pas encore de classement : le prochain scan écrit classement.json (↻).</Text>
322 const r = rank(cls, { now, preset, group: gid, top: c.top ?? 20, basis: m.basis, labels: cl })
323 const links: Record<string, any> = gid === 'conversation' ? linksFromFil(await readJson($, expandHome(c.links_file ?? '~/.claude/veille/fil.json', await home($)))) : {}
324 const maxShare = r.rows[0]?.share ?? 0
325 const W = c.bar_width ?? 12
326 const head = (x: any) => `${fmtTokens(tokensOf(x.v))} · ${fmtUsd(x.v[7])} · ${fmtPct(x.share * 100)} ${trendText(x, cl)}`.trim()
327 const split = gid === 'conversation' || gid === 'project'
328 const detail = (x: any) => `entrée ${fmtTokens(x.v[0])} · sortie ${fmtTokens(x.v[1])} · cache lu ${fmtTokens(x.v[2])} · cache écrit ${fmtTokens(x.v[3] + x.v[4])}${split ? ` · ${cl.sub ?? 'sous-agents'} ${fmtPct(tokensOf(x.v) ? (100 * x.sub) / tokensOf(x.v) : 0)}` : ''}`
329 const cxT = gid === 'source' && showCodex ? codexTokens(cxRaw, r.window, cls.tzMin ?? 0) : undefined
330 const hours = Math.round((r.window.to - r.window.from) / 3600000)
331 return (
332 <Box flexDirection="column" gap={1}>
333 <Box flexDirection="row" gap={1} flexWrap="wrap">
334 {presets.map(p => <Button key={`p-${p.id}`} {...(p.id !== preset.id ? { plain: true as const } : {})} onPress={() => update($, cPreset, () => p.id)}>{p.label ?? p.id}</Button>)}
335 </Box>
336 <Box flexDirection="row" gap={1} flexWrap="wrap">
337 {groups.filter(g => g.id !== 'source' || showCodex).map(g => <Button key={`g-${g.id}`} {...(g.id !== gid ? { plain: true as const } : {})} onPress={() => update($, cGroup, () => g.id)}>{g.label ?? g.id}</Button>)}
338 </Box>
339 <Text bold>{`Total ${preset.label ?? preset.id} : ${amount(r.total)} · ${r.n} ligne(s)${r.trend !== undefined ? ` · ${trendText({ trend: r.trend }, cl)} vs les ${hours} h d'avant` : ''}`}</Text>
340 <Box flexDirection="column">
341 {r.rows.map((x: any, i: number) => {
342 const lk = x.id ? links[x.id] : undefined
343 return (
344 <Box key={`r-${i}`} flexDirection="column">
345 <Text>{`${String(i + 1).padStart(2)}. ${bar(x.share, maxShare, W)} ${String(x.label).slice(0, 44)}${x.project ? ` (${x.project})` : ''} · ${head(x)}`}</Text>
346 <Text dimColor>{` ${detail(x)}`}</Text>
347 {x.id && i < (c.buttons_top ?? 10) ? (
348 <Box flexDirection="row" gap={1}>
349 <Text dimColor>{` ${x.id}`}</Text>
350 <Button key={`cp-${i}`} plain onPress={async press => {
351 const res = await $.ui.copy({ text: x.id, surface: press.surface })
352 await update($, copied, () => (res.isCopied ? `copié : ${x.id}` : 'copie impossible : sélectionne le texte'))
353 }}>{cl.copy ?? "Copier l'id"}</Button>
354 {lk ? <Button key={`op-${i}`} plain onPress={() => { void openUrl($, lk.link) }}>{cl.open ?? 'Ouvrir'}</Button> : null}
355 </Box>
356 ) : null}
357 </Box>
358 )
359 })}
360 {r.others ? <Text dimColor>{` ${r.others.label} (${r.others.count}) · ${head(r.others)}`}</Text> : null}
361 </Box>
362 {last ? <Text dimColor>{last}</Text> : null}
363 {cxT !== undefined ? <Text>{`Codex · ${fmtTokens(cxT)} tokens · ${cl.codex_note ?? ''}`}</Text> : null}
364 {r.partialPrev ? <Text dimColor>{`Tendance vide : la fenêtre d'avant sort de l'historique gardé (${cls.days} j).`}</Text> : null}
365 <Text dimColor>{`Sous-agents et agents de Workflow comptés dans leur conversation parente. ${cl.no_tool ?? ''} Coûts : ${l.estimate}.`}</Text>
366 </Box>
367 )
368 }
369 const historyPane = async () => {
370 const hc = cfg.history ?? {}
371 const hl = hc.labels ?? {}
372 const presets: any[] = hc.presets ?? [{ id: '90d', label: '90 j', days: 90 }]
373 const pid = ((await read($, hPreset)) as string) || hc.default_preset || presets[0]?.id
374 const preset = presets.find(p => p.id === pid) ?? presets[0]
375 if (!hist || !Object.keys(hist.days ?? {}).length) return <Text>{hl.empty ?? "Pas encore d'historique : le prochain scan écrit history.json (↻)."}</Text>
376 const hv = historyView(hist, { now, tzMin: aggr?.tzMin ?? 0, days: preset?.days ?? 90, maxRows: hc.max_rows ?? 16 })
377 const unitLabel = (hl.units ?? {})[hv.unit] ?? hv.unit
378 const maxB = Math.max(...hv.buckets.map((b: any) => tokensOf(b.v)), 0)
379 const W = hc.bar_width ?? 16
380 const barOf = (v: number[]) => (maxB > 0 ? '█'.repeat(Math.max(tokensOf(v) > 0 ? 1 : 0, Math.round((W * tokensOf(v)) / maxB))) : '').padEnd(W)
381 return (
382 <Box flexDirection="column" gap={1}>
383 <Box flexDirection="row" gap={1} flexWrap="wrap">
384 {presets.map(p => <Button key={`h-${p.id}`} {...(p.id !== preset?.id ? { plain: true as const } : {})} onPress={() => update($, hPreset, () => p.id)}>{p.label ?? p.id}</Button>)}
385 </Box>
386 <Text bold>{`Total ${preset?.label ?? ''} : ${amount(hv.total)} · ${hv.n} jour(s) gardé(s) · depuis ${hv.first}`}</Text>
387 <Box flexDirection="column">
388 <Text bold>{`Par ${unitLabel}`}</Text>
389 {hv.buckets.map((b: any) => <Text key={`hb-${b.key}`}>{` ${b.key.padEnd(10)} ${barOf(b.v)} ${amount(b.v)}`}</Text>)}
390 </Box>
391 {rows('Par modèle', hv.model, hc.top ?? 8)}
392 {rows('Par projet', hv.project, hc.top ?? 8)}
393 <Text dimColor>{hl.note ?? 'Historique local (history.json du dossier du mod), gardé après le nettoyage des transcripts ; Claude seulement.'}</Text>
394 </Box>
395 )
396 }
397 const tpsPane = async () => {
398 const tc = cfg.tps ?? {}
399 const tl = tc.labels ?? {}
400 const by = ((await read($, tBy)) as string) || tc.default_by || 'model'
401 const range = tc.range_pct ?? [10, 90]
402 const table = tpsTable(aggr, showCodex ? cxRaw : undefined, { by, days: cfg.view_days ?? 8, now, tzMin: aggr?.tzMin ?? 0, range })
403 const name = (r: any) => `${showCodex ? `${r.source === 'codex' ? cxl.codex ?? 'Codex' : cxl.claude ?? 'Claude'} ` : ''}${shortModel(r.model, tc.aliases ?? {})}${r.effort ? ` ${r.effort}` : ''}`
404 const cols = tl.columns ?? ['Modèle', 'Tokens', 'Temps', 'TPS pondéré', 'médiane', 'plage']
405 const W = Math.max(14, ...table.map((r: any) => name(r).length)) + 1
406 const line = (a: string[]) => ` ${a[0].padEnd(W)}${a[1].padStart(9)}${a[2].padStart(12)}${a[3].padStart(13)}${a[4].padStart(9)}${a[5].padStart(11)}`
407 const streams = recentStreams(aggr, showCodex ? cxRaw : undefined, tc.recent ?? 12)
408 return (
409 <Box flexDirection="column" gap={1}>
410 <Box flexDirection="row" gap={1} flexWrap="wrap">
411 {(tc.groupings ?? [{ id: 'model', label: 'par modèle' }, { id: 'effort', label: 'par modèle et effort' }]).map((g: any) => (
412 <Button key={`t-${g.id}`} {...(g.id !== by ? { plain: true as const } : {})} onPress={() => update($, tBy, () => g.id)}>{g.label ?? g.id}</Button>
413 ))}
414 </Box>
415 <Box flexDirection="column">
416 <Text bold>{line(cols)}</Text>
417 {table.length === 0 ? <Text dimColor> —</Text> : table.map((r: any, i: number) => (
418 <Text key={`tr-${i}`}>{line([name(r), fmtTokens(r.out), fmtDur(r.ms), fmtTps(r.weighted), fmtTps(r.median), `${fmtTps(r.lo)}–${fmtTps(r.hi)}`])}</Text>
419 ))}
420 </Box>
421 <Box flexDirection="column">
422 <Text bold>{tl.recent_title ?? 'Derniers flux'}</Text>
423 {streams.length === 0 ? <Text dimColor> —</Text> : streams.map((x: any, i: number) => (
424 <Text key={`ts-${i}`}>{` ${fill(tl.stream ?? '{model} {effort} : {tps} TPS ({tokens} tokens en {time})', { model: `${showCodex ? `${x.source === 'codex' ? cxl.codex ?? 'Codex' : cxl.claude ?? 'Claude'} ` : ''}${shortModel(x.model, tc.aliases ?? {})}`, effort: x.effort || '?', tps: fmtTps(x.tps), tokens: String(x.out), time: fmtDur(x.ms) })}`}</Text>
425 ))}
426 </Box>
427 <Text dimColor>{fill(tl.note ?? 'TPS = tokens de sortie (réflexion comprise) ÷ temps du flux, attente du 1er token comprise. Pondéré = tous les tokens ÷ tout le temps ; médiane et plage (p{lo}–p{hi}) par flux, à ±7 %. Flux de moins de 50 tokens de sortie exclus. {days} derniers jours.', { lo: String(range[0]), hi: String(range[1]), days: String(cfg.view_days ?? 8) })}</Text>
428 </Box>
429 )
430 }
431 const s5 = (sc?.scenarios ?? []).find((s: any) => s.id === (cfg.plein_regime?.scenario ?? 'S5'))
432 const views: Record<string, any> = {
433 today: period(m.today, false),
434 week: period(m.weekView, true),
435 forecast: (
436 <Box flexDirection="column" gap={1}>
437 {fc(l.week_label, m.week)}
438 {fc(l.five_hour_label, m.five)}
439 <Text dimColor>{`Calibration : ${fmtTokens(m.cal.seven_day.tokensPerPct ?? 0)} tokens par 1 % de la semaine (${m.cal.seven_day.n} échantillon(s), transcripts de ce PC seulement, non vérifié).`}</Text>
440 <Text dimColor>{`Rythme mesuré (${unit}/h) : 2 h ${m.rates.recent.toFixed(2)} · jour ${m.rates.today.toFixed(2)} · 7 j ${m.rates.week.toFixed(2)}`}</Text>
441 </Box>
442 ),
443 scenarios: (
444 <Box flexDirection="column" gap={1}>
445 <Text dimColor>Débit estimé de chaque scénario du mod scenarios (/scenarios), à partir de l'historique mesuré par type d'agent et modèle (non vérifié).</Text>
446 {m.fit.rows.map((r: any) => (
447 <Text {...(r.id === m.fit.best ? { bold: true } : {})}>{`${r.id} ${r.name} : ${fmtPct(r.pctPerHour)}/h actif · ${fmtPct(r.needPct)} d'ici le reset · ${r.fits === undefined ? '?' : r.fits ? l.fits : l.too_much}${r.id === m.fit.best ? ' ← le plus soutenu qui tient' : ''}`}</Text>
448 ))}
449 <Text dimColor>{`Reste ${fmtPct(m.fit.left)} · ${m.fit.hoursLeft === undefined ? '?' : Math.round(m.fit.hoursLeft)} h avant le reset, dont ${m.fit.activeHours === undefined ? '?' : Math.round(m.fit.activeHours)} h actives.`}</Text>
450 </Box>
451 ),
452 codex: codexPane(),
453 classement: current === 'classement' ? await classementPane() : null,
454 history: current === 'history' ? await historyPane() : null,
455 tps: current === 'tps' ? await tpsPane() : null,
456 full: (
457 <Box flexDirection="column" gap={1}>
458 <Text bold color={m.full.suggest ? 'success' : undefined}>{m.full.suggest ? l.suggest_full : l.no_full}</Text>
459 <Text dimColor>{`Seuils : plus de ${cfg.plein_regime?.min_left_pct} % restant au reset et moins de ${cfg.plein_regime?.max_hours_left} h avant le reset. Maintenant : ${fmtPct(m.full.left)} · ${m.full.hoursLeft === undefined ? '?' : Math.round(m.full.hoursLeft)} h.`}</Text>
460 {s5 ? (
461 <Box flexDirection="column" gap={1}>
462 <Text>{`${s5.id} ${s5.name} : ${s5.summary ?? ''}`}</Text>
463 <Code source={promptLine(s5, sc?.models)} language="text" />
464 <Box flexDirection="row" gap={1}>
465 <Button key="copy" hotkey="c" onPress={async press => {
466 const r = await $.ui.copy({ text: promptLine(s5, sc?.models), surface: press.surface })
467 await update($, copied, () => (r.isCopied ? `copié : ${s5.id}` : 'copie impossible : sélectionne le texte'))
468 }}>Copier</Button>
469 {last ? <Text dimColor>{last}</Text> : null}
470 </Box>
471 </Box>
472 ) : <Text dimColor>{`Scénario ${cfg.plein_regime?.scenario} introuvable dans ${cfg.scenarios_file}.`}</Text>}
473 <Text dimColor>Le mod ne lance rien : il suggère et montre le texte à coller.</Text>
474 </Box>
475 ),
476 }
477 return (
478 <Box flexDirection="column" gap={1}>
479 {header}
480 {views[current] ?? views.today}
481 </Box>
482 )
483 })
484}
485lib/transcripts.mjs 329 lines1// Canonical copy: lib/core/transcripts.mjs (repo root); usage/lib/transcripts.mjs and mods/*/lib/core copies are made by
2// scripts/sync-libs.mjs: never edit a copy by hand.
3// Shared, pure transcript parsing for the usage mod (and any mod that needs tokens: band imports it).
4// No I/O here: lib/scan-node.mjs reads the files, this module turns lines into usage records and per-hour rows.
5// Ideas ported from claude-goblin (MIT, github.com/data-goblin/claude-goblin): dedupe on message.id + requestId,
6// incremental per-file state, list-price cost table.
7
8/** Index of each figure in a usage vector. */
9export const V = { in: 0, out: 1, cache_read: 2, cache_write_5m: 3, cache_write_1h: 4, msgs: 5, active_s: 6, cost: 7 }
10export const zero = () => [0, 0, 0, 0, 0, 0, 0, 0]
11export const addInto = (a, b, sign = 1) => {
12 for (let i = 0; i < a.length; i++) a[i] += sign * (b[i] || 0)
13 return a
14}
15/** All tokens of a vector: input + output + cache read + cache writes. */
16export const tokensOf = v => v[0] + v[1] + v[2] + v[3] + v[4]
17
18const HOUR = 3600000
19
20/**
21 * What kind of transcript a path is, from its place under ~/.claude/projects:
22 * <project>/<session>.jsonl main thread (or a sub-conversation, see `isSubConversation`)
23 * <project>/<session>/subagents/agent-<id>.jsonl Agent subagent
24 * <project>/<session>/subagents/workflows/wf_<id>/agent-*.jsonl Workflow agent()
25 * .../journal.jsonl Workflow journal (no tokens)
26 */
27export function kindOfPath(rel) {
28 const parts = String(rel).replace(/\\/g, '/').split('/').filter(Boolean)
29 const file = parts[parts.length - 1] || ''
30 if (file === 'journal.jsonl') return 'journal'
31 if (parts.includes('workflows')) return 'workflow'
32 if (parts.includes('subagents')) return 'subagent'
33 return 'main'
34}
35
36/** Project name from a cwd: the folder name, with any `.claude/worktrees/<lane>` tail removed. */
37export function projectOf(cwd) {
38 if (!cwd) return '?'
39 const p = String(cwd).replace(/\\/g, '/').replace(/\/+$/, '')
40 const base = p.replace(/\/\.claude\/worktrees\/[^/]+.*$/, '').replace(/\/\.worktrees\/[^/]+.*$/, '')
41 const parts = base.split('/').filter(Boolean)
42 return parts[parts.length - 1] || '?'
43}
44
45/** Model family from an id (opus, sonnet, haiku, fable) or the id itself. */
46export function familyOf(model) {
47 const m = String(model || '').toLowerCase()
48 for (const f of ['fable', 'opus', 'sonnet', 'haiku']) if (m.includes(f)) return f
49 return m || '?'
50}
51
52/** Price entry for a model id: exact id, then the id without a date suffix, then its family, then the default. */
53export function priceFor(model, table) {
54 const models = (table && table.models) || {}
55 const id = String(model || '')
56 if (models[id]) return models[id]
57 const undated = id.replace(/-\d{8}$/, '')
58 if (models[undated]) return models[undated]
59 const fam = table && table.families && table.families[familyOf(id)]
60 if (fam && models[fam]) return models[fam]
61 return models[(table && table.default) || ''] || { in: 0, out: 0, cache_write_5m: 0, cache_write_1h: 0, cache_read: 0 }
62}
63
64/** List-price cost in USD of one request's tokens. */
65export function costOf(v, model, table) {
66 let p = priceFor(model, table)
67 if (p.above && v[0] + v[2] + v[3] + v[4] > p.above.tokens) p = { ...p, ...p.above }
68 return (v[0] * p.in + v[1] * p.out + v[2] * p.cache_read + v[3] * p.cache_write_5m + v[4] * (p.cache_write_1h ?? p.in * 2)) / 1e6
69}
70
71/**
72 * The key an API response is counted once under: message.id + requestId. Every content block of one response is a
73 * separate transcript line with its own uuid but the same message.id, and a fork, a resume or a subagent file can
74 * repeat the same response, so the uuid is used only when the line has no message.id at all.
75 */
76export function keyOf(o) {
77 const m = (o && o.message) || {}
78 if (m.id) return `${m.id}|${o.requestId || ''}`
79 return `|${o.requestId || o.uuid || o.timestamp || ''}`
80}
81
82/** Timestamp (ms) of a raw transcript line without parsing it, or 0. Only an unescaped top-level key matches. */
83export function lineTs(line) {
84 const i = line.indexOf('"timestamp":"')
85 if (i < 0) return 0
86 const t = Date.parse(line.slice(i + 13, line.indexOf('"', i + 13)))
87 return Number.isFinite(t) ? t : 0
88}
89
90/** Whether a raw line sends a request (a prompt or a tool result): only those start a stream. Bookkeeping lines (queue,
91 * hooks, PR links, titles, attachments) can be written at any time, even while a response streams. */
92const sendsRequest = line => line.slice(0, 400).includes('"type":"user"')
93
94/**
95 * Output speed. One stream = one API response: it starts at the last transcript line written before its first
96 * content block (the prompt or tool result that sent it) and ends at its last content block, so the time includes the
97 * wait for the first token. TPS = output tokens (thinking included) / that time. Per hour, model and effort the state
98 * keeps [output tokens, ms, streams, {bucket: streams}]; buckets are geometric (x1.15 from 1 tok/s) for the median
99 * and the range. Streams under TPS_MIN_OUT output tokens or longer than TPS_MAX_MS are left out (latency or a pause,
100 * not speed).
101 */
102export const TPS_MIN_OUT = 50
103export const TPS_MAX_MS = 30 * 60000
104const TPS_STEP = Math.log(1.15)
105export const tpsBucket = tps => Math.max(0, Math.floor(Math.log(Math.max(tps, 1)) / TPS_STEP))
106export const tpsOfBucket = b => Math.exp((b + 0.5) * TPS_STEP)
107/** Adds (sign 1) or removes (sign -1) one stream in a TPS row [out, ms, n, hist]. */
108export function tpsAdd(row, out, ms, sign = 1) {
109 row[0] += sign * out
110 row[1] += sign * ms
111 row[2] += sign
112 const b = tpsBucket(out / (ms / 1000))
113 row[3][b] = (row[3][b] || 0) + sign
114 if (!row[3][b]) delete row[3][b]
115 return row
116}
117const STREAMS_KEPT = 10
118/** Besides the last STREAMS_KEPT, a file keeps every stream of the hour before its newest (at most STREAMS_HOUR_MAX):
119 * the exact streams behind the Panneau's « 30 min » row (cockpit Débit). */
120export const STREAMS_HOUR_MS = HOUR
121const STREAMS_HOUR_MAX = 400
122
123/**
124 * One usage record from a parsed transcript line, or null. Only assistant rows with a `message.usage` count;
125 * `<synthetic>` rows (local notices) are skipped.
126 */
127export function usageOf(o) {
128 if (!o || o.type !== 'assistant') return null
129 const m = o.message
130 if (!m || typeof m !== 'object' || !m.usage || typeof m.usage !== 'object') return null
131 if (!m.model || m.model === '<synthetic>') return null
132 const u = m.usage
133 const cc = u.cache_creation && typeof u.cache_creation === 'object' ? u.cache_creation : null
134 const cw = Number(u.cache_creation_input_tokens) || 0
135 const cw1h = cc ? Number(cc.ephemeral_1h_input_tokens) || 0 : 0
136 const cw5 = cc ? Number(cc.ephemeral_5m_input_tokens) || 0 : cw
137 const ts = Date.parse(o.timestamp)
138 if (!Number.isFinite(ts)) return null
139 return {
140 key: keyOf(o),
141 ts,
142 model: m.model,
143 v: [Number(u.input_tokens) || 0, Number(u.output_tokens) || 0, Number(u.cache_read_input_tokens) || 0, cw5, cw1h, 1, 0, 0],
144 effort: o.effort || o.perTurnEffort || '',
145 agent: o.attributionAgent || '',
146 skill: o.attributionSkill || '',
147 mcp: o.attributionMcpServer || '',
148 session: o.sessionId || '',
149 cwd: o.cwd || '',
150 entrypoint: o.entrypoint || '',
151 }
152}
153
154/** Fresh per-file state: what the incremental scanner keeps for each transcript. */
155export const newFileState = (kind = 'main') => ({
156 kind, offset: 0, session: '', cwd: '', entrypoint: '', title: '', agent: '', firstOrigin: '', lastTs: 0,
157 rows: {}, recent: {}, keys: {}, lineTs: 0, tps: {}, streams: [],
158})
159
160const RECENT = 200
161
162/** Puts one stream's timing in the file state, replacing what an earlier line of the same response gave. */
163function timeStream(st, r, rec) {
164 st.tps = st.tps || {}
165 st.streams = st.streams || []
166 if (rec.t) {
167 const old = st.tps[rec.t.row]
168 if (old) {
169 tpsAdd(old, rec.t.out, rec.t.ms, -1)
170 if (old[2] <= 0) delete st.tps[rec.t.row]
171 }
172 }
173 const out = r.v[V.out]
174 const ms = r.ts - rec.start
175 rec.t = null
176 const i = st.streams.findIndex(x => x.k === r.key)
177 if (i >= 0) st.streams.splice(i, 1)
178 if (!(rec.start > 0) || ms <= 0 || ms > TPS_MAX_MS || out < TPS_MIN_OUT) return
179 const row = `${Math.floor(rec.start / HOUR) * HOUR}|${r.model}|${r.effort}`
180 tpsAdd((st.tps[row] = st.tps[row] || [0, 0, 0, {}]), out, ms)
181 rec.t = { row, out, ms }
182 st.streams.push({ k: r.key, ts: r.ts, model: r.model, effort: r.effort, out, ms })
183 while (st.streams.length > STREAMS_KEPT && (st.streams.length > STREAMS_HOUR_MAX || st.streams[0].ts < r.ts - STREAMS_HOUR_MS)) st.streams.shift()
184}
185
186/**
187 * Feeds one transcript line into a file state. `claim(key)` says whether this response is new across files
188 * (true) or already counted in another file (false); `prices` turns tokens into a list-price cost.
189 * Rows are keyed `hourMs|model|effort|skill|mcp|agent`, each a usage vector.
190 * The same response repeated in one file (one line per content block) replaces its earlier figures.
191 */
192export function feedLine(st, line, { prices, claim = () => true, idleGapS = 300 } = {}) {
193 if (!line || line.length < 20) return
194 const hasUsage = line.includes('"usage"')
195 const wantsMeta = (!st.firstOrigin && line.includes('"origin"')) || line.includes('"custom-title"') || line.includes('"agent-name"')
196 const ts = lineTs(line)
197 if (!hasUsage && !wantsMeta) {
198 if (ts && sendsRequest(line)) st.lineTs = ts
199 return
200 }
201 let o
202 try {
203 o = JSON.parse(line)
204 } catch {
205 return
206 }
207 if (o.type === 'custom-title' && o.customTitle) st.title = String(o.customTitle).slice(0, 120)
208 if (o.type === 'agent-name' && o.agentName) st.agent = st.agent || String(o.agentName).slice(0, 60)
209 if (!st.firstOrigin && o.type === 'user' && o.origin && o.origin.kind) st.firstOrigin = String(o.origin.kind)
210 const r = usageOf(o)
211 const before = st.lineTs || 0
212 if (ts && (r || o.type === 'user')) st.lineTs = ts
213 if (!r) return
214 st.session = st.session || r.session
215 st.cwd = st.cwd || r.cwd
216 st.entrypoint = st.entrypoint || r.entrypoint
217 if (r.agent && !st.agent) st.agent = r.agent
218 const prev = st.recent[r.key]
219 let start = before
220 if (!prev) {
221 if (st.keys[r.key] !== undefined || !claim(r.key)) return // counted already (here, or in another file)
222 if (st.lastTs && r.ts > st.lastTs) r.v[V.active_s] = Math.min((r.ts - st.lastTs) / 1000, idleGapS)
223 if (r.ts > st.lastTs) st.lastTs = r.ts
224 } else {
225 // a later line of the same response: drop the earlier figures, keep its active time and message count
226 const old = st.rows[prev.row]
227 if (old) addInto(old, prev.v, -1)
228 r.v[V.active_s] = prev.v[V.active_s]
229 start = prev.start
230 }
231 r.v[V.cost] = costOf(r.v, r.model, prices)
232 const hour = Math.floor(r.ts / HOUR) * HOUR
233 const row = `${hour}|${r.model}|${r.effort}|${r.skill}|${r.mcp}|${r.agent}`
234 st.rows[row] = addInto(st.rows[row] || zero(), r.v)
235 const rec = { row, v: r.v, start, t: prev ? prev.t : null }
236 timeStream(st, r, rec)
237 delete st.recent[r.key]
238 st.recent[r.key] = rec
239 st.keys[r.key] = hour
240 const ks = Object.keys(st.recent)
241 if (ks.length > RECENT) for (const k of ks.slice(0, ks.length - RECENT)) delete st.recent[k]
242}
243
244/** Drops rows and keys older than `fromMs` (retention). */
245export function prune(st, fromMs) {
246 for (const k of Object.keys(st.rows)) if (Number(k.split('|')[0]) < fromMs) delete st.rows[k]
247 for (const k of Object.keys(st.tps || {})) if (Number(k.split('|')[0]) < fromMs) delete st.tps[k]
248 for (const [k, h] of Object.entries(st.keys)) if (h < fromMs) delete st.keys[k]
249}
250
251/** Whether a main transcript is a sub-conversation: its first prompt came from another session (non vérifié). */
252export const isSubConversation = (st, rule = 'peer') => st.kind === 'main' && Boolean(rule) && st.firstOrigin === rule
253
254const pad = n => String(n).padStart(2, '0')
255/** Local day key YYYY-MM-DD for a time, `tzMin` minutes east of UTC. */
256export function dayKey(ms, tzMin = 0) {
257 const d = new Date(ms + tzMin * 60000)
258 return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
259}
260
261const bump = (map, key, v) => {
262 const k = key || '?'
263 map[k] = addInto(map[k] || zero(), v)
264}
265
266/**
267 * Totals over all file states, for the views: hourly global vectors, per-day breakdowns (project, model, kind,
268 * surface, skill, mcp, session, effort) for the last `days` days, session labels, and per-(kind, family) totals with
269 * active seconds (the measured burn per agent-hour that the scenario estimates use).
270 */
271export function aggregate(files, { now, tzMin = 0, days = 8, surfaces = {}, subRule = 'peer' } = {}) {
272 const from = now - days * 86400000
273 const out = { generated: now, tzMin, hourly: {}, hourlyModel: {}, days: {}, sessions: {}, rates: {}, tps: {}, streams: [], tpsHourly: {}, recentStreams: [] }
274 for (const st of Object.values(files)) {
275 if (st.kind === 'journal') continue
276 const kind = isSubConversation(st, subRule) ? 'subconversation' : st.kind
277 const surface = surfaces[st.entrypoint] || st.entrypoint || '?'
278 const project = projectOf(st.cwd)
279 const sid = st.session || '?'
280 if (kind === 'main' || kind === 'subconversation') {
281 const s = (out.sessions[sid] = out.sessions[sid] || { title: '', project, kind, surface })
282 if (st.title) s.title = st.title
283 } else if (!out.sessions[sid]) out.sessions[sid] = { title: '', project, kind: 'main', surface }
284 for (const [rk, t] of Object.entries(st.tps || {})) {
285 const [h, model, effort] = rk.split('|')
286 if (Number(h) < from) continue
287 for (const row of [(out.tps[`${model}|${effort}`] = out.tps[`${model}|${effort}`] || [0, 0, 0, {}]), (out.tpsHourly[rk] = out.tpsHourly[rk] || [0, 0, 0, {}])]) {
288 row[0] += t[0]
289 row[1] += t[1]
290 row[2] += t[2]
291 for (const [b, n] of Object.entries(t[3])) row[3][b] = (row[3][b] || 0) + n
292 }
293 }
294 for (const x of st.streams || []) {
295 if (x.ts < from) continue
296 const one = { ts: x.ts, model: x.model, effort: x.effort, out: x.out, ms: x.ms, kind, project, session: sid }
297 out.streams.push(one)
298 if (x.ts >= now - STREAMS_HOUR_MS) out.recentStreams.push(one)
299 }
300 for (const [rk, v] of Object.entries(st.rows)) {
301 const [h, model, effort, skill, mcp] = rk.split('|')
302 const hour = Number(h)
303 const fam = familyOf(model)
304 const rate = (out.rates[`${kind}|${fam}`] = out.rates[`${kind}|${fam}`] || zero())
305 addInto(rate, v)
306 bump(out.hourly, String(hour), v)
307 if (hour < from) continue
308 const hm = (out.hourlyModel[String(hour)] = out.hourlyModel[String(hour)] || {})
309 bump(hm, model, v)
310 const day = dayKey(hour, tzMin)
311 const d = (out.days[day] = out.days[day] || { total: zero(), project: {}, model: {}, kind: {}, surface: {}, skill: {}, mcp: {}, session: {}, effort: {} })
312 addInto(d.total, v)
313 bump(d.project, project, v)
314 bump(d.model, model, v)
315 bump(d.kind, kind, v)
316 bump(d.surface, surface, v)
317 if (skill) bump(d.skill, skill, v)
318 if (mcp) bump(d.mcp, mcp, v)
319 bump(d.session, sid, v)
320 bump(d.effort, effort || '?', v)
321 }
322 }
323 out.streams = out.streams.sort((a, b) => b.ts - a.ts).slice(0, STREAMS_KEPT * 3)
324 // tpsHourly: the same TPS rows per `hour|model|effort`, so a reader can cut its own windows (cockpit's Débit);
325 // recentStreams: every stream of the last hour, newest first
326 out.recentStreams = out.recentStreams.sort((a, b) => b.ts - a.ts).slice(0, 4000)
327 return out
328}
329lib/codex.mjs 199 lines1// Canonical copy: lib/core/codex.mjs (repo root); usage/lib/codex.mjs and mods/*/lib/core copies are made by
2// scripts/sync-libs.mjs: never edit a copy by hand.
3// Codex usage, read from Codex's own local session logs (pure part: no I/O).
4// Codex CLI / app writes one rollout file per session: <sessions_dir>/YYYY/MM/DD/rollout-*.jsonl. Two line types matter:
5// {"type":"turn_context","payload":{"model":"gpt-…", …}} the model of the turns that follow
6// {"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{…}},"rate_limits":{…}}}
7// total_token_usage is cumulative per session, so the tokens of one event are the difference with the previous one;
8// each difference goes to the day (local time) of the event and to the current model. rate_limits is the plan's
9// snapshot (used_percent, window_minutes, resets_at in epoch seconds) at that moment. The installed codex-hud plugin
10// reads the same files (src/local-logs.ts). Nothing else is read: never auth.json, config.toml or the sqlite logs.
11
12/** Token vector of a Codex event: [input (cache included), cached input, output (reasoning included), reasoning, total]. */
13export const CV = { input: 0, cached: 1, output: 2, reasoning: 3, total: 4 }
14const zero = () => [0, 0, 0, 0, 0]
15const vec = u => [u.input_tokens || 0, u.cached_input_tokens || 0, u.output_tokens || 0, u.reasoning_output_tokens || 0, u.total_tokens || (u.input_tokens || 0) + (u.output_tokens || 0)]
16const add = (a, b) => {
17 for (let i = 0; i < a.length; i++) a[i] += b[i] || 0
18 return a
19}
20
21export function newCodexState() {
22 return { offset: 0, model: '', effort: '', cum: null, days: {}, rl: null, last: 0, trig: 0, tps: {}, streams: [], resp: [] }
23}
24
25const dayKey = (ms, tzMin) => new Date(ms + tzMin * 60000).toISOString().slice(0, 10)
26
27// Output speed of Codex responses, same rule as the Claude transcripts (lib/core/transcripts.mjs): a response starts at
28// the last line that sent it (task_started, a tool output, or the end of the previous response) and ends at its
29// token_usage_record, whose usage is that response's own tokens. Per day, model and effort: [output, ms, n, {bucket: n}].
30const TPS_MIN_OUT = 50
31const TPS_MAX_MS = 30 * 60000
32const TPS_STEP = Math.log(1.15)
33const TRIGGERS = ['"task_started"', '_call_output"', '"user_message"']
34const tsOf = line => {
35 const i = line.indexOf('"timestamp":"')
36 if (i < 0) return 0
37 const t = Date.parse(line.slice(i + 13, line.indexOf('"', i + 13)))
38 return Number.isFinite(t) ? t : 0
39}
40
41function timeResponse(st, ts, p, tzMin) {
42 const u = p.usage || {}
43 const id = String(p.response_id || '')
44 st.resp = st.resp || []
45 if (id && st.resp.includes(id)) return
46 if (id) {
47 st.resp.push(id)
48 if (st.resp.length > 50) st.resp.shift()
49 }
50 const start = st.trig || 0
51 st.trig = ts
52 const out = Number(u.output_tokens) || 0
53 const ms = ts - start
54 if (!(start > 0) || ms <= 0 || ms > TPS_MAX_MS || out < TPS_MIN_OUT) return
55 const key = `${dayKey(start, tzMin)}|${st.model || '?'}|${st.effort || ''}`
56 const row = ((st.tps = st.tps || {})[key] ||= [0, 0, 0, {}])
57 row[0] += out
58 row[1] += ms
59 row[2] += 1
60 const b = Math.max(0, Math.floor(Math.log(Math.max(out / (ms / 1000), 1)) / TPS_STEP))
61 row[3][b] = (row[3][b] || 0) + 1
62 st.streams = st.streams || []
63 st.streams.push({ ts, model: st.model || '?', effort: st.effort || '', out, ms })
64 if (st.streams.length > 10) st.streams.shift()
65}
66
67/** Feeds one line of a rollout file into the per-file state. Lines without a marker are skipped without parsing. */
68export function feedCodexLine(st, line, tzMin = 0) {
69 if (!line) return
70 const usageLine = line.indexOf('"token_usage_record"') >= 0
71 if (!usageLine && line.indexOf('"token_count"') < 0 && line.indexOf('"turn_context"') < 0) {
72 // a line that sends a request: only its time is needed (tool outputs can be large, never parsed)
73 const head = line.slice(0, 300)
74 if (TRIGGERS.some(t => head.indexOf(t) >= 0)) st.trig = tsOf(line) || st.trig
75 return
76 }
77 let o
78 try {
79 o = JSON.parse(line)
80 } catch {
81 return
82 }
83 const p = o && o.payload
84 if (!p) return
85 if (o.type === 'turn_context') {
86 if (typeof p.model === 'string' && p.model) st.model = p.model
87 if (typeof p.effort === 'string') st.effort = p.effort
88 return
89 }
90 if (o.type === 'token_usage_record') {
91 const ts = Date.parse(o.timestamp)
92 if (Number.isFinite(ts)) timeResponse(st, ts, p, tzMin)
93 return
94 }
95 if (o.type !== 'event_msg' || p.type !== 'token_count') return
96 const ts = Date.parse(o.timestamp)
97 if (!Number.isFinite(ts)) return
98 st.last = Math.max(st.last, ts)
99 const total = p.info && p.info.total_token_usage
100 if (total) {
101 const now = vec(total)
102 // A counter that went down started over (new thread state): the whole value is new.
103 const delta = st.cum && now[CV.total] >= st.cum[CV.total] ? now.map((x, i) => x - st.cum[i]) : now
104 st.cum = now
105 if (delta[CV.total] > 0) {
106 const d = (st.days[dayKey(ts, tzMin)] ||= {})
107 add((d[st.model || '?'] ||= zero()), delta)
108 }
109 }
110 if (p.rate_limits && (!st.rl || ts >= st.rl.ts)) st.rl = { ts, ...limitsOf(p.rate_limits) }
111}
112
113/** Keeps only the documented-looking fields of a rate_limits snapshot (no credit balance, no ids). */
114export function limitsOf(rl) {
115 const w = x => (x && Number.isFinite(x.used_percent) ? { pct: x.used_percent, windowMin: x.window_minutes ?? null, reset: x.resets_at ? x.resets_at * 1000 : null } : null)
116 return { primary: w(rl.primary), secondary: w(rl.secondary), plan: rl.plan_type ?? null, reached: rl.rate_limit_reached_type ?? null }
117}
118
119/** Merges the per-file states into what the pane needs: tokens per day and model, and the newest limits snapshot. */
120export function aggregateCodex(files, { now, available = true, dir = '', error = '' } = {}) {
121 const days = {}
122 let rl = null
123 let model = ''
124 let lastTs = 0
125 let sessions = 0
126 const tps = {}
127 let streams = []
128 for (const st of Object.values(files)) {
129 if (Object.keys(st.days).length) sessions++
130 for (const [k, t] of Object.entries(st.tps || {})) {
131 const row = (tps[k] ||= [0, 0, 0, {}])
132 row[0] += t[0]
133 row[1] += t[1]
134 row[2] += t[2]
135 for (const [b, n] of Object.entries(t[3])) row[3][b] = (row[3][b] || 0) + n
136 }
137 streams = streams.concat(st.streams || [])
138 for (const [d, byModel] of Object.entries(st.days)) {
139 const t = (days[d] ||= {})
140 for (const [m, v] of Object.entries(byModel)) add((t[m] ||= zero()), v)
141 }
142 if (st.rl && (!rl || st.rl.ts > rl.ts)) rl = st.rl
143 if (st.model && st.last >= lastTs) {
144 lastTs = st.last
145 model = st.model
146 }
147 }
148 streams = streams.sort((a, b) => b.ts - a.ts).slice(0, 30)
149 return { generated: now, available, dir, error, sessions, days, limits: rl, model, tps, streams }
150}
151
152/** Day keys of the last `n` days ending today (local time). */
153export function lastDays(now, tzMin, n) {
154 const out = []
155 for (let i = n - 1; i >= 0; i--) out.push(dayKey(now - i * 86400000, tzMin))
156 return out
157}
158
159/** Totals and per-model rows for today and the last 7 days, plus the limit windows with their labels. */
160export function codexView(cx, cfg = {}, { now, tzMin = 0 } = {}) {
161 const c = cfg.codex || {}
162 if (!cx || !cx.available) return { available: false, reason: cx?.error || 'no_dir' }
163 const period = n => {
164 const total = zero()
165 const by = {}
166 const perDay = []
167 for (const d of lastDays(now, tzMin, n)) {
168 const day = zero()
169 for (const [m, v] of Object.entries(cx.days[d] || {})) {
170 add(total, v)
171 add((by[m] ||= zero()), v)
172 add(day, v)
173 }
174 perDay.push({ key: d, v: day })
175 }
176 return { total, perDay, model: Object.entries(by).map(([key, v]) => ({ key, v })).sort((a, b) => b.v[CV.total] - a.v[CV.total]) }
177 }
178 const labels = c.window_labels || {}
179 const staleMs = (c.stale_limits_h ?? 24) * 3600000
180 const windows = []
181 if (cx.limits) {
182 for (const w of [cx.limits.primary, cx.limits.secondary]) {
183 if (!w) continue
184 windows.push({ ...w, label: labels[String(w.windowMin)] || (w.windowMin ? `${w.windowMin} min` : '?'), at: cx.limits.ts, stale: now - cx.limits.ts > staleMs, past: !!(w.reset && w.reset < now) })
185 }
186 }
187 const want = c.expected_windows || []
188 const missing = want.filter(m => !windows.some(w => String(w.windowMin) === String(m))).map(m => labels[String(m)] || `${m} min`)
189 const hasData = Object.keys(cx.days).length > 0 || !!cx.limits
190 return { available: true, hasData, sessions: cx.sessions, model: cx.model, plan: cx.limits?.plan ?? null, today: period(1), week: period(7), windows, missing, at: cx.limits?.ts ?? null }
191}
192
193/** Whether the Codex tab is on: "on", "off", or "auto" (on when the sessions folder exists). */
194export function codexEnabled(setting, dirExists) {
195 if (setting === 'off' || setting === false) return false
196 if (setting === 'on' || setting === true) return true
197 return !!dirExists
198}
199lib/classement.mjs 209 lines1// Classement ("qui consomme"): which conversation, project, model, agent type or feature burned the most tokens in a
2// time window, ranked, with the trend vs the previous window of the same length. Pure, no I/O.
3// The scanner (scanner/usage_scan.mjs) builds `classement.json` from the same incremental cache as aggregate.json
4// (byte offsets per transcript), so the totals here and in the other tabs come from the same rows.
5// Shared: mods/cockpit keeps a byte-for-byte copy (mods/cockpit/scripts/sync-libs.mjs) for its « Qui consomme » card.
6import { V, addInto, familyOf, isSubConversation, projectOf, tokensOf, zero, dayKey } from './transcripts.mjs'
7
8const HOUR = 3600000
9const r6 = n => Math.round(n * 1e6) / 1e6
10
11/**
12 * Compact rows from the scanner's file states: one row per (hour, conversation, kind, model, agent, skill, mcp).
13 * A subagent's or Workflow agent's transcript carries its parent's sessionId, so its tokens land on the parent
14 * conversation (kind tells them apart for the split column). A sub-conversation is its own conversation.
15 * Returns { v, generated, tzMin, days, sessions: { sid: { t: title, p: project } }, rows: [[hour, sid, kind, model, agent, skill, mcp, ...vector]] }.
16 */
17export function buildClassement(files, { now, tzMin = 0, days = 31, subRule = 'peer' } = {}) {
18 const from = now - days * 86400000
19 const acc = new Map()
20 const sessions = {}
21 for (const st of Object.values(files || {})) {
22 if (st.kind === 'journal') continue
23 const kind = isSubConversation(st, subRule) ? 'subconversation' : st.kind
24 const sid = st.session || '?'
25 const s = (sessions[sid] = sessions[sid] || { t: '', p: '' })
26 if (kind === 'main' || kind === 'subconversation') {
27 if (st.title) s.t = st.title
28 s.p = projectOf(st.cwd)
29 } else if (!s.p) s.p = projectOf(st.cwd)
30 for (const [rk, v] of Object.entries(st.rows || {})) {
31 const [h, model, , skill, mcp, agent] = rk.split('|')
32 const hour = Number(h)
33 if (hour < from) continue
34 const key = [hour, sid, kind, model, agent || st.agent || '', skill || '', mcp || ''].join('\u0001')
35 acc.set(key, addInto(acc.get(key) || zero(), v))
36 }
37 }
38 const rows = []
39 for (const [k, v] of acc) {
40 const d = k.split('\u0001')
41 rows.push([Number(d[0]), ...d.slice(1), ...v.slice(0, 7), r6(v[V.cost])])
42 }
43 return { v: 1, generated: now, tzMin, days, sessions, rows }
44}
45
46const DIM = 7 // index where the usage vector starts in a row
47const vecOf = r => r.slice(DIM, DIM + 8)
48
49/** Feature of a row: skill, MCP server, Workflow agent, Agent subagent, sub-conversation or the main thread. */
50export function featureOf(kind, skill, mcp, labels = {}) {
51 const f = labels.features || {}
52 if (skill) return `${f.skill ?? 'skill'} ${skill}`
53 if (mcp) return `${f.mcp ?? 'MCP'} ${mcp}`
54 return f[kind] ?? kind
55}
56
57/** The group key and label of one row. */
58export function groupOf(group, r, data, labels = {}) {
59 const [, sid, kind, model, agent, skill, mcp] = r
60 const s = (data.sessions || {})[sid] || {}
61 switch (group) {
62 case 'project':
63 return { key: s.p || '?', label: s.p || '?' }
64 case 'model': {
65 const f = familyOf(model)
66 return { key: f, label: (labels.families || {})[f] ?? f }
67 }
68 case 'agent': {
69 const k = (labels.kinds || {})[kind] ?? kind
70 const name = kind === 'main' ? '' : agent
71 return { key: `${kind}|${name}`, label: name ? `${k} · ${name}` : k }
72 }
73 case 'feature': {
74 const l = featureOf(kind, skill, mcp, labels)
75 return { key: l, label: l }
76 }
77 case 'source':
78 return { key: 'claude', label: (labels.sources || {}).claude ?? 'Claude' }
79 default:
80 return { key: sid, label: s.t || String(sid).slice(0, 8), project: s.p || '?', id: sid }
81 }
82}
83
84const floorHour = ms => Math.floor(ms / HOUR) * HOUR
85
86/**
87 * A preset ({ hours } or { since: 'midnight' }) as hour buckets: { from, to, prevFrom, today }. Rows are per clock
88 * hour, so « 1 h » is every hour bucket that overlaps the last hour (between 1 and 2 hours of data). « aujourd'hui »
89 * is the buckets of the local day, the same ones the other tabs sum. The previous window has as many hours, just before.
90 */
91export function windowOf(preset, now, tzMin = 0) {
92 const to = floorHour(now) + HOUR
93 if (preset && preset.since === 'midnight') {
94 const mid = Math.floor((now + tzMin * 60000) / 86400000) * 86400000 - tzMin * 60000
95 const from = floorHour(mid) === mid ? mid : floorHour(mid) + HOUR
96 return { from, to, prevFrom: from - (to - from), today: dayKey(now, tzMin) }
97 }
98 const from = floorHour(now - (Number(preset && preset.hours) || 1) * HOUR)
99 return { from, to, prevFrom: from - (to - from) }
100}
101
102/** Units of a vector for the ranking: list-price cost or all tokens. */
103export const unitsOfVec = (v, basis) => (basis === 'tokens' ? tokensOf(v) : v[V.cost])
104
105/**
106 * The ranking for one window and one grouping:
107 * { total, prevTotal, trend, rows: [{ key, label, id?, project?, v, sub, units, share, trend, isNew }], others, n, window, partialPrev }.
108 * `sub` is the part of the tokens spent by subagents and Workflow agents (the split column). `trend` is
109 * (now - before) / before on the basis; undefined when the previous window starts before the kept data.
110 */
111export function rank(data, { now, preset, group = 'conversation', top = 20, basis = 'cost', labels = {} } = {}) {
112 const tz = data.tzMin || 0
113 const w = windowOf(preset, now, tz)
114 const kept = (data.generated || now) - (data.days || 31) * 86400000
115 const partialPrev = w.prevFrom < kept
116 const inWin = h => (w.today ? h >= w.from && dayKey(h, tz) === w.today : h >= w.from && h < w.to)
117 const groups = new Map()
118 const total = zero()
119 const prevTotal = zero()
120 for (const r of data.rows || []) {
121 const h = r[0]
122 const cur = inWin(h)
123 const prev = !cur && h >= w.prevFrom && h < w.from
124 if (!cur && !prev) continue
125 const v = vecOf(r)
126 const g = groupOf(group, r, data, labels)
127 const e = groups.get(g.key) || { ...g, v: zero(), prev: zero(), sub: 0 }
128 if (cur) {
129 addInto(e.v, v)
130 addInto(total, v)
131 if (r[2] === 'subagent' || r[2] === 'workflow') e.sub += tokensOf(v)
132 } else {
133 addInto(e.prev, v)
134 addInto(prevTotal, v)
135 }
136 groups.set(g.key, e)
137 }
138 const T = unitsOfVec(total, basis)
139 const trendOf = (a, b) => (partialPrev ? undefined : b > 0 ? (a - b) / b : undefined)
140 const all = [...groups.values()]
141 .filter(e => tokensOf(e.v) > 0)
142 .map(e => {
143 const u = unitsOfVec(e.v, basis)
144 const p = unitsOfVec(e.prev, basis)
145 return { ...e, units: u, share: T > 0 ? u / T : 0, trend: trendOf(u, p), isNew: !partialPrev && p === 0 && u > 0 }
146 })
147 .sort((a, b) => b.units - a.units || tokensOf(b.v) - tokensOf(a.v))
148 const rows = all.slice(0, top)
149 const rest = all.slice(top)
150 let others
151 if (rest.length) {
152 const v = zero()
153 const prev = zero()
154 let sub = 0
155 for (const e of rest) {
156 addInto(v, e.v)
157 addInto(prev, e.prev)
158 sub += e.sub
159 }
160 const u = unitsOfVec(v, basis)
161 others = { key: '__others', label: labels.others ?? 'autres', count: rest.length, v, prev, sub, units: u, share: T > 0 ? u / T : 0, trend: trendOf(u, unitsOfVec(prev, basis)) }
162 }
163 return { total, prevTotal, trend: trendOf(T, unitsOfVec(prevTotal, basis)), rows, others, n: all.length, window: w, partialPrev }
164}
165
166/**
167 * Codex tokens of the days the window touches, from the usage mod's codex.json ({ days: { YYYY-MM-DD: { model: [input,
168 * cached, output, reasoning, total] } } }). Whole days only (Codex is kept per day), so it is an upper bound for 1 h / 4 h.
169 */
170export function codexTokens(codex, win, tzMin = 0) {
171 if (!codex || !codex.days) return undefined
172 const keys = new Set()
173 for (let t = win.from; t < win.to; t += HOUR) keys.add(dayKey(t, tzMin))
174 let n = 0
175 let any = false
176 for (const k of keys) for (const v of Object.values(codex.days[k] || {})) { n += Number(v[4]) || 0; any = true }
177 return any ? n : 0
178}
179
180/** Text bar of `share` (0..1) relative to the biggest row, `width` characters. */
181export function bar(share, maxShare, width = 12) {
182 const n = maxShare > 0 ? Math.round((share / maxShare) * width) : 0
183 return '█'.repeat(Math.max(0, Math.min(width, n))).padEnd(width, '·')
184}
185
186/** "+40 %", "-12 %", "nouveau" or "" (no previous data). */
187export function trendText(row, labels = {}) {
188 if (row.isNew) return labels.new ?? 'nouveau'
189 if (row.trend === undefined || !Number.isFinite(row.trend)) return ''
190 const p = Math.round(row.trend * 100)
191 return `${p > 0 ? '↑ +' : p < 0 ? '↓ ' : '= '}${p} %`
192}
193
194/** Conversation id -> { sid, link } from veille's fil.json (projects[].convs[] with children), for « Ouvrir ». */
195export function linksFromFil(fil) {
196 const out = {}
197 const walk = cs => {
198 for (const c of cs || []) {
199 if (c && c.cli && c.link) out[c.cli] = { sid: c.sid, link: c.link }
200 walk(c && c.children)
201 }
202 }
203 for (const p of (fil && fil.projects) || []) walk(p.convs)
204 return out
205}
206
207/** Only a desktop-app conversation link is handed to the OS (same rule as veille's Fil). */
208export const isAppLink = url => /^claude:\/\/claude\.ai\/epitaxy\/[\w-]+$/.test(String(url || ''))
209hooks/logic.mjs 311 lines1// Pure logic of the usage mod ("Consommation"): views, calibration, burn rates, forecast, scenario fit and the
2// Plein régime suggestion. No `$` here: register.tsx draws, node --test checks this file on its own.
3import { V, addInto, familyOf, tokensOf, zero } from '../lib/transcripts.mjs'
4
5export const HOUR = 3600000
6export const WINDOWS = { seven_day: 168, five_hour: 5 }
7
8export function deepMerge(a, b) {
9 if (Array.isArray(b)) return b.slice()
10 if (!b || typeof b !== 'object') return b === undefined ? a : b
11 const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
12 for (const [k, v] of Object.entries(b)) out[k] = deepMerge(out[k], v)
13 return out
14}
15
16export const expandHome = (p, home) => String(p || '').replace(/^~(?=[\\/]|$)/, home).replace(/\\/g, '/')
17
18/** The figure the forecast works in: list-price cost or all tokens. */
19export const unitsOf = (v, basis) => (!v ? 0 : basis === 'tokens' ? tokensOf(v) : v[V.cost])
20
21/** Units in [from, to), from hourly buckets; a bucket partly inside counts pro rata (flat within its hour). */
22export function sumRange(hourly, from, to, basis) {
23 let n = 0
24 for (const [h, v] of Object.entries(hourly || {})) {
25 const start = Number(h)
26 const overlap = Math.min(start + HOUR, to) - Math.max(start, from)
27 if (overlap > 0) n += (unitsOf(v, basis) * overlap) / HOUR
28 }
29 return n
30}
31
32/** Rate-limit readings from the session (`$.session.usage().rateLimits`) or, failing that, the latest saved sample. */
33export function readings(live, samples, now) {
34 const out = {}
35 for (const r of live || []) {
36 const reset = Date.parse(r.resetsAt || '')
37 if (r.kind in WINDOWS && Number.isFinite(r.percentUsed)) out[r.kind] = { pct: r.percentUsed, reset: Number.isFinite(reset) ? reset : undefined, at: now, live: true }
38 }
39 for (const s of [...(samples || [])].reverse()) {
40 for (const kind of Object.keys(WINDOWS)) {
41 const w = s[kind]
42 if (out[kind] || !w || !Number.isFinite(w.pct)) continue
43 if (w.reset && w.reset > now) out[kind] = { pct: w.pct, reset: w.reset, at: s.ts, live: false }
44 }
45 }
46 return out
47}
48
49const median = xs => {
50 const s = [...xs].sort((a, b) => a - b)
51 return s.length ? (s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2) : undefined
52}
53
54/**
55 * Units (cost or tokens) per 1 % of each window, from every (time, %, reset) sample: the units counted locally
56 * between the window's start (reset minus its length) and the sample, divided by the %. Median over samples.
57 * Local transcripts only: work on another machine or in the cloud makes the % move without local tokens.
58 */
59export function calibrate(samples, hourly, basis, cfg = {}) {
60 const minPct = cfg.min_pct ?? 2
61 const keep = cfg.max_samples ?? 50
62 const out = {}
63 for (const [kind, hours] of Object.entries(WINDOWS)) {
64 const per = []
65 const tok = []
66 for (const s of (samples || []).slice(-keep)) {
67 const w = s[kind]
68 if (!w || !w.reset || !(w.pct >= minPct)) continue
69 const from = w.reset - hours * HOUR
70 const at = Math.min(s.ts, w.reset)
71 const units = sumRange(hourly, from, at, basis)
72 const tokens = sumRange(hourly, from, at, 'tokens')
73 if (units > 0) per.push(units / w.pct)
74 if (tokens > 0) tok.push(tokens / w.pct)
75 }
76 out[kind] = { unitsPerPct: median(per), tokensPerPct: median(tok), n: per.length }
77 }
78 return out
79}
80
81/** Local midnight (ms) of the day `now` falls in, `tzMin` minutes east of UTC. */
82export const midnight = (now, tzMin = 0) => Math.floor((now + tzMin * 60000) / 86400000) * 86400000 - tzMin * 60000
83
84/** Burn in units per hour: recent (last N hours), today (since local midnight), week (last 7 days). */
85export function burnRates(hourly, now, tzMin, basis, recentHours = 2) {
86 const m = midnight(now, tzMin)
87 const sinceMidnight = Math.max((now - m) / HOUR, 0.25)
88 return {
89 recent: sumRange(hourly, now - recentHours * HOUR, now, basis) / recentHours,
90 today: sumRange(hourly, m, now, basis) / sinceMidnight,
91 week: sumRange(hourly, now - 168 * HOUR, now, basis) / 168,
92 }
93}
94
95/**
96 * Where a window ends up at a steady pace: { pctPerHour, hoursLeft, exhaustedAt } when it runs out before its
97 * reset, else { pctPerHour, hoursLeft, leftAtReset }.
98 */
99export function forecast(reading, pctPerHour, now) {
100 if (!reading || !reading.reset || !Number.isFinite(pctPerHour)) return undefined
101 const hoursLeft = Math.max((reading.reset - now) / HOUR, 0)
102 const left = Math.max(100 - reading.pct, 0)
103 if (pctPerHour > 0 && left / pctPerHour < hoursLeft) return { pctPerHour, hoursLeft, exhaustedAt: now + (left / pctPerHour) * HOUR }
104 return { pctPerHour, hoursLeft, leftAtReset: Math.max(left - pctPerHour * hoursLeft, 0) }
105}
106
107/** % per hour for a window: units per hour / units per 1 %. */
108export const pctRate = (unitsPerHour, unitsPerPct) => (unitsPerPct > 0 ? unitsPerHour / unitsPerPct : undefined)
109
110/** The configured projections (calme / normal / intense …) for one window. */
111export function projections(cfg, reading, rates, unitsPerPct, now) {
112 return (cfg.projections || []).map(p => {
113 const base = p.basis === 'max' ? Math.max(rates.recent, rates.today, rates.week) : rates[p.basis] ?? rates.week
114 const pph = pctRate(base * (p.factor ?? 1), unitsPerPct)
115 return { id: p.id, label: p.label ?? p.id, pctPerHour: pph, result: forecast(reading, pph, now) }
116 })
117}
118
119const pad = n => String(n).padStart(2, '0')
120/** "ven 16/10 à 14h05" (long) or "ven 14h" (short), in local time, day names from the config. */
121export function when(ms, tzMin, labels, short = false) {
122 const d = new Date(ms + tzMin * 60000)
123 const day = (labels.days || ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'])[d.getUTCDay()]
124 const hm = `${d.getUTCHours()}h${d.getUTCMinutes() ? pad(d.getUTCMinutes()) : ''}`
125 return short ? `${day} ${d.getUTCHours()}h` : `${day} ${pad(d.getUTCDate())}/${pad(d.getUTCMonth() + 1)} à ${hm}`
126}
127
128const fill = (tpl, vars) => String(tpl).replace(/\{(\w+)\}/g, (_, k) => (vars[k] ?? `{${k}}`))
129
130/** One sentence for a forecast: "épuisé ven 16/10 à 14h" or "il restera 12 % au reset". */
131export function outcome(fc, tzMin, labels, short = false) {
132 if (!fc) return ''
133 if (fc.exhaustedAt) return fill(labels.exhausted, { when: when(fc.exhaustedAt, tzMin, labels, short) })
134 return fill(labels.left_at_reset, { pct: Math.round(fc.leftAtReset) })
135}
136
137/** Day keys of a period: today, or the last 7 days ending today. */
138export function periodDays(now, tzMin, period) {
139 const n = period === 'week' ? 7 : 1
140 const out = []
141 for (let i = n - 1; i >= 0; i--) {
142 const d = new Date(midnight(now, tzMin) + tzMin * 60000 - i * 86400000 + 43200000)
143 out.push(`${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`)
144 }
145 return out
146}
147
148/** Totals and breakdowns of a period, each breakdown sorted by the basis, biggest first. */
149export function periodView(aggr, now, period, basis) {
150 const keys = periodDays(now, aggr.tzMin || 0, period)
151 const total = zero()
152 const maps = { project: {}, model: {}, kind: {}, surface: {}, skill: {}, mcp: {}, session: {}, effort: {} }
153 for (const k of keys) {
154 const d = aggr.days && aggr.days[k]
155 if (!d) continue
156 addInto(total, d.total)
157 for (const f of Object.keys(maps)) for (const [x, v] of Object.entries(d[f] || {})) maps[f][x] = addInto(maps[f][x] || zero(), v)
158 }
159 const sorted = m => Object.entries(m).map(([key, v]) => ({ key, v })).sort((a, b) => unitsOf(b.v, basis) - unitsOf(a.v, basis))
160 const out = { days: keys, total }
161 for (const f of Object.keys(maps)) out[f] = sorted(maps[f])
162 out.perDay = keys.map(k => ({ day: k, v: (aggr.days && aggr.days[k] && aggr.days[k].total) || zero() }))
163 return out
164}
165
166/** 7 x 24 grid of units per local hour, oldest day first. */
167export function heatmap(aggr, now, basis, days = 7) {
168 const tz = aggr.tzMin || 0
169 const start = midnight(now, tz) - (days - 1) * 86400000
170 const grid = Array.from({ length: days }, () => new Array(24).fill(0))
171 for (const [h, v] of Object.entries(aggr.hourly || {})) {
172 const t = Number(h)
173 if (t < start || t > now) continue
174 const idx = Math.floor((t - start) / 86400000)
175 const hour = new Date(t + tz * 60000).getUTCHours()
176 if (grid[idx]) grid[idx][hour] += unitsOf(v, basis)
177 }
178 return { start, grid }
179}
180
181/** One heatmap row as characters, from the config's ramp (" ░▒▓█"). */
182export function heatRow(row, max, ramp = ' ░▒▓█') {
183 return row.map(x => (max > 0 && x > 0 ? ramp[Math.min(ramp.length - 1, 1 + Math.floor(((ramp.length - 1) * x) / (max * 1.0001)))] : ramp[0])).join('')
184}
185
186/** Measured units per active agent-hour, by kind and model family (rates from the aggregate). */
187export function measuredRate(aggrRates, kind, family, basis) {
188 const pick = pred => {
189 const v = zero()
190 for (const [k, x] of Object.entries(aggrRates || {})) if (pred(...k.split('|'))) addInto(v, x)
191 return v[V.active_s] > 0 ? unitsOf(v, basis) / (v[V.active_s] / 3600) : undefined
192 }
193 return pick((k, f) => k === kind && f === family) ?? pick((k, f) => f === family) ?? pick(() => true) ?? 0
194}
195
196/** A scenario's expected units per hour of work, role by role. */
197export function scenarioRate(s, aggrRates, cfg, basis) {
198 const e = cfg.scenario_estimate || {}
199 let total = 0
200 const roles = (s.roles || []).map(r => {
201 const kind = (e.unit_kind || {})[r.unit] || 'main'
202 const fam = familyOf((e.aliases || {})[r.model] || r.model)
203 const per = measuredRate(aggrRates, kind, fam, basis)
204 const rate = per * r.max_parallel * ((e.duty || {})[r.unit] ?? 0.5) * ((e.effort_factor || {})[r.effort] ?? 1)
205 total += rate
206 return { role: r.label || r.role, kind, family: fam, per, rate }
207 })
208 return { total, roles }
209}
210
211/** Which scenarios fit what is left of the week before its reset, at their estimated pace over the active hours. */
212export function scenarioFit(scenarios, aggrRates, cfg, basis, reading, unitsPerPct, now) {
213 const hoursLeft = reading && reading.reset ? Math.max((reading.reset - now) / HOUR, 0) : undefined
214 const activeHours = hoursLeft === undefined ? undefined : (hoursLeft * ((cfg.scenario_estimate || {}).active_hours_per_day ?? 14)) / 24
215 const left = reading ? Math.max(100 - reading.pct, 0) : undefined
216 const rows = (scenarios || []).map(s => {
217 const r = scenarioRate(s, aggrRates, cfg, basis)
218 const pph = pctRate(r.total, unitsPerPct)
219 const need = pph !== undefined && activeHours !== undefined ? pph * activeHours : undefined
220 return { id: s.id, name: s.name, unitsPerHour: r.total, pctPerHour: pph, needPct: need, fits: need !== undefined && left !== undefined ? need <= left : undefined, roles: r.roles }
221 })
222 const fitting = rows.filter(r => r.fits).sort((a, b) => b.unitsPerHour - a.unitsPerHour)
223 return { hoursLeft, activeHours, left, rows, best: fitting[0] && fitting[0].id }
224}
225
226/**
227 * Plein régime: suggested when the week will still have more than `min_left_pct` % left at its reset (at the
228 * current pace; the raw % left without a pace) and the reset is less than `max_hours_left` hours away.
229 */
230export function pleinRegime(cfg, reading, fc, now) {
231 const p = cfg.plein_regime || {}
232 if (!reading || !reading.reset) return { suggest: false, why: 'no_reading' }
233 const hoursLeft = (reading.reset - now) / HOUR
234 const left = fc ? (fc.exhaustedAt ? 0 : fc.leftAtReset) : 100 - reading.pct
235 const suggest = hoursLeft > 0 && hoursLeft < (p.max_hours_left ?? 48) && left > (p.min_left_pct ?? 30)
236 return { suggest, left, hoursLeft, scenario: p.scenario || 'S5' }
237}
238
239/** Ready-to-paste prompt line for a scenario (same shape as the scenarios mod's "prompt" snippet). */
240export function promptLine(s, models = {}) {
241 const label = m => (models[m] && models[m].label) || m
242 const caps = Object.entries(s.caps || {}).map(([k, v]) => `${k.replace(/_/g, ' ')} <= ${v}`)
243 return (
244 `Scenario ${s.id} "${s.name}": ` +
245 (s.roles || []).map(r => `${r.label || r.role} ${label(r.model)} ${r.effort} as ${r.unit} (max ${r.max_parallel})`).join('; ') +
246 '. Every Agent and Workflow agent() call names its model.' +
247 (caps.length ? ` Caps: ${caps.join(', ')}.` : '')
248 )
249}
250
251/** The status-line text, from the config's format: "Semaine 34 % · épuisé ven 14h au rythme actuel". */
252export function statusText(cfg, reading, fc, tzMin) {
253 const l = cfg.labels || {}
254 if (!reading) return undefined
255 const pace = fc ? `${outcome(fc, tzMin, l, true)} ${l.pace_now || ''}`.trim() : ''
256 return fill(cfg.status_format || '{week_label} {week_pct} % · {pace}', { week_label: l.week_label || 'week', week_pct: Math.round(reading.pct), pace })
257 .replace(/\s*·\s*$/, '')
258}
259
260/** Everything the pane draws, in one object (also used by the CLI report). */
261export function model(aggr, cfg, { now, live = [], samples = [], scenarios = [] }) {
262 const basis = cfg.basis === 'tokens' ? 'tokens' : 'cost'
263 const tz = aggr.tzMin || 0
264 const read = readings(live, samples, now)
265 const withLive = [...samples]
266 if (read.seven_day && read.seven_day.live) withLive.push({ ts: now, seven_day: read.seven_day, five_hour: read.five_hour })
267 const cal = calibrate(withLive, aggr.hourly, basis, cfg.calibration)
268 const rates = burnRates(aggr.hourly, now, tz, basis, (cfg.rates || {}).recent_hours ?? 2)
269 const pace = rates[cfg.current_pace] ?? rates.recent
270 const week = read.seven_day && {
271 reading: read.seven_day,
272 current: forecast(read.seven_day, pctRate(pace, cal.seven_day.unitsPerPct), now),
273 byRate: Object.fromEntries(Object.entries(rates).map(([k, r]) => [k, forecast(read.seven_day, pctRate(r, cal.seven_day.unitsPerPct), now)])),
274 projections: projections(cfg, read.seven_day, rates, cal.seven_day.unitsPerPct, now),
275 }
276 const five = read.five_hour && {
277 reading: read.five_hour,
278 current: forecast(read.five_hour, pctRate(rates.recent, cal.five_hour.unitsPerPct), now),
279 }
280 const fit = scenarioFit(scenarios, aggr.rates, cfg, basis, read.seven_day, cal.seven_day.unitsPerPct, now)
281 const full = pleinRegime(cfg, read.seven_day, week && week.current, now)
282 return {
283 basis, tz, rates, cal, week, five, fit, full,
284 today: periodView(aggr, now, 'today', basis),
285 weekView: periodView(aggr, now, 'week', basis),
286 heat: heatmap(aggr, now, basis),
287 status: statusText(cfg, read.seven_day, week && week.current, tz),
288 }
289}
290
291export const fmtTokens = n => (n >= 1e9 ? `${(n / 1e9).toFixed(2)} G` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} M` : n >= 1e3 ? `${(n / 1e3).toFixed(0)} k` : `${Math.round(n)}`)
292export const fmtUsd = n => `${n >= 100 ? Math.round(n) : n.toFixed(2)} $`
293export const fmtPct = n => (n === undefined || !Number.isFinite(n) ? '?' : `${n < 10 ? n.toFixed(1) : Math.round(n)} %`)
294
295// « Claude 5 h 24 % · semaine 14 % · épuisé ven 14:00 | Codex 5 h ? · semaine 18 % »: lib/core/usage.mjs readQuotas
296// (scanner/usage_scan.mjs quotas) as one line; band and cockpit read the same windows, so the three print the same %.
297const QDAYS = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
298const qAt = ms => { const d = new Date(ms); return `${QDAYS[d.getDay()]} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` }
299export function quotaPart(p) {
300 if (!p || (!p.five && !p.week)) return ''
301 const pct = w => (w ? `${Math.round(w.pct)} %` : '?')
302 const bits = [`5 h ${pct(p.five)}`, `semaine ${pct(p.week)}`]
303 const out = [p.five, p.week].filter(w => w && w.runOutAt).sort((a, b) => a.runOutAt - b.runOutAt)[0]
304 if (out) bits.push(`épuisé ${qAt(out.runOutAt)}`)
305 return bits.join(' · ')
306}
307export function quotaLine(q, unknown = 'non lu') {
308 if (!q) return ''
309 return `Claude ${quotaPart(q.claude) || unknown} | Codex ${quotaPart(q.codex) || unknown}`
310}
311lib/history.mjs 81 lines1// Local daily history of the usage mod (pure part, no I/O): one record per day, kept in <data_dir>/history.json after
2// the transcripts are gone. Claude Code deletes transcripts after `cleanupPeriodDays` (30 by default) and the scanner
3// keeps only `retention_days`, so without this file nothing older than a month could be shown.
4// A day is (re)written only while the scanner still sees all of it: from the first full day of the retention window to
5// today. Inside that window a recomputed day replaces the saved one when it is recent (trust_days), when the counting
6// rule changed (HISTORY_VERSION) or when it is not smaller; an older day that shrank lost transcripts to the cleanup,
7// so the saved figures stay.
8import { addInto, tokensOf, zero } from './transcripts.mjs'
9
10/** Counting rule of the saved days: bump it when the way tokens are counted changes. 2 = message.id + requestId. */
11export const HISTORY_VERSION = 2
12const DAY = 86400000
13const round = v => v.map((x, i) => (i === 7 ? Math.round(x * 10000) / 10000 : Math.round(x)))
14const roundMap = m => Object.fromEntries(Object.entries(m || {}).map(([k, v]) => [k, round(v)]))
15
16/** Local day key (YYYY-MM-DD) of a time, `tzMin` minutes east of UTC. */
17const dayOf = (ms, tzMin) => new Date(ms + tzMin * 60000).toISOString().slice(0, 10)
18/** Start (ms) of a local day key. */
19export const dayStart = (key, tzMin = 0) => Date.parse(`${key}T00:00:00Z`) - tzMin * 60000
20
21/**
22 * Merges the per-day breakdowns of `aggregate()` (computed over the whole retention window) into a saved history.
23 * Returns the new history { v, updated, days: { day: { total, model, project, kind, v } } }.
24 */
25export function mergeHistory(hist, aggrDays, { now, tzMin = 0, retentionDays = 31, trustDays = 25, keepDays = 0 } = {}) {
26 const out = { v: HISTORY_VERSION, updated: now, days: { ...((hist && hist.days) || {}) } }
27 const firstFull = dayOf(now - retentionDays * DAY, tzMin) // partly outside the window: never written
28 const trustFrom = dayOf(now - trustDays * DAY, tzMin)
29 for (const [day, d] of Object.entries(aggrDays || {})) {
30 if (day <= firstFull) continue
31 const rec = { total: round(d.total), model: roundMap(d.model), project: roundMap(d.project), kind: roundMap(d.kind), v: HISTORY_VERSION }
32 const old = out.days[day]
33 if (!old || old.v !== HISTORY_VERSION || day >= trustFrom || tokensOf(rec.total) >= tokensOf(old.total)) out.days[day] = rec
34 }
35 if (keepDays > 0) {
36 const from = dayOf(now - keepDays * DAY, tzMin)
37 for (const day of Object.keys(out.days)) if (day < from) delete out.days[day]
38 }
39 return out
40}
41
42const bump = (map, key, v) => addInto((map[key] = map[key] || zero()), v)
43const sorted = m => Object.entries(m).map(([key, v]) => ({ key, v })).sort((a, b) => tokensOf(b.v) - tokensOf(a.v))
44
45/** Monday (local) of a day key, as a day key. */
46function weekOf(key) {
47 const d = new Date(`${key}T00:00:00Z`)
48 d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7))
49 return d.toISOString().slice(0, 10)
50}
51
52/**
53 * What the Historique tab draws for one preset ({ days } or { days: 0 } for everything): the total, buckets per day,
54 * week or month (the finest that fits in `maxRows`), and per-model / per-project totals. Days without data count as 0.
55 */
56export function historyView(hist, { now, tzMin = 0, days = 90, maxRows = 16 } = {}) {
57 const all = Object.keys((hist && hist.days) || {}).sort()
58 const first = all[0]
59 const from = days > 0 ? dayOf(now - (days - 1) * DAY, tzMin) : first
60 const keys = all.filter(k => !from || k >= from)
61 const total = zero()
62 const model = {}
63 const project = {}
64 const span = from ? Math.round((dayStart(dayOf(now, tzMin)) - dayStart(from)) / DAY) + 1 : 0
65 const unit = span <= maxRows ? 'day' : span <= maxRows * 7 ? 'week' : 'month'
66 const bucketOf = k => (unit === 'day' ? k : unit === 'week' ? weekOf(k) : k.slice(0, 7))
67 const buckets = {}
68 for (const k of keys) {
69 const d = hist.days[k]
70 addInto(total, d.total)
71 for (const [m, v] of Object.entries(d.model || {})) bump(model, m, v)
72 for (const [p, v] of Object.entries(d.project || {})) bump(project, p, v)
73 bump(buckets, bucketOf(k), d.total)
74 }
75 return {
76 first, from, span, unit, n: keys.length, total,
77 buckets: Object.entries(buckets).sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([key, v]) => ({ key, v })),
78 model: sorted(model), project: sorted(project),
79 }
80}
81lib/tps.mjs 90 lines1// Tokens per second (pure, no I/O): the Débit tab's table and recent streams, from aggregate.json (Claude) and
2// codex.json (Codex). A TPS row is [output tokens, ms, streams, {bucket: streams}] with geometric buckets (x1.15), so
3// the median and the range are read from the buckets (±7 %); the weighted TPS is exact (all tokens / all time).
4// How a stream is timed: lib/core/transcripts.mjs (Claude) and lib/core/codex.mjs (Codex).
5const STEP = Math.log(1.15)
6const mid = b => Math.exp((Number(b) + 0.5) * STEP)
7
8/** Percentile `q` (0-100) of the per-stream TPS, from the buckets. */
9export function tpsPercentile(hist, q) {
10 const bs = Object.entries(hist || {}).map(([b, n]) => [Number(b), n]).filter(x => x[1] > 0).sort((a, b) => a[0] - b[0])
11 const n = bs.reduce((s, x) => s + x[1], 0)
12 if (!n) return undefined
13 const want = Math.max(1, Math.ceil((q / 100) * n))
14 let seen = 0
15 for (const [b, c] of bs) {
16 seen += c
17 if (seen >= want) return mid(b)
18 }
19 return mid(bs[bs.length - 1][0])
20}
21
22/** Sums TPS rows into one. */
23export function mergeTps(rows) {
24 const out = [0, 0, 0, {}]
25 for (const r of rows) {
26 out[0] += r[0]
27 out[1] += r[1]
28 out[2] += r[2]
29 for (const [b, n] of Object.entries(r[3] || {})) out[3][b] = (out[3][b] || 0) + n
30 }
31 return out
32}
33
34/** Weighted TPS, median and range of one row. */
35export function tpsStats(row, range = [10, 90]) {
36 const [out, ms, n, hist] = row
37 return { out, ms, n, weighted: ms > 0 ? out / (ms / 1000) : undefined, median: tpsPercentile(hist, 50), lo: tpsPercentile(hist, range[0]), hi: tpsPercentile(hist, range[1]) }
38}
39
40const dayKey = (ms, tzMin) => new Date(ms + tzMin * 60000).toISOString().slice(0, 10)
41
42/**
43 * Table rows for the Débit tab: one per source and model (`by: 'model'`) or per source, model and effort
44 * (`by: 'effort'`), sorted by output tokens. Claude rows come from aggregate.tps (`model|effort` over the scan's
45 * view_days); Codex rows from codex.tps (`day|model|effort`, kept for the last `days` days) when `codex` is given.
46 */
47export function tpsTable(aggr, codex, { by = 'model', days = 7, now = Date.now(), tzMin = 0, range = [10, 90] } = {}) {
48 const groups = {}
49 const put = (source, model, effort, row) => {
50 const key = by === 'effort' ? `${source}|${model}|${effort || '?'}` : `${source}|${model}`
51 ;(groups[key] = groups[key] || { source, model, effort: by === 'effort' ? effort || '?' : '', rows: [] }).rows.push(row)
52 }
53 for (const [k, row] of Object.entries((aggr && aggr.tps) || {})) {
54 const [model, effort] = k.split('|')
55 put('claude', model, effort, row)
56 }
57 if (codex && codex.tps) {
58 const from = dayKey(now - (days - 1) * 86400000, tzMin)
59 for (const [k, row] of Object.entries(codex.tps)) {
60 const [day, model, effort] = k.split('|')
61 if (day >= from) put('codex', model, effort, row)
62 }
63 }
64 const rows = Object.values(groups).map(g => ({ source: g.source, model: g.model, effort: g.effort, ...tpsStats(mergeTps(g.rows), range) }))
65 return rows.filter(r => r.n > 0).sort((a, b) => (a.source === b.source ? b.out - a.out : a.source === 'claude' ? -1 : 1))
66}
67
68/** The most recent streams, Claude and (when given) Codex, newest first. */
69export function recentStreams(aggr, codex, n = 12) {
70 const xs = [...((aggr && aggr.streams) || []).map(s => ({ ...s, source: 'claude' })), ...((codex && codex.streams) || []).map(s => ({ ...s, source: 'codex' }))]
71 return xs.sort((a, b) => b.ts - a.ts).slice(0, n).map(s => ({ ...s, tps: s.out / (s.ms / 1000) }))
72}
73
74/** Short model name: the configured alias, else the id without "claude-" and a date suffix. */
75export function shortModel(id, aliases = {}) {
76 if (aliases[id]) return aliases[id]
77 return String(id || '?').replace(/^claude-/, '').replace(/-\d{8}$/, '')
78}
79
80/** « 12 s », « 3 min 05 », « 1 h 02 ». */
81export function fmtDur(ms) {
82 const s = Math.round(ms / 1000)
83 if (s < 60) return `${s} s`
84 if (s < 3600) return `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')}`
85 return `${Math.floor(s / 3600)} h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}`
86}
87
88/** « 48 » / « 7.5 » tokens per second, « ? » when unknown. */
89export const fmtTps = x => (x === undefined || !Number.isFinite(x) ? '?' : x >= 10 ? String(Math.round(x)) : x.toFixed(1))
90types/index.d.ts 26 lines1// `$.state` values of the usage mod.
2
3/** Tabs of the Consommation pane. */
4export type UsageTab = 'today' | 'week' | 'classement' | 'history' | 'tps' | 'forecast' | 'scenarios' | 'full' | 'codex'
5
6declare module 'claude-code' {
7 interface PluginState {
8 usage: {
9 /** tab the pane shows: today, week, classement, history, tps, forecast, scenarios, full, codex */
10 tab: string
11 /** last copy's outcome line */
12 copied: string
13 /** Classement tab: time preset id (classement.presets) */
14 preset: string
15 /** Classement tab: grouping id (classement.groups) */
16 group: string
17 /** Historique tab: preset id (history.presets) */
18 history: string
19 /** Débit tab: grouping id (tps.groupings) */
20 tps_by: string
21 /** '' = codex.show_default; 'on' / 'off' after the Codex button */
22 show_codex: string
23 }
24 }
25}
26