glassbox-trace 0.11.1 → 0.12.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.
package/README.md CHANGED
@@ -58,9 +58,9 @@ The npm package is `glassbox-trace` (plain `glassbox` was already taken); the co
58
58
 
59
59
  **Let the agent read its own recorder.** `glassbox hook install` adds a Claude Code Stop hook (the command it writes is `npx -y glassbox-trace hook …`, which resolves the package on each run; pass `--command glassbox` after `npm i -g glassbox-trace@<version>` to pin it, or use the plugin below, which runs its bundled copy): every session ends with a one-screen Glassbox summary (turns, tool calls, context peak, cost, top findings). Add `--feedback` and the findings — with their evidence and the "next time" advice — are handed back to the agent; it reads them and says, in a sentence, what it would do differently. Each finding is handed back once per session, it never loops (`stop_hook_active` is respected), never blocks a clean session, and `glassbox hook uninstall` removes it, with a `.glassbox-backup` of `settings.json` kept.
60
60
 
61
- Feedback at the end of a session can only change that session's last reply. Add `--context` to carry it forward: the Stop hook keeps a memory of the project in `<project>/.glassbox/` — git-ignored, owner-only, secrets masked — and a SessionStart hook gives the next session there the lessons that keep coming back, a line for each recent session, and what the agent said it would do differently. See [Memory across sessions](#memory-across-sessions--glassbox-recall-and-glassbox-memory) below.
61
+ Feedback at the end of a session can only change that session's last reply. Add `--context` to carry it forward: the Stop hook keeps a memory of the project in `<project>/.glassbox/` — git-ignored, owner-only, secrets masked — and writes the lessons that keep coming back into the agent's own memory (auto memory's `MEMORY.md`, else `CLAUDE.local.md`), as one block it rewrites every turn and never beyond its markers. One set of lessons, in the place the agent already reads. (`--memory glassbox` keeps 0.11's SessionStart note instead.) See [Memory across sessions](#memory-across-sessions--glassbox-recall-and-glassbox-memory) below.
62
62
 
63
- **Stop a loop before it happens.** `--guard` adds a PreToolUse hook: when the agent is about to repeat a call that has already failed twice in a row, unchanged, in the current turn, it blocks the call and tells the agent why, with the last error, and to change something or ask you rather than try again. It is deliberately narrow. Anything that could change the outcome in between — an edit, another command, a success, a new prompt — resets it, so re-running the tests after a fix is never blocked; calls a person stopped don't count; it never approves anything, so your permission prompts are untouched. Replayed over 4,121 real tool calls it blocked none, so it stays out of the way until an agent is actually stuck. It runs before every tool call, so it needs an installed copy: `--guard` writes a direct `node "…/bin/glassbox.mjs" hook …` command (for the Stop hook too) and refuses to install from an `npx` cache, where each call would cost seconds. The plugin doesn't include the guard ([why](docs/PLUGIN.md#the-loop-guard-is-not-in-the-plugin)); if both are installed, the plugin sees the CLI hook and steps aside.
63
+ **Stop a loop before it happens.** `--guard` adds a PreToolUse hook: when the agent is about to repeat a call that has already failed twice in a row, unchanged, in the current turn, it blocks the call and tells the agent why, with the last error, and to change something or ask you rather than try again. It is deliberately narrow. Anything that could change the outcome in between — an edit, another command, a success, a new prompt — resets it, so re-running the tests after a fix is never blocked; calls a person stopped don't count; it never approves anything, so your permission prompts are untouched. Replayed over 4,121 real tool calls it blocked none, so it stays out of the way until an agent is actually stuck. The plugin runs it before Bash and PowerShell commands, on by default ([why only those](docs/PLUGIN.md#the-loop-guard-012-in-the-plugin-before-shell-commands)). For every tool, install it through the CLI: `--guard` writes a direct `node "…/bin/glassbox.mjs" hook …` command (for the Stop hook too) and refuses to install from an `npx` cache, where each call would cost seconds; the plugin's guard sees the CLI hook and steps aside.
64
64
 
65
65
  ```
66
66
  glassbox hook install --feedback --context --fail-on warn # summary, feedback, and notes for the next session
@@ -169,7 +169,7 @@ Six kinds of claim (tests pass, shipped, verified, fixed, nothing changed, file
169
169
 
170
170
  ### Memory across sessions — `glassbox recall` and `glassbox memory`
171
171
 
172
- A Claude Code session starts knowing nothing about the last one, and Claude Code deletes transcripts after 30 days by default. With `--context` (or the plugin's `context` option), the Stop hook keeps `<project>/.glassbox/memory.json`: a digest of each of the project's last 50 sessions — what was asked, the files written, the checks and pushes that ran and how they ended, the commits with their shas, the agent's last words, the findings — and **lessons**: findings counted across sessions by what they are about (`retry-loop · Bash npm test`, `failed-tool · Edit`, `unverified-claim · test`). The next session starts with this note (≤ 8,000 characters, under Claude Code's 10,000 cap):
172
+ A Claude Code session starts knowing nothing about the last one, and Claude Code deletes transcripts after 30 days by default. With `--context` (or the plugin's `context` option), the Stop hook keeps `<project>/.glassbox/memory.json`: a digest of each of the project's last 50 sessions — what was asked, the files written, the checks and pushes that ran and how they ended, the commits with their shas, the agent's last words, the findings — and **lessons**: findings counted across sessions by what they are about (`retry-loop · Bash npm test`, `failed-tool · Edit`, `unverified-claim · test`). The lessons still recurring go into the agent's own memory — one block in auto memory's `MEMORY.md` (else `CLAUDE.local.md`) that Glassbox rewrites every turn and never edits beyond its markers — so there is one set of lessons, in the place the agent already reads. With `--memory glassbox`, the next session instead starts with 0.11's note (≤ 8,000 characters, under Claude Code's 10,000 cap):
173
173
 
174
174
  ```
175
175
  ## Recurring in this project (worst first)
@@ -181,7 +181,7 @@ A Claude Code session starts knowing nothing about the last one, and Claude Code
181
181
  - 2026-10-09 14:02 · a1b2c3d4 · "fix the login redirect" — wrote src/auth.js, test/auth.test.js · ran npm test (passed, last of 3) · committed 1a2b3c4 "fix: login redirect loop" · pushed
182
182
  ```
183
183
 
184
- A lesson is handed on until ten sessions in a row go by without it, then it retires (and comes back if it fires again). Each session that started with a lesson is counted, so `glassbox memory` can say what nobody could before: *told in 9 sessions, came back in 1*. Everything is copied or counted from the transcripts — no model call, no tokens — and whatever a `fence` detector recognises as a secret is masked before it is written.
184
+ A lesson is handed on until ten sessions in a row go by without it, then it retires (and comes back if it fires again). Each session that started with a lesson in its memory is counted, so `glassbox memory` can say what nobody could before: *told in 9 sessions, came back in 1*. Everything is copied or counted from the transcripts — no model call, no tokens — and whatever a `fence` detector recognises as a secret is masked before it is written.
185
185
 
186
186
  ```
187
187
  glassbox recall login redirect sessions of this project that mention it, best match first: asked / said / wrote / ran / commit
@@ -197,6 +197,59 @@ glassbox memory audit everything loaded before the first prompt,
197
197
 
198
198
  **`glassbox memory audit`** lists what Claude Code loads into every session before the first prompt — managed, user, parent-directory and project `CLAUDE.md` files, `CLAUDE.local.md`, unscoped `.claude/rules/`, `@imports` (four hops, relative to the importing file), the first 200 lines or 25 KB of the auto-memory `MEMORY.md`, and Glassbox's own note — with an estimated token cost (characters ÷ 4, HTML comments stripped as Claude Code strips them), then flags what a mechanical reader can: imports that load nothing, backticked paths and `npm run` scripts that don't exist, the same line in two places, rules that pull opposite ways (`use pnpm` here, `use npm, not pnpm` there — the same parser as `adhere`), files over the 200 lines Claude Code's docs recommend, and `MEMORY.md` lines past the cut-off that never load. `--fail-on warn` for CI. Details in [docs/MEMORY.md](docs/MEMORY.md).
199
199
 
200
+ ### On a Pro or Max plan — `glassbox plan`
201
+
202
+ What the plan carried, at list price: each billing month against the plan's price, the last 7 days by model, 5-hour windows rebuilt from request times (heaviest first, with the session that used most of each), and any limit hits.
203
+
204
+ ```
205
+ glassbox plan plan from ~/.claude.json, windows over the last 7 days
206
+ glassbox plan --since 30d --top 10
207
+ glassbox plan --plan-price 300 what you actually pay (default: the USD list price for the tier)
208
+ glassbox plan --format md --redact no session titles
209
+ ```
210
+
211
+ ```
212
+ Glassbox plan · Max 20x (from .claude.json) · $200/month (USD list; --plan-price for yours) · Claude Code on this machine only
213
+
214
+ billing month from Mon 12 Oct $730.53 list vs $200 plan 3.7× (day 14 of 31)
215
+ Sat 12 Sep – Mon 12 Oct $2,056.02 list vs $200 plan 10.3×
216
+ last 7 days $597.21 list Opus 5.5 97% · Fable 5.1 3% · Sonnet 5.5 1%
217
+
218
+ 5-hour windows, last 7d: 19 (rebuilt from request times; usage elsewhere can shift them)
219
+ now Sun 25 Oct 16:35–21:35 $6.76 76 req top 19dfbfc1 51%
220
+ Sun 18 Oct 23:45–04:45 $103.72 1084 req top fe8c79e9 58%
221
+
222
+ limit hits: none in the last 7d
223
+ ```
224
+
225
+ On its own, `plan` can't tell you your share of the plan's limits. Claude Code passes the 5-hour and weekly percentages only to the status line, never to a transcript, and those limits also count claude.ai, the apps and your other machines. That's what the status line below is for. List price is what the same tokens would cost on the API. It isn't money saved. From `~/.claude.json` it reads two fields, the rate-limit tier and the subscription start, and nothing else. Without a plan (API key, Bedrock, Vertex) it shows list price only, which is then what you pay.
226
+
227
+ ### The plan's real limits — `glassbox statusline` and `plan --live`
228
+
229
+ ```
230
+ glassbox statusline install the 5-hour and weekly % in Claude Code's status bar
231
+ glassbox plan --live exact windows, their peak %, and what each session saw move
232
+ glassbox statusline uninstall and your old status line is back
233
+ ```
234
+
235
+ ```
236
+ Opus 5.5 · ctx 32% │ 5h 20% · 1h16m left │ week 78% · resets 14:29
237
+ ```
238
+
239
+ The status line shows the plan's own numbers, the ones Claude Code receives from the API, coloured green, yellow and red at 50% and 80%. Each time they change it appends one line to `~/.claude/glassbox/limits.jsonl` (owner-only; at 4 MB it becomes `limits.1.jsonl`). If you already have a status line, `install` keeps it running in front of Glassbox's, and `uninstall` puts it back. With those readings `plan --live` adds:
240
+
241
+ ```
242
+ From the status line: 2 readings, newest just now
243
+ latest 5h 20% (resets 21:19) · week 78% (resets Mon 26 Oct 14:29)
244
+ 5-hour windows (exact)
245
+ Sun 25 Oct 16:19–21:19 peak 20% $18.87 local (open)
246
+ weeks
247
+ Mon 19 Oct 14:29 – Mon 26 Oct 14:29 peak 78% $420.08 local (open)
248
+ sessions, by how much the week moved while each ran (other usage in that time counts too)
249
+ ```
250
+
251
+ A window's start is its reset time minus 5 hours (or 7 days), so these are exact, unlike the rebuilt ones. A session's figure is how far the % moved between its own first and last reading. Other sessions, claude.ai and other machines in that time count too, so it means "while it ran", not "because of it". There's no forecast of when you'll hit the limit.
252
+
200
253
  ## What it flags
201
254
 
202
255
  | rule | fires when |
@@ -227,7 +280,7 @@ Identical findings print once with a `×N` count in `check`. All mechanical, no
227
280
  - Usage is counted once per API request (assistant records sharing a `requestId`), not once per record. Subagent transcripts rewrite `output_tokens` while a response streams, so the largest value across the request's records is used. Since Claude Code ~2.1.278 many subagent responses never get their final record written at all (every record has `stop_reason: null` and a partial count — [anthropics/claude-code#93620](https://github.com/anthropics/claude-code/issues/93620)); those output tokens are not on disk anywhere, so Glassbox counts the responses, shows the cost as **≥ $X** (a floor) and estimates the gap from the median of the session's own complete subagent tool-use responses (needs five). Compare with Claude Code's own total (`/cost`, the status line) before budgeting from a floor.
228
281
  - Context size = uncached input + cache read + cache write — the prompt the model actually saw.
229
282
  - Thinking tokens are part of output tokens; shown as a share, never added twice.
230
- - Cost is estimated from an editable rate card (defaults from the Claude pricing page, 2026-09-05; prefix-matched so dated model ids resolve). A `stream-json` `result` record with `total_cost_usd` overrides the estimate. Unknown models show "—" rather than a wrong number.
283
+ - Cost is estimated from an editable rate card (defaults from the Claude pricing page, 2026-10-25; prefix-matched so dated model ids resolve; Haiku 5.5's higher price for prompts over 100k tokens applies to the whole request). `check` and `plan` say when the card may be wrong: a model priced through an older version's row (Opus 5.5 by Opus 5's, as before 0.11), a model with no price, or a session that ran more than 60 days after the card's date. A `--rates` row covers its whole family, so `claude-sonnet-4` also prices `claude-sonnet-4-5-…`. A `stream-json` `result` record with `total_cost_usd` overrides the estimate. Unknown models show "—" rather than a wrong number.
231
284
 
232
285
  ## Formats
233
286
 
@@ -250,6 +303,11 @@ src/viewer.html the UI; the build inlines trace-core and the demo
250
303
  src/cli.mjs CLI library (session discovery, embed, check, compare, hook); bin/glassbox.mjs is the entry point
251
304
  src/tail.mjs live tail: byte-offset tailer + loopback SSE server for `glassbox watch`
252
305
  src/guard.mjs the PreToolUse loop guard (`hook --guard`)
306
+ src/jsonl.mjs transcripts of any size: line-by-line reading past 64 MB (V8 can't hold ~512 MB in one string)
307
+ src/plan.mjs what a Pro/Max plan carried: billing months, 5-hour windows, limit hits (`plan`, `plan --live`)
308
+ src/statusline.mjs the plan's real 5-hour and weekly % in the status bar, logged for `plan --live`
309
+ src/managed.mjs managed-settings drop-in and the temp-folder policy (`managed`, `GLASSBOX_TEMP`)
310
+ scripts/bundle.mjs offline bundle: tarball, viewer, CycloneDX SBOM, SHA256SUMS (`npm run bundle`)
253
311
  src/claims.mjs said vs did: claims out of the agent's text, judged against the tool calls (`claims`, and three `check` rules)
254
312
  scripts/build.mjs build
255
313
  scripts/sanitize.mjs turn a real transcript into a shareable fixture (demo or structure mode)
@@ -261,7 +319,7 @@ STUDY-IMPLEMENTATION.md adoption study (solo dev, Cowork/SDK, CI, feedback loop
261
319
  docs/ CI recipe, plugin, feedback-loop study protocol, launch kit, Community Extensions PR, post copy
262
320
  scripts/feedback-study.mjs measures with/without --feedback session groups
263
321
  scripts/feedback-study-run.mjs runs that study with `claude -p` (plan only unless --run)
264
- .claude-plugin/ hooks/ skills/ the Claude Code plugin: manifest + marketplace, Stop/SessionStart hook, skills
322
+ .claude-plugin/ hooks/ skills/ the Claude Code plugin: manifest + marketplace, Stop/SessionStart hook, the guard (glassbox-guard.mjs), skills
265
323
  ```
266
324
 
267
325
  Use the engine on its own:
@@ -275,12 +333,22 @@ console.log(reportMarkdown(trace, findings, cost)); // the agent-ready
275
333
  console.log(compare({ trace, findings, cost }, other)); // other = the same three for a second session
276
334
  ```
277
335
 
336
+ ## In a company
337
+
338
+ What an IT or security team needs before Glassbox goes on developer machines. The full inventory of files read, written and run is in [docs/IT.md](docs/IT.md).
339
+
340
+ - **Offline install.** `node scripts/bundle.mjs` writes `release/glassbox-trace-<version>/`: the npm tarball, the standalone viewer, a CycloneDX 1.5 SBOM, `SHA256SUMS` and `INSTALL.md`. With no runtime dependencies, `npm install --global --offline ./glassbox-trace-<version>.tgz` needs no registry. The tarball is reproducible from the same commit. `--verify <folder>` re-checks the hashes.
341
+ - **Every user at once.** `glassbox managed --hook --statusline --temp redact` prints Claude Code managed settings for the Stop hook, the status line and the temp policy. Add `--install` to write them as `managed-settings.d/glassbox.json` next to your `managed-settings.json`, which it never edits. `--uninstall` removes it.
342
+ - **Transcripts out of the temp folder.** `open` writes the viewer, transcript inside, to the temp folder unless you pass `--out`. `GLASSBOX_TEMP=redact` makes that a structure-only copy, and `GLASSBOX_TEMP=deny` forbids it. It's read from the environment or from the managed settings' `env`. `open --redact` does the same for one run.
343
+ - **Export-control markings.** `glassbox fence --markings` reports marked text that reached a transcript: classification banners, ITAR and EAR legends, CUI, DoD distribution statements B–F, ECCNs. `--markings-file` adds your own, such as a company's proprietary legend. They're reported, never shredded, and `--fail-on error` makes it a gate.
344
+ - **Bedrock and Vertex.** Bedrock and Vertex model ids are priced from the same rows. A regional endpoint adds 10% on Claude 4.5 and later, read from the Bedrock id (`us.anthropic.…`) or set with `--endpoint regional`. `--geo us` prices first-party US-only inference. Each report says in one line how it priced the session.
345
+
278
346
  ## Privacy
279
347
 
280
348
  Your context is private — by construction, not by promise.
281
349
 
282
350
  - **Nothing leaves.** The viewer makes no network requests at all — fonts, scripts and the demo are inside the one file, and the test suite fails if a build references anything outside it. The CLI sends nothing anywhere. `glassbox watch` binds to `127.0.0.1` only and stops with the command. The one network activity is installation: `npx` fetching the package, or the plugin marketplace fetching this repo.
283
- - **What it touches.** It reads `~/.claude/projects/**` and writes only what you ask for: `open` writes a self-contained HTML to your temp folder (transcript included — delete it when done, or use `--out`), `hook install` edits `~/.claude/settings.json` with a backup beside it, `--context` writes `<project>/.glassbox/last-session.md` and `memory.json` (owner-only, git-ignored, secrets masked; they quote prompts and commands), `--legend` writes the legend. The full inventory — files, processes, hooks, network, uninstall — is in [docs/IT.md](docs/IT.md), written for whoever has to approve it.
351
+ - **What it touches.** It reads `~/.claude/projects/**` (and, for `plan` only, two fields of `~/.claude.json`: the rate-limit tier and the subscription start) and writes only what you ask for: `open` writes a self-contained HTML to your temp folder (transcript included — delete it when done, or use `--out`), `hook install` and `statusline install` edit `~/.claude/settings.json` with a backup beside it, the status line logs the plan's % to `~/.claude/glassbox/limits.jsonl`, `--context` writes `<project>/.glassbox/last-session.md` and `memory.json` (owner-only, git-ignored, secrets masked; they quote prompts and commands) and one lessons block in auto memory's `MEMORY.md` or `CLAUDE.local.md`, `--legend` writes the legend. The full inventory — files, processes, hooks, network, uninstall — is in [docs/IT.md](docs/IT.md), written for whoever has to approve it.
284
352
  - **Counts, not text.** Transcripts contain everything the agent saw; that's why share mode and `--redact` exist. Redacted output keeps tool names, numbers, timings and cost and drops every string — prompts, commands, results, titles. Where a finding would quote something it says `«240 chars»`.
285
353
  - **What is already on disk.** `glassbox fence` finds credentials that reached a transcript (known key formats, secrets named by context, credential-file reads), reports them masked and fingerprinted, and `--shred` overwrites them in place. See above.
286
354
  - **The shape, not the names.** A redacted report still needs to say "this one file was read 48 times and 6 of those failed". `check --redact --legend audit.legend.json` replaces every path with a keyed hash (`file:b94b1a35`, HMAC-SHA256 with a random salt) and writes the key → path map to the legend file, which stays on your machine. The redacted JSON carries `summary.files` (reads, writes, failures, agents, chars per key) and `evidence.files` on findings; `duplicate-subagent-read` keeps its sentence with the key in it. `glassbox reveal report.md --legend audit.legend.json` turns the keys back into paths for you. Re-using the legend keeps keys stable across runs; two machines with two legends produce different keys for the same file. Treat the legend like a password file: local, git-ignored, never attached to the same email as the report. Design notes in DESIGN.md §12.
@@ -408,15 +408,20 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
408
408
  })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
409
409
  'use strict';
410
410
 
411
- const VERSION = '0.11.1';
411
+ const VERSION = '0.12.0';
412
412
 
413
413
  // ---------------------------------------------------------------------------
414
414
  // Rate card (USD per million tokens). Prefix-matched against model ids so dated
415
415
  // ids like claude-sonnet-4-5-20250929 resolve. Longest prefix wins.
416
- // Source: platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-05.
416
+ // Source: platform.claude.com/docs/en/about-claude/pricing, fetched 2026-10-25.
417
+ // `over`: a second price set for requests whose prompt (input + cache reads + cache writes) is over
418
+ // `at` tokens; the whole request pays it (Haiku 5.5).
417
419
  // ---------------------------------------------------------------------------
418
420
  const RATES = {
419
421
  'claude-fable-5-1': { in: 10, out: 50, w5m: 12.5, w1h: 20, read: 0.25 },
422
+ 'claude-opus-5-5': { in: 4, out: 20, w5m: 5, w1h: 8, read: 0.2 },
423
+ 'claude-sonnet-5-5': { in: 2, out: 10, w5m: 2.5, w1h: 4, read: 0.1 },
424
+ 'claude-haiku-5-5': { in: 0.1, out: 0.5, w5m: 0.125, w1h: 0.2, read: 0.01, over: { at: 100000, in: 0.5, out: 2.5, w5m: 0.625, w1h: 1, read: 0.05 } },
420
425
  'claude-mythos-5-1': { in: 10, out: 50, w5m: 12.5, w1h: 20, read: 0.25 },
421
426
  'claude-fable-5': { in: 10, out: 50, w5m: 12.5, w1h: 20, read: 1.0 },
422
427
  'claude-mythos-5': { in: 10, out: 50, w5m: 12.5, w1h: 20, read: 1.0 },
@@ -428,10 +433,15 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
428
433
  'claude-opus-4-1': { in: 15, out: 75, w5m: 18.75, w1h: 30, read: 1.5 },
429
434
  'claude-opus-4-2025': { in: 15, out: 75, w5m: 18.75, w1h: 30, read: 1.5 },
430
435
  'claude-sonnet-5': { in: 2, out: 10, w5m: 2.5, w1h: 4, read: 0.2 },
436
+ 'claude-sonnet-4-6': { in: 3, out: 15, w5m: 3.75, w1h: 6, read: 0.3 },
437
+ 'claude-sonnet-4-5': { in: 3, out: 15, w5m: 3.75, w1h: 6, read: 0.3 },
431
438
  'claude-sonnet-4': { in: 3, out: 15, w5m: 3.75, w1h: 6, read: 0.3 },
432
439
  'claude-haiku-4-5': { in: 1, out: 5, w5m: 1.25, w1h: 2, read: 0.1 },
433
440
  'claude-3-5-haiku': { in: 0.8, out: 4, w5m: 1.0, w1h: 1.6, read: 0.08 },
434
441
  };
442
+ // The day RATES was checked against the pricing page. rateCardCheck() warns when a session ran long after it.
443
+ const RATES_DATE = '2026-10-25';
444
+ const RATES_STALE_DAYS = 60;
435
445
 
436
446
  const DEFAULTS = {
437
447
  retryLoopMin: 3,
@@ -511,6 +521,37 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
511
521
  return Number.isFinite(ms) ? ms : null;
512
522
  }
513
523
 
524
+ // A big transcript's huge strings (multi-MB tool results, images) are kept as head + marker + tail when it is read
525
+ // line by line (src/jsonl.mjs); the marker carries how many characters were left out, so every size below is the
526
+ // original size. Strings without a marker cost one indexOf. DESIGN.md §21.
527
+ // The marker holds a nonce made once per process, so a marker-shaped string already in a transcript (a web page,
528
+ // a file the agent read) counts as ordinary text, and a hash of the cut middle, so two big strings that share a
529
+ // head and a tail are still different (0.12 review).
530
+ const NONCE = Math.random().toString(36).slice(2, 10);
531
+ const ELIDED = '⁣glassbox-elided:' + NONCE + ':';
532
+ const ELIDED_RE = new RegExp('⁣glassbox-elided:' + NONCE + ':(\\d+):[0-9a-f]+⁣', 'g');
533
+ function fnv(s) { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return (h >>> 0).toString(16); }
534
+ function isElided(s) { return typeof s === 'string' && s.indexOf(ELIDED) >= 0; }
535
+ function charsOf(s) {
536
+ s = s == null ? '' : String(s);
537
+ if (s.indexOf(ELIDED) < 0) return s.length;
538
+ let n = s.length; ELIDED_RE.lastIndex = 0; let m;
539
+ while ((m = ELIDED_RE.exec(s))) n += +m[1] - m[0].length;
540
+ return n;
541
+ }
542
+ function slimString(s, keep) {
543
+ keep = keep || 32768;
544
+ if (typeof s !== 'string' || s.length <= keep * 2 + 64) return s;
545
+ const mid = s.slice(keep, s.length - keep);
546
+ return s.slice(0, keep) + ELIDED + mid.length + ':' + fnv(mid) + '⁣' + s.slice(-keep);
547
+ }
548
+ function slimRecord(v, keep) {
549
+ if (typeof v === 'string') return slimString(v, keep);
550
+ if (Array.isArray(v)) { for (let i = 0; i < v.length; i++) v[i] = slimRecord(v[i], keep); return v; }
551
+ if (v && typeof v === 'object') { for (const k of Object.keys(v)) v[k] = slimRecord(v[k], keep); return v; }
552
+ return v;
553
+ }
554
+
514
555
  function textOf(content) {
515
556
  if (content == null) return '';
516
557
  if (typeof content === 'string') return content;
@@ -647,10 +688,11 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
647
688
  } catch (e) { problems.push({ file: name, line: 0, reason: 'bad meta.json' }); }
648
689
  continue;
649
690
  }
650
- const { records, problems: p } = parseLines(f.text, name);
691
+ // Big transcripts arrive already parsed, line by line (src/jsonl.mjs): { name, records, problems, bytes }.
692
+ const { records, problems: p } = Array.isArray(f.records) ? { records: f.records, problems: f.problems || [] } : parseLines(f.text, name);
651
693
  problems.push(...p);
652
- fileMeta.push({ name, records: records.length, bytes: f.text ? f.text.length : 0 });
653
- allRecords.push(...records);
694
+ fileMeta.push({ name, records: records.length, bytes: f.text ? f.text.length : f.bytes || 0 });
695
+ for (const r of records) allRecords.push(r); // not push(...records): a spread of millions overflows the stack
654
696
  }
655
697
 
656
698
  // Stable sort by timestamp where available; keep file order otherwise.
@@ -781,12 +823,13 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
781
823
  call.status = tr.is_error ? 'error' : 'ok';
782
824
  call.isError = !!tr.is_error;
783
825
  call.resultText = text;
784
- call.resultChars = text.length;
826
+ call.resultChars = charsOf(text);
827
+ if (isElided(text)) call.resultElided = true; // kept as head + tail (src/jsonl.mjs)
785
828
  call.resultStructured = structured != null ? structured : null;
786
829
  const imgs = Array.isArray(tr.content) ? tr.content.filter((b) => b && b.type === 'image') : [];
787
830
  call.resultImages = imgs.length;
788
- call.imageBytes = imgs.reduce((s, b) => s + Math.round(((b.source && b.source.data) || '').length * 0.75), 0);
789
- if (structured && typeof structured === 'object' && !text && !imgs.length) call.resultChars = JSON.stringify(structured).length;
831
+ call.imageBytes = imgs.reduce((s, b) => s + Math.round(charsOf((b.source && b.source.data) || '') * 0.75), 0);
832
+ if (structured && typeof structured === 'object' && !text && !imgs.length) call.resultChars = charsOf(JSON.stringify(structured));
790
833
  // stream-json puts is_error only in block; Claude Code also stores toolUseResult as an Error string
791
834
  if (!tr.is_error && typeof structured === 'string' && /^Error/i.test(structured)) { call.status = 'error'; call.isError = true; }
792
835
  if (call.isError) call.denial = denialKind(rec, text);
@@ -818,7 +861,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
818
861
  req = {
819
862
  id: key, agent: a.id, model: msg.model || null, stopReason: null, start: t, end: t, version: rec.version || null,
820
863
  usage: emptyUsage(), blocks: [], turnIndex: turn.index, contextTokens: 0, records: 0,
821
- isApiError: !!rec.isApiErrorMessage, uuid: rec.uuid || null,
864
+ isApiError: !!rec.isApiErrorMessage, apiError: rec.error || null, uuid: rec.uuid || null,
822
865
  };
823
866
  requestByKey.set(key, req);
824
867
  requests.push(req);
@@ -833,6 +876,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
833
876
  if (msg.stop_reason) req.stopReason = msg.stop_reason;
834
877
  if (!req.version && rec.version) req.version = rec.version;
835
878
  if (rec.isApiErrorMessage) req.isApiError = true;
879
+ if (rec.error && !req.apiError) req.apiError = rec.error;
836
880
  const content = Array.isArray(msg.content) ? msg.content : (msg.content != null ? [{ type: 'text', text: String(msg.content) }] : []);
837
881
  const blockIndex = typeof rec.apiBlockIndex === 'number' ? rec.apiBlockIndex : req.blocks.length;
838
882
  for (const b of content) {
@@ -842,7 +886,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
842
886
  id: b.id || (key + ':' + blockIndex), agent: a.id, requestId: req.id, name: b.name || 'unknown', input: b.input || {},
843
887
  start: t, end: null, durationMs: null, status: 'orphan', isError: false, resultText: '', resultChars: 0,
844
888
  resultStructured: null, category: toolCategory(b.name), turnIndex: req.turnIndex, subagentId: null,
845
- inputChars: JSON.stringify(b.input || {}).length, resultImages: 0, imageBytes: 0, denial: null,
889
+ inputChars: charsOf(JSON.stringify(b.input || {})), resultImages: 0, imageBytes: 0, denial: null,
846
890
  };
847
891
  toolCalls.push(call);
848
892
  toolCallById.set(call.id, call);
@@ -960,7 +1004,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
960
1004
  return {
961
1005
  id: tr.tool_use_id || ('result:' + rec._seq), agent: agentId, requestId: null, name: '(unmatched result)', input: {},
962
1006
  start: ts(rec), end: ts(rec), durationMs: 0, status: tr.is_error ? 'error' : 'ok', isError: !!tr.is_error, resultText: text,
963
- resultChars: text.length, resultStructured: null, category: 'other', turnIndex, subagentId: null, inputChars: 0, unmatched: true,
1007
+ resultChars: charsOf(text), resultElided: isElided(text) || undefined, resultStructured: null, category: 'other', turnIndex, subagentId: null, inputChars: 0, unmatched: true,
964
1008
  };
965
1009
  }
966
1010
 
@@ -1309,16 +1353,115 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1309
1353
  // ---------------------------------------------------------------------------
1310
1354
  // Cost
1311
1355
  // ---------------------------------------------------------------------------
1312
- function rateFor(model, rates) {
1356
+ // ---------------------------------------------------------------------------
1357
+ // Platforms. Bedrock ids carry the endpoint: `global.anthropic.claude-…-v1:0` is the global endpoint,
1358
+ // `us.` / `eu.` / `apac.` … (geographic cross-region) and bare `anthropic.` (in-region) are regional.
1359
+ // Vertex ids are `claude-…@20250929`; the endpoint is not in the id. From Sonnet 4.5 / Haiku 4.5 / Opus 4.5
1360
+ // on, regional and multi-region endpoints cost 10% more than global (pricing page, 2026-10-25); earlier
1361
+ // models keep one price. First-party `inference_geo: "us"` is 1.1× for 4.6 and later. Bedrock's and Vertex's
1362
+ // global prices are taken to be Anthropic's list: a cloud price sheet that differs goes in --rates.
1363
+ // ---------------------------------------------------------------------------
1364
+ const NO_REGIONAL_PREMIUM = new Set(['claude-opus-4-1', 'claude-opus-4-2025', 'claude-sonnet-4', 'claude-3-5-haiku']);
1365
+ const NO_GEO_PREMIUM = new Set([...NO_REGIONAL_PREMIUM, 'claude-opus-4-5', 'claude-sonnet-4-5', 'claude-haiku-4-5']);
1366
+ function normaliseModel(id) {
1367
+ let s = String(id || ''), platform = null, endpoint = null;
1368
+ const b = s.match(/^(?:(global|us|us-gov|eu|apac|jp|au|ca)\.)?anthropic\.(claude-.+)$/);
1369
+ if (b) { platform = 'bedrock'; endpoint = b[1] === 'global' ? 'global' : 'regional'; s = b[2].replace(/-v\d+(?::\d+)?$/, ''); }
1370
+ else if (/^claude-[^@]+@/.test(s)) { platform = 'vertex'; s = s.replace(/@.*$/, ''); }
1371
+ return { id: s, platform, endpoint };
1372
+ }
1373
+ // A card with a platform setting: { platform, endpoint: 'global'|'regional', geo: 'us' }, kept off the
1374
+ // enumerable keys so the viewer's rate editor and JSON output never see it.
1375
+ function withPricing(rates, pricing) {
1376
+ const base = rates || RATES, out = Object.assign({}, base);
1377
+ for (const k of ['_custom', '_userKeys']) if (base[k]) Object.defineProperty(out, k, { value: base[k] });
1378
+ Object.defineProperty(out, '_pricing', { value: Object.assign({}, pricing) });
1379
+ return out;
1380
+ }
1381
+ const scaled = new WeakMap();
1382
+ function scaleRate(rate, f) {
1383
+ let byF = scaled.get(rate); if (!byF) { byF = new Map(); scaled.set(rate, byF); }
1384
+ if (!byF.has(f)) { const s = (r) => ({ in: r.in * f, out: r.out * f, w5m: r.w5m * f, w1h: r.w1h * f, read: r.read * f }); const o = s(rate); if (rate.over) o.over = Object.assign(s(rate.over), { at: rate.over.at }); byF.set(f, o); }
1385
+ return byF.get(f);
1386
+ }
1387
+ function rateKey(model, rates) {
1313
1388
  rates = rates || RATES;
1314
1389
  if (!model) return null;
1315
- if (rates[model]) return rates[model];
1390
+ model = normaliseModel(model).id;
1391
+ if (rates[model]) return model;
1316
1392
  let best = null;
1317
1393
  for (const k of Object.keys(rates)) if (model.startsWith(k) && (!best || k.length > best.length)) best = k;
1318
- return best ? rates[best] : null;
1394
+ return best;
1395
+ }
1396
+ // The multiplier for one model id under a card's platform setting. The endpoint in a Bedrock id applies on
1397
+ // its own, except to a row the user priced themselves (their sheet already has the regional price in it);
1398
+ // an explicit --endpoint applies to every row.
1399
+ function premiumFor(model, rates) {
1400
+ const card = rates || RATES, n = normaliseModel(model), k = rateKey(model, card);
1401
+ if (!k) return 1;
1402
+ const p = card._pricing || {}, userRow = card._userKeys && card._userKeys.has(k);
1403
+ const endpoint = p.endpoint || (userRow ? null : n.endpoint);
1404
+ let f = 1;
1405
+ if (endpoint === 'regional' && !NO_REGIONAL_PREMIUM.has(k)) f *= 1.1;
1406
+ if (p.geo === 'us' && (n.platform || p.platform || 'anthropic') === 'anthropic' && !NO_GEO_PREMIUM.has(k)) f *= 1.1;
1407
+ return f;
1408
+ }
1409
+ function rateFor(model, rates) {
1410
+ const k = rateKey(model, rates);
1411
+ if (!k) return null;
1412
+ const rate = (rates || RATES)[k], f = premiumFor(model, rates);
1413
+ return f === 1 ? rate : scaleRate(rate, f);
1414
+ }
1415
+ // One line naming how a session was priced, or null for plain first-party list prices.
1416
+ function pricingNote(models, rates) {
1417
+ const card = rates || RATES, p = card._pricing || {};
1418
+ const seen = { bedrock: new Set(), vertex: false };
1419
+ for (const m of new Set(models || [])) { if (!m || m === '<synthetic>') continue; const n = normaliseModel(m); if (n.platform === 'bedrock') seen.bedrock.add(n.endpoint); if (n.platform === 'vertex') seen.vertex = true; }
1420
+ const platform = p.platform || (seen.bedrock.size ? 'bedrock' : seen.vertex ? 'vertex' : 'anthropic');
1421
+ const parts = [];
1422
+ if (platform === 'bedrock') parts.push(`Bedrock, ${p.endpoint ? `${p.endpoint} endpoint` : seen.bedrock.size ? `${[...seen.bedrock].sort().join(' and ')} endpoint${seen.bedrock.size > 1 ? 's' : ''} from the model ids` : 'global endpoint'}`);
1423
+ else if (platform === 'vertex') parts.push(`Vertex, ${p.endpoint ? `${p.endpoint} endpoint` : 'global endpoint assumed (--endpoint regional if not)'}`);
1424
+ else if (p.endpoint === 'regional') parts.push('a regional endpoint');
1425
+ if (p.geo === 'us') parts.push('US-only inference');
1426
+ if (!parts.length) return null;
1427
+ const extras = [];
1428
+ if (p.endpoint === 'regional' || (p.endpoint == null && seen.bedrock.has('regional'))) extras.push('+10% for regional on Claude 4.5 and later');
1429
+ if (p.geo === 'us') extras.push('+10% for US-only on Claude 4.6 and later');
1430
+ const cloud = platform === 'bedrock' || platform === 'vertex';
1431
+ return `pricing: ${parts.join(', ')}${extras.length ? `; ${extras.join('; ')}` : ''}.${cloud ? ` Global prices taken as Anthropic's list; put your cloud price sheet in --rates if it differs.` : ''}`;
1432
+ }
1433
+ // Is the card still right for these models? A model priced through a shorter row of another version
1434
+ // (claude-opus-5-5 by claude-opus-5 before 0.11) is "approximate": a dated id or a date-prefixed row is not.
1435
+ // `latest` is the newest request time: the card is "old" for a session that ran long after RATES_DATE.
1436
+ // With a custom card (--rates) the age is the user's business and is not reported.
1437
+ function rateCardCheck(models, rates, opts = {}) {
1438
+ const out = { date: RATES_DATE, ageDays: null, old: false, approximate: [], unpriced: [] };
1439
+ for (const m of new Set(models || [])) {
1440
+ if (!m || m === '<synthetic>') continue;
1441
+ const k = rateKey(m, rates);
1442
+ if (!k) { out.unpriced.push(m); continue; }
1443
+ const rest = normaliseModel(m).id.replace(/-\d{8}$/, '').slice(k.length);
1444
+ if (/^-\d/.test(rest)) out.approximate.push({ model: m, as: k });
1445
+ }
1446
+ if (opts.latest != null && !opts.custom) {
1447
+ out.ageDays = Math.floor((opts.latest - Date.parse(RATES_DATE + 'T00:00:00Z')) / 864e5);
1448
+ out.old = out.ageDays > RATES_STALE_DAYS;
1449
+ }
1450
+ out.ok = !out.old && !out.approximate.length && !out.unpriced.length;
1451
+ return out;
1452
+ }
1453
+ // One line for a report, or null when the card is fine.
1454
+ function rateCardLine(rc) {
1455
+ if (!rc || rc.ok) return null;
1456
+ const parts = [];
1457
+ if (rc.approximate.length) parts.push(rc.approximate.map((a) => `${a.model} priced as ${a.as}`).join(', ') + ' (no row of its own)');
1458
+ if (rc.unpriced.length) parts.push(`no price for ${rc.unpriced.join(', ')}`);
1459
+ if (rc.old) parts.push(`the built-in card is from ${rc.date}, ${rc.ageDays} days before this ran`);
1460
+ return `rate card: ${parts.join('; ')}. Check platform.claude.com/docs/en/about-claude/pricing, or pass --rates FILE.`;
1319
1461
  }
1320
1462
  function requestCost(u, rate) {
1321
1463
  if (!rate) return null;
1464
+ if (rate.over && u.input + u.cacheRead + u.cacheWrite > rate.over.at) rate = rate.over;
1322
1465
  const w5 = u.cacheWrite5m || 0, w1 = u.cacheWrite1h || 0;
1323
1466
  const write = (w5 || w1) ? (w5 * rate.w5m + w1 * rate.w1h) : (u.cacheWrite * rate.w5m);
1324
1467
  return (u.input * rate.in + u.cacheRead * rate.read + write + u.output * rate.out) / 1e6;
@@ -1628,7 +1771,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1628
1771
  }
1629
1772
  function toJsonl(records) { return records.map((r) => JSON.stringify(r)).join('\n') + '\n'; }
1630
1773
 
1631
- return { VERSION, RATES, DEFAULTS, ADVICE, METRICS, parseLines, parseTrace, diagnose, denialKind, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, fmtMetric, fmtRatio, fmtChange, stableStringify, inputSummary, adviceFor, redactDetail, reportMarkdown, compare, compareMarkdown, toolTable, fileStats, filePathOf, normalisePath };
1774
+ return { VERSION, RATES, DEFAULTS, ADVICE, METRICS, parseLines, normaliseLoose, charsOf, isElided, slimString, slimRecord, parseTrace, diagnose, denialKind, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, fmtMetric, fmtRatio, fmtChange, stableStringify, inputSummary, adviceFor, redactDetail, reportMarkdown, compare, compareMarkdown, toolTable, fileStats, filePathOf, normalisePath, RATES_DATE, rateKey, rateCardCheck, rateCardLine, normaliseModel, withPricing, premiumFor, pricingNote, requestCost };
1632
1775
  });
1633
1776
  </script>
1634
1777
  <script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glassbox-trace",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Other tools show what happened in a Claude Code session; Glassbox tells you what went wrong. Timeline, token and cost burn, auto-diagnostics with evidence and advice, session compare, live tail, a fleet view, a secrets scanner for transcripts (fence), a CLAUDE.md compliance report (adhere), a memory the next session starts with plus a search over past sessions (recall) and an audit of what loads before the first prompt, a CLI and a Claude Code Stop hook that feeds the findings back to the agent.",
5
5
  "main": "src/trace-core.js",
6
6
  "scripts": {
@@ -10,7 +10,8 @@
10
10
  "e2e": "node test/browser.e2e.mjs",
11
11
  "check": "npm test && npm run build && npm run e2e",
12
12
  "audit": "node test/audit.mjs",
13
- "prepublishOnly": "npm test && npm run build"
13
+ "prepublishOnly": "npm test && npm run build",
14
+ "bundle": "node scripts/bundle.mjs"
14
15
  },
15
16
  "license": "MIT",
16
17
  "author": "Aldo Hushi",