@xamukavila/pxpipe 0.8.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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/bin/cli.js +7 -0
  4. package/dist/core/applicability.d.ts +31 -0
  5. package/dist/core/applicability.js +96 -0
  6. package/dist/core/atlas-gray.d.ts +26 -0
  7. package/dist/core/atlas-gray.js +64 -0
  8. package/dist/core/atlas.d.ts +34 -0
  9. package/dist/core/atlas.js +71 -0
  10. package/dist/core/baseline.d.ts +80 -0
  11. package/dist/core/baseline.js +101 -0
  12. package/dist/core/caveman.d.ts +38 -0
  13. package/dist/core/caveman.js +183 -0
  14. package/dist/core/export.d.ts +128 -0
  15. package/dist/core/export.js +390 -0
  16. package/dist/core/factsheet.d.ts +78 -0
  17. package/dist/core/factsheet.js +216 -0
  18. package/dist/core/gpt-model-profiles.d.ts +60 -0
  19. package/dist/core/gpt-model-profiles.js +161 -0
  20. package/dist/core/history.d.ts +141 -0
  21. package/dist/core/history.js +553 -0
  22. package/dist/core/index.d.ts +8 -0
  23. package/dist/core/index.js +8 -0
  24. package/dist/core/library.d.ts +74 -0
  25. package/dist/core/library.js +133 -0
  26. package/dist/core/measurement.d.ts +22 -0
  27. package/dist/core/measurement.js +213 -0
  28. package/dist/core/openai-history.d.ts +124 -0
  29. package/dist/core/openai-history.js +494 -0
  30. package/dist/core/openai-savings.d.ts +44 -0
  31. package/dist/core/openai-savings.js +75 -0
  32. package/dist/core/openai.d.ts +24 -0
  33. package/dist/core/openai.js +839 -0
  34. package/dist/core/png.d.ts +11 -0
  35. package/dist/core/png.js +132 -0
  36. package/dist/core/proxy.d.ts +81 -0
  37. package/dist/core/proxy.js +730 -0
  38. package/dist/core/render.d.ts +188 -0
  39. package/dist/core/render.js +785 -0
  40. package/dist/core/schema-strip.d.ts +29 -0
  41. package/dist/core/schema-strip.js +160 -0
  42. package/dist/core/tracker.d.ts +154 -0
  43. package/dist/core/tracker.js +216 -0
  44. package/dist/core/transform.d.ts +362 -0
  45. package/dist/core/transform.js +1828 -0
  46. package/dist/core/types.d.ts +77 -0
  47. package/dist/core/types.js +8 -0
  48. package/dist/dashboard/fragments.d.ts +36 -0
  49. package/dist/dashboard/fragments.js +938 -0
  50. package/dist/dashboard/types.d.ts +154 -0
  51. package/dist/dashboard/types.js +3 -0
  52. package/dist/dashboard/vendor.d.ts +3 -0
  53. package/dist/dashboard/vendor.js +6 -0
  54. package/dist/dashboard.d.ts +245 -0
  55. package/dist/dashboard.js +1140 -0
  56. package/dist/export-collect.d.ts +36 -0
  57. package/dist/export-collect.js +59 -0
  58. package/dist/node.d.ts +9 -0
  59. package/dist/node.js +9038 -0
  60. package/dist/sessions.d.ts +172 -0
  61. package/dist/sessions.js +510 -0
  62. package/dist/stats.d.ts +74 -0
  63. package/dist/stats.js +248 -0
  64. package/dist/worker.d.ts +53 -0
  65. package/dist/worker.js +102 -0
  66. package/package.json +96 -0
@@ -0,0 +1,1140 @@
1
+ /**
2
+ * Live dashboard for the Node host. Serves the main HTML page and JSON
3
+ * polling endpoints. All "/api/*.json" endpoints recompute from disk on
4
+ * every request — pxpipe doesn't have a query layer, but a 1.5 MB JSONL
5
+ * streams in well under 100 ms.
6
+ *
7
+ * Legacy live-poll endpoints (left in place, the existing tick() loop uses
8
+ * them):
9
+ *
10
+ * GET /, /dashboard → main HTML page
11
+ * GET /proxy-stats → JSON aggregate over the in-mem ring
12
+ * GET /proxy-recent → JSON ring buffer of recent requests
13
+ * GET /proxy-latest-png[?crop=N] → raw PNG of the latest rendered image
14
+ *
15
+ * Session endpoints (read-only telemetry — no destructive operations):
16
+ *
17
+ * GET /api/sessions.json → grouped sessions (sha8 + project + counts)
18
+ * GET /api/stats.json → full-history aggregate (formerly `pxpipe stats`)
19
+ *
20
+ * Metric formulas and HTML shell originally ported from the Python reference
21
+ * implementation (deleted after live cache-rate validation hit 98.7% by tokens).
22
+ *
23
+ * Node-only by design. Workers host has no dashboard; use Workers Logs.
24
+ *
25
+ * Memory bound: ring buffer cap 50 events + a parallel ring of the last 50
26
+ * rendered PNGs (images are never persisted to disk, so this ring is the
27
+ * only place to view them). At a typical 75 KB PNG that's ~3-4 MB resident;
28
+ * a process restart starts the image ring empty.
29
+ */
30
+ import * as fs from 'node:fs';
31
+ import * as readline from 'node:readline';
32
+ import { computeActualInputEff, computeBaselineInputEff, deriveBaselineWarmth, } from './core/baseline.js';
33
+ import { computeOpenAIActualInputEff, computeOpenAIBaselineInputEff, computeOpenAIBaselineRawTokens, openAIOutputRate, } from './core/openai-savings.js';
34
+ import { aggregateSessions, claudeCodeMap, filterSessions, } from './sessions.js';
35
+ import { aggregateEventsFile, summaryToJson } from './stats.js';
36
+ // Server-rendered UI (htmx + Alpine, vendored). No client bundle - the
37
+ // fragments module renders finished HTML from the same payloads the JSON
38
+ // endpoints serve.
39
+ import { renderPage, renderToggleFragment, renderModelsFragment, renderContextMapFragment, renderSessionSummaryFragment, renderHeaderFragment, renderRecentFragment, renderLatestFragment, renderSessionsFragment, renderStatsTableFragment, } from './dashboard/fragments.js';
40
+ import { getAllowedModelBases, getConfiguredModelBases, setAllowedModelBases, } from './core/applicability.js';
41
+ const RECENT_CAP = 50;
42
+ /** How many rendered PNGs to keep in the in-memory image ring. Matches
43
+ * RECENT_CAP so every visible recent-requests row can still resolve its
44
+ * image. Images are never written to disk — this ring is the only store. */
45
+ const IMAGE_RING_CAP = 800;
46
+ /*
47
+ * ─────────────────────────────────────────────────────────────────────────
48
+ * PROVENANCE — every magic number below should trace to one of these:
49
+ *
50
+ * [docs-pricing] docs.anthropic.com/en/docs/about-claude/pricing
51
+ * Verified 2026-05-19 via WebFetch. The page lists per-model
52
+ * per-million-token rates and the cache-tier multipliers.
53
+ *
54
+ * [count_tokens] docs.anthropic.com/en/api/messages-count-tokens
55
+ * The dashboard's baseline number comes from a free side
56
+ * call to /v1/messages/count_tokens on the PRE-COMPRESSION
57
+ * body. No estimation, no α, no regression.
58
+ * ─────────────────────────────────────────────────────────────────────────
59
+ */
60
+ /** Output-token rate multiplier (referenced to the input base rate).
61
+ * Source: [docs-pricing] — Opus 4.7 lists $5/Mtok input and $25/Mtok
62
+ * output (5×); Sonnet 4.7 lists $3/Mtok input and $15/Mtok output (5×).
63
+ * Same ratio holds on Haiku 4.5 ($1/$5). */
64
+ const OUTPUT_TOKEN_RATE = 5.0;
65
+ /** Per-million-token input rate ASSUMED for the headline dollar figure.
66
+ * Source: https://docs.claude.com/en/docs/about-claude/pricing — Opus 4.7
67
+ * input is $5/Mtok (same as Opus 4.5 / 4.6; the previous "$2.50" value
68
+ * here was a regression). Cache-write 5m = $6.25/Mtok (1.25×),
69
+ * cache-read = $0.50/Mtok (0.10×).
70
+ *
71
+ * This is exposed on /proxy-stats as `pricing_assumptions.input_per_mtok`
72
+ * so the operator can see what we assumed and override if they're
73
+ * running against a non-default deployment (Bedrock/Vertex add a 10%
74
+ * premium; Sonnet would be $3/Mtok, etc.).
75
+ *
76
+ * NOTE: Opus 4.7 uses a different tokenizer than 4.5/4.6 (per
77
+ * docs.claude.com/en/docs/about-claude/pricing), so even at the same
78
+ * $/Mtok rate, the same string maps to a different token count. The
79
+ * honest oracle for "tokens in this body" is `count_tokens` against the
80
+ * actual target model; do not trust hardcoded chars-per-token or
81
+ * tokens-per-image constants on 4.7 without verifying against the
82
+ * upstream probe. */
83
+ // 2026-06-09: gate is Fable-5-only and Fable 5 bills $10/MTok input,
84
+ // so the dashboard dollar figure uses that rate. Output tokens are
85
+ // excluded entirely (the proxy can't move them), so this still
86
+ // understates the real bill - treat it as "input-side $ saved".
87
+ export const ASSUMED_INPUT_USD_PER_MTOK = 10.0;
88
+ /** Route per-event accounting by upstream. OpenAI paths use the GPT cost
89
+ * model (vision-token imaging, automatic 0.1× prefix cache, no count_tokens
90
+ * probe, 8× output); everything else uses the Anthropic cache-aware baseline.
91
+ * Anthropic paths are `/v1/messages[/count_tokens]`; neither word appears. */
92
+ function isOpenAIEvent(path) {
93
+ if (!path)
94
+ return false;
95
+ return path.includes('responses') || path.includes('chat/completions');
96
+ }
97
+ /** Cache-aware eff bundle for one GPT event. Shared by the live `update()`
98
+ * and `replay()` paths so both read identical per-row numbers. Pure: takes
99
+ * plain scalars (replay has no Usage/TransformInfo objects, only JSONL fields).
100
+ *
101
+ * GPT differs from Anthropic on every axis: input_tokens already INCLUDES the
102
+ * cached subset (`cachedTokens`), there is no cache-create premium, the cached
103
+ * prefix reads at ~0.1×, and the baseline is the measured `baselineImagedTokens`
104
+ * (o200k text-token cost of the imaged content) vs the vision-token `imageTokens`
105
+ * pxpipe actually paid — not a count_tokens probe. No per-session warmth state:
106
+ * OpenAI caching is automatic/prefix-based and the discount is already folded
107
+ * into the cached-input rate. See src/core/openai-savings.ts. */
108
+ function gptEff(args) {
109
+ const { model, inputTokens: inp, outputTokens: out, cachedTokens: cached } = args;
110
+ const { imageTokens, baselineImagedTokens, compressed } = args;
111
+ const haveUsage = inp > 0 || out > 0;
112
+ // The transform measured what the imaged content would have cost as o200k
113
+ // text; without it there is no counterfactual to credit.
114
+ const haveBaseline = baselineImagedTokens > 0;
115
+ const actualInputEff = haveUsage ? computeOpenAIActualInputEff(inp, cached, model) : 0;
116
+ const creditSaving = haveBaseline && haveUsage && compressed;
117
+ const baselineInputEff = creditSaving
118
+ ? computeOpenAIBaselineInputEff(inp, cached, imageTokens, baselineImagedTokens, model)
119
+ : actualInputEff;
120
+ const outputEquiv = haveUsage ? out * openAIOutputRate(model) : 0;
121
+ // Raw, rate-free token counts for the session's compression ratio and the
122
+ // Details panel: actual = what we sent; baseline = the text-only equivalent.
123
+ const rawActual = inp;
124
+ const rawBaseline = computeOpenAIBaselineRawTokens(inp, imageTokens, baselineImagedTokens);
125
+ return {
126
+ haveUsage,
127
+ haveBaseline,
128
+ creditSaving,
129
+ actualInputEff,
130
+ baselineInputEff,
131
+ outputEquiv,
132
+ rawActual,
133
+ rawBaseline,
134
+ };
135
+ }
136
+ export class DashboardState {
137
+ recent = [];
138
+ /** Per-session dollar-weighted totals, keyed by `info.firstUserSha8`. The
139
+ * dashboard surfaces ONLY the most-recently-active session via the
140
+ * `serveCurrentSessionJson` endpoint — older sessions linger in the Map
141
+ * so a tab refresh during a brief lull still finds the previous session,
142
+ * but get evicted at `SESSION_CAP` to bound memory in long-running hosts. */
143
+ sessions = new Map();
144
+ /** sha8 of the most-recently-active session id. null when no events have
145
+ * ever carried a `firstUserSha8` (e.g. a cold start with only passthrough
146
+ * hits that the upstream probe never tagged). */
147
+ currentSessionId = null;
148
+ /** Per-session prior prefix size for the cache-aware TEXT baseline. Warm/cold
149
+ * comes only from the server-observed cache_read on the actual request; this
150
+ * map is used only after cr>0 to split the text counterfactual into reused vs
151
+ * grown prefix tokens. Reconstructed identically in replay() from persisted
152
+ * timestamps, so live and restored numbers agree. Capped with sessions. */
153
+ baselineWarmth = new Map();
154
+ /** Max age for reusing a prior prefix size after cr>0 has proved warmth. */
155
+ static CACHE_TTL_SEC = 300;
156
+ /** Hard cap on `sessions` Map entries. Keeps memory bounded in
157
+ * long-running deployments. 50 sessions × ~13 numeric fields each is
158
+ * comfortably under a MB even with fat bucket/passthrough histograms. */
159
+ static SESSION_CAP = 50;
160
+ totals = {
161
+ requests: 0,
162
+ compressedRequests: 0,
163
+ actualInputWeighted: 0,
164
+ baselineInputWeighted: 0,
165
+ outputWeighted: 0,
166
+ allBaselineEquivalentWeighted: 0,
167
+ allActualInputWeighted: 0,
168
+ allOutputWeighted: 0,
169
+ allUsageRequests: 0,
170
+ compressedPaidRequests: 0,
171
+ compressedActualInputWeighted: 0,
172
+ compressedOutputWeighted: 0,
173
+ passthroughPaidRequests: 0,
174
+ passthroughActualInputWeighted: 0,
175
+ passthroughOutputWeighted: 0,
176
+ textCharsMeasured: 0,
177
+ thinkingCharsMeasured: 0,
178
+ toolUseCharsMeasured: 0,
179
+ redactedBlockCountMeasured: 0,
180
+ eventsWithMeasurement: 0,
181
+ startedAt: Date.now() / 1000,
182
+ };
183
+ /** Bounded ring of the most recently rendered images (last IMAGE_RING_CAP).
184
+ * Each request that rendered an image pushes one entry; the matching
185
+ * RecentRow carries `img_id` so the dashboard can pull any image still in
186
+ * the ring via /proxy-latest-png?id=N. In-memory only — images are never
187
+ * persisted, so a restart starts this empty. */
188
+ images = [];
189
+ /** Monotonic image id source. Never reset, never reused — an evicted id
190
+ * stays dangling on its RecentRow rather than pointing at a new image. */
191
+ nextImageId = 1;
192
+ /** Runtime kill switch for compression. When false, the proxy forwards
193
+ * /v1/messages unchanged to upstream — pure passthrough, no images,
194
+ * no transforms. Controlled by the dashboard "passthrough" toggle so
195
+ * the operator can toggle the proxy's transform instantly.
196
+ *
197
+ * Defaults to TRUE since 2026-06-09: scope is Fable 5 only, which reads
198
+ * renders at 100/100 (no Opus read tax) with the same image billing, and
199
+ * the live proxy record measured ~68% real input-token savings on dense
200
+ * traffic — the old off-default rationale ("cache-illusory savings")
201
+ * cited the superseded dead verdict. Verbatim recall is still lossy;
202
+ * the dashboard toggle remains the kill switch. See FINDINGS.md. */
203
+ compressionEnabled = true;
204
+ /** Recent requests' transform breakdowns, for the Context Map panel + its
205
+ * history selector. In-memory ring, newest last. */
206
+ contextHistory = [];
207
+ setCompressionEnabled(on) {
208
+ this.compressionEnabled = on;
209
+ }
210
+ getCompressionEnabled() {
211
+ return this.compressionEnabled;
212
+ }
213
+ /** Resolved disk paths for the events.jsonl + 4xx-bodies sidecar dir. The
214
+ * new sessions / cleanup endpoints need this; legacy callers that don't
215
+ * pass `paths` opt out of those endpoints by returning 503. */
216
+ paths;
217
+ /** Test hook: when set, /api/sessions.json and /api/sessions/${id}.json
218
+ * call this instead of `claudeCodeMap()` with the real `~/.claude/projects/`
219
+ * path. Lets unit tests run in tens of ms instead of scanning hundreds of
220
+ * the developer's actual Claude Code session files. */
221
+ ccMapFn;
222
+ constructor(paths, ccMapFn) {
223
+ this.paths = paths;
224
+ this.ccMapFn = ccMapFn ?? (() => claudeCodeMap());
225
+ }
226
+ /** Stash every rendered image into the ring (called from onRequest with the
227
+ * raw ProxyEvent before info.firstImagePng is dropped by toTrackEvent).
228
+ * Returns the assigned image ids in render order; empty array when there
229
+ * are no images. The caller stamps ids[0] onto the RecentRow as `img_id`
230
+ * for back-compat and the full list as `img_ids`. */
231
+ captureImage(info) {
232
+ const pngs = info.imagePngs ?? (info.firstImagePng ? [info.firstImagePng] : []);
233
+ if (pngs.length === 0)
234
+ return [];
235
+ const dims = info.imageDims ??
236
+ (info.firstImagePng
237
+ ? [{ width: info.firstImageWidth ?? 0, height: info.firstImageHeight ?? 0 }]
238
+ : []);
239
+ const ids = [];
240
+ for (let i = 0; i < pngs.length; i++) {
241
+ const id = this.nextImageId++;
242
+ const width = dims[i]?.width ?? 0;
243
+ const height = dims[i]?.height ?? 0;
244
+ const kb = (pngs[i].length / 1024).toFixed(1);
245
+ const meta = `${width}×${height} · ${kb} KB · image ${i + 1}/${pngs.length}`;
246
+ this.images.push({
247
+ id,
248
+ png: pngs[i],
249
+ meta,
250
+ width,
251
+ height,
252
+ ts: Date.now() / 1000,
253
+ sourceText: info.imageSourceText,
254
+ });
255
+ ids.push(id);
256
+ }
257
+ // Evict the oldest entries past the cap. splice() keeps insertion order
258
+ // so images[images.length - 1] is always the latest render.
259
+ if (this.images.length > IMAGE_RING_CAP) {
260
+ this.images.splice(0, this.images.length - IMAGE_RING_CAP);
261
+ }
262
+ return ids;
263
+ }
264
+ /** Fold one event into the running totals + ring buffer.
265
+ *
266
+ * Savings math is gated on a per-request `baseline_tokens` measurement
267
+ * from the parallel count_tokens probe AND an upstream usage block.
268
+ * When either is missing, we still count the request but skip its
269
+ * savings contribution — no estimation. */
270
+ update(ev) {
271
+ // Stash the image bytes before they get GC'd by the request finishing.
272
+ // The returned id (if any) is stamped onto this request's RecentRow so
273
+ // the dashboard can pull the exact image that request rendered.
274
+ const imgIds = ev.info ? this.captureImage(ev.info) : [];
275
+ const imgId = imgIds[0];
276
+ const u = ev.usage;
277
+ const info = ev.info;
278
+ const compressed = info?.compressed === true;
279
+ const inp = u?.input_tokens ?? 0;
280
+ const out = u?.output_tokens ?? 0;
281
+ const cc = u?.cache_creation_input_tokens ?? 0;
282
+ const cr = u?.cache_read_input_tokens ?? 0;
283
+ const gpt = isOpenAIEvent(ev.path);
284
+ // Unified per-row accounting, filled by the provider branch below. The
285
+ // downstream totals / per-session / recent-row code reads only these —
286
+ // it never re-derives cache math, so Anthropic and GPT can't drift.
287
+ let haveUsage;
288
+ let haveBaseline;
289
+ let creditSaving;
290
+ let actualInputEff;
291
+ let baselineInputEff;
292
+ let outputEquiv;
293
+ let rawActual; // raw tokens sent (session ratio + Details realInput)
294
+ let rawBaseline; // raw text-only counterfactual tokens
295
+ let baselineForRow; // baseline token count for contextHistory/recent
296
+ let cacheReadForRow; // tokens to surface in the "Cache hits" column
297
+ let warmForRow; // did the TEXT baseline read warm? Server-observed:
298
+ // Anthropic cr>0 or GPT cached_tokens>0. Drives Context Map narration.
299
+ if (gpt) {
300
+ // GPT cost model: no count_tokens probe, no cache-create premium, no
301
+ // per-session warmth — the discount is automatic and folded into the
302
+ // cached-input rate. Baseline is the measured imaged-vs-text delta.
303
+ const e = gptEff({
304
+ model: ev.model,
305
+ inputTokens: inp,
306
+ outputTokens: out,
307
+ cachedTokens: u?.cached_tokens ?? 0,
308
+ imageTokens: info?.imageTokens ?? 0,
309
+ baselineImagedTokens: info?.baselineImagedTokens ?? 0,
310
+ compressed,
311
+ });
312
+ haveUsage = e.haveUsage;
313
+ haveBaseline = e.haveBaseline;
314
+ creditSaving = e.creditSaving;
315
+ actualInputEff = e.actualInputEff;
316
+ baselineInputEff = e.baselineInputEff;
317
+ outputEquiv = e.outputEquiv;
318
+ rawActual = e.rawActual;
319
+ rawBaseline = e.rawBaseline;
320
+ baselineForRow = e.rawBaseline;
321
+ cacheReadForRow = u?.cached_tokens ?? 0;
322
+ // GPT's prefix discount is automatic: cached_tokens>0 ⇒ it read warm.
323
+ warmForRow = (u?.cached_tokens ?? 0) > 0;
324
+ }
325
+ else {
326
+ haveUsage = u !== undefined && (inp > 0 || out > 0 || cc > 0 || cr > 0);
327
+ const baseline = info?.baselineTokens;
328
+ // Honest gating: only attribute savings when BOTH baseline probes
329
+ // resolved (status === 'ok'). When the cacheable-prefix probe failed
330
+ // (status === 'partial') we previously fell through to cacheable=0,
331
+ // which silently charges the unproxied counterfactual the cold-input
332
+ // rate on tokens that actually would have been cache-discounted —
333
+ // fabricating "$ saved". Excluding the row is the only honest move
334
+ // until the probe succeeds.
335
+ const probeOk = info?.baselineProbeStatus === 'ok'
336
+ // Back-compat: hosts that haven't adopted baselineProbeStatus yet
337
+ // still see fields land; we accept legacy rows where the full-body
338
+ // probe resolved AND (either no markers existed OR cacheable did too).
339
+ || (info?.baselineProbeStatus === undefined && baseline !== undefined && baseline > 0);
340
+ haveBaseline = typeof baseline === 'number' && baseline > 0 && probeOk;
341
+ // Weighted INPUT cost we actually paid this turn.
342
+ actualInputEff = haveUsage ? computeActualInputEff(inp, cc, cr) : 0;
343
+ // pxpipe only reduces input by imaging the static slab. An UNCOMPRESSED
344
+ // row had its body forwarded untouched, so its unproxied counterfactual
345
+ // IS exactly what it paid — crediting the cache-modeled baseline there
346
+ // (which prices the prefix at the cache-READ rate) fabricates savings on
347
+ // passthrough traffic. Only credit the counterfactual when the row was
348
+ // actually compressed AND we have a usable probe.
349
+ creditSaving = haveBaseline && haveUsage && compressed;
350
+ // Cache-aware, server-observed baseline. INVARIANT: pxpipe is credited ONLY
351
+ // for the text it imaged away — NEVER for caching. The imagined text path
352
+ // gets the same observed cache state as the actual request: cr>0 means warm
353
+ // for both, cr===0 means cold for both. No wall-clock-only inference.
354
+ // Uncompressed rows fall back to actualInputEff → zero savings.
355
+ const cacheable = info?.baselineCacheableTokens ?? 0;
356
+ // If cr>0 proved warmth, a completed prior with the same prefix refines the
357
+ // reused/grown split for the text baseline. Use request start for that
358
+ // lookup; an overlapping request that had not completed could not provide a
359
+ // prior prefix size for this in-flight request.
360
+ const sidNow = info?.firstUserSha8;
361
+ const prefixShaNow = info?.systemSha8;
362
+ const completionSec = Date.now() / 1000;
363
+ const requestStartSec = completionSec - Math.max(0, ev.durationMs || 0) / 1000;
364
+ const warmthPrev = typeof sidNow === 'string' && sidNow.length > 0
365
+ ? this.baselineWarmth.get(sidNow)
366
+ : undefined;
367
+ // Warmth itself is cr-only; prior state only estimates the warm split.
368
+ // Centralised in deriveBaselineWarmth so update()/replay()/sessions can't drift.
369
+ const { warm, prevCacheable } = deriveBaselineWarmth(warmthPrev, requestStartSec, cacheable, cr, DashboardState.CACHE_TTL_SEC, prefixShaNow);
370
+ baselineInputEff = creditSaving
371
+ ? computeBaselineInputEff(baseline, cacheable, inp, cc, cr, warm, prevCacheable)
372
+ : actualInputEff;
373
+ // Record this completed turn's prefix size for future cr>0 split estimates.
374
+ // Carry the prior cacheable when this row has no probe.
375
+ if (typeof sidNow === 'string' && sidNow.length > 0 && haveUsage) {
376
+ this.baselineWarmth.set(sidNow, {
377
+ ts: completionSec,
378
+ cacheable: cacheable > 0 ? cacheable : (warmthPrev?.cacheable ?? 0),
379
+ prefixSha: prefixShaNow ?? warmthPrev?.prefixSha,
380
+ });
381
+ if (this.baselineWarmth.size > DashboardState.SESSION_CAP) {
382
+ const firstKey = this.baselineWarmth.keys().next().value;
383
+ if (firstKey !== undefined)
384
+ this.baselineWarmth.delete(firstKey);
385
+ }
386
+ }
387
+ // Output tokens are identical with/without compression — the proxy never
388
+ // touches the response body. They show up on BOTH sides of the savings
389
+ // ratio at their actual rate (OUTPUT_TOKEN_RATE × input rate) so the
390
+ // denominator reflects the full bill the user actually pays. Without
391
+ // this, an output-heavy turn would silently inflate the "saved %"
392
+ // headline relative to what Anthropic's weekly limit meters as token
393
+ // consumption (input + output × 5).
394
+ outputEquiv = haveUsage ? out * OUTPUT_TOKEN_RATE : 0;
395
+ rawActual = inp + cc + cr;
396
+ rawBaseline = baseline ?? 0;
397
+ baselineForRow = baseline ?? 0;
398
+ cacheReadForRow = cr;
399
+ warmForRow = warm; // server-observed cache read (cr>0)
400
+ }
401
+ // Record the request's transform breakdown for the Context Map panel. This
402
+ // runs AFTER the eff-tokens are computed so the Details headline reads the
403
+ // SAME cache-weighted pair as the recent row's As-text / Sent / Saved
404
+ // columns (baselineInputEff / actualInputEff) — the two panels can no longer
405
+ // disagree. Raw counts are kept for the cache-blind sub-line. Gate on
406
+ // haveUsage so an in-flight request doesn't render a bogus "-100%".
407
+ if (info && haveUsage && imgId !== undefined) {
408
+ // Key by the request's first image id so the recent table's "view" link
409
+ // (which carries that id) maps straight to this breakdown.
410
+ this.contextHistory.push({
411
+ id: imgId,
412
+ baselineTokens: baselineForRow,
413
+ realInput: rawActual,
414
+ baselineInputEff,
415
+ actualInputEff,
416
+ haveBaseline,
417
+ cacheRead: cacheReadForRow,
418
+ warm: warmForRow,
419
+ output: out,
420
+ imageCount: info.imageCount ?? 0,
421
+ buckets: { ...(info.bucketChars ?? {}) },
422
+ imageIds: [...imgIds],
423
+ compressed,
424
+ });
425
+ // Keep in lockstep with RECENT_CAP so every "view" link in the recent
426
+ // table resolves to a real breakdown (was 30 < 50, so older visible rows
427
+ // silently fell back to the latest request's data).
428
+ if (this.contextHistory.length > RECENT_CAP) {
429
+ this.contextHistory.splice(0, this.contextHistory.length - RECENT_CAP);
430
+ }
431
+ }
432
+ this.totals.requests += 1;
433
+ if (compressed)
434
+ this.totals.compressedRequests += 1;
435
+ // Measured headline: only compressed rows with a usable probe. An
436
+ // uncompressed row contributes zero saved (baseline === actual), so
437
+ // including it here would only dilute the "saved on rows we moved" %.
438
+ if (creditSaving) {
439
+ this.totals.baselineInputWeighted += baselineInputEff;
440
+ this.totals.actualInputWeighted += actualInputEff;
441
+ this.totals.outputWeighted += outputEquiv;
442
+ }
443
+ // All-rows COUNTERFACTUAL spend, ungated on the probe — the honest
444
+ // denominator for "did pxpipe move my real bill". Measured rows
445
+ // contribute their cache-aware baseline (what the unproxied path
446
+ // would have billed); unmeasured/probe-failed/passthrough rows
447
+ // contribute their actual input (pxpipe either didn't run or we
448
+ // can't measure the counterfactual, so actual ≈ baseline). This
449
+ // keeps the ratio bounded at 100% — you can't save more than you
450
+ // would have paid.
451
+ if (haveUsage) {
452
+ // baselineInputEff already folds the uncompressed/probe-failed fallback
453
+ // to actualInputEff, so passthrough rows contribute zero saved here.
454
+ this.totals.allBaselineEquivalentWeighted += baselineInputEff;
455
+ this.totals.allActualInputWeighted += actualInputEff;
456
+ this.totals.allOutputWeighted += outputEquiv;
457
+ this.totals.allUsageRequests += 1;
458
+ // Direct observed compressed-vs-passthrough split. No counterfactual,
459
+ // no probe gating — just partition the paid-rows set by which path
460
+ // actually ran this turn. Headline answers "is the compressed path
461
+ // cheaper per request on real traffic". Selection bias (the gate
462
+ // routes each turn) is real; sample counts go to the UI so the
463
+ // operator can judge sufficiency.
464
+ if (compressed) {
465
+ this.totals.compressedPaidRequests += 1;
466
+ this.totals.compressedActualInputWeighted += actualInputEff;
467
+ this.totals.compressedOutputWeighted += outputEquiv;
468
+ }
469
+ else {
470
+ this.totals.passthroughPaidRequests += 1;
471
+ this.totals.passthroughActualInputWeighted += actualInputEff;
472
+ this.totals.passthroughOutputWeighted += outputEquiv;
473
+ }
474
+ }
475
+ // Per-session aggregation. Uses the SAME baseline/actual/output math as
476
+ // the global accumulators above, partitioned by `info.firstUserSha8`
477
+ // so the dashboard's "current session" panel can show what's happening
478
+ // RIGHT NOW instead of stale lifetime numbers. Untagged events (no
479
+ // firstUserSha8 — cold start, passthrough probe failures) are skipped
480
+ // rather than bucketed into a synthetic "unknown" session.
481
+ const sid = info?.firstUserSha8;
482
+ if (typeof sid === 'string' && sid.length > 0) {
483
+ this.currentSessionId = sid;
484
+ let s = this.sessions.get(sid);
485
+ if (!s) {
486
+ s = {
487
+ sessionId: sid,
488
+ baselineInputWeighted: 0,
489
+ actualInputWeighted: 0,
490
+ baselineMeasuredCount: 0,
491
+ allActualInputWeighted: 0,
492
+ allOutputWeighted: 0,
493
+ rawActualTokens: 0,
494
+ rawBaselineTokens: 0,
495
+ rawOutputTokens: 0,
496
+ };
497
+ this.sessions.set(sid, s);
498
+ // Cap memory — drop the first (oldest by insertion order) session
499
+ // when over budget. We no longer track lastSeen privately on the
500
+ // class — insertion order is a fine proxy because `currentSessionId`
501
+ // (set above on every update) is what the serve path uses to pick
502
+ // the most-recent session, not a scan of `this.sessions`.
503
+ if (this.sessions.size > DashboardState.SESSION_CAP) {
504
+ const firstKey = this.sessions.keys().next().value;
505
+ if (firstKey !== undefined)
506
+ this.sessions.delete(firstKey);
507
+ }
508
+ }
509
+ // Reuse the same haveUsage / haveBaseline guards + the
510
+ // baselineInputEff / actualInputEff locals computed earlier in
511
+ // update() so the lifetime totals block (above) and the per-session
512
+ // block (here) read the same values. Re-deriving them here would
513
+ // duplicate the cache-aware-baseline math and invite drift.
514
+ if (creditSaving) {
515
+ s.baselineInputWeighted += baselineInputEff;
516
+ s.actualInputWeighted += actualInputEff;
517
+ s.baselineMeasuredCount += 1;
518
+ // RAW, rate-free compression: real tokens sent vs the same body as text.
519
+ s.rawActualTokens += rawActual;
520
+ s.rawBaselineTokens += rawBaseline;
521
+ s.rawOutputTokens += out; // not compressed; added to BOTH sides for the honest total
522
+ }
523
+ // ALL-rows session bill — mirrors the global `if (haveUsage)` block
524
+ // above (allActualInputWeighted / allOutputWeighted). Used as the
525
+ // honest denominator for the session's saved-% so caching wins on
526
+ // unmeasured requests still count toward "what you actually paid".
527
+ if (haveUsage) {
528
+ s.allActualInputWeighted += actualInputEff;
529
+ s.allOutputWeighted += outputEquiv;
530
+ }
531
+ }
532
+ // Measurement totals are independent of usage/baseline gating — they
533
+ // accumulate whenever the scanner produced numbers. The scanner sets
534
+ // measurement to undefined on 5xx (no body to scan) and on unknown
535
+ // content-types; we count an event as "measured" when it has any.
536
+ const m = ev.measurement;
537
+ if (m) {
538
+ this.totals.textCharsMeasured += m.textChars;
539
+ this.totals.thinkingCharsMeasured += m.thinkingChars;
540
+ this.totals.toolUseCharsMeasured += m.toolUseChars;
541
+ this.totals.redactedBlockCountMeasured += m.redactedBlockCount;
542
+ this.totals.eventsWithMeasurement += 1;
543
+ }
544
+ const row = {
545
+ ts: Date.now() / 1000,
546
+ method: ev.method,
547
+ path: ev.path,
548
+ model: ev.model,
549
+ status: ev.status,
550
+ compressed,
551
+ cc_added: compressed ? 1 : undefined,
552
+ input_tokens: haveUsage ? inp : undefined,
553
+ output_tokens: haveUsage ? out : undefined,
554
+ cache_create: haveUsage ? cc : undefined,
555
+ cache_read: haveUsage ? cacheReadForRow : undefined,
556
+ actual_input: haveUsage ? round1(actualInputEff) : undefined,
557
+ baseline_input: creditSaving ? round1(baselineInputEff) : undefined,
558
+ session_saved_so_far_delta: creditSaving ? round1(baselineInputEff - actualInputEff) : undefined,
559
+ img_id: imgId,
560
+ img_ids: imgIds,
561
+ };
562
+ this.recent.push(row);
563
+ if (this.recent.length > RECENT_CAP)
564
+ this.recent.splice(0, this.recent.length - RECENT_CAP);
565
+ }
566
+ /** On startup, fold the last N entries from the JSONL events file back
567
+ * into the ring buffer so a process restart doesn't show an empty table.
568
+ * Cumulative totals are *not* restored (the file may have rotated, and
569
+ * double-counting is worse than starting fresh). */
570
+ async replay(filePath) {
571
+ try {
572
+ await fs.promises.access(filePath, fs.constants.R_OK);
573
+ }
574
+ catch {
575
+ return; // no file yet, nothing to replay
576
+ }
577
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
578
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
579
+ const tail = [];
580
+ for await (const line of rl) {
581
+ if (!line)
582
+ continue;
583
+ try {
584
+ const ev = JSON.parse(line);
585
+ tail.push(ev);
586
+ if (tail.length > RECENT_CAP)
587
+ tail.shift();
588
+ }
589
+ catch {
590
+ /* skip malformed line */
591
+ }
592
+ }
593
+ // Replay mirrors the live update() warmth logic (per-session baselineWarmth,
594
+ // cr-grounded) so it produces byte-identical per-row numbers to update().
595
+ for (const t of tail) {
596
+ const inp = t.input_tokens ?? 0;
597
+ const out = t.output_tokens ?? 0;
598
+ const cc = t.cache_create_tokens ?? 0;
599
+ const cr = t.cache_read_tokens ?? 0;
600
+ const compressed = t.compressed === true;
601
+ const gpt = isOpenAIEvent(t.path);
602
+ // Same unified accounting as update(); see the branch comments there.
603
+ let haveUsage;
604
+ let haveBaseline;
605
+ let creditSaving;
606
+ let actualInputEff;
607
+ let baselineInputEff;
608
+ let rawActual;
609
+ let rawBaseline;
610
+ let baselineForRow;
611
+ let cacheReadForRow;
612
+ let warmForRow; // text-baseline warmth for the Context Map narration
613
+ if (gpt) {
614
+ const e = gptEff({
615
+ model: t.model,
616
+ inputTokens: inp,
617
+ outputTokens: out,
618
+ cachedTokens: t.cached_tokens ?? 0,
619
+ imageTokens: t.image_tokens ?? 0,
620
+ baselineImagedTokens: t.baseline_imaged_tokens ?? 0,
621
+ compressed,
622
+ });
623
+ haveUsage = e.haveUsage;
624
+ haveBaseline = e.haveBaseline;
625
+ creditSaving = e.creditSaving;
626
+ actualInputEff = e.actualInputEff;
627
+ baselineInputEff = e.baselineInputEff;
628
+ rawActual = e.rawActual;
629
+ rawBaseline = e.rawBaseline;
630
+ baselineForRow = e.rawBaseline;
631
+ cacheReadForRow = t.cached_tokens ?? 0;
632
+ warmForRow = (t.cached_tokens ?? 0) > 0;
633
+ }
634
+ else {
635
+ haveUsage = inp > 0 || out > 0 || cc > 0 || cr > 0;
636
+ const baseline = t.baseline_tokens;
637
+ const cacheable = t
638
+ .baseline_cacheable_tokens ?? 0;
639
+ const probeStatus = t.baseline_probe_status;
640
+ // Same gating rule as update(): require an explicit 'ok' status when
641
+ // present; fall back to "have a baseline number" for legacy JSONL.
642
+ const probeOk = probeStatus === 'ok'
643
+ || (probeStatus === undefined && typeof baseline === 'number' && baseline > 0);
644
+ haveBaseline = typeof baseline === 'number' && baseline > 0 && probeOk;
645
+ actualInputEff = haveUsage ? computeActualInputEff(inp, cc, cr) : 0;
646
+ // Mirror update(): only credit the cache-modeled counterfactual on
647
+ // compressed rows. Uncompressed/passthrough rows fall back to the
648
+ // actual cost so they show zero saved (no fabricated savings).
649
+ creditSaving = haveBaseline && haveUsage && compressed;
650
+ // Warm/cold is reconstructed from server-observed cr only. Persisted
651
+ // completion ts + duration_ms are used only to find a prior prefix size
652
+ // for the reused/grown split after cr>0 has proved warmth.
653
+ const sidR = t.first_user_sha8;
654
+ const prefixShaR = t.system_sha8;
655
+ const completionSecR = Date.parse(t.ts) / 1000;
656
+ const requestStartSecR = completionSecR - Math.max(0, t.duration_ms || 0) / 1000;
657
+ const warmthPrevR = typeof sidR === 'string' && sidR.length > 0 ? this.baselineWarmth.get(sidR) : undefined;
658
+ // Same cr-only warmth as update(); prior state only refines the split.
659
+ const { warm: warmR, prevCacheable: prevCacheableR } = deriveBaselineWarmth(warmthPrevR, requestStartSecR, cacheable, cr, DashboardState.CACHE_TTL_SEC, prefixShaR);
660
+ baselineInputEff = creditSaving
661
+ ? computeBaselineInputEff(baseline, cacheable, inp, cc, cr, warmR, prevCacheableR)
662
+ : actualInputEff;
663
+ if (typeof sidR === 'string' && sidR.length > 0 && haveUsage) {
664
+ this.baselineWarmth.set(sidR, {
665
+ ts: completionSecR,
666
+ cacheable: cacheable > 0 ? cacheable : (warmthPrevR?.cacheable ?? 0),
667
+ prefixSha: prefixShaR ?? warmthPrevR?.prefixSha,
668
+ });
669
+ if (this.baselineWarmth.size > DashboardState.SESSION_CAP) {
670
+ const firstKey = this.baselineWarmth.keys().next().value;
671
+ if (firstKey !== undefined)
672
+ this.baselineWarmth.delete(firstKey);
673
+ }
674
+ }
675
+ rawActual = inp + cc + cr;
676
+ rawBaseline = baseline ?? 0;
677
+ baselineForRow = baseline ?? 0;
678
+ cacheReadForRow = cr;
679
+ warmForRow = warmR; // server-observed cache read
680
+ }
681
+ // Rebuild the Context Map breakdown so old rows keep their "Saved" value
682
+ // and "Details" link after a restart. The PNG ring is in-memory and gone,
683
+ // so thumbnails can't return (imageIds: [], flagged `restored`) — but the
684
+ // headline %, buckets and Saved delta all reconstruct from the persisted
685
+ // event with the same cache-weighted math as the live update() path.
686
+ const imageCount = t.image_count ?? 0;
687
+ let imgId;
688
+ if (compressed && haveUsage && (imageCount > 0 || rawBaseline > 0)) {
689
+ imgId = this.nextImageId++;
690
+ this.contextHistory.push({
691
+ id: imgId,
692
+ baselineTokens: baselineForRow,
693
+ realInput: rawActual,
694
+ baselineInputEff,
695
+ actualInputEff,
696
+ haveBaseline,
697
+ cacheRead: cacheReadForRow,
698
+ warm: warmForRow,
699
+ output: out,
700
+ imageCount,
701
+ buckets: { ...(t.bucket_chars ?? {}) },
702
+ imageIds: [], // PNG ring is in-memory; not restorable across restart
703
+ compressed,
704
+ restored: true,
705
+ });
706
+ if (this.contextHistory.length > RECENT_CAP) {
707
+ this.contextHistory.splice(0, this.contextHistory.length - RECENT_CAP);
708
+ }
709
+ }
710
+ const row = {
711
+ ts: Date.parse(t.ts) / 1000,
712
+ method: t.method,
713
+ path: t.path,
714
+ model: t.model,
715
+ status: t.status,
716
+ compressed,
717
+ cc_added: compressed ? 1 : undefined,
718
+ input_tokens: t.input_tokens,
719
+ output_tokens: t.output_tokens,
720
+ cache_create: t.cache_create_tokens,
721
+ cache_read: gpt ? cacheReadForRow : t.cache_read_tokens,
722
+ actual_input: haveUsage ? round1(actualInputEff) : undefined,
723
+ baseline_input: creditSaving ? round1(baselineInputEff) : undefined,
724
+ session_saved_so_far_delta: creditSaving ? round1(baselineInputEff - actualInputEff) : undefined,
725
+ img_id: imgId,
726
+ img_ids: imgId !== undefined ? [imgId] : undefined,
727
+ };
728
+ this.recent.push(row);
729
+ }
730
+ }
731
+ // ---- HTTP handlers ------------------------------------------------------
732
+ /**
733
+ * Per-session "what's happening right now" payload backing the
734
+ * `SessionSummary` panel. Scopes the dollar-weighted savings ratio + the
735
+ * per-bucket char attribution + the passthrough-reason histogram to the
736
+ * most-recently-active session (tracked via `info.firstUserSha8`) so the
737
+ * top-of-dashboard headline reflects the live session rather than stale
738
+ * lifetime aggregates from a previous run.
739
+ *
740
+ * Returns `{ sessionId: null, message: 'no active session yet' }` when no
741
+ * events have been received yet (or the first events were all untagged —
742
+ * cold start, probe failures) — `update()` only sets `currentSessionId`
743
+ * when a `firstUserSha8`-tagged event lands. The client renders a stale
744
+ * panel rather than zeroes when a session goes idle (NO `lastSeen >
745
+ * threshold` check here; see comment in `update()` for the rationale).
746
+ */
747
+ serveCurrentSessionJson() {
748
+ if (!this.currentSessionId) {
749
+ return jsonResponse({
750
+ sessionId: null,
751
+ message: 'no active session yet',
752
+ });
753
+ }
754
+ const s = this.sessions.get(this.currentSessionId);
755
+ if (!s) {
756
+ return jsonResponse({ sessionId: null, message: 'no active session yet' });
757
+ }
758
+ // Headline payload: the same dollar-weighted baseline/actual input
759
+ // accumulators the global Totals block exposes, plus the honest
760
+ // denominator (`baselineMeasuredCount` — only requests that carried a
761
+ // baseline measurement). We ALSO ship the ALL-rows session bill
762
+ // (`allActualInputWeighted` + `allOutputWeighted`) so the Svelte panel
763
+ // can compute saved-% against the full session bill instead of just
764
+ // the measured slice — matching the global `saved_pct_of_all_spend`
765
+ // math but scoped to one session. The Svelte panel does the dollar
766
+ // conversion itself so we don't round-trip pricing through the wire.
767
+ return jsonResponse({
768
+ sessionId: s.sessionId,
769
+ baselineInputWeighted: s.baselineInputWeighted,
770
+ actualInputWeighted: s.actualInputWeighted,
771
+ baselineMeasuredCount: s.baselineMeasuredCount,
772
+ allActualInputWeighted: s.allActualInputWeighted,
773
+ allOutputWeighted: s.allOutputWeighted,
774
+ rawActualTokens: s.rawActualTokens,
775
+ rawBaselineTokens: s.rawBaselineTokens,
776
+ rawOutputTokens: s.rawOutputTokens,
777
+ });
778
+ }
779
+ serveStats() {
780
+ // Two headline numbers, derived from the same per-event accumulators:
781
+ //
782
+ // saved_pct_input_only = Σ saved / Σ baseline_input_eff × 100
783
+ // What the proxy actually saved on the slice it can move (input).
784
+ // Numerator = input tokens we didn't pay for (cache-aware).
785
+ // Denominator = input tokens we WOULD have paid (cache-aware).
786
+ // Output is excluded because the proxy doesn't touch it.
787
+ //
788
+ // saved_pct_of_total_bill = Σ saved / Σ (baseline_input + output × 5) × 100
789
+ // What share of the TOTAL bill the proxy saved. Honest counter to the
790
+ // input-only number: on output-heavy sessions (long thinking blocks,
791
+ // big edits) the percentage shrinks because output dominates.
792
+ //
793
+ // token_equivalent_total = Σ (actual_input + output × 5)
794
+ // What Anthropic's weekly limit actually meters — input × 1.0 +
795
+ // output × 5.0 (the same ratio as the per-MTok price card). This is
796
+ // the number that moves your "%% used this week" indicator.
797
+ const baseline = this.totals.baselineInputWeighted;
798
+ const actual = this.totals.actualInputWeighted;
799
+ const output = this.totals.outputWeighted; // already × OUTPUT_TOKEN_RATE
800
+ const saved = baseline - actual;
801
+ const pctInput = baseline > 0 ? (saved / baseline) * 100 : 0;
802
+ const baselineTotal = baseline + output;
803
+ const actualTotal = actual + output;
804
+ const pctTotal = baselineTotal > 0 ? (saved / baselineTotal) * 100 : 0;
805
+ // Share-of-all-spend: honest denominator. The numerator can only credit
806
+ // savings against rows where we have a probe baseline (otherwise it's
807
+ // estimation), but the denominator MUST include every request the user
808
+ // actually paid for — including passthrough rows, probe-failed rows,
809
+ // and untransformed turns the gate said no to. Otherwise the headline
810
+ // answers "did pxpipe help on the rows where it ran" instead of
811
+ // "did pxpipe move my real bill". The first is a cherry-pick.
812
+ const allBaselineEquiv = this.totals.allBaselineEquivalentWeighted;
813
+ const allActual = this.totals.allActualInputWeighted;
814
+ const allOutput = this.totals.allOutputWeighted;
815
+ // Denominator = counterfactual all-rows bill: what the user would have
816
+ // paid with no pxpipe. Bounded ratio at 100%; a single cold-miss
817
+ // compressed request on an otherwise empty session shows ~99% saved,
818
+ // not 280%.
819
+ const allCounterfactualBill = allBaselineEquiv + allOutput;
820
+ const pctAllSpend = allCounterfactualBill > 0 ? (saved / allCounterfactualBill) * 100 : 0;
821
+ // Direct observed split — actual $ per request, partitioned by which
822
+ // path ran. Token-equivalent (input × 1.0 + cache_create × 1.25 +
823
+ // cache_read × 0.10 + output × 5) → $ at the assumed Opus 4.7 input
824
+ // rate. Same $/Mtok rate is applied to both buckets, so the bias from
825
+ // the rate assumption cancels in the delta. Selection bias from the
826
+ // gate is NOT cancelled — the operator interprets that via the
827
+ // sample-count caveat below.
828
+ const compressedTokenEquiv = this.totals.compressedActualInputWeighted +
829
+ this.totals.compressedOutputWeighted;
830
+ const passthroughTokenEquiv = this.totals.passthroughActualInputWeighted +
831
+ this.totals.passthroughOutputWeighted;
832
+ const compressedActualUsd = (compressedTokenEquiv * ASSUMED_INPUT_USD_PER_MTOK) / 1e6;
833
+ const passthroughActualUsd = (passthroughTokenEquiv * ASSUMED_INPUT_USD_PER_MTOK) / 1e6;
834
+ const compressedAvgUsd = this.totals.compressedPaidRequests > 0
835
+ ? compressedActualUsd / this.totals.compressedPaidRequests
836
+ : 0;
837
+ const passthroughAvgUsd = this.totals.passthroughPaidRequests > 0
838
+ ? passthroughActualUsd / this.totals.passthroughPaidRequests
839
+ : 0;
840
+ // Sufficient-sample threshold is a soft heuristic. 20 paid requests per
841
+ // bucket is enough to see a real effect on Opus 4.7 traffic (a single
842
+ // big cold-miss can dominate at lower n). Below this, the UI shows the
843
+ // bucket numbers but hides the delta and surfaces "small sample".
844
+ const SUFFICIENT = 20;
845
+ const splitSufficient = this.totals.compressedPaidRequests >= SUFFICIENT &&
846
+ this.totals.passthroughPaidRequests >= SUFFICIENT;
847
+ const splitDeltaUsd = compressedAvgUsd - passthroughAvgUsd;
848
+ const uptimeSec = Date.now() / 1000 - this.totals.startedAt;
849
+ const payload = {
850
+ requests: this.totals.requests,
851
+ compressed_requests: this.totals.compressedRequests,
852
+ baseline_input_weighted: Math.round(baseline),
853
+ actual_input_weighted: Math.round(actual),
854
+ saved_input_tokens: Math.round(saved),
855
+ // saved_pct kept for back-compat with existing dashboard HTML; it is
856
+ // the input-only number. New code should read saved_pct_input_only.
857
+ saved_pct: round1(pctInput),
858
+ saved_pct_input_only: round1(pctInput),
859
+ saved_pct_of_total_bill: round1(pctTotal),
860
+ // Honest "share of total bill saved" — measured-rows numerator over
861
+ // ALL paid requests in the denominator (compressed + passthrough +
862
+ // probe-failed). This is the number users actually want when they
863
+ // ask "is pxpipe helping". Negative when flap-pollution from
864
+ // passthrough turns exceeds the collapse win on measured turns.
865
+ saved_pct_of_all_spend: round1(pctAllSpend),
866
+ all_baseline_equivalent_weighted: Math.round(allBaselineEquiv),
867
+ all_actual_input_weighted: Math.round(allActual),
868
+ all_output_weighted: Math.round(allOutput),
869
+ all_usage_requests: this.totals.allUsageRequests,
870
+ // Direct observed split — replaces "share of spend saved" as the
871
+ // headline. Total actual $ and average $/req per path, plus a delta
872
+ // gated on `split_sufficient_sample`. No counterfactual: each
873
+ // bucket is what each path actually billed.
874
+ compressed_paid_requests: this.totals.compressedPaidRequests,
875
+ passthrough_paid_requests: this.totals.passthroughPaidRequests,
876
+ compressed_actual_usd: round4(compressedActualUsd),
877
+ passthrough_actual_usd: round4(passthroughActualUsd),
878
+ compressed_avg_usd_per_request: round4(compressedAvgUsd),
879
+ passthrough_avg_usd_per_request: round4(passthroughAvgUsd),
880
+ compressed_minus_passthrough_avg_usd: round4(splitDeltaUsd),
881
+ split_sufficient_sample: splitSufficient,
882
+ split_min_sample_per_bucket: SUFFICIENT,
883
+ saved_usd: round4((saved * ASSUMED_INPUT_USD_PER_MTOK) / 1e6),
884
+ output_weighted: Math.round(output),
885
+ baseline_token_equivalent: Math.round(baselineTotal),
886
+ actual_token_equivalent: Math.round(actualTotal),
887
+ pricing_assumptions: {
888
+ input_per_mtok: ASSUMED_INPUT_USD_PER_MTOK,
889
+ output_multiplier: OUTPUT_TOKEN_RATE,
890
+ cache_write_5m_multiplier: 1.25,
891
+ cache_write_1h_multiplier: 2.0,
892
+ cache_read_multiplier: 0.1,
893
+ source: 'docs.anthropic.com/en/docs/about-claude/pricing (verified 2026-05-19)',
894
+ },
895
+ // Honest output measurement — char counts from the SSE/JSON scanner,
896
+ // independent of Anthropic's `usage.output_tokens`. Surfaces the gap
897
+ // the May-2026 weekly-meter audit hypothesized (redacted_thinking adds
898
+ // billed tokens that we can't see). `events_with_measurement` lets the
899
+ // operator weigh how representative the numbers are; when it's near
900
+ // `requests`, the gap is real. When it's 0, the scanner never landed
901
+ // (5xx-heavy session, no /v1/messages traffic).
902
+ measured_text_chars: this.totals.textCharsMeasured,
903
+ measured_thinking_chars: this.totals.thinkingCharsMeasured,
904
+ measured_tool_use_chars: this.totals.toolUseCharsMeasured,
905
+ measured_redacted_block_count: this.totals.redactedBlockCountMeasured,
906
+ events_with_measurement: this.totals.eventsWithMeasurement,
907
+ uptime_sec: uptimeSec,
908
+ compression_enabled: this.compressionEnabled,
909
+ };
910
+ return new Response(JSON.stringify(payload, null, 2), {
911
+ headers: { 'content-type': 'application/json' },
912
+ });
913
+ }
914
+ serveRecent() {
915
+ const latest = this.images[this.images.length - 1];
916
+ const payload = {
917
+ recent: this.recent,
918
+ has_preview: latest !== undefined,
919
+ preview_meta: latest?.meta ?? '',
920
+ // Image ids currently resident in the ring. The dashboard uses this to
921
+ // tell which RecentRow.img_id values still resolve — older ones have
922
+ // been evicted and their "view" button should be disabled.
923
+ image_ids: this.images.map((im) => im.id),
924
+ };
925
+ return new Response(JSON.stringify(payload), {
926
+ headers: { 'content-type': 'application/json' },
927
+ });
928
+ }
929
+ /** GET /proxy-latest-png[?id=N] — raw PNG from the image ring. With no id
930
+ * (or an unknown id) the latest render is returned; ?id=N pulls that
931
+ * specific image while it's still in the ring. 404 once evicted. */
932
+ servePng(id) {
933
+ // Cropping is done client-side via CSS (object-position + overflow:hidden).
934
+ // Python decoded the PNG to crop server-side; we skip that to avoid
935
+ // pulling a PNG decoder back in — the CSS approach renders identically.
936
+ const entry = id !== undefined
937
+ ? this.images.find((im) => im.id === id)
938
+ : this.images[this.images.length - 1];
939
+ if (!entry) {
940
+ return new Response('no image yet', { status: 404 });
941
+ }
942
+ return new Response(entry.png, {
943
+ headers: { 'content-type': 'image/png', 'cache-control': 'no-cache' },
944
+ });
945
+ }
946
+ /** GET /api/image-source[?id=N] — the source text the PNG was rendered
947
+ * from, so the operator can see the text → image conversion side by side.
948
+ * Falls back to the latest image; 404 if evicted or text wasn't captured. */
949
+ serveImageSource(id) {
950
+ const entry = id !== undefined
951
+ ? this.images.find((im) => im.id === id)
952
+ : this.images[this.images.length - 1];
953
+ if (!entry || entry.sourceText === undefined) {
954
+ return new Response(JSON.stringify({ error: 'no source text' }), {
955
+ status: 404,
956
+ headers: { 'content-type': 'application/json' },
957
+ });
958
+ }
959
+ return new Response(JSON.stringify({ id: entry.id, meta: entry.meta, source_text: entry.sourceText }), { headers: { 'content-type': 'application/json' } });
960
+ }
961
+ serveHtml(port) {
962
+ return htmlResponse(renderPage(port));
963
+ }
964
+ /** GET /fragments/<name> — server-rendered htmx fragments. Each one reuses
965
+ * the corresponding JSON endpoint's payload (via Response.json()) so the
966
+ * HTML and JSON surfaces can't drift apart. */
967
+ async serveFragment(name, url, port) {
968
+ switch (name) {
969
+ case 'toggle':
970
+ return htmlResponse(renderToggleFragment(this.compressionEnabled));
971
+ case 'models':
972
+ return htmlResponse(renderModelsFragment(getAllowedModelBases(), getConfiguredModelBases(), this.compressionEnabled));
973
+ case 'context-map': {
974
+ const reqParam = url.searchParams.get('req');
975
+ if (reqParam) {
976
+ // Explicit request: show ONLY that one. If its breakdown was evicted
977
+ // or never recorded (no usage on that completion), say so — don't
978
+ // silently fall back to the latest request's data under its label.
979
+ const found = this.contextHistory.find((h) => h.id === Number(reqParam));
980
+ return htmlResponse(renderContextMapFragment(found, this.contextHistory, !found));
981
+ }
982
+ // No specific request → default to the latest.
983
+ return htmlResponse(renderContextMapFragment(this.contextHistory[this.contextHistory.length - 1], this.contextHistory));
984
+ }
985
+ case 'session-summary': {
986
+ // Lifetime hero — same cumulative payload as the header strip so the
987
+ // headline and the "$ saved" tiles never disagree and it stops jumping.
988
+ const s = (await this.serveStats().json());
989
+ return htmlResponse(renderSessionSummaryFragment(s));
990
+ }
991
+ case 'header': {
992
+ const s = (await this.serveStats().json());
993
+ return htmlResponse(renderHeaderFragment(s, port));
994
+ }
995
+ case 'recent': {
996
+ const r = (await this.serveRecent().json());
997
+ return htmlResponse(renderRecentFragment(r));
998
+ }
999
+ case 'latest': {
1000
+ const r = (await this.serveRecent().json());
1001
+ const pinRaw = url.searchParams.get('pin');
1002
+ const pinNum = pinRaw != null && pinRaw !== '' ? Number(pinRaw) : NaN;
1003
+ const pin = Number.isFinite(pinNum) ? pinNum : null;
1004
+ const showSource = url.searchParams.get('source') === '1';
1005
+ let sourceText = null;
1006
+ if (showSource) {
1007
+ const entry = pin != null
1008
+ ? this.images.find((im) => im.id === pin)
1009
+ : this.images[this.images.length - 1];
1010
+ sourceText = entry?.sourceText ?? null;
1011
+ }
1012
+ return htmlResponse(renderLatestFragment({ payload: r, pin, showSource, sourceText }));
1013
+ }
1014
+ case 'sessions': {
1015
+ const res = await this.serveSessionsJson();
1016
+ if (!res.ok)
1017
+ return htmlResponse(`<div class="status">sessions unavailable</div>`);
1018
+ const p = (await res.json());
1019
+ return htmlResponse(renderSessionsFragment(p));
1020
+ }
1021
+ case 'stats': {
1022
+ const res = await this.serveApiStats();
1023
+ const p = (await res.json());
1024
+ return htmlResponse(renderStatsTableFragment(p));
1025
+ }
1026
+ default:
1027
+ return new Response('unknown fragment', { status: 404 });
1028
+ }
1029
+ }
1030
+ // ---- session / cleanup endpoints --------------------------------------
1031
+ //
1032
+ // Every endpoint below recomputes from disk on demand. The dashboard polls
1033
+ // these on a 5s cadence, which is fine for a single-user dev tool — even at
1034
+ // ~3k events / 1.5 MB the round-trip is <100ms on a warm SSD.
1035
+ /** GET /api/sessions.json — grouped sessions enriched with the Claude Code
1036
+ * cross-reference. The body is the top-level `sessions` array; the client
1037
+ * renders a bar chart of the top savers. */
1038
+ async serveSessionsJson(opts = {}) {
1039
+ if (!this.paths)
1040
+ return notConfigured('sessions');
1041
+ const [{ sessions }, ccMap] = await Promise.all([
1042
+ aggregateSessions(this.paths),
1043
+ this.ccMapFn(),
1044
+ ]);
1045
+ const rows = filterSessions(sessions, opts);
1046
+ const enriched = rows.map((s) => ({
1047
+ ...s,
1048
+ claudeCode: ccMap.get(s.id) ?? null,
1049
+ }));
1050
+ return jsonResponse({ sessions: enriched, count: enriched.length });
1051
+ }
1052
+ /** GET /api/stats.json — full-history aggregate. Migrated from the
1053
+ * former `pxpipe stats` CLI. */
1054
+ async serveApiStats() {
1055
+ if (!this.paths)
1056
+ return notConfigured('stats');
1057
+ const result = await aggregateEventsFile(this.paths.eventsFile);
1058
+ if (!result) {
1059
+ return jsonResponse({
1060
+ error: 'no events file yet',
1061
+ path: this.paths.eventsFile,
1062
+ }, 404);
1063
+ }
1064
+ return jsonResponse({
1065
+ parsed: result.parsed,
1066
+ dropped: result.dropped,
1067
+ summary: summaryToJson(result.summary),
1068
+ });
1069
+ }
1070
+ /** POST /api/compression — flip the runtime kill switch.
1071
+ * Body: { enabled: boolean }. Returns the new state. In-memory only;
1072
+ * restart resets to the default (on). */
1073
+ handleCompressionToggle(body) {
1074
+ const on = body.enabled === true;
1075
+ this.compressionEnabled = on;
1076
+ return jsonResponse({ compression_enabled: on });
1077
+ }
1078
+ /** POST /fragments/models — add/remove ONE model (Claude or GPT) from the
1079
+ * runtime compress scope. In-memory only; restart resets to the PXPIPE_MODELS
1080
+ * env / built-in default. The model checks read this live. */
1081
+ handleModelsToggle(model, on) {
1082
+ const next = new Set(getAllowedModelBases());
1083
+ if (on)
1084
+ next.add(model);
1085
+ else
1086
+ next.delete(model);
1087
+ setAllowedModelBases([...next]);
1088
+ }
1089
+ }
1090
+ function jsonResponse(body, status = 200) {
1091
+ return new Response(JSON.stringify(body), {
1092
+ status,
1093
+ headers: {
1094
+ 'content-type': 'application/json',
1095
+ },
1096
+ });
1097
+ }
1098
+ function notConfigured(what) {
1099
+ // The dashboard was constructed without SessionsPaths (e.g. a legacy host
1100
+ // that doesn't track to disk). Return 503 so the client can surface a
1101
+ // helpful error rather than failing silently.
1102
+ return jsonResponse({ error: `${what} unavailable: dashboard not configured with event paths` }, 503);
1103
+ }
1104
+ function round1(n) {
1105
+ return Math.round(n * 10) / 10;
1106
+ }
1107
+ function round4(n) {
1108
+ return Math.round(n * 10000) / 10000;
1109
+ }
1110
+ /** Match dashboard paths (handle query strings on /proxy-latest-png). */
1111
+ export function dashboardPath(pathname) {
1112
+ if (pathname === '/' || pathname === '/dashboard')
1113
+ return { kind: 'html' };
1114
+ if (pathname === '/proxy-stats')
1115
+ return { kind: 'stats' };
1116
+ if (pathname === '/proxy-recent')
1117
+ return { kind: 'recent' };
1118
+ if (pathname === '/proxy-latest-png')
1119
+ return { kind: 'png' };
1120
+ if (pathname === '/api/sessions.json')
1121
+ return { kind: 'api-sessions' };
1122
+ if (pathname === '/api/stats.json')
1123
+ return { kind: 'api-stats' };
1124
+ if (pathname === '/api/current-session.json')
1125
+ return { kind: 'current-session' };
1126
+ if (pathname === '/api/compression')
1127
+ return { kind: 'api-compression' };
1128
+ if (pathname === '/api/image-source')
1129
+ return { kind: 'api-image-source' };
1130
+ if (pathname.startsWith('/fragments/')) {
1131
+ return { kind: 'fragment', name: pathname.slice('/fragments/'.length) };
1132
+ }
1133
+ return null;
1134
+ }
1135
+ function htmlResponse(body) {
1136
+ return new Response(body, {
1137
+ headers: { 'content-type': 'text/html; charset=utf-8' },
1138
+ });
1139
+ }
1140
+ //# sourceMappingURL=dashboard.js.map