ccdeck 3.35.1 → 3.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -62,8 +62,8 @@
62
62
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
63
63
  })();
64
64
  </script>
65
- <script type="module" crossorigin src="/assets/index-CR_USWmE.js"></script>
66
- <link rel="stylesheet" crossorigin href="/assets/index-8Q-n811d.css">
65
+ <script type="module" crossorigin src="/assets/index-MjcQ231B.js"></script>
66
+ <link rel="stylesheet" crossorigin href="/assets/index-CuVOi3gc.css">
67
67
  </head>
68
68
  <body>
69
69
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.35.1",
3
+ "version": "3.36.0",
4
4
  "description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,7 +60,8 @@
60
60
  "3.29.9",
61
61
  "3.33.1",
62
62
  "3.35.0",
63
- "3.35.1"
63
+ "3.35.1",
64
+ "3.36.0"
64
65
  ],
65
66
  "3.34.1": [
66
67
  {
@@ -1,5 +1,5 @@
1
1
  // The versions of the Claude Code and Codex CLIs this machine has, for the
2
- // anonymous "which CLIs, at which version" count the reporter sends (reports.mjs).
2
+ // "which CLIs, at which version" count the reporter sends (reports.mjs).
3
3
  //
4
4
  // SPAWNED, BUT NEVER ON THE BOOT PATH. claude-dir.mjs measured a Claude Code
5
5
  // child at ~3.0s and says out loud that boot is the wrong place to spend one, so
@@ -78,7 +78,7 @@ export const DEFAULTS = Object.freeze({
78
78
  // `auto when idle`, which defaulted on as a localStorage key; it moved here so
79
79
  // the server can read it with no page open.
80
80
  autoUpdate: true,
81
- // ANONYMOUS REPORTS (#1853), ON unless somebody turns them off — the owner's
81
+ // USAGE REPORTS (#1853), ON unless somebody turns them off — the owner's
82
82
  // decision, 2026-09-30: nobody is asked, the README says what is sent, and
83
83
  // Appearance holds the switch. See reports.mjs for what is sent, and
84
84
  // reportsVetoed for the launch-time veto that wins over this.
@@ -660,7 +660,7 @@ export function notificationsVetoed(env = process.env) {
660
660
  return env[OFF_ENV] === "1";
661
661
  }
662
662
 
663
- /** Did the machine rule out anonymous reports (#1853), whatever the switch says?
663
+ /** Did the machine rule out usage reports (#1853), whatever the switch says?
664
664
  * AGENTS_DECK_NO_REPORTS=1 does, and so does AGENTS_DECK_NO_INSTALL=1, which
665
665
  * the README promises "turns off everything but the quota reads". */
666
666
  export function reportsVetoed(env = process.env) {
@@ -135,7 +135,7 @@ import { applyLanPrefs, resetLanLoaded } from "./lan-deck.mjs";
135
135
  import { handleLanInvite, handleLanPeer, handleLanStatus, handleLanSync } from "./lan-routes.mjs";
136
136
  // GET and POST /api/prefs — see prefs-routes.mjs.
137
137
  import { handlePrefsGiveBack, handlePrefsRead, handlePrefsWrite } from "./prefs-routes.mjs";
138
- // Anonymous reports and the feedback dialog (#1853). The requests to
138
+ // Usage reports and the feedback dialog (#1853). The requests to
139
139
  // api.ccdeck.dev are made in reports.mjs, never in this file.
140
140
  import { handleClientError, handleFeedback, handleReportsWrite } from "./reports-routes.mjs";
141
141
  import { reporter } from "./reports.mjs";
@@ -481,9 +481,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
481
481
  // deck that had it on comes back with it on, and a launcher that only
482
482
  // asked the registry never binds a port it is about to walk away from.
483
483
  prefsRead.then(() => applyLanPrefs()).catch(() => {});
484
- // Anonymous reports (#1853): what is due once the prefs are read, then a
485
- // check every few hours on an unref'd timer. Switched off in Appearance,
486
- // or with AGENTS_DECK_NO_REPORTS / AGENTS_DECK_NO_INSTALL, nothing is sent.
484
+ // Usage reports (#1853): what is due once the prefs are read, then a
485
+ // check every few hours plus a lighter ~10-minute heartbeat, both on
486
+ // unref'd timers. Switched off in Appearance, or with
487
+ // AGENTS_DECK_NO_REPORTS / AGENTS_DECK_NO_INSTALL, nothing is sent.
487
488
  reporter.start();
488
489
  // Auto-switch resumes only if the user previously turned it on; the
489
490
  // module reads its own persisted flag and does nothing otherwise. Its
@@ -18,7 +18,7 @@ async function readJson(req, res, limit) {
18
18
  }
19
19
  }
20
20
 
21
- /** `POST /api/reports` `{ on }`: Appearance's "Send anonymous reports" switch. */
21
+ /** `POST /api/reports` `{ on }`: Appearance's "Send usage reports" switch. */
22
22
  export async function handleReportsWrite(req, res, { report = reporter } = {}) {
23
23
  const body = await readJson(req, res, 1_000);
24
24
  if (typeof body?.on !== "boolean") return send(res, 400, { ok: false, reason: "bad_request" });
@@ -1,4 +1,8 @@
1
- // Anonymous reports, on unless the person switched them off (#1853).
1
+ // Usage reports, on unless the person switched them off (#1853). They are NOT
2
+ // anonymous: with reports on, every one carries a stable, hashed device id — an
3
+ // identifier for this machine — and the server records the IP the report arrives
4
+ // from. What never leaves is the person's sessions, prompts, files, project
5
+ // names and paths.
2
6
  //
3
7
  // WHAT GOES OUT, to api.ccdeck.dev, while `prefs.reports` is true and the
4
8
  // machine has not vetoed it (reportsVetoed):
@@ -11,11 +15,15 @@
11
15
  // subagents and projects — numbers, never their names,
12
16
  // - errors: a request handler that threw on this server, or an error the page
13
17
  // caught, with home folders, email addresses and key-shaped strings
14
- // scrubbed out before they leave.
18
+ // scrubbed out before they leave,
19
+ // - a "ping" heartbeat, every ten minutes or so while the deck runs, which
20
+ // moves the install's "last seen" so the admin can show who is online now.
21
+ // It writes no event and carries the install id alone — no facts, no
22
+ // fingerprint.
15
23
  //
16
- // Each carries the install id, the version, the OS and CPU architecture, the
17
- // channel (the desktop app, npm, or a source checkout) and the runtime, plus a
18
- // coarse sketch of the environment: the logical CPU count, the RAM in MB, the
24
+ // Each event carries the install id, the version, the OS and CPU architecture,
25
+ // the channel (the desktop app, npm, or a source checkout) and the runtime, plus
26
+ // a coarse sketch of the environment: the logical CPU count, the RAM in MB, the
19
27
  // locale, the shell and terminal names, and the Claude Code and Codex CLI
20
28
  // versions. Every one of those is a small fixed token or a number, safe by
21
29
  // construction — no path, no hostname, no user name, no project name, no prompt,
@@ -23,6 +31,14 @@
23
31
  // field that cannot be told is left out rather than guessed. The install id is
24
32
  // random, made at the first check-in, and tied to nothing on the machine.
25
33
  //
34
+ // The one field that IS derived from the machine is `deviceId`: a stable, hashed
35
+ // device id sent with EVERY report — install, update and active — while reports
36
+ // are on, never on the ping and never on errors. It is an identifier: it follows
37
+ // one machine across runs, which is why the reports are no longer anonymous. It
38
+ // is personal data, so it leaves only as a one-way hash of stable machine traits,
39
+ // never those traits in the clear (see deviceIdToken). It rides with the facts,
40
+ // so it stops entirely the moment reports are switched off or vetoed.
41
+ //
26
42
  // NOBODY IS ASKED, AND NOTHING IS HIDDEN. The owner chose on-by-default
27
43
  // (2026-09-30): the README says what is sent, Appearance holds the switch, and
28
44
  // AGENTS_DECK_NO_REPORTS=1 keeps it off from the first start.
@@ -36,8 +52,8 @@
36
52
  // dropped: it is a nicety for the people who make ccdeck, and no part of the
37
53
  // deck depends on it.
38
54
 
39
- import { randomUUID } from "node:crypto";
40
- import { cpus as osCpus, homedir, totalmem } from "node:os";
55
+ import { createHash, randomUUID } from "node:crypto";
56
+ import { arch as osArch, cpus as osCpus, homedir, hostname, platform as osPlatform, totalmem } from "node:os";
41
57
  import { dirname, resolve } from "node:path";
42
58
  import { fileURLToPath } from "node:url";
43
59
  import { inApp } from "./app-host.mjs";
@@ -51,6 +67,12 @@ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
51
67
  const TIMEOUT_MS = 6000;
52
68
  /** How often a long-running deck checks whether a new day wants its "active". */
53
69
  const CHECK_IN_EVERY_MS = 6 * 60 * 60 * 1000;
70
+ /** The heartbeat's beat: a lighter timer than the check-in that only moves the
71
+ * install's "last seen", so the admin can count who is online now. Ten minutes,
72
+ * plus up to `PING_JITTER_MS` of random spread, so a fleet started together does
73
+ * not all beat on the same tick. */
74
+ const PING_EVERY_MS = 10 * 60 * 1000;
75
+ const PING_JITTER_MS = 2 * 60 * 1000;
54
76
  /** Errors per hour this deck may send, and how long one error is not repeated. */
55
77
  const ERRORS_PER_HOUR = 20;
56
78
  const SAME_ERROR_QUIET_MS = 60 * 60 * 1000;
@@ -69,14 +91,18 @@ export function reportsOn(prefs, env = process.env) {
69
91
  //
70
92
  // Everything added to an install's facts here is a count or a small fixed token,
71
93
  // safe by construction: no path, no hostname, no user name, no free text ever
72
- // reaches any of them, so there is nothing to scrub. Each is optional — a field
73
- // that cannot be told is left undefined and so never sent, and none of these can
74
- // throw, because a report is a nicety and no part of the deck may hang on it.
94
+ // reaches any of them, so there is nothing to scrub. The ONE exception is
95
+ // `deviceId` below — it is derived from identifying machine traits (the hostname
96
+ // among them), so it is personal data, which is exactly why it leaves only as a
97
+ // one-way hash and never as the traits themselves (see deviceIdToken). Each is
98
+ // optional — a field that cannot be told is left undefined and so never sent, and
99
+ // none of these can throw, because a report is a nicety and no part of the deck
100
+ // may hang on it.
75
101
 
76
102
  // The keys the API caps, so a value that would be rejected for length is not
77
103
  // sent: it is trimmed to the cap here instead. Only ever tokens the shape of a
78
104
  // terminal name, a shell name, a locale or a version — never anything free.
79
- const TOKEN_CAPS = { locale: 16, shell: 16, term: 32, claudeVersion: 32, codexVersion: 32 };
105
+ const TOKEN_CAPS = { locale: 16, shell: 16, term: 32, claudeVersion: 32, codexVersion: 32, deviceId: 64 };
80
106
 
81
107
  /** A value reduced to the API's token shape `^[0-9A-Za-z.+_-]+$`: spaces become
82
108
  * "_" (so "Apple Terminal" rides as "Apple_Terminal"), every other disallowed
@@ -104,6 +130,54 @@ function totalMemMb() {
104
130
  try { return count(Math.round(totalmem() / 1024 / 1024)); } catch { return undefined; }
105
131
  }
106
132
 
133
+ /**
134
+ * A stable, non-reversible fingerprint of the machine, as an opaque token.
135
+ *
136
+ * PRIVACY — READ THIS. Unlike every other facts field, this is PERSONAL DATA: a
137
+ * stable id that follows one machine across runs, and one of its ingredients
138
+ * (`os.hostname()`) can name a person. That is exactly why it leaves only as a
139
+ * one-way hash, never as the traits in the clear — the traits are concatenated,
140
+ * run through SHA-256, and only the first 16 hex characters ship, so the machine
141
+ * details cannot be read back out of what leaves. It is identifying all the same:
142
+ * it is stable per machine, so it is what makes the reports no longer anonymous,
143
+ * and it ships with every report while reports are on. The token is hex, so it
144
+ * matches the API's `^[0-9A-Za-z.+_-]+$` shape and its 16 characters sit well
145
+ * under the 64 cap.
146
+ * Built from traits that do not change between restarts — platform, arch, CPU
147
+ * model and count, total RAM, and the hostname folded into the hash ONLY — and it
148
+ * never throws: a trait that cannot be read just yields a different, still stable,
149
+ * token. The traits are a parameter so a test can hash a fixed machine.
150
+ */
151
+ export function deviceIdToken(traits = machineTraits()) {
152
+ try {
153
+ const seed = [traits.platform, traits.arch, traits.cpuModel, traits.cpuCount, traits.memBytes, traits.hostname]
154
+ .map(part => String(part ?? ""))
155
+ .join("\u0000");
156
+ return createHash("sha256").update(seed).digest("hex").slice(0, 16);
157
+ } catch {
158
+ return undefined;
159
+ }
160
+ }
161
+
162
+ /** The stable machine traits the fingerprint hashes — never sent themselves,
163
+ * only the hash of them. `hostname()` is identifying and is read here solely to
164
+ * be folded into that hash, never returned to a path that sends facts. */
165
+ function machineTraits() {
166
+ try {
167
+ const list = osCpus();
168
+ return {
169
+ platform: osPlatform(),
170
+ arch: osArch(),
171
+ cpuModel: list?.[0]?.model,
172
+ cpuCount: list?.length,
173
+ memBytes: totalmem(),
174
+ hostname: hostname(),
175
+ };
176
+ } catch {
177
+ return {};
178
+ }
179
+ }
180
+
107
181
  /**
108
182
  * The UI locale as a token — "en-US", "de-DE". The runtime's own resolved
109
183
  * locale first, then `LANG`/`LC_ALL` with any `.UTF-8` encoding stripped. The
@@ -168,6 +242,7 @@ export function installFacts({
168
242
  locale = localeToken(env),
169
243
  shell = shellToken(env, platform),
170
244
  term = termToken(env),
245
+ deviceId = deviceIdToken(),
171
246
  claudeVersion,
172
247
  codexVersion,
173
248
  } = {}) {
@@ -185,6 +260,9 @@ export function installFacts({
185
260
  addIf(facts, "locale", token(locale, TOKEN_CAPS.locale));
186
261
  addIf(facts, "shell", token(shell, TOKEN_CAPS.shell));
187
262
  addIf(facts, "term", token(term, TOKEN_CAPS.term));
263
+ // The one derived, hashed, personal field. Present on the events that carry
264
+ // facts; stripped from errors, and never on the heartbeat (see below).
265
+ addIf(facts, "deviceId", token(deviceId, TOKEN_CAPS.deviceId));
188
266
  addIf(facts, "claudeVersion", token(claudeVersion, TOKEN_CAPS.claudeVersion));
189
267
  addIf(facts, "codexVersion", token(codexVersion, TOKEN_CAPS.codexVersion));
190
268
  return facts;
@@ -246,6 +324,7 @@ export function createReporter({
246
324
  const errorsSent = [];
247
325
  const lastSentAt = new Map();
248
326
  let timer = null;
327
+ let pingTimer = null;
249
328
 
250
329
  /** The CLI versions to add to the facts right now, tokenised and omitted when
251
330
  * not yet known — read on every send so the background probe's result appears
@@ -358,6 +437,23 @@ export function createReporter({
358
437
  }
359
438
  }
360
439
 
440
+ /** The heartbeat. While reports are on and an id exists, tell the API this deck
441
+ * is still here — it moves the install's "last seen" and writes no event, so it
442
+ * carries the id and nothing else: no facts, no deviceId. Like every send it is
443
+ * dropped if it cannot get through, and it never throws. No id yet means the
444
+ * first check-in has not run, so there is nothing to be online under and
445
+ * nothing is sent; the check-in that makes the id is what puts this deck
446
+ * online, and the ping only keeps that fresh. */
447
+ async function ping() {
448
+ try {
449
+ const p = prefs.current();
450
+ if (!reportsOn(p, env) || !p.report.installId) return;
451
+ await call("POST", "/v1/app/ping", { installId: p.report.installId });
452
+ } catch {
453
+ // The lightest of the niceties: a heartbeat that could not be sent is gone.
454
+ }
455
+ }
456
+
361
457
  /** The switch. On checks in, which makes an id if there is none; off drops the id and asks for a deletion. */
362
458
  async function setReports(on) {
363
459
  if (on) {
@@ -389,26 +485,44 @@ export function createReporter({
389
485
  errorsSent.push(at);
390
486
  lastSentAt.set(key, at);
391
487
  const stack = typeof error?.stack === "string" ? scrub(error.stack, home).slice(0, STACK_MAX) : undefined;
392
- const { runtime: _runtime, ...fields } = factsNow();
488
+ // Less the runtime, and less the deviceId — an error is not one of the events
489
+ // the fingerprint rides on, so the personal field never leaves on one.
490
+ const { runtime: _runtime, deviceId: _deviceId, ...fields } = factsNow();
393
491
  return call("POST", "/v1/app/errors", { installId: p.report.installId, ...fields, where, message, stack });
394
492
  }
395
493
 
396
- /** Check in once the prefs are read, and every few hours after, on a timer
397
- * that never holds the process open. */
494
+ /** The heartbeat timer, rescheduled from itself so each beat carries fresh
495
+ * jitter — a fixed interval would let a fleet that started as one beat as one.
496
+ * Unref'd, so it never holds the process open. */
497
+ function scheduleNextPing() {
498
+ pingTimer = setTimeout(() => {
499
+ void ping();
500
+ scheduleNextPing();
501
+ }, PING_EVERY_MS + Math.floor(Math.random() * PING_JITTER_MS));
502
+ pingTimer.unref?.();
503
+ }
504
+
505
+ /** Check in once the prefs are read, and every few hours after; and beat a
506
+ * heartbeat on its own, lighter timer. Both timers never hold the process
507
+ * open. The first check-in already puts a freshly launched deck online, so the
508
+ * first ping is a full interval out — nothing pings at boot. */
398
509
  function start() {
399
510
  Promise.resolve(ready).catch(() => {}).then(checkIn);
400
511
  if (!timer) {
401
512
  timer = setInterval(() => void checkIn(), CHECK_IN_EVERY_MS);
402
513
  timer.unref?.();
403
514
  }
515
+ if (!pingTimer) scheduleNextPing();
404
516
  }
405
517
 
406
518
  function stop() {
407
519
  if (timer) clearInterval(timer);
408
520
  timer = null;
521
+ if (pingTimer) clearTimeout(pingTimer);
522
+ pingTimer = null;
409
523
  }
410
524
 
411
- return { checkIn, setReports, reportError, start, stop };
525
+ return { checkIn, ping, setReports, reportError, start, stop };
412
526
  }
413
527
 
414
528
  // ── the deck's own providers ─────────────────────────────────────────────────
@@ -20,7 +20,7 @@ import { scanAgentsMdFiles, scanClaudeMdFiles } from "./memory-files.mjs";
20
20
  import { RECAP_MARK } from "./session-recap.mjs";
21
21
  // The shared transcript cursor every pass below reads through — see
22
22
  // transcript-scan.mjs.
23
- import { hasSpend, mergeUsageByModel, newUsageTotals, scanTranscript } from "./transcript-scan.mjs";
23
+ import { copyUsageBucket, hasSpend, mergeUsageByModel, newUsageTotals, scanTranscript } from "./transcript-scan.mjs";
24
24
  // event-pipeline.mjs's pushEvent, reached without importing it — see
25
25
  // event-sink.mjs.
26
26
  import { pushEvent } from "./event-sink.mjs";
@@ -78,7 +78,7 @@ function knownModelId(sid) {
78
78
  }
79
79
 
80
80
  /** How many subagents the deck has seen across the sessions it still tracks —
81
- * the coarse "subagents" count the anonymous "active" report carries
81
+ * the coarse "subagents" count the "active" report carries
82
82
  * (reports.mjs). Reused, not new state: `subsSig` is already the per-session
83
83
  * signature of the subagent models resolved for it, so its keys are the
84
84
  * subagents. A signature that will not parse is skipped rather than guessed at.
@@ -314,7 +314,7 @@ export async function readUsageByModelFromTranscript(path) {
314
314
  const out = {};
315
315
  for (const [model, u] of Object.entries(state.usageByModel)) {
316
316
  if (!hasSpend(u)) continue;
317
- out[model] = { ...u };
317
+ out[model] = copyUsageBucket(u);
318
318
  }
319
319
  return Object.keys(out).length ? out : null;
320
320
  }
@@ -148,7 +148,7 @@ function forgetSession(sid) {
148
148
  }
149
149
 
150
150
  /** How many sessions the deck is still tracking — the size of the LRU the watch
151
- * polls, which is the coarse "how many sessions" the anonymous "active" report
151
+ * polls, which is the coarse "how many sessions" the "active" report
152
152
  * counts (reports.mjs). Reused, not new state: the Map is already the deck's
153
153
  * own answer to which sessions it still holds anything for. Never a session id,
154
154
  * a name or a path — only the count. */
@@ -104,6 +104,19 @@ const USAGE_FIELD_RE = {
104
104
  // `server_tool_use`, several fields earlier. Match the sub-object on the raw
105
105
  // line instead of on the extracted blob.
106
106
  const CACHE_CREATION_BLOCK_RE = /"cache_creation"\s*:\s*\{([^}]*)\}/g;
107
+ // Claude Code writes an `iterations` array into the usage block, one entry per
108
+ // model iteration of the request, and every entry carries a `cache_creation`
109
+ // sub-object of its own. The request's split is the top-level one; the entries
110
+ // restate it. Matching every `cache_creation` on the line counted the split once
111
+ // per copy — 141,066 of 192,917 assistant lines on one machine carry the array,
112
+ // and on each of them the 1-hour and 5-minute tokens summed to twice the flat
113
+ // `cache_creation_input_tokens`, so every cache write was billed twice. The
114
+ // array is cut out before the split is read. Its entries hold only numbers and
115
+ // sub-objects, never a bracket inside a string, so one level of nesting is the
116
+ // whole of what the pattern has to span; a key quoted inside a message reaches
117
+ // the line as `\"iterations\"` and is not matched, for the reason
118
+ // TOOL_USE_RESULT_KEY_RE gives below.
119
+ const ITERATIONS_ARRAY_RE = /"iterations"\s*:\s*\[(?:[^[\]]|\[[^[\]]*\])*\]/g;
107
120
  // A finished `Task`/`Agent` call is written into the PARENT's transcript as a
108
121
  // top-level `toolUseResult`, and that object carries a `usage` block of its own
109
122
  // — the subagent's LAST API turn, restated on the parent's line. Those same
@@ -149,6 +162,50 @@ function grabUsageField(blob, key) {
149
162
  // model — the behaviour the whole deck had before #686.
150
163
  const MAX_TRANSCRIPT_USAGE_MODELS = 32;
151
164
 
165
+ // A request's `usage.speed` (#754). Claude Code's fast mode runs the same model
166
+ // id at `speed: "fast"`, and Anthropic bills it at a premium, so the speed is
167
+ // part of which rate card a token is billed against, the way the model is. The
168
+ // API documents the value as "fast" or "standard", and Claude Code writes the
169
+ // usage object into the transcript verbatim — `speed` is on every final
170
+ // assistant line it writes today, "standard" on all 140,532 of them on one
171
+ // machine. Absent, null and "standard" are all the standard speed and add
172
+ // nothing here, so a session that never ran fast has buckets of exactly the
173
+ // shape it always had.
174
+ //
175
+ // Anything else is kept as a share of its model's bucket, under `speeds`, and
176
+ // the client prices each share at its own speed's rates or not at all. The
177
+ // value is bytes this process was handed, so it is held to a short token and a
178
+ // few distinct values per model, the same reason MAX_TRANSCRIPT_USAGE_MODELS
179
+ // caps the models; whatever fails either test is kept under
180
+ // UNRECOGNISED_SPEED, which no rate table names, so it is counted and left
181
+ // unpriced rather than priced at the standard rate.
182
+ const STANDARD_SPEED = "standard";
183
+ const UNRECOGNISED_SPEED = "unrecognised";
184
+ const SPEED_VALUE_RE = /^[a-z][a-z0-9_-]{0,31}$/i;
185
+ const MAX_SPEEDS_PER_MODEL = 4;
186
+
187
+ /** The speed a line's request was billed at, or null for the standard one.
188
+ * `record` is the line parsed, or null when it names no model — and a line
189
+ * that names no model is billed at the standard speed, as every line was. */
190
+ function billedSpeed(record) {
191
+ const speed = record?.message?.usage?.speed;
192
+ if (speed === undefined || speed === null || speed === STANDARD_SPEED) return null;
193
+ return typeof speed === "string" && SPEED_VALUE_RE.test(speed) ? speed : UNRECOGNISED_SPEED;
194
+ }
195
+
196
+ /** The share of `bucket` billed at `speed`, created on first sight. Null for
197
+ * the standard speed or a bucket we could not attribute. `Object.hasOwn`
198
+ * rather than a plain read, because `constructor` passes SPEED_VALUE_RE and
199
+ * every object already answers it. */
200
+ function speedBucketFor(bucket, speed) {
201
+ if (!bucket || !speed) return null;
202
+ const speeds = bucket.speeds ?? (bucket.speeds = {});
203
+ if (Object.hasOwn(speeds, speed)) return speeds[speed];
204
+ const key = Object.keys(speeds).length < MAX_SPEEDS_PER_MODEL ? speed : UNRECOGNISED_SPEED;
205
+ if (!Object.hasOwn(speeds, key)) speeds[key] = newUsageTotals();
206
+ return speeds[key];
207
+ }
208
+
152
209
  /** The per-model usage bucket for `model`, created on first sight. Null for a
153
210
  * line whose tokens we cannot attribute — no model seen yet in this file, or
154
211
  * the cap above already reached. */
@@ -166,19 +223,40 @@ function usageBucketFor(state, model) {
166
223
  * `cache_creation` sub-object splits cache writes into by TTL. */
167
224
  const USAGE_BLOCK_FIELDS = ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"];
168
225
  const CACHE_SPLIT_FIELDS = ["ephemeral_1h_input_tokens", "ephemeral_5m_input_tokens"];
226
+ const USAGE_TOTAL_FIELDS = [...USAGE_BLOCK_FIELDS, ...CACHE_SPLIT_FIELDS];
169
227
 
170
228
  /** Charge `fields` of one usage blob to the file's flat totals and, when the
171
229
  * tokens can be attributed, to their model's bucket too — the same number
172
230
  * into both, read once, so the split and the total it splits go on summing
173
- * to each other. */
174
- function chargeUsage(state, bucket, blob, fields) {
231
+ * to each other. A request billed at a speed other than standard is charged
232
+ * to that speed's share of the bucket as well, which is a subset of the
233
+ * bucket and not an addition to it. */
234
+ function chargeUsage(state, bucket, blob, fields, speedBucket) {
175
235
  for (const k of fields) {
176
236
  const n = grabUsageField(blob, k);
177
237
  state.usage[k] += n;
178
238
  if (bucket) bucket[k] += n;
239
+ if (speedBucket) speedBucket[k] += n;
179
240
  }
180
241
  }
181
242
 
243
+ /** Add one bucket's counters, and its speed shares, into `dst`. */
244
+ function addUsageBucket(dst, src) {
245
+ for (const k of USAGE_TOTAL_FIELDS) dst[k] += src[k] ?? 0;
246
+ if (!src.speeds) return dst;
247
+ for (const [speed, share] of Object.entries(src.speeds)) {
248
+ const into = speedBucketFor(dst, speed);
249
+ for (const k of USAGE_TOTAL_FIELDS) into[k] += share[k] ?? 0;
250
+ }
251
+ return dst;
252
+ }
253
+
254
+ /** A bucket's counters and speed shares, sharing no object with the scan state
255
+ * it was read from. */
256
+ function copyUsageBucket(u) {
257
+ return addUsageBucket(newUsageTotals(), u);
258
+ }
259
+
182
260
  /** Add `src`'s per-model buckets into `dst`, key for key, and return `dst`.
183
261
  * Under the same cap as the per-file map, so a session that delegates to
184
262
  * hundreds of files cannot assemble an unbounded one out of bounded parts. */
@@ -191,7 +269,7 @@ function mergeUsageByModel(dst, src) {
191
269
  bucket = newUsageTotals();
192
270
  dst[model] = bucket;
193
271
  }
194
- for (const k of Object.keys(bucket)) bucket[k] += u[k] ?? 0;
272
+ addUsageBucket(bucket, u);
195
273
  }
196
274
  return dst;
197
275
  }
@@ -394,11 +472,12 @@ function foldUsageLine(state, line, record) {
394
472
  // and the two would stop summing to each other.
395
473
  const billed = billedUsageText(line);
396
474
  const bucket = usageBucketFor(state, state.lastModel);
475
+ const speedBucket = speedBucketFor(bucket, billedSpeed(record));
397
476
  for (const m of billed.matchAll(USAGE_BLOCK_RE)) {
398
- chargeUsage(state, bucket, m[1], USAGE_BLOCK_FIELDS);
477
+ chargeUsage(state, bucket, m[1], USAGE_BLOCK_FIELDS, speedBucket);
399
478
  }
400
- for (const m of billed.matchAll(CACHE_CREATION_BLOCK_RE)) {
401
- chargeUsage(state, bucket, m[1], CACHE_SPLIT_FIELDS);
479
+ for (const m of billed.replace(ITERATIONS_ARRAY_RE, "").matchAll(CACHE_CREATION_BLOCK_RE)) {
480
+ chargeUsage(state, bucket, m[1], CACHE_SPLIT_FIELDS, speedBucket);
402
481
  }
403
482
  }
404
483
 
@@ -580,5 +659,5 @@ function scanTranscript(path) {
580
659
  // rather than marked at each declaration so that every declaration above reads
581
660
  // exactly as it did where it came from.
582
661
  export {
583
- scanTranscript, newUsageTotals, hasSpend, mergeUsageByModel, repeatsRequestUsage,
662
+ scanTranscript, newUsageTotals, hasSpend, mergeUsageByModel, copyUsageBucket, repeatsRequestUsage,
584
663
  };