@linxiraos/pi-stats 1.1.26 → 1.2.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 (125) hide show
  1. package/CHANGELOG.md +12 -5
  2. package/build.ts +4 -0
  3. package/dist/client/favicon.ico +0 -0
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +1 -0
  6. package/dist/client/index.js +96 -96
  7. package/dist/types/aggregator.d.ts +4 -1
  8. package/dist/types/client/api.d.ts +5 -1
  9. package/dist/types/client/app/nav.d.ts +5 -2
  10. package/dist/types/client/data/formatters.d.ts +7 -2
  11. package/dist/types/client/data/live.d.ts +16 -11
  12. package/dist/types/client/data/query-store.d.ts +39 -0
  13. package/dist/types/client/data/query.d.ts +10 -5
  14. package/dist/types/client/data/range.d.ts +4 -3
  15. package/dist/types/client/i18n/format.d.ts +9 -0
  16. package/dist/types/client/i18n/index.d.ts +18 -0
  17. package/dist/types/client/i18n/messages/charts.d.ts +8 -0
  18. package/dist/types/client/i18n/messages/common.d.ts +153 -0
  19. package/dist/types/client/i18n/messages/costs.d.ts +92 -0
  20. package/dist/types/client/i18n/messages/en.d.ts +1255 -0
  21. package/dist/types/client/i18n/messages/errors.d.ts +78 -0
  22. package/dist/types/client/i18n/messages/frustration.d.ts +191 -0
  23. package/dist/types/client/i18n/messages/gain.d.ts +54 -0
  24. package/dist/types/client/i18n/messages/models.d.ts +94 -0
  25. package/dist/types/client/i18n/messages/nav.d.ts +46 -0
  26. package/dist/types/client/i18n/messages/overview.d.ts +80 -0
  27. package/dist/types/client/i18n/messages/projects.d.ts +84 -0
  28. package/dist/types/client/i18n/messages/providers.d.ts +142 -0
  29. package/dist/types/client/i18n/messages/requests.d.ts +72 -0
  30. package/dist/types/client/i18n/messages/tools.d.ts +119 -0
  31. package/dist/types/client/i18n/messages/traces.d.ts +144 -0
  32. package/dist/types/client/i18n/messages/ui.d.ts +44 -0
  33. package/dist/types/client/i18n/messages/zh-CN.d.ts +13 -0
  34. package/dist/types/client/i18n/types.d.ts +15 -0
  35. package/dist/types/client/traces/Minimap.d.ts +2 -0
  36. package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
  37. package/dist/types/client/traces/TranscriptList.d.ts +3 -1
  38. package/dist/types/db.d.ts +14 -21
  39. package/dist/types/embedded-client.d.ts +5 -0
  40. package/dist/types/frustration.d.ts +18 -9
  41. package/dist/types/live.d.ts +5 -3
  42. package/dist/types/locale.d.ts +16 -0
  43. package/dist/types/parser.d.ts +0 -17
  44. package/dist/types/rollup.d.ts +9 -1
  45. package/dist/types/server.d.ts +6 -0
  46. package/dist/types/shared-types.d.ts +7 -0
  47. package/dist/types/trace.d.ts +18 -6
  48. package/dist/types/types.d.ts +13 -1
  49. package/dist/types/user-metrics.d.ts +23 -1
  50. package/package.json +5 -5
  51. package/src/aggregator.ts +31 -11
  52. package/src/client/api.ts +40 -5
  53. package/src/client/app/LiveChip.tsx +14 -13
  54. package/src/client/app/Shell.tsx +19 -13
  55. package/src/client/app/ThemeToggle.tsx +4 -3
  56. package/src/client/app/nav.ts +19 -16
  57. package/src/client/data/formatters.ts +80 -13
  58. package/src/client/data/live.tsx +85 -45
  59. package/src/client/data/query-store.ts +108 -0
  60. package/src/client/data/query.ts +47 -56
  61. package/src/client/data/range.ts +34 -14
  62. package/src/client/data/series.ts +7 -1
  63. package/src/client/favicon.ico +0 -0
  64. package/src/client/i18n/format.ts +28 -0
  65. package/src/client/i18n/index.ts +53 -0
  66. package/src/client/i18n/messages/charts.ts +8 -0
  67. package/src/client/i18n/messages/common.ts +157 -0
  68. package/src/client/i18n/messages/costs.ts +104 -0
  69. package/src/client/i18n/messages/en.ts +40 -0
  70. package/src/client/i18n/messages/errors.ts +80 -0
  71. package/src/client/i18n/messages/frustration.ts +198 -0
  72. package/src/client/i18n/messages/gain.ts +54 -0
  73. package/src/client/i18n/messages/models.ts +94 -0
  74. package/src/client/i18n/messages/nav.ts +46 -0
  75. package/src/client/i18n/messages/overview.ts +90 -0
  76. package/src/client/i18n/messages/projects.ts +84 -0
  77. package/src/client/i18n/messages/providers.ts +165 -0
  78. package/src/client/i18n/messages/requests.ts +73 -0
  79. package/src/client/i18n/messages/tools.ts +120 -0
  80. package/src/client/i18n/messages/traces.ts +160 -0
  81. package/src/client/i18n/messages/ui.ts +46 -0
  82. package/src/client/i18n/messages/zh-CN.ts +43 -0
  83. package/src/client/i18n/types.ts +19 -0
  84. package/src/client/index.tsx +4 -0
  85. package/src/client/routes/CostsRoute.tsx +61 -48
  86. package/src/client/routes/ErrorsRoute.tsx +71 -47
  87. package/src/client/routes/FrustrationRoute.tsx +131 -116
  88. package/src/client/routes/GainRoute.tsx +42 -30
  89. package/src/client/routes/ModelsRoute.tsx +90 -73
  90. package/src/client/routes/OverviewRoute.tsx +109 -63
  91. package/src/client/routes/ProjectsRoute.tsx +73 -46
  92. package/src/client/routes/ProvidersRoute.tsx +151 -114
  93. package/src/client/routes/RequestsRoute.tsx +69 -46
  94. package/src/client/routes/ToolsRoute.tsx +108 -94
  95. package/src/client/routes/TracesRoute.tsx +25 -23
  96. package/src/client/styles.css +5 -2
  97. package/src/client/traces/AggregatesPanel.tsx +19 -8
  98. package/src/client/traces/Minimap.tsx +65 -27
  99. package/src/client/traces/SpanDrawer.tsx +46 -21
  100. package/src/client/traces/SummaryStrip.tsx +27 -11
  101. package/src/client/traces/TimelineCanvas.tsx +245 -116
  102. package/src/client/traces/TraceView.tsx +51 -36
  103. package/src/client/traces/TranscriptList.tsx +20 -11
  104. package/src/client/traces/traces.css +26 -0
  105. package/src/client/ui/JsonBlock.tsx +3 -2
  106. package/src/client/ui/Modal.tsx +3 -2
  107. package/src/client/ui/RequestDrawer.tsx +33 -30
  108. package/src/client/ui/SearchInput.tsx +2 -1
  109. package/src/client/ui/States.tsx +3 -2
  110. package/src/client/ui/Table.tsx +7 -3
  111. package/src/db.ts +150 -135
  112. package/src/embedded-client.ts +9 -0
  113. package/src/frustration.ts +9 -30
  114. package/src/index.ts +2 -1
  115. package/src/live.ts +36 -11
  116. package/src/locale.ts +29 -0
  117. package/src/parser.ts +37 -72
  118. package/src/port-conflict.ts +1 -1
  119. package/src/prompts/judge/frustration.json +19 -0
  120. package/src/rollup.ts +41 -20
  121. package/src/server.ts +67 -27
  122. package/src/shared-types.ts +7 -0
  123. package/src/trace.ts +54 -40
  124. package/src/types.ts +13 -1
  125. package/src/user-metrics.ts +220 -6
package/src/server.ts CHANGED
@@ -14,9 +14,12 @@ import {
14
14
  getRecentErrors,
15
15
  getRecentRequests,
16
16
  getRequestDetails,
17
+ getTimeRangeConfig,
17
18
  getToolDashboardStats,
18
19
  } from "./aggregator";
19
- import { decodeEmbeddedClientArchive } from "./embedded-client";
20
+ import { initDb } from "./db";
21
+ import { decodeEmbeddedClientArchive, hasEmbeddedClientArchive } from "./embedded-client";
22
+ import { resolveLocaleTag, type StatsLocale } from "./locale";
20
23
  import embeddedClientArchiveTxt from "./embedded-client.generated.txt";
21
24
  import {
22
25
  cancelFrustrationRun,
@@ -36,18 +39,17 @@ import {
36
39
  STATS_DASHBOARD_HOSTNAME_HEADER,
37
40
  STATS_DASHBOARD_SECURITY_VERSION,
38
41
  } from "./port-conflict";
39
- import type { LiveStatus } from "./shared-types";
42
+ import { getStatsByModel, getTimeSeries } from "./rollup";
40
43
  import {
41
44
  buildSessionTrace,
42
45
  getTraceEntry,
43
46
  listSessionSummaries,
44
47
  TRACE_ETAG_VERSION,
48
+ type TraceFingerprint,
45
49
  traceFingerprintForEtag,
46
50
  TracePathError,
47
51
  } from "./trace";
48
52
 
49
- const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt);
50
-
51
53
  const CLIENT_DIR = path.join(import.meta.dir, "client");
52
54
  const STATIC_DIR = path.join(import.meta.dir, "..", "dist", "client");
53
55
  const IS_BUN_COMPILED =
@@ -60,21 +62,27 @@ const IS_BUN_COMPILED =
60
62
  // dashboard sources or prebuilt dist/client next to the bundle, so the
61
63
  // embedded archive is the only viable asset source.
62
64
  const IS_PREBUILT = IS_BUN_COMPILED || Boolean(process.env.PI_BUNDLED || Bun.env.PI_BUNDLED);
63
- const USE_EMBEDDED_CLIENT = EMBEDDED_CLIENT_ARCHIVE !== null || IS_PREBUILT;
65
+ // Sniff the base64 gzip magic instead of decoding: importing this module (every
66
+ // TUI start) must not pay for a multi-megabyte archive nobody may serve.
67
+ const USE_EMBEDDED_CLIENT = hasEmbeddedClientArchive(embeddedClientArchiveTxt) || IS_PREBUILT;
68
+
69
+ /** Host UI language for the served shell; `startServer` sets it from `StartServerOptions.language`. */
70
+ let dashboardLocale: StatsLocale = "en";
64
71
 
65
72
  let embeddedClientFilesPromise: Promise<Map<string, Blob>> | null = null;
66
73
 
67
74
  async function getEmbeddedClientFiles(): Promise<Map<string, Blob>> {
68
75
  if (embeddedClientFilesPromise) return embeddedClientFilesPromise;
69
76
 
70
- if (!EMBEDDED_CLIENT_ARCHIVE) {
77
+ const archive = decodeEmbeddedClientArchive(embeddedClientArchiveTxt);
78
+ if (!archive) {
71
79
  throw new Error(
72
80
  "Embedded stats client bundle missing. Rebuild the zeta binary or npm bundle with embedded stats assets.",
73
81
  );
74
82
  }
75
83
 
76
84
  // Keep bundled assets in memory so OS temporary-file cleanup cannot break a live dashboard.
77
- embeddedClientFilesPromise = new Bun.Archive(EMBEDDED_CLIENT_ARCHIVE).files().then(files => {
85
+ embeddedClientFilesPromise = new Bun.Archive(archive).files().then(files => {
78
86
  for (const [name, file] of files) {
79
87
  // Archive entries are untyped blobs; infer MIME types just as disk-backed Bun files do.
80
88
  files.set(name, new File([file], name, { type: Bun.file(name).type }));
@@ -241,8 +249,9 @@ export async function handleApi(req: Request): Promise<Response> {
241
249
  }
242
250
 
243
251
  if (path === "/api/stats/models") {
244
- const stats = await getDashboardStats(range);
245
- return Response.json(stats.byModel);
252
+ // Also the port-conflict identity probe: keep it to the one query it serves.
253
+ await initDb();
254
+ return Response.json(getStatsByModel(getTimeRangeConfig(range).cutoff));
246
255
  }
247
256
 
248
257
  if (path === "/api/stats/folders") {
@@ -251,8 +260,8 @@ export async function handleApi(req: Request): Promise<Response> {
251
260
  }
252
261
 
253
262
  if (path === "/api/stats/timeseries") {
254
- const stats = await getDashboardStats(range);
255
- return Response.json(stats.timeSeries);
263
+ await initDb();
264
+ return Response.json(getTimeSeries(getTimeRangeConfig(range)));
256
265
  }
257
266
 
258
267
  if (path.startsWith("/api/request/")) {
@@ -293,14 +302,15 @@ export async function handleApi(req: Request): Promise<Response> {
293
302
  // The fingerprint covers child transcripts, so a subagent-only
294
303
  // append changes the ETag and never 304s stale.
295
304
  const clientEtag = req.headers.get("if-none-match");
305
+ let fingerprint: TraceFingerprint | undefined;
296
306
  if (clientEtag) {
297
- const fingerprint = await traceFingerprintForEtag(file);
307
+ fingerprint = await traceFingerprintForEtag(file);
298
308
  if (fingerprint !== undefined) {
299
- const etag = `"${TRACE_ETAG_VERSION}:${fingerprint}"`;
309
+ const etag = `"${TRACE_ETAG_VERSION}:${fingerprint.rootMtimeMs}:${fingerprint.childFingerprint}"`;
300
310
  if (clientEtag === etag) return new Response(null, { status: 304 });
301
311
  }
302
312
  }
303
- const trace = await buildSessionTrace(file);
313
+ const trace = await buildSessionTrace(file, fingerprint);
304
314
  const etag = `"${TRACE_ETAG_VERSION}:${trace.etag}"`;
305
315
  if (clientEtag === etag) return new Response(null, { status: 304 });
306
316
  return Response.json(trace, { headers: { ETag: etag } });
@@ -332,10 +342,12 @@ export async function handleApi(req: Request): Promise<Response> {
332
342
  * Handle static file requests.
333
343
  */
334
344
  async function handleStatic(requestPath: string): Promise<Response> {
345
+ const wantsIndex = requestPath === "/" || requestPath.endsWith("index.html");
335
346
  if (USE_EMBEDDED_CLIENT) {
336
347
  const files = await getEmbeddedClientFiles();
337
348
  const file = files.get(requestPath.slice(1)) ?? files.get("index.html");
338
- return file ? new Response(file) : new Response("Not Found", { status: 404 });
349
+ if (!file) return new Response("Not Found", { status: 404 });
350
+ return wantsIndex ? shellResponse(await file.text()) : new Response(file);
339
351
  }
340
352
 
341
353
  const filePath = requestPath === "/" ? "/index.html" : requestPath;
@@ -343,18 +355,31 @@ async function handleStatic(requestPath: string): Promise<Response> {
343
355
 
344
356
  const file = Bun.file(fullPath);
345
357
  if (await file.exists()) {
346
- return new Response(file);
358
+ return filePath.endsWith("index.html") ? shellResponse(await file.text()) : new Response(file);
347
359
  }
348
360
 
349
361
  // SPA fallback
350
362
  const index = Bun.file(path.join(STATIC_DIR, "index.html"));
351
363
  if (await index.exists()) {
352
- return new Response(index);
364
+ return shellResponse(await index.text());
353
365
  }
354
366
 
355
367
  return new Response("Not Found", { status: 404 });
356
368
  }
357
369
 
370
+ /**
371
+ * The shell HTML with the host's language stamped in — `<html lang>` plus an
372
+ * inline global the client reads at boot (`resolveInitialLocale`), the same
373
+ * pre-paint pattern the persisted theme uses.
374
+ */
375
+ function shellResponse(html: string): Response {
376
+ const locale = dashboardLocale;
377
+ const stamped = html
378
+ .replace(/<html lang="[^"]*"/, `<html lang="${locale}"`)
379
+ .replace("</head>", `<script>window.__ZETA_STATS_LOCALE__=${JSON.stringify(locale)}</script></head>`);
380
+ return new Response(stamped, { headers: { "content-type": "text/html; charset=utf-8" } });
381
+ }
382
+
358
383
  /** Format a dashboard origin, including brackets required by IPv6 literals. */
359
384
  export function formatStatsDashboardUrl(hostname: string, port: number): string {
360
385
  const urlHostname = hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
@@ -383,7 +408,7 @@ function createDashboardServer(port: number, hostname: string): Server<undefined
383
408
  if (path === "/api/events") {
384
409
  // Long-lived stream: exempt from the idle timeout.
385
410
  server.timeout(req, 0);
386
- return liveEventStream(dashboardHeaders);
411
+ return liveEventStream(dashboardHeaders, req.signal);
387
412
  }
388
413
 
389
414
  try {
@@ -423,8 +448,9 @@ const EVENT_HEARTBEAT_MS = 15_000;
423
448
  /**
424
449
  * Server-sent events of {@link LiveStatus}: the current status immediately,
425
450
  * then every change (sync progress, data version bumps, indexing backlog).
451
+ * Disconnects unsubscribe, so ingest winds down once no page is listening.
426
452
  */
427
- function liveEventStream(headers: Record<string, string>): Response {
453
+ function liveEventStream(headers: Record<string, string>, signal: AbortSignal): Response {
428
454
  const live = statsLive();
429
455
  // Ingest starts when the first page connects, not when the server binds.
430
456
  live.start();
@@ -432,19 +458,26 @@ function liveEventStream(headers: Record<string, string>): Response {
432
458
  let cleanup = () => {};
433
459
  const stream = new ReadableStream<Uint8Array>({
434
460
  start(controller) {
435
- const send = (status: LiveStatus) => {
436
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(status)}\n\n`));
461
+ const write = (chunk: string) => {
462
+ try {
463
+ controller.enqueue(encoder.encode(chunk));
464
+ } catch {
465
+ // Stream already closed: the client left without a cancel.
466
+ cleanup();
467
+ }
437
468
  };
438
- send(live.status());
439
- const unsubscribe = live.subscribe(send);
440
- const heartbeat = setInterval(
441
- () => controller.enqueue(encoder.encode(": keep-alive\n\n")),
442
- EVENT_HEARTBEAT_MS,
443
- );
469
+ write(`data: ${JSON.stringify(live.status())}\n\n`);
470
+ const unsubscribe = live.subscribe((_status, frame) => write(`data: ${frame}\n\n`));
471
+ const heartbeat = setInterval(() => write(": keep-alive\n\n"), EVENT_HEARTBEAT_MS);
472
+ const onAbort = () => cleanup();
444
473
  cleanup = () => {
474
+ cleanup = () => {};
445
475
  unsubscribe();
446
476
  clearInterval(heartbeat);
477
+ signal.removeEventListener("abort", onAbort);
447
478
  };
479
+ if (signal.aborted) cleanup();
480
+ else signal.addEventListener("abort", onAbort, { once: true });
448
481
  },
449
482
  cancel() {
450
483
  cleanup();
@@ -480,6 +513,12 @@ let liveServers = 0;
480
513
  export interface StartServerOptions {
481
514
  /** Host judge for the Frustration dashboard's judge runs; replaces any previously registered one. */
482
515
  judge?: StatsJudgeProvider;
516
+ /**
517
+ * Host UI language (`zh`, `zh-CN`, `en`, …). Stamped into the served page so
518
+ * the dashboard renders in the same language as the CLI that launched it;
519
+ * anything unrecognized resolves to English.
520
+ */
521
+ language?: string;
483
522
  }
484
523
 
485
524
  export async function startServer(
@@ -488,6 +527,7 @@ export async function startServer(
488
527
  options: StartServerOptions = {},
489
528
  ): Promise<StatsServerHandle> {
490
529
  if (options.judge) setStatsJudgeProvider(options.judge);
530
+ dashboardLocale = resolveLocaleTag(options.language);
491
531
  const activeKey = `${hostname}:${port}`;
492
532
  if (port !== 0) {
493
533
  const active = activeServers.get(activeKey);
@@ -203,6 +203,13 @@ export interface FrustrationCounts {
203
203
  messages: number;
204
204
  /** Messages whose prose has a cached judge verdict; the rest use regex fallback. */
205
205
  judged: number;
206
+ /**
207
+ * Unjudged messages whose prose is written in a script no lexicon covers
208
+ * (Kana, Hangul, Cyrillic, …). Their regex counts are zeros because there is
209
+ * no lexicon, not because the user was calm - the dashboard says so instead
210
+ * of folding them into "not annoyed", and the judge is the fix for them.
211
+ */
212
+ unscored: number;
206
213
  /** Annoyed at anything (the assistant, tooling, other people, ...). */
207
214
  annoyed: number;
208
215
  /** Annoyed and aimed at the assistant. Subset of `annoyed`. */
package/src/trace.ts CHANGED
@@ -750,11 +750,6 @@ async function buildTrackTree(
750
750
  // ---------------------------------------------------------------------------
751
751
  // Public surface
752
752
 
753
- /**
754
- * Assemble the full span tree for one root session transcript.
755
- * Throws {@link TracePathError} for paths outside the sessions root; ENOENT
756
- * passes through for the caller's 404 mapping.
757
- */
758
753
  // Single-entry memo: the dashboard polls one open trace every 15s. Keyed on
759
754
  // the root file's mtime PLUS a fingerprint of the child-transcript SET under
760
755
  // the root's artifacts tree (one cheap readdir+stat walk, no parse). A
@@ -788,35 +783,46 @@ async function childTranscriptsFingerprint(rootFile: string, depth = 0): Promise
788
783
  return "";
789
784
  }
790
785
  names.sort();
786
+ const dir = transcriptStem(rootFile);
791
787
  const transcriptNames = names.filter(name => name.endsWith(".jsonl") || name.endsWith(".jsonl.gz"));
792
- const parts: string[] = [`n=${transcriptNames.length}`];
793
- for (const name of transcriptNames) {
794
- const childFile = path.join(transcriptStem(rootFile), name);
795
- try {
796
- const stat = await fs.stat(childFile);
797
- parts.push(`${name}@${stat.mtimeMs}`);
798
- // Recurse like the builder (nested subagents), without parsing.
799
- const nested = await childTranscriptsFingerprint(childFile, depth + 1);
800
- if (nested) parts.push(`${name}/{${nested}}`);
801
- } catch {
802
- parts.push(`${name}@gone`);
803
- }
804
- }
805
- return parts.join(",");
788
+ const childParts = await Promise.all(
789
+ transcriptNames.map(async name => {
790
+ const childFile = path.join(dir, name);
791
+ try {
792
+ const stat = await fs.stat(childFile);
793
+ // Recurse like the builder (nested subagents), without parsing.
794
+ const nested = await childTranscriptsFingerprint(childFile, depth + 1);
795
+ return nested ? `${name}@${stat.mtimeMs},${name}/{${nested}}` : `${name}@${stat.mtimeMs}`;
796
+ } catch {
797
+ return `${name}@gone`;
798
+ }
799
+ }),
800
+ );
801
+ return [`n=${transcriptNames.length}`, ...childParts].join(",");
802
+ }
803
+
804
+ /** Freshness of a root trace: root mtime plus the child-transcript set fingerprint. */
805
+ export interface TraceFingerprint {
806
+ rootMtimeMs: number;
807
+ childFingerprint: string;
808
+ }
809
+
810
+ /** Stat the root and walk its child transcripts (readdir+stat, no parse). */
811
+ async function readTraceFingerprint(resolved: string): Promise<TraceFingerprint> {
812
+ const rootMtimeMs = (await fs.stat(resolved)).mtimeMs;
813
+ const childFingerprint = await childTranscriptsFingerprint(resolved);
814
+ return { rootMtimeMs, childFingerprint };
806
815
  }
807
816
 
808
817
  /**
809
- * Freshness fingerprint for the trace ETag pre-check: root mtime plus the
810
- * child-transcript set fingerprint (readdir+stat walk, no parse). Returns
811
- * undefined when the path is rejected or missing (the caller falls through
812
- * to the full build, which maps those to 400/404).
818
+ * Freshness fingerprint for the trace ETag pre-check; `SessionTrace.etag` is
819
+ * `${rootMtimeMs}:${childFingerprint}` for an unraced build. Returns undefined
820
+ * when the path is rejected or missing (the caller falls through to the full
821
+ * build, which maps those to 400/404).
813
822
  */
814
- export async function traceFingerprintForEtag(fileParam: string): Promise<string | undefined> {
823
+ export async function traceFingerprintForEtag(fileParam: string): Promise<TraceFingerprint | undefined> {
815
824
  try {
816
- const resolved = resolveSessionPath(fileParam);
817
- const rootMs = (await fs.stat(resolved)).mtimeMs;
818
- const childFp = await childTranscriptsFingerprint(resolved);
819
- return `${rootMs}:${childFp}`;
825
+ return await readTraceFingerprint(resolveSessionPath(fileParam));
820
826
  } catch {
821
827
  return undefined;
822
828
  }
@@ -827,16 +833,22 @@ export function traceMemoForTests(): { file: string; mtimeMs: number } | undefin
827
833
  return { file: traceMemo.file, mtimeMs: traceMemo.entry.mtimeMs };
828
834
  }
829
835
 
830
- export async function buildSessionTrace(fileParam: string): Promise<SessionTrace> {
836
+ /**
837
+ * Assemble the full span tree for one root session transcript.
838
+ * Throws {@link TracePathError} for paths outside the sessions root; ENOENT
839
+ * passes through for the caller's 404 mapping. `fingerprint` may carry the
840
+ * {@link traceFingerprintForEtag} value the caller just computed for the same
841
+ * file (its ETag pre-check), sparing a second walk.
842
+ */
843
+ export async function buildSessionTrace(fileParam: string, fingerprint?: TraceFingerprint): Promise<SessionTrace> {
831
844
  const resolved = resolveSessionPath(fileParam);
832
- const rootStat = await fs.stat(resolved);
833
845
  const memo = traceMemo?.file === resolved ? traceMemo.entry : undefined;
834
846
  // Pre-build child fingerprint on EVERY path (hit check and rebuild
835
847
  // alike): the post-build race guard compares against this, so a child
836
848
  // append landing mid-parse is detected even on first builds and
837
849
  // memo-less rebuilds, where no prior fingerprint exists to compare.
838
- const preChildFingerprint = await childTranscriptsFingerprint(resolved);
839
- if (memo && memo.mtimeMs === rootStat.mtimeMs) {
850
+ const { rootMtimeMs, childFingerprint: preChildFingerprint } = fingerprint ?? (await readTraceFingerprint(resolved));
851
+ if (memo && memo.mtimeMs === rootMtimeMs) {
840
852
  // Equality, not <=: a LOWER mark (delete/older-mtime replace) is
841
853
  // also stale — only an identical child set may reuse the trace.
842
854
  if (preChildFingerprint === memo.childFingerprint) return memo.trace;
@@ -901,7 +913,7 @@ export async function buildSessionTrace(fileParam: string): Promise<SessionTrace
901
913
  else if (span.kind === "turn") turns++;
902
914
  }
903
915
 
904
- const start = startedAt ?? rootScan.headerTs ?? rootStat.mtimeMs;
916
+ const start = startedAt ?? rootScan.headerTs ?? rootMtimeMs;
905
917
  const end = endedAt ?? start;
906
918
  const wallMs = end - start;
907
919
 
@@ -925,7 +937,7 @@ export async function buildSessionTrace(fileParam: string): Promise<SessionTrace
925
937
  // neither the trace nor the key.
926
938
  const childFingerprint = await childTranscriptsFingerprint(resolved);
927
939
  // Root rewritten mid-build: same no-cache rule as the child race below.
928
- const rootChanged = (await fs.stat(resolved).catch(() => null))?.mtimeMs !== rootStat.mtimeMs;
940
+ const rootChanged = (await fs.stat(resolved).catch(() => null))?.mtimeMs !== rootMtimeMs;
929
941
  const raced = childFingerprint !== preChildFingerprint;
930
942
  // Raced builds keep the PRE-build fingerprint as their ETag: the parsed
931
943
  // bytes predate the racy write, so claiming the post-write state would
@@ -941,15 +953,15 @@ export async function buildSessionTrace(fileParam: string): Promise<SessionTrace
941
953
  cwd: rootScan.cwd,
942
954
  startedAt: start,
943
955
  endedAt: end,
944
- mtimeMs: rootStat.mtimeMs,
945
- etag: `${rootStat.mtimeMs}:${etagFingerprint}`,
956
+ mtimeMs: rootMtimeMs,
957
+ etag: `${rootMtimeMs}:${etagFingerprint}`,
946
958
  tracks,
947
959
  summary,
948
960
  };
949
961
  if (!raced && !rootChanged) {
950
962
  traceMemo = {
951
963
  file: resolved,
952
- entry: { mtimeMs: rootStat.mtimeMs, childFingerprint, trace },
964
+ entry: { mtimeMs: rootMtimeMs, childFingerprint, trace },
953
965
  };
954
966
  }
955
967
  return trace;
@@ -1068,7 +1080,8 @@ function basenameTimestamp(base: string): number | undefined {
1068
1080
  // Short-TTL memo for the disk sweep behind /api/sessions (30s poll): the
1069
1081
  // session list changes only on create/edit, so readdir+stat of every file per
1070
1082
  // poll is pure syscall churn. TTL is deliberately short so a just-created
1071
- // session appears within seconds.
1083
+ // session appears within seconds. Only the newest `limit` roots are kept, so
1084
+ // the memo stays small however many sessions exist.
1072
1085
  let diskRootsMemo:
1073
1086
  | {
1074
1087
  atMs: number;
@@ -1117,8 +1130,9 @@ async function scanDiskRoots(limit: number): Promise<Array<{ file: string; mtime
1117
1130
  }),
1118
1131
  );
1119
1132
  roots.sort((a, b) => b.mtimeMs - a.mtimeMs);
1120
- if (roots.length <= 1000) diskRootsMemo = { atMs: Date.now(), sessionsDir, limit, roots };
1121
- return roots.slice(0, limit);
1133
+ const newest = roots.slice(0, limit);
1134
+ diskRootsMemo = { atMs: Date.now(), sessionsDir, limit, roots: newest };
1135
+ return newest.slice();
1122
1136
  }
1123
1137
 
1124
1138
  /**
package/src/types.ts CHANGED
@@ -35,6 +35,14 @@ export interface MessageStats {
35
35
  usage: Usage;
36
36
  /** Which agent produced this message (main agent, task subagent, advisor) */
37
37
  agentType: AgentType;
38
+ /**
39
+ * Service tier the provider reported serving this turn, or `null` when the
40
+ * provider reported none. Lets consumers separate a fast serving path's
41
+ * throughput and premium traffic from standard-tier traffic. Always written by
42
+ * `rowToMessageStats`; optional only because session fixtures that stand in
43
+ * for ingest input are typed as `MessageStats` too.
44
+ */
45
+ serviceTier?: ServiceTier | null;
38
46
  /**
39
47
  * Ingest refused to price this request: a scheduled (time-based) card with no
40
48
  * recoverable request timestamp, so `usage.cost.total` of 0 is unknown spend
@@ -63,7 +71,7 @@ export interface MessageStatsInput extends Omit<MessageStats, "usage" | "costUnp
63
71
  export interface RequestDetails extends MessageStats {
64
72
  /** The full conversation history or just the last turn. */
65
73
  messages: unknown[];
66
- /** The model's response. */
74
+ /** The model's response; `null` for `model_usage` entries, which journal no payload. */
67
75
  output: unknown;
68
76
  }
69
77
 
@@ -175,6 +183,10 @@ export interface UserMessageStats {
175
183
  repetition: number;
176
184
  /** Second-person reproach ("you didnt", "why did you", "stop X-ing") */
177
185
  blame: number;
186
+ /** Emphatic CJK/voice markers the caps-based yelling cannot see (see `UserMessageMetrics.emphasis`). */
187
+ emphasis: number;
188
+ /** 1 when the prose leans on a script no lexicon covers; such rows are unscored, not calm. */
189
+ unsupportedScript: number;
178
190
  /** Judge-facing prose (see `judgeProse`); empty when the message was all markup. */
179
191
  prose: string;
180
192
  /** Hex `Bun.hash` of `prose`, keying the shared verdict cache; empty when `prose` is. */