SLOPSHOPPER

usage

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…

panecommandstatusprocesstimer
v0.5.0MITupdated 2026-10-19julian-passebecq/claude-mod/mods/usage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage
│ ┃ usage ✕ › fix the failing auth test and add an audit log call │ ┃ undefined · lecture seule · coûts : │ ┃ undefined ⏺ Read(src/auth.ts) │ ┃ [ today ] week Classement history tps foreca ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Pas encore de données : le premier scan lit ⎿ Added 2 lines, removed 1 line │ ┃ les transcripts des derniers jours. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /conso │ ⎿ usage: conso: premier scan en cours (undefined) │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · usage
undefined · lecture seule · coûts : undefined [ today ] week Classement history tps forecast scenarios ful Pas encore de données : le premier scan lit les transcripts des derniers jours.
README

usage — « Consommation »

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.

Onglets

  • Aujourd'hui / Semaine : tokens et coût équivalent par jour, carte de chaleur par heure (7 jours), par projet, modèle, type (fil principal, sous-agent Agent, agent de Workflow, sous-conversation), surface de l'app (entrypoint du transcript : onglet Code, CLI, SDK, cloud), effort, skill (attributionSkill) et serveur MCP (attributionMcpServer), top 10 des sessions de la semaine.
  • Prévision : % semaine et 5 h, reset, rythme sur les 2 dernières heures, aujourd'hui et en moyenne 7 jours ; trois projections configurables (calme / normal / intense) ; « épuisé le <jour> à <heure> » ou « il restera X % au reset ».
  • Scénarios de consommation : débit estimé de S1-S5 du mod 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.
  • Plein régime : quand il restera plus de 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.
  • Classement (/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.
  • Codex : tokens Codex aujourd'hui et sur 7 jours (entrée dont cache, sortie dont raisonnement), par modèle et par jour, et les % de limite que Codex écrit lui-même (« Semaine 40 % · reset … · lu à … »). Une ligne en tête du panneau met Claude et Codex côte à côte, et /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).
  • Historique (/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.
  • Débit (/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 %).
  • Claude seul par défaut : l'en-tête, la réponse de /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).
  • Quotas (ligne en tête du panneau et dans la réponse de /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.
  • Ligne de statut (option, off par défaut) : « Semaine 34 % · épuisé ven 14h au rythme actuel ».

D'où viennent les chiffres

ChiffreSourceStatut
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ébutlu 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 échantillonsnon 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ûtprix 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-conversationtranscript principal dont le premier prompt vient d'une autre session (origin.kind = peer)non vérifié
Débit d'un scénariodé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).

Réglages

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.

Module partagé (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).
  • Vecteur de chiffres : [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).
  • Exemple : 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.

Source 8 files
hooks/register.tsx 485 lines
1// 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}
485
lib/transcripts.mjs 329 lines
1// 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}
329
lib/codex.mjs 199 lines
1// 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}
199
lib/classement.mjs 209 lines
1// 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 || ''))
209
hooks/logic.mjs 311 lines
1// 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}
311
lib/history.mjs 81 lines
1// 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}
81
lib/tps.mjs 90 lines
1// 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))
90
types/index.d.ts 26 lines
1// `$.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