@oh-my-pi/omp-stats 18.3.4 → 18.4.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 +16 -0
- package/README.md +13 -11
- package/build.ts +5 -49
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/index.js +105 -126
- package/dist/types/aggregator.d.ts +25 -22
- package/dist/types/client/api.d.ts +11 -7
- package/dist/types/client/app/LiveChip.d.ts +2 -0
- package/dist/types/client/app/Shell.d.ts +16 -0
- package/dist/types/client/app/ThemeToggle.d.ts +1 -0
- package/dist/types/client/app/nav.d.ts +18 -0
- package/dist/types/client/charts/BarList.d.ts +17 -0
- package/dist/types/client/charts/Chart.d.ts +49 -0
- package/dist/types/client/charts/Legend.d.ts +16 -0
- package/dist/types/client/charts/ShareBar.d.ts +11 -0
- package/dist/types/client/charts/Sparkline.d.ts +11 -0
- package/dist/types/client/charts/TimeChart.d.ts +9 -0
- package/dist/types/client/charts/index.d.ts +7 -0
- package/dist/types/client/charts/types.d.ts +25 -0
- package/dist/types/client/charts/useWidth.d.ts +3 -0
- package/dist/types/client/data/colors.d.ts +25 -0
- package/dist/types/client/data/formatters.d.ts +9 -1
- package/dist/types/client/data/live.d.ts +29 -0
- package/dist/types/client/data/query.d.ts +35 -0
- package/dist/types/client/data/range.d.ts +31 -0
- package/dist/types/client/data/series.d.ts +28 -0
- package/dist/types/client/data/useHashRoute.d.ts +18 -4
- package/dist/types/client/data/view-models.d.ts +111 -28
- package/dist/types/client/index.d.ts +0 -1
- package/dist/types/client/routes/CostsRoute.d.ts +2 -2
- package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
- package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
- package/dist/types/client/routes/GainRoute.d.ts +1 -2
- package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
- package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
- package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
- package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
- package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
- package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
- package/dist/types/client/routes/TracesRoute.d.ts +2 -2
- package/dist/types/client/routes/index.d.ts +1 -1
- package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
- package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
- package/dist/types/client/traces/trace-colors.d.ts +18 -12
- package/dist/types/client/ui/Badge.d.ts +19 -0
- package/dist/types/client/ui/Card.d.ts +25 -0
- package/dist/types/client/ui/Drawer.d.ts +25 -0
- package/dist/types/client/ui/JsonBlock.d.ts +2 -2
- package/dist/types/client/ui/Modal.d.ts +23 -0
- package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
- package/dist/types/client/ui/SearchInput.d.ts +8 -0
- package/dist/types/client/ui/Segmented.d.ts +15 -0
- package/dist/types/client/ui/Stat.d.ts +36 -0
- package/dist/types/client/ui/States.d.ts +41 -0
- package/dist/types/client/ui/Table.d.ts +48 -0
- package/dist/types/client/ui/index.d.ts +9 -10
- package/dist/types/db.d.ts +90 -106
- package/dist/types/frustration.d.ts +70 -0
- package/dist/types/index.d.ts +3 -1
- package/dist/types/live.d.ts +30 -0
- package/dist/types/rollup.d.ts +115 -0
- package/dist/types/server.d.ts +6 -1
- package/dist/types/shared-types.d.ts +116 -56
- package/dist/types/types.d.ts +4 -0
- package/dist/types/user-metrics.d.ts +8 -0
- package/package.json +7 -13
- package/src/aggregator.ts +276 -211
- package/src/client/App.tsx +38 -80
- package/src/client/api.ts +65 -7
- package/src/client/app/LiveChip.tsx +97 -0
- package/src/client/app/Shell.tsx +181 -0
- package/src/client/app/ThemeToggle.tsx +5 -2
- package/src/client/app/nav.ts +78 -0
- package/src/client/charts/BarList.tsx +48 -0
- package/src/client/charts/Chart.tsx +388 -0
- package/src/client/charts/Legend.tsx +61 -0
- package/src/client/charts/ShareBar.tsx +26 -0
- package/src/client/charts/Sparkline.tsx +38 -0
- package/src/client/charts/TimeChart.tsx +22 -0
- package/src/client/charts/index.ts +7 -0
- package/src/client/charts/types.ts +27 -0
- package/src/client/charts/useWidth.ts +19 -0
- package/src/client/data/colors.ts +45 -0
- package/src/client/data/formatters.ts +30 -9
- package/src/client/data/live.tsx +85 -0
- package/src/client/data/query.ts +166 -0
- package/src/client/data/range.ts +82 -0
- package/src/client/data/series.ts +85 -0
- package/src/client/data/useHashRoute.ts +41 -83
- package/src/client/data/view-models.ts +347 -140
- package/src/client/index.tsx +3 -6
- package/src/client/routes/CostsRoute.tsx +394 -222
- package/src/client/routes/ErrorsRoute.tsx +401 -98
- package/src/client/routes/FrustrationRoute.tsx +924 -0
- package/src/client/routes/GainRoute.tsx +209 -206
- package/src/client/routes/ModelsRoute.tsx +540 -431
- package/src/client/routes/OverviewRoute.tsx +283 -311
- package/src/client/routes/ProjectsRoute.tsx +298 -156
- package/src/client/routes/ProvidersRoute.tsx +948 -416
- package/src/client/routes/RequestsRoute.tsx +295 -107
- package/src/client/routes/ToolsRoute.tsx +495 -398
- package/src/client/routes/TracesRoute.tsx +136 -117
- package/src/client/routes/costs.css +24 -0
- package/src/client/routes/errors.css +60 -0
- package/src/client/routes/frustration.css +123 -0
- package/src/client/routes/index.ts +1 -1
- package/src/client/routes/models.css +43 -0
- package/src/client/routes/projects.css +6 -0
- package/src/client/routes/providers.css +28 -0
- package/src/client/routes/tools.css +14 -0
- package/src/client/styles.css +1396 -1299
- package/src/client/traces/AggregatesPanel.tsx +55 -74
- package/src/client/traces/Minimap.tsx +6 -15
- package/src/client/traces/SpanDrawer.tsx +128 -194
- package/src/client/traces/SummaryStrip.tsx +21 -24
- package/src/client/traces/TimelineCanvas.tsx +27 -44
- package/src/client/traces/TraceView.tsx +175 -130
- package/src/client/traces/TranscriptList.tsx +13 -41
- package/src/client/traces/trace-colors.ts +60 -53
- package/src/client/traces/traces.css +279 -0
- package/src/client/ui/Badge.tsx +29 -0
- package/src/client/ui/Card.tsx +61 -0
- package/src/client/ui/Drawer.tsx +93 -0
- package/src/client/ui/JsonBlock.tsx +26 -44
- package/src/client/ui/Modal.tsx +106 -0
- package/src/client/ui/RequestDrawer.tsx +163 -188
- package/src/client/ui/SearchInput.tsx +25 -0
- package/src/client/ui/Segmented.tsx +61 -0
- package/src/client/ui/Stat.tsx +104 -0
- package/src/client/ui/States.tsx +81 -0
- package/src/client/ui/Table.tsx +229 -0
- package/src/client/ui/index.ts +9 -10
- package/src/client/ui/modal.css +54 -0
- package/src/client/ui/request-drawer.css +40 -0
- package/src/db.ts +379 -983
- package/src/frustration.ts +473 -0
- package/src/index.ts +22 -22
- package/src/live.ts +259 -0
- package/src/parser.ts +55 -66
- package/src/rollup.ts +951 -0
- package/src/server.ts +123 -40
- package/src/shared-types.ts +120 -56
- package/src/trace.ts +14 -7
- package/src/types.ts +4 -0
- package/src/user-metrics.ts +13 -0
- package/dist/client/styles.css +0 -1976
- package/dist/types/client/app/AppLayout.d.ts +0 -16
- package/dist/types/client/app/NavRail.d.ts +0 -7
- package/dist/types/client/app/RangeControl.d.ts +0 -7
- package/dist/types/client/app/SyncButton.d.ts +0 -14
- package/dist/types/client/app/TopBar.d.ts +0 -15
- package/dist/types/client/app/routes.d.ts +0 -12
- package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
- package/dist/types/client/components/chart-shared.d.ts +0 -178
- package/dist/types/client/components/models-table-shared.d.ts +0 -175
- package/dist/types/client/components/range-meta.d.ts +0 -21
- package/dist/types/client/data/charts.d.ts +0 -1
- package/dist/types/client/data/useResource.d.ts +0 -13
- package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
- package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
- package/dist/types/client/ui/DataTable.d.ts +0 -17
- package/dist/types/client/ui/EmptyState.d.ts +0 -7
- package/dist/types/client/ui/ErrorState.d.ts +0 -6
- package/dist/types/client/ui/MetricCluster.d.ts +0 -5
- package/dist/types/client/ui/Panel.d.ts +0 -7
- package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
- package/dist/types/client/ui/Skeleton.d.ts +0 -8
- package/dist/types/client/ui/StatusPill.d.ts +0 -7
- package/src/client/app/AppLayout.tsx +0 -93
- package/src/client/app/NavRail.tsx +0 -44
- package/src/client/app/RangeControl.tsx +0 -39
- package/src/client/app/SyncButton.tsx +0 -75
- package/src/client/app/TopBar.tsx +0 -73
- package/src/client/app/routes.ts +0 -93
- package/src/client/components/AgentTokenShare.tsx +0 -68
- package/src/client/components/chart-shared.tsx +0 -273
- package/src/client/components/models-table-shared.tsx +0 -255
- package/src/client/components/range-meta.ts +0 -73
- package/src/client/data/charts.ts +0 -14
- package/src/client/data/useResource.ts +0 -154
- package/src/client/routes/BehaviorRoute.tsx +0 -623
- package/src/client/ui/AsyncBoundary.tsx +0 -54
- package/src/client/ui/DataTable.tsx +0 -122
- package/src/client/ui/EmptyState.tsx +0 -16
- package/src/client/ui/ErrorState.tsx +0 -25
- package/src/client/ui/MetricCluster.tsx +0 -92
- package/src/client/ui/Panel.tsx +0 -24
- package/src/client/ui/SegmentedControl.tsx +0 -36
- package/src/client/ui/Skeleton.tsx +0 -17
- package/src/client/ui/StatusPill.tsx +0 -15
- package/tailwind.config.js +0 -40
package/src/live.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live ingest for a running dashboard: background session sync, rollup
|
|
3
|
+
* refresh, and a status stream the client subscribes to (`/api/events`).
|
|
4
|
+
*
|
|
5
|
+
* The dashboard never waits for ingest. {@link StatsLive.start} kicks a full
|
|
6
|
+
* sync in the background and watches the sessions directory; changed
|
|
7
|
+
* transcripts are re-synced (only those files) shortly after they are written.
|
|
8
|
+
* Every committed batch bumps {@link LiveStatus.version} (throttled), so open
|
|
9
|
+
* pages refetch and fill in while parsing is still running.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { getSessionsDir, logger } from "@oh-my-pi/pi-utils";
|
|
15
|
+
import { syncAllSessions } from "./aggregator";
|
|
16
|
+
import { initDb } from "./db";
|
|
17
|
+
import { getRollupStatus, refreshRollups } from "./rollup";
|
|
18
|
+
import type { LiveStatus, LiveSyncStatus } from "./shared-types";
|
|
19
|
+
|
|
20
|
+
/** Wait this long after the last transcript write before syncing it. */
|
|
21
|
+
const WATCH_DEBOUNCE_MS = 800;
|
|
22
|
+
/** Full resync cadence, catching anything the watcher missed (or everything, without one). */
|
|
23
|
+
const FULL_SYNC_INTERVAL_MS = 5 * 60 * 1000;
|
|
24
|
+
/** Minimum spacing of `version` bumps; each bump makes open pages refetch. */
|
|
25
|
+
const VERSION_THROTTLE_MS = 1000;
|
|
26
|
+
/** Delay before retrying a failed sync (typically a lock held by another omp process). */
|
|
27
|
+
const SYNC_RETRY_MS = 10_000;
|
|
28
|
+
/** Minimum spacing of progress-only status events. */
|
|
29
|
+
const PROGRESS_THROTTLE_MS = 150;
|
|
30
|
+
|
|
31
|
+
type Listener = (status: LiveStatus) => void;
|
|
32
|
+
|
|
33
|
+
/** One per process; owned by the dashboard server (see `startServer`). */
|
|
34
|
+
export class StatsLive {
|
|
35
|
+
#version = 1;
|
|
36
|
+
#sync: LiveSyncStatus = { phase: "idle", current: 0, total: 0, processed: 0, lastSyncedAt: null, error: null };
|
|
37
|
+
#indexingHours = 0;
|
|
38
|
+
#listeners = new Set<Listener>();
|
|
39
|
+
|
|
40
|
+
#started = false;
|
|
41
|
+
#watcher: fs.FSWatcher | null = null;
|
|
42
|
+
#fullSyncTimer: NodeJS.Timeout | null = null;
|
|
43
|
+
#debounceTimer: NodeJS.Timeout | null = null;
|
|
44
|
+
#versionTimer: NodeJS.Timeout | null = null;
|
|
45
|
+
#progressTimer: NodeJS.Timeout | null = null;
|
|
46
|
+
#retryTimer: NodeJS.Timeout | null = null;
|
|
47
|
+
#lastVersionAt = 0;
|
|
48
|
+
|
|
49
|
+
/** Queued work: specific files, or `"all"` for a full sync. */
|
|
50
|
+
#queued: Set<string> | "all" | null = null;
|
|
51
|
+
#syncing = false;
|
|
52
|
+
#refreshing = false;
|
|
53
|
+
#refreshAgain = false;
|
|
54
|
+
|
|
55
|
+
status(): LiveStatus {
|
|
56
|
+
return { version: this.#version, sync: { ...this.#sync }, indexingHours: this.#indexingHours };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Receive every status change; returns the unsubscribe function. */
|
|
60
|
+
subscribe(listener: Listener): () => void {
|
|
61
|
+
this.#listeners.add(listener);
|
|
62
|
+
return () => this.#listeners.delete(listener);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Begin background ingest: an immediate full sync, the transcript watcher, and periodic resyncs. Idempotent. */
|
|
66
|
+
start(): void {
|
|
67
|
+
if (this.#started) return;
|
|
68
|
+
this.#started = true;
|
|
69
|
+
this.#watch();
|
|
70
|
+
this.#fullSyncTimer = setInterval(() => this.requestSync(), FULL_SYNC_INTERVAL_MS);
|
|
71
|
+
this.#fullSyncTimer.unref?.();
|
|
72
|
+
this.requestSync();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
stop(): void {
|
|
76
|
+
this.#started = false;
|
|
77
|
+
this.#watcher?.close();
|
|
78
|
+
this.#watcher = null;
|
|
79
|
+
clearInterval(this.#fullSyncTimer ?? undefined);
|
|
80
|
+
for (const timer of [this.#debounceTimer, this.#versionTimer, this.#progressTimer, this.#retryTimer]) {
|
|
81
|
+
clearTimeout(timer ?? undefined);
|
|
82
|
+
}
|
|
83
|
+
this.#fullSyncTimer = this.#debounceTimer = this.#versionTimer = this.#progressTimer = this.#retryTimer = null;
|
|
84
|
+
this.#queued = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Queue a sync of `files`, or of every session when omitted. Requests made
|
|
89
|
+
* while a sync runs coalesce into one follow-up run.
|
|
90
|
+
*/
|
|
91
|
+
requestSync(files?: readonly string[]): void {
|
|
92
|
+
if (!files) this.#queued = "all";
|
|
93
|
+
else if (this.#queued !== "all") {
|
|
94
|
+
this.#queued ??= new Set();
|
|
95
|
+
for (const file of files) this.#queued.add(file);
|
|
96
|
+
}
|
|
97
|
+
void this.#drain();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async #drain(): Promise<void> {
|
|
101
|
+
if (this.#syncing) return;
|
|
102
|
+
this.#syncing = true;
|
|
103
|
+
try {
|
|
104
|
+
while (this.#queued) {
|
|
105
|
+
const work = this.#queued;
|
|
106
|
+
this.#queued = null;
|
|
107
|
+
await this.#runSync(work === "all" ? undefined : [...work]);
|
|
108
|
+
}
|
|
109
|
+
} finally {
|
|
110
|
+
this.#syncing = false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async #runSync(files: string[] | undefined): Promise<void> {
|
|
115
|
+
// Targeted syncs of a file or two stay quiet; only full syncs show progress.
|
|
116
|
+
const visible = files === undefined;
|
|
117
|
+
if (visible) {
|
|
118
|
+
this.#sync = { ...this.#sync, phase: "syncing", current: 0, total: 0, processed: 0, error: null };
|
|
119
|
+
this.#emit();
|
|
120
|
+
}
|
|
121
|
+
let committed = 0;
|
|
122
|
+
try {
|
|
123
|
+
await initDb();
|
|
124
|
+
const result = await syncAllSessions({
|
|
125
|
+
files,
|
|
126
|
+
onProgress: event => {
|
|
127
|
+
if (visible) {
|
|
128
|
+
this.#sync = {
|
|
129
|
+
...this.#sync,
|
|
130
|
+
current: event.current,
|
|
131
|
+
total: event.total,
|
|
132
|
+
processed: event.processed,
|
|
133
|
+
};
|
|
134
|
+
this.#emitProgress();
|
|
135
|
+
}
|
|
136
|
+
if (event.processed > committed) {
|
|
137
|
+
committed = event.processed;
|
|
138
|
+
this.#changed();
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
this.#sync = {
|
|
143
|
+
...this.#sync,
|
|
144
|
+
phase: "idle",
|
|
145
|
+
processed: visible ? result.processed : this.#sync.processed,
|
|
146
|
+
lastSyncedAt: Date.now(),
|
|
147
|
+
error: null,
|
|
148
|
+
};
|
|
149
|
+
} catch (error) {
|
|
150
|
+
logger.warn("Stats live sync failed", { error: String(error) });
|
|
151
|
+
this.#sync = { ...this.#sync, phase: "error", error: error instanceof Error ? error.message : String(error) };
|
|
152
|
+
// Usually lock contention with another omp process writing the same
|
|
153
|
+
// database; committed batches are kept, so a retry resumes where it stopped.
|
|
154
|
+
if (this.#started) {
|
|
155
|
+
clearTimeout(this.#retryTimer ?? undefined);
|
|
156
|
+
this.#retryTimer = setTimeout(() => {
|
|
157
|
+
this.#retryTimer = null;
|
|
158
|
+
this.requestSync(files);
|
|
159
|
+
}, SYNC_RETRY_MS);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Another process may have ingested too; always let clients revalidate.
|
|
163
|
+
this.#changed();
|
|
164
|
+
this.#emit();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Stored data changed: refresh rollups and (throttled) bump the version. */
|
|
168
|
+
#changed(): void {
|
|
169
|
+
void this.#refresh();
|
|
170
|
+
if (this.#versionTimer) return;
|
|
171
|
+
const wait = Math.max(0, this.#lastVersionAt + VERSION_THROTTLE_MS - Date.now());
|
|
172
|
+
this.#versionTimer = setTimeout(() => {
|
|
173
|
+
this.#versionTimer = null;
|
|
174
|
+
this.#lastVersionAt = Date.now();
|
|
175
|
+
this.#version++;
|
|
176
|
+
this.#indexingHours = getRollupStatus().dirtyHours;
|
|
177
|
+
this.#emit();
|
|
178
|
+
}, wait);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async #refresh(): Promise<void> {
|
|
182
|
+
if (this.#refreshing) {
|
|
183
|
+
this.#refreshAgain = true;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.#refreshing = true;
|
|
187
|
+
try {
|
|
188
|
+
do {
|
|
189
|
+
this.#refreshAgain = false;
|
|
190
|
+
await refreshRollups({
|
|
191
|
+
onProgress: remaining => {
|
|
192
|
+
this.#indexingHours = remaining;
|
|
193
|
+
// Each rolled batch fills in more history for open pages.
|
|
194
|
+
if (remaining > 0) this.#changed();
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
} while (this.#refreshAgain);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
logger.warn("Stats rollup refresh failed", { error: String(error) });
|
|
200
|
+
} finally {
|
|
201
|
+
this.#refreshing = false;
|
|
202
|
+
}
|
|
203
|
+
this.#indexingHours = getRollupStatus().dirtyHours;
|
|
204
|
+
this.#emit();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#watch(): void {
|
|
208
|
+
const root = getSessionsDir();
|
|
209
|
+
const pending = new Set<string>();
|
|
210
|
+
try {
|
|
211
|
+
this.#watcher = fs.watch(root, { recursive: true }, (_event, filename) => {
|
|
212
|
+
if (!filename?.endsWith(".jsonl")) return;
|
|
213
|
+
pending.add(path.join(root, filename));
|
|
214
|
+
clearTimeout(this.#debounceTimer ?? undefined);
|
|
215
|
+
this.#debounceTimer = setTimeout(() => {
|
|
216
|
+
this.#debounceTimer = null;
|
|
217
|
+
const files = [...pending];
|
|
218
|
+
pending.clear();
|
|
219
|
+
this.requestSync(files);
|
|
220
|
+
}, WATCH_DEBOUNCE_MS);
|
|
221
|
+
});
|
|
222
|
+
this.#watcher.on("error", error => {
|
|
223
|
+
logger.warn("Stats session watcher failed; relying on periodic sync", { error: String(error) });
|
|
224
|
+
this.#watcher?.close();
|
|
225
|
+
this.#watcher = null;
|
|
226
|
+
});
|
|
227
|
+
} catch (error) {
|
|
228
|
+
// No sessions dir yet, or recursive watch unsupported: periodic full syncs cover it.
|
|
229
|
+
logger.debug("Stats session watcher unavailable", { root, error: String(error) });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#emitProgress(): void {
|
|
234
|
+
if (this.#progressTimer) return;
|
|
235
|
+
this.#progressTimer = setTimeout(() => {
|
|
236
|
+
this.#progressTimer = null;
|
|
237
|
+
this.#emit();
|
|
238
|
+
}, PROGRESS_THROTTLE_MS);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#emit(): void {
|
|
242
|
+
const status = this.status();
|
|
243
|
+
for (const listener of this.#listeners) {
|
|
244
|
+
try {
|
|
245
|
+
listener(status);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
logger.warn("Stats live listener failed", { error: String(error) });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let instance: StatsLive | null = null;
|
|
254
|
+
|
|
255
|
+
/** The process-wide live hub. */
|
|
256
|
+
export function statsLive(): StatsLive {
|
|
257
|
+
instance ??= new StatsLive();
|
|
258
|
+
return instance;
|
|
259
|
+
}
|
package/src/parser.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
UserMessageLink,
|
|
26
26
|
UserMessageStats,
|
|
27
27
|
} from "./types";
|
|
28
|
-
import { computeUserMessageMetrics } from "./user-metrics";
|
|
28
|
+
import { computeUserMessageMetrics, judgeProse } from "./user-metrics";
|
|
29
29
|
|
|
30
30
|
/** Basename of an advisor agent's transcript inside a session artifacts dir. */
|
|
31
31
|
const ADVISOR_TRANSCRIPT_BASENAME = "__advisor.jsonl";
|
|
@@ -135,6 +135,7 @@ function extractUserStats(sessionFile: string, folder: string, entry: SessionMes
|
|
|
135
135
|
const text = extractUserText(msg.content);
|
|
136
136
|
if (!text.trim()) return null;
|
|
137
137
|
const metrics = computeUserMessageMetrics(text);
|
|
138
|
+
const prose = judgeProse(text);
|
|
138
139
|
const ts = Date.parse(entry.timestamp);
|
|
139
140
|
return {
|
|
140
141
|
sessionFile,
|
|
@@ -151,6 +152,8 @@ function extractUserStats(sessionFile: string, folder: string, entry: SessionMes
|
|
|
151
152
|
negation: metrics.negation,
|
|
152
153
|
repetition: metrics.repetition,
|
|
153
154
|
blame: metrics.blame,
|
|
155
|
+
prose,
|
|
156
|
+
proseHash: prose ? Bun.hash(prose).toString(16) : "",
|
|
154
157
|
};
|
|
155
158
|
}
|
|
156
159
|
|
|
@@ -465,14 +468,11 @@ function visitSessionEntriesLenient(bytes: Uint8Array, visit: (entry: SessionEnt
|
|
|
465
468
|
return read;
|
|
466
469
|
}
|
|
467
470
|
|
|
468
|
-
function parseSessionEntriesLenient(bytes: Uint8Array): { entries: SessionEntry[]; read: number } {
|
|
469
|
-
const entries: SessionEntry[] = [];
|
|
470
|
-
const read = visitSessionEntriesLenient(bytes, entry => entries.push(entry));
|
|
471
|
-
return { entries, read };
|
|
472
|
-
}
|
|
473
471
|
/** Parse every well-formed entry in a transcript buffer (malformed lines skipped). */
|
|
474
472
|
export function parseAllSessionEntries(bytes: Uint8Array): SessionEntry[] {
|
|
475
|
-
|
|
473
|
+
const entries: SessionEntry[] = [];
|
|
474
|
+
visitSessionEntriesLenient(bytes, entry => entries.push(entry));
|
|
475
|
+
return entries;
|
|
476
476
|
}
|
|
477
477
|
|
|
478
478
|
function scanLastServiceTier(bytes: Uint8Array): ServiceTierByFamily | undefined {
|
|
@@ -538,7 +538,53 @@ export async function parseSessionFile(
|
|
|
538
538
|
let info: nodeFs.Stats;
|
|
539
539
|
let checkpoint: string;
|
|
540
540
|
let read: number;
|
|
541
|
-
|
|
541
|
+
const folder = extractFolderFromPath(sessionPath);
|
|
542
|
+
const agentType = classifyAgentType(sessionPath);
|
|
543
|
+
const stats: MessageStatsInput[] = [];
|
|
544
|
+
const userStats: UserMessageStats[] = [];
|
|
545
|
+
const userLinks: UserMessageLink[] = [];
|
|
546
|
+
const toolCalls: ToolCallStats[] = [];
|
|
547
|
+
const toolResults: ToolResultLink[] = [];
|
|
548
|
+
|
|
549
|
+
// Reduce each entry immediately so full replays do not retain tool-output/message bodies.
|
|
550
|
+
const visit = (entry: SessionEntry): void => {
|
|
551
|
+
if (isServiceTierChange(entry)) {
|
|
552
|
+
currentServiceTier = coerceServiceTierByFamily(entry.serviceTier);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (isUserMessage(entry)) {
|
|
556
|
+
const userMsg = extractUserStats(sessionPath, folder, entry);
|
|
557
|
+
if (userMsg) userStats.push(userMsg);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (isToolResultMessage(entry)) {
|
|
561
|
+
const link = extractToolResultLink(sessionPath, entry);
|
|
562
|
+
if (link) toolResults.push(link);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (isModelUsage(entry)) {
|
|
566
|
+
const modelUsageStats = extractModelUsageStats(sessionPath, folder, entry, agentType);
|
|
567
|
+
if (modelUsageStats) stats.push(modelUsageStats);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (isAssistantMessage(entry)) {
|
|
571
|
+
const msgStats = extractStats(sessionPath, folder, entry, currentServiceTier, agentType);
|
|
572
|
+
if (msgStats) stats.push(msgStats);
|
|
573
|
+
toolCalls.push(...extractToolCalls(sessionPath, folder, entry, agentType));
|
|
574
|
+
// Persist links even when the user entry was ingested in an earlier tail read.
|
|
575
|
+
const parentId = entry.parentId;
|
|
576
|
+
const msg = entry.message;
|
|
577
|
+
if (parentId && msg.role === "assistant" && msg.model && msg.provider) {
|
|
578
|
+
userLinks.push({
|
|
579
|
+
sessionFile: sessionPath,
|
|
580
|
+
entryId: parentId,
|
|
581
|
+
model: msg.model,
|
|
582
|
+
provider: msg.provider,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
|
|
542
588
|
try {
|
|
543
589
|
const handle = await fs.open(sessionPath, "r");
|
|
544
590
|
try {
|
|
@@ -564,7 +610,7 @@ export async function parseSessionFile(
|
|
|
564
610
|
currentServiceTier = resume
|
|
565
611
|
? (state?.serviceTier ?? undefined)
|
|
566
612
|
: scanLastServiceTier(bytes.subarray(0, start));
|
|
567
|
-
|
|
613
|
+
read = visitSessionEntriesLenient(bytes.subarray(start - readStart), visit);
|
|
568
614
|
const newOffset = start + read;
|
|
569
615
|
const checkpointStart = Math.max(0, newOffset - CHECKPOINT_BYTES);
|
|
570
616
|
const previous =
|
|
@@ -581,63 +627,6 @@ export async function parseSessionFile(
|
|
|
581
627
|
throw err;
|
|
582
628
|
}
|
|
583
629
|
|
|
584
|
-
const folder = extractFolderFromPath(sessionPath);
|
|
585
|
-
const agentType = classifyAgentType(sessionPath);
|
|
586
|
-
const stats: MessageStatsInput[] = [];
|
|
587
|
-
const userStats: UserMessageStats[] = [];
|
|
588
|
-
const userLinks: UserMessageLink[] = [];
|
|
589
|
-
const toolCalls: ToolCallStats[] = [];
|
|
590
|
-
const toolResults: ToolResultLink[] = [];
|
|
591
|
-
const userByEntryId = new Map<string, UserMessageStats>();
|
|
592
|
-
for (const entry of entries) {
|
|
593
|
-
if (isServiceTierChange(entry)) {
|
|
594
|
-
currentServiceTier = coerceServiceTierByFamily(entry.serviceTier);
|
|
595
|
-
continue;
|
|
596
|
-
}
|
|
597
|
-
if (isUserMessage(entry)) {
|
|
598
|
-
const userMsg = extractUserStats(sessionPath, folder, entry);
|
|
599
|
-
if (userMsg) {
|
|
600
|
-
userStats.push(userMsg);
|
|
601
|
-
userByEntryId.set(entry.id, userMsg);
|
|
602
|
-
}
|
|
603
|
-
continue;
|
|
604
|
-
}
|
|
605
|
-
if (isToolResultMessage(entry)) {
|
|
606
|
-
const link = extractToolResultLink(sessionPath, entry);
|
|
607
|
-
if (link) toolResults.push(link);
|
|
608
|
-
continue;
|
|
609
|
-
}
|
|
610
|
-
if (isModelUsage(entry)) {
|
|
611
|
-
const modelUsageStats = extractModelUsageStats(sessionPath, folder, entry, agentType);
|
|
612
|
-
if (modelUsageStats) stats.push(modelUsageStats);
|
|
613
|
-
continue;
|
|
614
|
-
}
|
|
615
|
-
if (isAssistantMessage(entry)) {
|
|
616
|
-
const msgStats = extractStats(sessionPath, folder, entry, currentServiceTier, agentType);
|
|
617
|
-
if (msgStats) stats.push(msgStats);
|
|
618
|
-
toolCalls.push(...extractToolCalls(sessionPath, folder, entry, agentType));
|
|
619
|
-
// Link assistant's responding model back to the user message it answered.
|
|
620
|
-
const parentId = (entry as SessionMessageEntry).parentId;
|
|
621
|
-
if (parentId) {
|
|
622
|
-
const msg = entry.message as AssistantMessage;
|
|
623
|
-
if (msg.model && msg.provider) {
|
|
624
|
-
// Emit unconditionally. The aggregator's UPDATE is guarded by
|
|
625
|
-
// `model IS NULL` so this is idempotent: a no-op for already
|
|
626
|
-
// linked rows, a fix-up for fresh inserts (which start NULL
|
|
627
|
-
// because the user row is recorded before its reply lands) and
|
|
628
|
-
// for cross-pass orphans whose parent was committed by an
|
|
629
|
-
// earlier incremental sync.
|
|
630
|
-
userLinks.push({
|
|
631
|
-
sessionFile: sessionPath,
|
|
632
|
-
entryId: parentId,
|
|
633
|
-
model: msg.model,
|
|
634
|
-
provider: msg.provider,
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
|
|
641
630
|
const newOffset = start + read;
|
|
642
631
|
return {
|
|
643
632
|
stats,
|