@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.
- package/CHANGELOG.md +12 -5
- package/build.ts +4 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +1 -0
- package/dist/client/index.js +96 -96
- package/dist/types/aggregator.d.ts +4 -1
- package/dist/types/client/api.d.ts +5 -1
- package/dist/types/client/app/nav.d.ts +5 -2
- package/dist/types/client/data/formatters.d.ts +7 -2
- package/dist/types/client/data/live.d.ts +16 -11
- package/dist/types/client/data/query-store.d.ts +39 -0
- package/dist/types/client/data/query.d.ts +10 -5
- package/dist/types/client/data/range.d.ts +4 -3
- package/dist/types/client/i18n/format.d.ts +9 -0
- package/dist/types/client/i18n/index.d.ts +18 -0
- package/dist/types/client/i18n/messages/charts.d.ts +8 -0
- package/dist/types/client/i18n/messages/common.d.ts +153 -0
- package/dist/types/client/i18n/messages/costs.d.ts +92 -0
- package/dist/types/client/i18n/messages/en.d.ts +1255 -0
- package/dist/types/client/i18n/messages/errors.d.ts +78 -0
- package/dist/types/client/i18n/messages/frustration.d.ts +191 -0
- package/dist/types/client/i18n/messages/gain.d.ts +54 -0
- package/dist/types/client/i18n/messages/models.d.ts +94 -0
- package/dist/types/client/i18n/messages/nav.d.ts +46 -0
- package/dist/types/client/i18n/messages/overview.d.ts +80 -0
- package/dist/types/client/i18n/messages/projects.d.ts +84 -0
- package/dist/types/client/i18n/messages/providers.d.ts +142 -0
- package/dist/types/client/i18n/messages/requests.d.ts +72 -0
- package/dist/types/client/i18n/messages/tools.d.ts +119 -0
- package/dist/types/client/i18n/messages/traces.d.ts +144 -0
- package/dist/types/client/i18n/messages/ui.d.ts +44 -0
- package/dist/types/client/i18n/messages/zh-CN.d.ts +13 -0
- package/dist/types/client/i18n/types.d.ts +15 -0
- package/dist/types/client/traces/Minimap.d.ts +2 -0
- package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
- package/dist/types/client/traces/TranscriptList.d.ts +3 -1
- package/dist/types/db.d.ts +14 -21
- package/dist/types/embedded-client.d.ts +5 -0
- package/dist/types/frustration.d.ts +18 -9
- package/dist/types/live.d.ts +5 -3
- package/dist/types/locale.d.ts +16 -0
- package/dist/types/parser.d.ts +0 -17
- package/dist/types/rollup.d.ts +9 -1
- package/dist/types/server.d.ts +6 -0
- package/dist/types/shared-types.d.ts +7 -0
- package/dist/types/trace.d.ts +18 -6
- package/dist/types/types.d.ts +13 -1
- package/dist/types/user-metrics.d.ts +23 -1
- package/package.json +5 -5
- package/src/aggregator.ts +31 -11
- package/src/client/api.ts +40 -5
- package/src/client/app/LiveChip.tsx +14 -13
- package/src/client/app/Shell.tsx +19 -13
- package/src/client/app/ThemeToggle.tsx +4 -3
- package/src/client/app/nav.ts +19 -16
- package/src/client/data/formatters.ts +80 -13
- package/src/client/data/live.tsx +85 -45
- package/src/client/data/query-store.ts +108 -0
- package/src/client/data/query.ts +47 -56
- package/src/client/data/range.ts +34 -14
- package/src/client/data/series.ts +7 -1
- package/src/client/favicon.ico +0 -0
- package/src/client/i18n/format.ts +28 -0
- package/src/client/i18n/index.ts +53 -0
- package/src/client/i18n/messages/charts.ts +8 -0
- package/src/client/i18n/messages/common.ts +157 -0
- package/src/client/i18n/messages/costs.ts +104 -0
- package/src/client/i18n/messages/en.ts +40 -0
- package/src/client/i18n/messages/errors.ts +80 -0
- package/src/client/i18n/messages/frustration.ts +198 -0
- package/src/client/i18n/messages/gain.ts +54 -0
- package/src/client/i18n/messages/models.ts +94 -0
- package/src/client/i18n/messages/nav.ts +46 -0
- package/src/client/i18n/messages/overview.ts +90 -0
- package/src/client/i18n/messages/projects.ts +84 -0
- package/src/client/i18n/messages/providers.ts +165 -0
- package/src/client/i18n/messages/requests.ts +73 -0
- package/src/client/i18n/messages/tools.ts +120 -0
- package/src/client/i18n/messages/traces.ts +160 -0
- package/src/client/i18n/messages/ui.ts +46 -0
- package/src/client/i18n/messages/zh-CN.ts +43 -0
- package/src/client/i18n/types.ts +19 -0
- package/src/client/index.tsx +4 -0
- package/src/client/routes/CostsRoute.tsx +61 -48
- package/src/client/routes/ErrorsRoute.tsx +71 -47
- package/src/client/routes/FrustrationRoute.tsx +131 -116
- package/src/client/routes/GainRoute.tsx +42 -30
- package/src/client/routes/ModelsRoute.tsx +90 -73
- package/src/client/routes/OverviewRoute.tsx +109 -63
- package/src/client/routes/ProjectsRoute.tsx +73 -46
- package/src/client/routes/ProvidersRoute.tsx +151 -114
- package/src/client/routes/RequestsRoute.tsx +69 -46
- package/src/client/routes/ToolsRoute.tsx +108 -94
- package/src/client/routes/TracesRoute.tsx +25 -23
- package/src/client/styles.css +5 -2
- package/src/client/traces/AggregatesPanel.tsx +19 -8
- package/src/client/traces/Minimap.tsx +65 -27
- package/src/client/traces/SpanDrawer.tsx +46 -21
- package/src/client/traces/SummaryStrip.tsx +27 -11
- package/src/client/traces/TimelineCanvas.tsx +245 -116
- package/src/client/traces/TraceView.tsx +51 -36
- package/src/client/traces/TranscriptList.tsx +20 -11
- package/src/client/traces/traces.css +26 -0
- package/src/client/ui/JsonBlock.tsx +3 -2
- package/src/client/ui/Modal.tsx +3 -2
- package/src/client/ui/RequestDrawer.tsx +33 -30
- package/src/client/ui/SearchInput.tsx +2 -1
- package/src/client/ui/States.tsx +3 -2
- package/src/client/ui/Table.tsx +7 -3
- package/src/db.ts +150 -135
- package/src/embedded-client.ts +9 -0
- package/src/frustration.ts +9 -30
- package/src/index.ts +2 -1
- package/src/live.ts +36 -11
- package/src/locale.ts +29 -0
- package/src/parser.ts +37 -72
- package/src/port-conflict.ts +1 -1
- package/src/prompts/judge/frustration.json +19 -0
- package/src/rollup.ts +41 -20
- package/src/server.ts +67 -27
- package/src/shared-types.ts +7 -0
- package/src/trace.ts +54 -40
- package/src/types.ts +13 -1
- 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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
255
|
-
return Response.json(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
436
|
-
|
|
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
|
-
|
|
439
|
-
const unsubscribe = live.subscribe(
|
|
440
|
-
const heartbeat = setInterval(
|
|
441
|
-
|
|
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);
|
package/src/shared-types.ts
CHANGED
|
@@ -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
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
return
|
|
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
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
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<
|
|
823
|
+
export async function traceFingerprintForEtag(fileParam: string): Promise<TraceFingerprint | undefined> {
|
|
815
824
|
try {
|
|
816
|
-
|
|
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
|
-
|
|
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
|
|
839
|
-
if (memo && memo.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 ??
|
|
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 !==
|
|
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:
|
|
945
|
-
etag: `${
|
|
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:
|
|
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
|
-
|
|
1121
|
-
|
|
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. */
|