glassbox-trace 0.11.0 → 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/src/trace-core.js CHANGED
@@ -9,15 +9,20 @@
9
9
  })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
10
10
  'use strict';
11
11
 
12
- const VERSION = '0.11.0';
12
+ const VERSION = '0.12.0';
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Rate card (USD per million tokens). Prefix-matched against model ids so dated
16
16
  // ids like claude-sonnet-4-5-20250929 resolve. Longest prefix wins.
17
- // Source: platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-05.
17
+ // Source: platform.claude.com/docs/en/about-claude/pricing, fetched 2026-10-25.
18
+ // `over`: a second price set for requests whose prompt (input + cache reads + cache writes) is over
19
+ // `at` tokens; the whole request pays it (Haiku 5.5).
18
20
  // ---------------------------------------------------------------------------
19
21
  const RATES = {
20
22
  'claude-fable-5-1': { in: 10, out: 50, w5m: 12.5, w1h: 20, read: 0.25 },
23
+ 'claude-opus-5-5': { in: 4, out: 20, w5m: 5, w1h: 8, read: 0.2 },
24
+ 'claude-sonnet-5-5': { in: 2, out: 10, w5m: 2.5, w1h: 4, read: 0.1 },
25
+ '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 } },
21
26
  'claude-mythos-5-1': { in: 10, out: 50, w5m: 12.5, w1h: 20, read: 0.25 },
22
27
  'claude-fable-5': { in: 10, out: 50, w5m: 12.5, w1h: 20, read: 1.0 },
23
28
  'claude-mythos-5': { in: 10, out: 50, w5m: 12.5, w1h: 20, read: 1.0 },
@@ -29,10 +34,15 @@
29
34
  'claude-opus-4-1': { in: 15, out: 75, w5m: 18.75, w1h: 30, read: 1.5 },
30
35
  'claude-opus-4-2025': { in: 15, out: 75, w5m: 18.75, w1h: 30, read: 1.5 },
31
36
  'claude-sonnet-5': { in: 2, out: 10, w5m: 2.5, w1h: 4, read: 0.2 },
37
+ 'claude-sonnet-4-6': { in: 3, out: 15, w5m: 3.75, w1h: 6, read: 0.3 },
38
+ 'claude-sonnet-4-5': { in: 3, out: 15, w5m: 3.75, w1h: 6, read: 0.3 },
32
39
  'claude-sonnet-4': { in: 3, out: 15, w5m: 3.75, w1h: 6, read: 0.3 },
33
40
  'claude-haiku-4-5': { in: 1, out: 5, w5m: 1.25, w1h: 2, read: 0.1 },
34
41
  'claude-3-5-haiku': { in: 0.8, out: 4, w5m: 1.0, w1h: 1.6, read: 0.08 },
35
42
  };
43
+ // The day RATES was checked against the pricing page. rateCardCheck() warns when a session ran long after it.
44
+ const RATES_DATE = '2026-10-25';
45
+ const RATES_STALE_DAYS = 60;
36
46
 
37
47
  const DEFAULTS = {
38
48
  retryLoopMin: 3,
@@ -112,6 +122,37 @@
112
122
  return Number.isFinite(ms) ? ms : null;
113
123
  }
114
124
 
125
+ // A big transcript's huge strings (multi-MB tool results, images) are kept as head + marker + tail when it is read
126
+ // line by line (src/jsonl.mjs); the marker carries how many characters were left out, so every size below is the
127
+ // original size. Strings without a marker cost one indexOf. DESIGN.md §21.
128
+ // The marker holds a nonce made once per process, so a marker-shaped string already in a transcript (a web page,
129
+ // a file the agent read) counts as ordinary text, and a hash of the cut middle, so two big strings that share a
130
+ // head and a tail are still different (0.12 review).
131
+ const NONCE = Math.random().toString(36).slice(2, 10);
132
+ const ELIDED = '⁣glassbox-elided:' + NONCE + ':';
133
+ const ELIDED_RE = new RegExp('⁣glassbox-elided:' + NONCE + ':(\\d+):[0-9a-f]+⁣', 'g');
134
+ 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); }
135
+ function isElided(s) { return typeof s === 'string' && s.indexOf(ELIDED) >= 0; }
136
+ function charsOf(s) {
137
+ s = s == null ? '' : String(s);
138
+ if (s.indexOf(ELIDED) < 0) return s.length;
139
+ let n = s.length; ELIDED_RE.lastIndex = 0; let m;
140
+ while ((m = ELIDED_RE.exec(s))) n += +m[1] - m[0].length;
141
+ return n;
142
+ }
143
+ function slimString(s, keep) {
144
+ keep = keep || 32768;
145
+ if (typeof s !== 'string' || s.length <= keep * 2 + 64) return s;
146
+ const mid = s.slice(keep, s.length - keep);
147
+ return s.slice(0, keep) + ELIDED + mid.length + ':' + fnv(mid) + '⁣' + s.slice(-keep);
148
+ }
149
+ function slimRecord(v, keep) {
150
+ if (typeof v === 'string') return slimString(v, keep);
151
+ if (Array.isArray(v)) { for (let i = 0; i < v.length; i++) v[i] = slimRecord(v[i], keep); return v; }
152
+ if (v && typeof v === 'object') { for (const k of Object.keys(v)) v[k] = slimRecord(v[k], keep); return v; }
153
+ return v;
154
+ }
155
+
115
156
  function textOf(content) {
116
157
  if (content == null) return '';
117
158
  if (typeof content === 'string') return content;
@@ -248,10 +289,11 @@
248
289
  } catch (e) { problems.push({ file: name, line: 0, reason: 'bad meta.json' }); }
249
290
  continue;
250
291
  }
251
- const { records, problems: p } = parseLines(f.text, name);
292
+ // Big transcripts arrive already parsed, line by line (src/jsonl.mjs): { name, records, problems, bytes }.
293
+ const { records, problems: p } = Array.isArray(f.records) ? { records: f.records, problems: f.problems || [] } : parseLines(f.text, name);
252
294
  problems.push(...p);
253
- fileMeta.push({ name, records: records.length, bytes: f.text ? f.text.length : 0 });
254
- allRecords.push(...records);
295
+ fileMeta.push({ name, records: records.length, bytes: f.text ? f.text.length : f.bytes || 0 });
296
+ for (const r of records) allRecords.push(r); // not push(...records): a spread of millions overflows the stack
255
297
  }
256
298
 
257
299
  // Stable sort by timestamp where available; keep file order otherwise.
@@ -382,12 +424,13 @@
382
424
  call.status = tr.is_error ? 'error' : 'ok';
383
425
  call.isError = !!tr.is_error;
384
426
  call.resultText = text;
385
- call.resultChars = text.length;
427
+ call.resultChars = charsOf(text);
428
+ if (isElided(text)) call.resultElided = true; // kept as head + tail (src/jsonl.mjs)
386
429
  call.resultStructured = structured != null ? structured : null;
387
430
  const imgs = Array.isArray(tr.content) ? tr.content.filter((b) => b && b.type === 'image') : [];
388
431
  call.resultImages = imgs.length;
389
- call.imageBytes = imgs.reduce((s, b) => s + Math.round(((b.source && b.source.data) || '').length * 0.75), 0);
390
- if (structured && typeof structured === 'object' && !text && !imgs.length) call.resultChars = JSON.stringify(structured).length;
432
+ call.imageBytes = imgs.reduce((s, b) => s + Math.round(charsOf((b.source && b.source.data) || '') * 0.75), 0);
433
+ if (structured && typeof structured === 'object' && !text && !imgs.length) call.resultChars = charsOf(JSON.stringify(structured));
391
434
  // stream-json puts is_error only in block; Claude Code also stores toolUseResult as an Error string
392
435
  if (!tr.is_error && typeof structured === 'string' && /^Error/i.test(structured)) { call.status = 'error'; call.isError = true; }
393
436
  if (call.isError) call.denial = denialKind(rec, text);
@@ -419,7 +462,7 @@
419
462
  req = {
420
463
  id: key, agent: a.id, model: msg.model || null, stopReason: null, start: t, end: t, version: rec.version || null,
421
464
  usage: emptyUsage(), blocks: [], turnIndex: turn.index, contextTokens: 0, records: 0,
422
- isApiError: !!rec.isApiErrorMessage, uuid: rec.uuid || null,
465
+ isApiError: !!rec.isApiErrorMessage, apiError: rec.error || null, uuid: rec.uuid || null,
423
466
  };
424
467
  requestByKey.set(key, req);
425
468
  requests.push(req);
@@ -434,6 +477,7 @@
434
477
  if (msg.stop_reason) req.stopReason = msg.stop_reason;
435
478
  if (!req.version && rec.version) req.version = rec.version;
436
479
  if (rec.isApiErrorMessage) req.isApiError = true;
480
+ if (rec.error && !req.apiError) req.apiError = rec.error;
437
481
  const content = Array.isArray(msg.content) ? msg.content : (msg.content != null ? [{ type: 'text', text: String(msg.content) }] : []);
438
482
  const blockIndex = typeof rec.apiBlockIndex === 'number' ? rec.apiBlockIndex : req.blocks.length;
439
483
  for (const b of content) {
@@ -443,7 +487,7 @@
443
487
  id: b.id || (key + ':' + blockIndex), agent: a.id, requestId: req.id, name: b.name || 'unknown', input: b.input || {},
444
488
  start: t, end: null, durationMs: null, status: 'orphan', isError: false, resultText: '', resultChars: 0,
445
489
  resultStructured: null, category: toolCategory(b.name), turnIndex: req.turnIndex, subagentId: null,
446
- inputChars: JSON.stringify(b.input || {}).length, resultImages: 0, imageBytes: 0, denial: null,
490
+ inputChars: charsOf(JSON.stringify(b.input || {})), resultImages: 0, imageBytes: 0, denial: null,
447
491
  };
448
492
  toolCalls.push(call);
449
493
  toolCallById.set(call.id, call);
@@ -561,7 +605,7 @@
561
605
  return {
562
606
  id: tr.tool_use_id || ('result:' + rec._seq), agent: agentId, requestId: null, name: '(unmatched result)', input: {},
563
607
  start: ts(rec), end: ts(rec), durationMs: 0, status: tr.is_error ? 'error' : 'ok', isError: !!tr.is_error, resultText: text,
564
- resultChars: text.length, resultStructured: null, category: 'other', turnIndex, subagentId: null, inputChars: 0, unmatched: true,
608
+ resultChars: charsOf(text), resultElided: isElided(text) || undefined, resultStructured: null, category: 'other', turnIndex, subagentId: null, inputChars: 0, unmatched: true,
565
609
  };
566
610
  }
567
611
 
@@ -910,16 +954,115 @@
910
954
  // ---------------------------------------------------------------------------
911
955
  // Cost
912
956
  // ---------------------------------------------------------------------------
913
- function rateFor(model, rates) {
957
+ // ---------------------------------------------------------------------------
958
+ // Platforms. Bedrock ids carry the endpoint: `global.anthropic.claude-…-v1:0` is the global endpoint,
959
+ // `us.` / `eu.` / `apac.` … (geographic cross-region) and bare `anthropic.` (in-region) are regional.
960
+ // Vertex ids are `claude-…@20250929`; the endpoint is not in the id. From Sonnet 4.5 / Haiku 4.5 / Opus 4.5
961
+ // on, regional and multi-region endpoints cost 10% more than global (pricing page, 2026-10-25); earlier
962
+ // models keep one price. First-party `inference_geo: "us"` is 1.1× for 4.6 and later. Bedrock's and Vertex's
963
+ // global prices are taken to be Anthropic's list: a cloud price sheet that differs goes in --rates.
964
+ // ---------------------------------------------------------------------------
965
+ const NO_REGIONAL_PREMIUM = new Set(['claude-opus-4-1', 'claude-opus-4-2025', 'claude-sonnet-4', 'claude-3-5-haiku']);
966
+ const NO_GEO_PREMIUM = new Set([...NO_REGIONAL_PREMIUM, 'claude-opus-4-5', 'claude-sonnet-4-5', 'claude-haiku-4-5']);
967
+ function normaliseModel(id) {
968
+ let s = String(id || ''), platform = null, endpoint = null;
969
+ const b = s.match(/^(?:(global|us|us-gov|eu|apac|jp|au|ca)\.)?anthropic\.(claude-.+)$/);
970
+ if (b) { platform = 'bedrock'; endpoint = b[1] === 'global' ? 'global' : 'regional'; s = b[2].replace(/-v\d+(?::\d+)?$/, ''); }
971
+ else if (/^claude-[^@]+@/.test(s)) { platform = 'vertex'; s = s.replace(/@.*$/, ''); }
972
+ return { id: s, platform, endpoint };
973
+ }
974
+ // A card with a platform setting: { platform, endpoint: 'global'|'regional', geo: 'us' }, kept off the
975
+ // enumerable keys so the viewer's rate editor and JSON output never see it.
976
+ function withPricing(rates, pricing) {
977
+ const base = rates || RATES, out = Object.assign({}, base);
978
+ for (const k of ['_custom', '_userKeys']) if (base[k]) Object.defineProperty(out, k, { value: base[k] });
979
+ Object.defineProperty(out, '_pricing', { value: Object.assign({}, pricing) });
980
+ return out;
981
+ }
982
+ const scaled = new WeakMap();
983
+ function scaleRate(rate, f) {
984
+ let byF = scaled.get(rate); if (!byF) { byF = new Map(); scaled.set(rate, byF); }
985
+ 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); }
986
+ return byF.get(f);
987
+ }
988
+ function rateKey(model, rates) {
914
989
  rates = rates || RATES;
915
990
  if (!model) return null;
916
- if (rates[model]) return rates[model];
991
+ model = normaliseModel(model).id;
992
+ if (rates[model]) return model;
917
993
  let best = null;
918
994
  for (const k of Object.keys(rates)) if (model.startsWith(k) && (!best || k.length > best.length)) best = k;
919
- return best ? rates[best] : null;
995
+ return best;
996
+ }
997
+ // The multiplier for one model id under a card's platform setting. The endpoint in a Bedrock id applies on
998
+ // its own, except to a row the user priced themselves (their sheet already has the regional price in it);
999
+ // an explicit --endpoint applies to every row.
1000
+ function premiumFor(model, rates) {
1001
+ const card = rates || RATES, n = normaliseModel(model), k = rateKey(model, card);
1002
+ if (!k) return 1;
1003
+ const p = card._pricing || {}, userRow = card._userKeys && card._userKeys.has(k);
1004
+ const endpoint = p.endpoint || (userRow ? null : n.endpoint);
1005
+ let f = 1;
1006
+ if (endpoint === 'regional' && !NO_REGIONAL_PREMIUM.has(k)) f *= 1.1;
1007
+ if (p.geo === 'us' && (n.platform || p.platform || 'anthropic') === 'anthropic' && !NO_GEO_PREMIUM.has(k)) f *= 1.1;
1008
+ return f;
1009
+ }
1010
+ function rateFor(model, rates) {
1011
+ const k = rateKey(model, rates);
1012
+ if (!k) return null;
1013
+ const rate = (rates || RATES)[k], f = premiumFor(model, rates);
1014
+ return f === 1 ? rate : scaleRate(rate, f);
1015
+ }
1016
+ // One line naming how a session was priced, or null for plain first-party list prices.
1017
+ function pricingNote(models, rates) {
1018
+ const card = rates || RATES, p = card._pricing || {};
1019
+ const seen = { bedrock: new Set(), vertex: false };
1020
+ 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; }
1021
+ const platform = p.platform || (seen.bedrock.size ? 'bedrock' : seen.vertex ? 'vertex' : 'anthropic');
1022
+ const parts = [];
1023
+ 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'}`);
1024
+ else if (platform === 'vertex') parts.push(`Vertex, ${p.endpoint ? `${p.endpoint} endpoint` : 'global endpoint assumed (--endpoint regional if not)'}`);
1025
+ else if (p.endpoint === 'regional') parts.push('a regional endpoint');
1026
+ if (p.geo === 'us') parts.push('US-only inference');
1027
+ if (!parts.length) return null;
1028
+ const extras = [];
1029
+ if (p.endpoint === 'regional' || (p.endpoint == null && seen.bedrock.has('regional'))) extras.push('+10% for regional on Claude 4.5 and later');
1030
+ if (p.geo === 'us') extras.push('+10% for US-only on Claude 4.6 and later');
1031
+ const cloud = platform === 'bedrock' || platform === 'vertex';
1032
+ 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.` : ''}`;
1033
+ }
1034
+ // Is the card still right for these models? A model priced through a shorter row of another version
1035
+ // (claude-opus-5-5 by claude-opus-5 before 0.11) is "approximate": a dated id or a date-prefixed row is not.
1036
+ // `latest` is the newest request time: the card is "old" for a session that ran long after RATES_DATE.
1037
+ // With a custom card (--rates) the age is the user's business and is not reported.
1038
+ function rateCardCheck(models, rates, opts = {}) {
1039
+ const out = { date: RATES_DATE, ageDays: null, old: false, approximate: [], unpriced: [] };
1040
+ for (const m of new Set(models || [])) {
1041
+ if (!m || m === '<synthetic>') continue;
1042
+ const k = rateKey(m, rates);
1043
+ if (!k) { out.unpriced.push(m); continue; }
1044
+ const rest = normaliseModel(m).id.replace(/-\d{8}$/, '').slice(k.length);
1045
+ if (/^-\d/.test(rest)) out.approximate.push({ model: m, as: k });
1046
+ }
1047
+ if (opts.latest != null && !opts.custom) {
1048
+ out.ageDays = Math.floor((opts.latest - Date.parse(RATES_DATE + 'T00:00:00Z')) / 864e5);
1049
+ out.old = out.ageDays > RATES_STALE_DAYS;
1050
+ }
1051
+ out.ok = !out.old && !out.approximate.length && !out.unpriced.length;
1052
+ return out;
1053
+ }
1054
+ // One line for a report, or null when the card is fine.
1055
+ function rateCardLine(rc) {
1056
+ if (!rc || rc.ok) return null;
1057
+ const parts = [];
1058
+ if (rc.approximate.length) parts.push(rc.approximate.map((a) => `${a.model} priced as ${a.as}`).join(', ') + ' (no row of its own)');
1059
+ if (rc.unpriced.length) parts.push(`no price for ${rc.unpriced.join(', ')}`);
1060
+ if (rc.old) parts.push(`the built-in card is from ${rc.date}, ${rc.ageDays} days before this ran`);
1061
+ return `rate card: ${parts.join('; ')}. Check platform.claude.com/docs/en/about-claude/pricing, or pass --rates FILE.`;
920
1062
  }
921
1063
  function requestCost(u, rate) {
922
1064
  if (!rate) return null;
1065
+ if (rate.over && u.input + u.cacheRead + u.cacheWrite > rate.over.at) rate = rate.over;
923
1066
  const w5 = u.cacheWrite5m || 0, w1 = u.cacheWrite1h || 0;
924
1067
  const write = (w5 || w1) ? (w5 * rate.w5m + w1 * rate.w1h) : (u.cacheWrite * rate.w5m);
925
1068
  return (u.input * rate.in + u.cacheRead * rate.read + write + u.output * rate.out) / 1e6;
@@ -1229,5 +1372,5 @@
1229
1372
  }
1230
1373
  function toJsonl(records) { return records.map((r) => JSON.stringify(r)).join('\n') + '\n'; }
1231
1374
 
1232
- 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 };
1375
+ 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 };
1233
1376
  });