@oh-my-pi/omp-stats 18.1.22 → 18.2.1
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 +11 -0
- package/THIRD-PARTY-NOTICES.txt +54 -25
- package/dist/client/index.css +1 -1
- package/dist/client/styles.css +20 -0
- package/dist/types/db.d.ts +9 -1
- package/dist/types/parser.d.ts +18 -17
- package/dist/types/sync-worker.d.ts +4 -2
- package/package.json +4 -4
- package/src/aggregator.ts +51 -30
- package/src/client/styles.css +30 -0
- package/src/db.ts +98 -12
- package/src/parser.ts +137 -31
- package/src/port-conflict.ts +18 -7
- package/src/sync-worker.ts +11 -4
- package/src/trace.ts +49 -13
package/src/aggregator.ts
CHANGED
|
@@ -3,6 +3,8 @@ import * as path from "node:path";
|
|
|
3
3
|
import { getStatsDbPath, workerHostEntry } from "@oh-my-pi/pi-utils";
|
|
4
4
|
import { withFileLock } from "@oh-my-pi/pi-utils/file-lock";
|
|
5
5
|
import {
|
|
6
|
+
applySessionParseResult,
|
|
7
|
+
completeSessionSync,
|
|
6
8
|
getRecentErrors as dbGetRecentErrors,
|
|
7
9
|
getRecentRequests as dbGetRecentRequests,
|
|
8
10
|
getBehaviorByModel,
|
|
@@ -26,15 +28,17 @@ import {
|
|
|
26
28
|
getToolStatsByModel,
|
|
27
29
|
getToolTimeSeries,
|
|
28
30
|
initDb,
|
|
29
|
-
insertMessageStats,
|
|
30
|
-
insertToolCalls,
|
|
31
|
-
insertUserMessageStats,
|
|
32
31
|
markSessionBackfillsComplete,
|
|
33
|
-
|
|
34
|
-
updateToolResults,
|
|
35
|
-
updateUserMessageLinks,
|
|
32
|
+
prepareSessionSync,
|
|
36
33
|
} from "./db";
|
|
37
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
getSessionEntry,
|
|
36
|
+
listAllSessionFiles,
|
|
37
|
+
matchesSessionFile,
|
|
38
|
+
type ParseSessionResult,
|
|
39
|
+
parseSessionFile,
|
|
40
|
+
type SessionParserState,
|
|
41
|
+
} from "./parser";
|
|
38
42
|
import type { SyncWorkerRequest, SyncWorkerResponse } from "./sync-worker";
|
|
39
43
|
// Coding-agent binary/bundle workers route through the CLI entrypoint with a
|
|
40
44
|
// hidden argv mode, so the compiled binary and npm bundle only need one
|
|
@@ -69,20 +73,6 @@ export async function withStatsSyncLock<T>(dbPath: string, fn: () => Promise<T>)
|
|
|
69
73
|
});
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
/**
|
|
73
|
-
* Apply a freshly parsed result to the database. Runs entirely on the
|
|
74
|
-
* main thread so the single SQLite handle owns every write.
|
|
75
|
-
*/
|
|
76
|
-
function applyParseResult(sessionFile: string, lastModified: number, result: ParseSessionResult): number {
|
|
77
|
-
if (result.stats.length > 0) insertMessageStats(result.stats);
|
|
78
|
-
if (result.userStats.length > 0) insertUserMessageStats(result.userStats);
|
|
79
|
-
if (result.userLinks.length > 0) updateUserMessageLinks(result.userLinks);
|
|
80
|
-
if (result.toolCalls.length > 0) insertToolCalls(result.toolCalls);
|
|
81
|
-
if (result.toolResults.length > 0) updateToolResults(result.toolResults);
|
|
82
|
-
setFileOffset(sessionFile, result.newOffset, lastModified);
|
|
83
|
-
return result.stats.length + result.userStats.length;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
76
|
/**
|
|
87
77
|
* Progress event emitted after each session file is fully processed.
|
|
88
78
|
* `current` is the number of files completed (skipped + parsed),
|
|
@@ -239,20 +229,34 @@ export async function smokeTestSyncWorker({ timeoutMs = 5_000 }: { timeoutMs?: n
|
|
|
239
229
|
* bar walks at a steady rate).
|
|
240
230
|
*/
|
|
241
231
|
export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed: number; files: number }> {
|
|
242
|
-
return withStatsSyncLock(getStatsDbPath(), () =>
|
|
232
|
+
return withStatsSyncLock(getStatsDbPath(), async () => {
|
|
233
|
+
let processed = 0;
|
|
234
|
+
let files = 0;
|
|
235
|
+
while (true) {
|
|
236
|
+
const result = await syncAllSessionsLocked(opts);
|
|
237
|
+
processed += result.processed;
|
|
238
|
+
files += result.files;
|
|
239
|
+
if (!result.reconcile) return { processed, files };
|
|
240
|
+
}
|
|
241
|
+
});
|
|
243
242
|
}
|
|
244
243
|
|
|
245
|
-
async function syncAllSessionsLocked(
|
|
244
|
+
async function syncAllSessionsLocked(
|
|
245
|
+
opts?: SyncOptions,
|
|
246
|
+
): Promise<{ processed: number; files: number; reconcile: boolean }> {
|
|
246
247
|
await initDb();
|
|
248
|
+
const replay = prepareSessionSync();
|
|
247
249
|
|
|
248
250
|
const files = await listAllSessionFiles();
|
|
249
251
|
let totalProcessed = 0;
|
|
250
252
|
let filesProcessed = 0;
|
|
251
253
|
let completed = 0;
|
|
252
254
|
let cursor = 0;
|
|
255
|
+
let reconcile = false;
|
|
253
256
|
const finish = () => {
|
|
257
|
+
completeSessionSync(reconcile);
|
|
254
258
|
markSessionBackfillsComplete();
|
|
255
|
-
return { processed: totalProcessed, files: filesProcessed };
|
|
259
|
+
return { processed: totalProcessed, files: filesProcessed, reconcile };
|
|
256
260
|
};
|
|
257
261
|
if (files.length === 0) return finish();
|
|
258
262
|
|
|
@@ -268,7 +272,12 @@ async function syncAllSessionsLocked(opts?: SyncOptions): Promise<{ processed: n
|
|
|
268
272
|
|
|
269
273
|
const processFile = async (
|
|
270
274
|
sessionFile: string,
|
|
271
|
-
parse: (
|
|
275
|
+
parse: (
|
|
276
|
+
sessionFile: string,
|
|
277
|
+
fromOffset: number,
|
|
278
|
+
state?: SessionParserState,
|
|
279
|
+
replay?: boolean,
|
|
280
|
+
) => Promise<ParseSessionResult>,
|
|
272
281
|
): Promise<void> => {
|
|
273
282
|
let fileStats: fs.Stats;
|
|
274
283
|
try {
|
|
@@ -279,14 +288,24 @@ async function syncAllSessionsLocked(opts?: SyncOptions): Promise<{ processed: n
|
|
|
279
288
|
}
|
|
280
289
|
const lastModified = fileStats.mtimeMs;
|
|
281
290
|
const stored = getFileOffset(sessionFile);
|
|
282
|
-
if (
|
|
291
|
+
if (
|
|
292
|
+
!replay &&
|
|
293
|
+
stored?.parserState &&
|
|
294
|
+
stored.lastModified === lastModified &&
|
|
295
|
+
stored.parserState.size === fileStats.size &&
|
|
296
|
+
matchesSessionFile(stored.parserState, fileStats)
|
|
297
|
+
) {
|
|
283
298
|
report(sessionFile);
|
|
284
299
|
return;
|
|
285
300
|
}
|
|
286
301
|
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
const
|
|
302
|
+
const unknownIdentity = stored !== null && !stored.parserState;
|
|
303
|
+
const fromOffset = unknownIdentity ? 0 : (stored?.offset ?? 0);
|
|
304
|
+
const result = await parse(sessionFile, fromOffset, stored?.parserState, replay);
|
|
305
|
+
if (unknownIdentity && result.parserState) result.reset = true;
|
|
306
|
+
const applied = applySessionParseResult(sessionFile, result, replay || !stored?.parserState);
|
|
307
|
+
const inserted = applied.processed;
|
|
308
|
+
if (applied.reconcile) reconcile = true;
|
|
290
309
|
if (inserted > 0) {
|
|
291
310
|
totalProcessed += inserted;
|
|
292
311
|
filesProcessed++;
|
|
@@ -312,7 +331,9 @@ async function syncAllSessionsLocked(opts?: SyncOptions): Promise<{ processed: n
|
|
|
312
331
|
const idx = cursor++;
|
|
313
332
|
if (idx >= files.length) return;
|
|
314
333
|
const sessionFile = files[idx];
|
|
315
|
-
await processFile(sessionFile, (file, fromOffset
|
|
334
|
+
await processFile(sessionFile, (file, fromOffset, parserState, replay) =>
|
|
335
|
+
dispatch(handle, { sessionFile: file, fromOffset, parserState, replay }),
|
|
336
|
+
);
|
|
316
337
|
}
|
|
317
338
|
}
|
|
318
339
|
|
package/src/client/styles.css
CHANGED
|
@@ -1509,10 +1509,35 @@
|
|
|
1509
1509
|
border: 1px solid var(--border);
|
|
1510
1510
|
border-radius: var(--radius-md);
|
|
1511
1511
|
color: var(--text);
|
|
1512
|
+
font-family: inherit;
|
|
1512
1513
|
font-size: 12px;
|
|
1513
1514
|
padding: 6px 10px;
|
|
1514
1515
|
}
|
|
1515
1516
|
|
|
1517
|
+
/* Prevent iOS from zooming the viewport when a search input receives focus
|
|
1518
|
+
(mirrors the .stats-select coarse-pointer treatment). */
|
|
1519
|
+
@media (pointer: coarse) {
|
|
1520
|
+
.stats-trace-input {
|
|
1521
|
+
font-size: 16px;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/* Native clear button is a dark glyph in WebKit; keep it visible in the dark
|
|
1526
|
+
theme by inverting it (no-op in light). */
|
|
1527
|
+
.stats-trace-input::-webkit-search-cancel-button {
|
|
1528
|
+
filter: invert(1);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
@media (prefers-color-scheme: light) {
|
|
1532
|
+
:root:not([data-theme]) .stats-trace-input::-webkit-search-cancel-button {
|
|
1533
|
+
filter: none;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
[data-theme="light"] .stats-trace-input::-webkit-search-cancel-button {
|
|
1538
|
+
filter: none;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1516
1541
|
.stats-trace-input:focus-visible {
|
|
1517
1542
|
outline: 2px solid var(--link);
|
|
1518
1543
|
outline-offset: 1px;
|
|
@@ -1532,6 +1557,11 @@
|
|
|
1532
1557
|
user-select: none;
|
|
1533
1558
|
}
|
|
1534
1559
|
|
|
1560
|
+
/* Brand-tint the native checkbox instead of leaving the browser default. */
|
|
1561
|
+
.stats-trace-check input[type="checkbox"] {
|
|
1562
|
+
accent-color: var(--accent);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1535
1565
|
.stats-trace-icon-btn {
|
|
1536
1566
|
display: inline-flex;
|
|
1537
1567
|
align-items: center;
|
package/src/db.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "@oh-my-pi/pi-catalog/models";
|
|
10
10
|
import type { ModelCost } from "@oh-my-pi/pi-catalog/types";
|
|
11
11
|
import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils";
|
|
12
|
-
import { classifyAgentType } from "./parser";
|
|
12
|
+
import { classifyAgentType, type ParseSessionResult, type SessionParserState } from "./parser";
|
|
13
13
|
import type {
|
|
14
14
|
AgentType,
|
|
15
15
|
AgentTypeStats,
|
|
@@ -144,7 +144,10 @@ const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
|
|
|
144
144
|
const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v1";
|
|
145
145
|
const AGENT_TYPE_BACKFILL_KEY = "agent_type_v1";
|
|
146
146
|
const FORK_DEDUPE_KEY = "fork_dedupe_v1";
|
|
147
|
-
|
|
147
|
+
// v2: tool-name sanitization at ingest (see `sanitizeToolName` in parser.ts)
|
|
148
|
+
// collapses provider-side garbage names; a full re-parse replaces the polluted
|
|
149
|
+
// rows already stored in existing databases.
|
|
150
|
+
const TOOL_CALLS_BACKFILL_KEY = "tool_calls_v2";
|
|
148
151
|
// Older ingests dropped `Usage.orchestration` (never a stored column) when
|
|
149
152
|
// pricing, so subscription models billed on orchestration tokens — multi-agent
|
|
150
153
|
// Grok most notably — were priced from conversation buckets alone and could not
|
|
@@ -222,7 +225,8 @@ export async function initDb(): Promise<Database> {
|
|
|
222
225
|
CREATE TABLE IF NOT EXISTS file_offsets (
|
|
223
226
|
session_file TEXT PRIMARY KEY,
|
|
224
227
|
offset INTEGER NOT NULL,
|
|
225
|
-
last_modified INTEGER NOT NULL
|
|
228
|
+
last_modified INTEGER NOT NULL,
|
|
229
|
+
parser_state TEXT
|
|
226
230
|
);
|
|
227
231
|
|
|
228
232
|
CREATE TABLE IF NOT EXISTS user_messages (
|
|
@@ -274,6 +278,10 @@ export async function initDb(): Promise<Database> {
|
|
|
274
278
|
);
|
|
275
279
|
`);
|
|
276
280
|
|
|
281
|
+
const offsetColumns = db.prepare("PRAGMA table_info(file_offsets)").all() as { name: string }[];
|
|
282
|
+
if (!offsetColumns.some(column => column.name === "parser_state")) {
|
|
283
|
+
db.run("ALTER TABLE file_offsets ADD COLUMN parser_state TEXT");
|
|
284
|
+
}
|
|
277
285
|
const messageColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
|
|
278
286
|
if (!messageColumns.some(column => column.name === "premium_requests")) {
|
|
279
287
|
db.run("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
|
|
@@ -590,26 +598,104 @@ function backfillNoCacheInputCosts(database: Database): void {
|
|
|
590
598
|
/**
|
|
591
599
|
* Get the stored offset for a session file.
|
|
592
600
|
*/
|
|
593
|
-
export function getFileOffset(
|
|
601
|
+
export function getFileOffset(
|
|
602
|
+
sessionFile: string,
|
|
603
|
+
): { offset: number; lastModified: number; parserState?: SessionParserState } | null {
|
|
594
604
|
if (!db) return null;
|
|
595
605
|
|
|
596
|
-
const stmt = db.prepare("SELECT offset, last_modified FROM file_offsets WHERE session_file = ?");
|
|
597
|
-
const row = stmt.get(sessionFile) as
|
|
598
|
-
|
|
599
|
-
|
|
606
|
+
const stmt = db.prepare("SELECT offset, last_modified, parser_state FROM file_offsets WHERE session_file = ?");
|
|
607
|
+
const row = stmt.get(sessionFile) as
|
|
608
|
+
| { offset: number; last_modified: number; parser_state: string | null }
|
|
609
|
+
| undefined;
|
|
610
|
+
if (!row) return null;
|
|
611
|
+
let parserState: SessionParserState | undefined;
|
|
612
|
+
if (row.parser_state) {
|
|
613
|
+
try {
|
|
614
|
+
const state = JSON.parse(row.parser_state) as SessionParserState;
|
|
615
|
+
if (state?.version === 1 && state.offset === row.offset) parserState = state;
|
|
616
|
+
} catch {
|
|
617
|
+
/* A missing cursor is reconstructed from the transcript. */
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return { offset: row.offset, lastModified: row.last_modified, parserState };
|
|
600
621
|
}
|
|
601
622
|
|
|
602
623
|
/**
|
|
603
624
|
* Update the stored offset for a session file.
|
|
604
625
|
*/
|
|
605
|
-
export function setFileOffset(
|
|
626
|
+
export function setFileOffset(
|
|
627
|
+
sessionFile: string,
|
|
628
|
+
offset: number,
|
|
629
|
+
lastModified: number,
|
|
630
|
+
parserState?: SessionParserState,
|
|
631
|
+
): void {
|
|
606
632
|
if (!db) return;
|
|
607
633
|
|
|
608
634
|
const stmt = db.prepare(`
|
|
609
|
-
INSERT OR REPLACE INTO file_offsets (session_file, offset, last_modified)
|
|
610
|
-
VALUES (?, ?, ?)
|
|
635
|
+
INSERT OR REPLACE INTO file_offsets (session_file, offset, last_modified, parser_state)
|
|
636
|
+
VALUES (?, ?, ?, ?)
|
|
611
637
|
`);
|
|
612
|
-
stmt.run(sessionFile, offset, lastModified);
|
|
638
|
+
stmt.run(sessionFile, offset, lastModified, parserState ? JSON.stringify(parserState) : null);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export function applySessionParseResult(
|
|
642
|
+
sessionFile: string,
|
|
643
|
+
result: ParseSessionResult,
|
|
644
|
+
rebuild = false,
|
|
645
|
+
): { processed: number; reconcile: boolean } {
|
|
646
|
+
const parserState = result.parserState;
|
|
647
|
+
if (!db || !parserState) return { processed: 0, reconcile: false };
|
|
648
|
+
const database = db;
|
|
649
|
+
return database.transaction(() => {
|
|
650
|
+
let reconcile = result.reset ?? false;
|
|
651
|
+
if (result.reset || rebuild) {
|
|
652
|
+
const retainedMessages = new Set(result.stats.map(row => JSON.stringify([row.entryId, row.timestamp])));
|
|
653
|
+
const retainedUsers = new Set(result.userStats.map(row => JSON.stringify([row.entryId, row.timestamp])));
|
|
654
|
+
const retainedTools = new Set(
|
|
655
|
+
result.toolCalls.map(row => JSON.stringify([row.entryId, row.timestamp, row.toolCallId])),
|
|
656
|
+
);
|
|
657
|
+
const messages = database
|
|
658
|
+
.prepare("SELECT entry_id, timestamp FROM messages WHERE session_file = ?")
|
|
659
|
+
.all(sessionFile) as {
|
|
660
|
+
entry_id: string;
|
|
661
|
+
timestamp: number;
|
|
662
|
+
}[];
|
|
663
|
+
const users = database
|
|
664
|
+
.prepare("SELECT entry_id, timestamp FROM user_messages WHERE session_file = ?")
|
|
665
|
+
.all(sessionFile) as { entry_id: string; timestamp: number }[];
|
|
666
|
+
const tools = database
|
|
667
|
+
.prepare("SELECT entry_id, timestamp, tool_call_id FROM tool_calls WHERE session_file = ?")
|
|
668
|
+
.all(sessionFile) as { entry_id: string; timestamp: number; tool_call_id: string }[];
|
|
669
|
+
// A removed owner may have surviving fork copies skipped earlier in this pass.
|
|
670
|
+
reconcile ||=
|
|
671
|
+
messages.some(row => !retainedMessages.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
|
|
672
|
+
users.some(row => !retainedUsers.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
|
|
673
|
+
tools.some(row => !retainedTools.has(JSON.stringify([row.entry_id, row.timestamp, row.tool_call_id])));
|
|
674
|
+
database.prepare("DELETE FROM messages WHERE session_file = ?").run(sessionFile);
|
|
675
|
+
database.prepare("DELETE FROM user_messages WHERE session_file = ?").run(sessionFile);
|
|
676
|
+
database.prepare("DELETE FROM tool_calls WHERE session_file = ?").run(sessionFile);
|
|
677
|
+
}
|
|
678
|
+
if (reconcile) {
|
|
679
|
+
database
|
|
680
|
+
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('session_reconciliation', 'pending')")
|
|
681
|
+
.run();
|
|
682
|
+
}
|
|
683
|
+
if (result.stats.length > 0) insertMessageStats(result.stats);
|
|
684
|
+
if (result.userStats.length > 0) insertUserMessageStats(result.userStats);
|
|
685
|
+
if (result.userLinks.length > 0) updateUserMessageLinks(result.userLinks);
|
|
686
|
+
if (result.toolCalls.length > 0) insertToolCalls(result.toolCalls);
|
|
687
|
+
if (result.toolResults.length > 0) updateToolResults(result.toolResults);
|
|
688
|
+
setFileOffset(sessionFile, result.newOffset, parserState.mtimeMs, parserState);
|
|
689
|
+
return { processed: result.stats.length + result.userStats.length, reconcile };
|
|
690
|
+
})();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export function prepareSessionSync(): boolean {
|
|
694
|
+
return Boolean(db?.prepare("SELECT 1 FROM meta WHERE key = 'session_reconciliation'").get());
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export function completeSessionSync(reconcile: boolean): void {
|
|
698
|
+
if (!reconcile) db?.prepare("DELETE FROM meta WHERE key = 'session_reconciliation'").run();
|
|
613
699
|
}
|
|
614
700
|
|
|
615
701
|
/**
|
package/src/parser.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type * as nodeFs from "node:fs";
|
|
1
2
|
import * as fs from "node:fs/promises";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import {
|
|
@@ -29,6 +30,9 @@ import { computeUserMessageMetrics } from "./user-metrics";
|
|
|
29
30
|
/** Basename of an advisor agent's transcript inside a session artifacts dir. */
|
|
30
31
|
const ADVISOR_TRANSCRIPT_BASENAME = "__advisor.jsonl";
|
|
31
32
|
|
|
33
|
+
/** Characters a persisted tool name may consist of without sanitization. */
|
|
34
|
+
const TOOL_NAME_PATTERN = /^[\w.:-]+$/;
|
|
35
|
+
|
|
32
36
|
/**
|
|
33
37
|
* Classify which agent produced a transcript from its path within the sessions
|
|
34
38
|
* directory. Layout: `<sessionsDir>/<project>/<file>.jsonl` is the `main`
|
|
@@ -354,27 +358,51 @@ function extractToolCalls(
|
|
|
354
358
|
);
|
|
355
359
|
if (blocks.length === 0) return [];
|
|
356
360
|
|
|
357
|
-
|
|
361
|
+
const calls: ToolCallStats[] = [];
|
|
362
|
+
for (const block of blocks) {
|
|
363
|
+
// Names reduced to nothing by sanitization carry no tool identity:
|
|
364
|
+
// skip them rather than attributing usage to garbage (see
|
|
365
|
+
// sanitizeToolName). callsInTurn still counts the raw block total.
|
|
366
|
+
const toolName = sanitizeToolName(block.name);
|
|
367
|
+
if (toolName === null) continue;
|
|
358
368
|
let argsChars = 0;
|
|
359
369
|
try {
|
|
360
370
|
argsChars = JSON.stringify(block.arguments ?? {}).length;
|
|
361
371
|
} catch {
|
|
362
372
|
// Non-serializable arguments (shouldn't happen in persisted JSONL); size unknown.
|
|
363
373
|
}
|
|
364
|
-
|
|
374
|
+
calls.push({
|
|
365
375
|
sessionFile,
|
|
366
376
|
entryId: entry.id,
|
|
367
377
|
toolCallId: block.id,
|
|
368
378
|
folder,
|
|
369
|
-
toolName
|
|
379
|
+
toolName,
|
|
370
380
|
model: msg.model,
|
|
371
381
|
provider: msg.provider,
|
|
372
382
|
timestamp: coerceEntryTimestamp(msg.timestamp, entry),
|
|
373
383
|
agentType,
|
|
374
384
|
callsInTurn: blocks.length,
|
|
375
385
|
argsChars,
|
|
376
|
-
};
|
|
377
|
-
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return calls;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Tool names as persisted can be polluted by provider-side parse garbage — a
|
|
393
|
+
* gateway may hand the model's whole invocation text back as the function
|
|
394
|
+
* name (e.g. `bash command="ls -la …"` with a stray in-band closer), which
|
|
395
|
+
* then shows up verbatim in every `GROUP BY tool_name` aggregate and the
|
|
396
|
+
* dashboard tool filter. Reduce such names to their leading identifier token;
|
|
397
|
+
* names that yield no identifier at all carry no tool identity and are
|
|
398
|
+
* returned as `null` so the row is skipped.
|
|
399
|
+
*/
|
|
400
|
+
function sanitizeToolName(name: string): string | null {
|
|
401
|
+
const trimmed = name.trim();
|
|
402
|
+
if (trimmed.length === 0) return null;
|
|
403
|
+
if (TOOL_NAME_PATTERN.test(trimmed)) return trimmed;
|
|
404
|
+
const candidate = trimmed.split(/[^\w.:-]/)[0] ?? "";
|
|
405
|
+
return candidate.length > 0 ? candidate : null;
|
|
378
406
|
}
|
|
379
407
|
|
|
380
408
|
/**
|
|
@@ -454,22 +482,18 @@ function scanLastServiceTier(bytes: Uint8Array): ServiceTierByFamily | undefined
|
|
|
454
482
|
});
|
|
455
483
|
return currentServiceTier;
|
|
456
484
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
* for the latest service-tier value before parsing the unprocessed tail.
|
|
470
|
-
* The scan only keeps the current tier and does not materialize prefix
|
|
471
|
-
* entries, preserving offset-based memory behavior for large sessions.
|
|
472
|
-
*/
|
|
485
|
+
export interface SessionParserState {
|
|
486
|
+
version: 1;
|
|
487
|
+
offset: number;
|
|
488
|
+
dev: number;
|
|
489
|
+
ino: number;
|
|
490
|
+
birthtimeMs: number;
|
|
491
|
+
size: number;
|
|
492
|
+
mtimeMs: number;
|
|
493
|
+
checkpoint: string;
|
|
494
|
+
serviceTier: ServiceTierByFamily | null;
|
|
495
|
+
}
|
|
496
|
+
|
|
473
497
|
export interface ParseSessionResult {
|
|
474
498
|
stats: MessageStatsInput[];
|
|
475
499
|
userStats: UserMessageStats[];
|
|
@@ -477,11 +501,80 @@ export interface ParseSessionResult {
|
|
|
477
501
|
toolCalls: ToolCallStats[];
|
|
478
502
|
toolResults: ToolResultLink[];
|
|
479
503
|
newOffset: number;
|
|
504
|
+
parserState?: SessionParserState;
|
|
505
|
+
reset?: boolean;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const CHECKPOINT_BYTES = 256;
|
|
509
|
+
|
|
510
|
+
async function readCheckpoint(handle: fs.FileHandle, end: number): Promise<Uint8Array> {
|
|
511
|
+
// Positional reads keep the descriptor at zero for Bun.file(fd)'s subsequent tail read.
|
|
512
|
+
const start = Math.max(0, end - CHECKPOINT_BYTES);
|
|
513
|
+
const bytes = new Uint8Array(end - start);
|
|
514
|
+
let read = 0;
|
|
515
|
+
while (read < bytes.length) {
|
|
516
|
+
const result = await handle.read(bytes, read, bytes.length - read, start + read);
|
|
517
|
+
if (result.bytesRead === 0) break;
|
|
518
|
+
read += result.bytesRead;
|
|
519
|
+
}
|
|
520
|
+
return bytes.subarray(0, read);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function matchesSessionFile(state: SessionParserState, info: nodeFs.Stats): boolean {
|
|
524
|
+
return state.dev === info.dev && state.ino === info.ino && state.birthtimeMs === info.birthtimeMs;
|
|
480
525
|
}
|
|
481
|
-
|
|
526
|
+
|
|
527
|
+
/** Offset-only callers reconstruct service-tier state once; persisted cursors read only the appended tail. */
|
|
528
|
+
export async function parseSessionFile(
|
|
529
|
+
sessionPath: string,
|
|
530
|
+
fromOffset = 0,
|
|
531
|
+
state?: SessionParserState,
|
|
532
|
+
replay = false,
|
|
533
|
+
): Promise<ParseSessionResult> {
|
|
482
534
|
let bytes: Uint8Array;
|
|
535
|
+
let start = fromOffset;
|
|
536
|
+
let reset = false;
|
|
537
|
+
let currentServiceTier: ServiceTierByFamily | undefined;
|
|
538
|
+
let info: nodeFs.Stats;
|
|
539
|
+
let checkpoint: string;
|
|
540
|
+
let read: number;
|
|
541
|
+
let entries: SessionEntry[];
|
|
483
542
|
try {
|
|
484
|
-
|
|
543
|
+
const handle = await fs.open(sessionPath, "r");
|
|
544
|
+
try {
|
|
545
|
+
info = await handle.stat();
|
|
546
|
+
const file = Bun.file(handle.fd);
|
|
547
|
+
let resume = state?.version === 1 && state.offset === fromOffset;
|
|
548
|
+
if (resume && state) {
|
|
549
|
+
reset =
|
|
550
|
+
!matchesSessionFile(state, info) ||
|
|
551
|
+
info.size < state.size ||
|
|
552
|
+
(info.size === state.size && info.mtimeMs !== state.mtimeMs);
|
|
553
|
+
if (!reset) {
|
|
554
|
+
const previous = await readCheckpoint(handle, fromOffset);
|
|
555
|
+
reset = Bun.hash(previous).toString(16) !== state.checkpoint;
|
|
556
|
+
}
|
|
557
|
+
resume = !reset;
|
|
558
|
+
}
|
|
559
|
+
if (fromOffset > info.size) reset = true;
|
|
560
|
+
if (replay) resume = false;
|
|
561
|
+
start = reset || replay ? 0 : Math.max(0, fromOffset);
|
|
562
|
+
const readStart = resume ? start : 0;
|
|
563
|
+
bytes = await file.slice(readStart, info.size).bytes();
|
|
564
|
+
currentServiceTier = resume
|
|
565
|
+
? (state?.serviceTier ?? undefined)
|
|
566
|
+
: scanLastServiceTier(bytes.subarray(0, start));
|
|
567
|
+
({ entries, read } = parseSessionEntriesLenient(bytes.subarray(start - readStart)));
|
|
568
|
+
const newOffset = start + read;
|
|
569
|
+
const checkpointStart = Math.max(0, newOffset - CHECKPOINT_BYTES);
|
|
570
|
+
const previous =
|
|
571
|
+
checkpointStart >= readStart
|
|
572
|
+
? bytes.subarray(checkpointStart - readStart, newOffset - readStart)
|
|
573
|
+
: await readCheckpoint(handle, newOffset);
|
|
574
|
+
checkpoint = Bun.hash(previous).toString(16);
|
|
575
|
+
} finally {
|
|
576
|
+
await handle.close();
|
|
577
|
+
}
|
|
485
578
|
} catch (err) {
|
|
486
579
|
if (isEnoent(err))
|
|
487
580
|
return { stats: [], userStats: [], userLinks: [], toolCalls: [], toolResults: [], newOffset: fromOffset };
|
|
@@ -496,13 +589,6 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
|
|
|
496
589
|
const toolCalls: ToolCallStats[] = [];
|
|
497
590
|
const toolResults: ToolResultLink[] = [];
|
|
498
591
|
const userByEntryId = new Map<string, UserMessageStats>();
|
|
499
|
-
const start = Math.max(0, Math.min(fromOffset, bytes.length));
|
|
500
|
-
const unprocessed = bytes.subarray(start);
|
|
501
|
-
const { entries, read } = parseSessionEntriesLenient(unprocessed);
|
|
502
|
-
let currentServiceTier: ServiceTierByFamily | undefined;
|
|
503
|
-
if (start > 0) {
|
|
504
|
-
currentServiceTier = scanLastServiceTier(bytes.subarray(0, start));
|
|
505
|
-
}
|
|
506
592
|
for (const entry of entries) {
|
|
507
593
|
if (isServiceTierChange(entry)) {
|
|
508
594
|
currentServiceTier = coerceServiceTierByFamily(entry.serviceTier);
|
|
@@ -552,7 +638,27 @@ export async function parseSessionFile(sessionPath: string, fromOffset = 0): Pro
|
|
|
552
638
|
}
|
|
553
639
|
}
|
|
554
640
|
|
|
555
|
-
|
|
641
|
+
const newOffset = start + read;
|
|
642
|
+
return {
|
|
643
|
+
stats,
|
|
644
|
+
userStats,
|
|
645
|
+
userLinks,
|
|
646
|
+
toolCalls,
|
|
647
|
+
toolResults,
|
|
648
|
+
newOffset,
|
|
649
|
+
reset,
|
|
650
|
+
parserState: {
|
|
651
|
+
version: 1,
|
|
652
|
+
offset: newOffset,
|
|
653
|
+
dev: info.dev,
|
|
654
|
+
ino: info.ino,
|
|
655
|
+
birthtimeMs: info.birthtimeMs,
|
|
656
|
+
size: info.size,
|
|
657
|
+
mtimeMs: info.mtimeMs,
|
|
658
|
+
checkpoint,
|
|
659
|
+
serviceTier: currentServiceTier ?? null,
|
|
660
|
+
},
|
|
661
|
+
};
|
|
556
662
|
}
|
|
557
663
|
|
|
558
664
|
/**
|
package/src/port-conflict.ts
CHANGED
|
@@ -26,7 +26,7 @@ export const STATS_DASHBOARD_SECURITY_VERSION = "3";
|
|
|
26
26
|
/** IPv4 loopback address shared by the dashboard server and reuse probe. */
|
|
27
27
|
export const STATS_DASHBOARD_HOSTNAME = "127.0.0.1";
|
|
28
28
|
|
|
29
|
-
type StatsDashboardProbe = "reusable" | "occupied" | "unreachable";
|
|
29
|
+
type StatsDashboardProbe = "reusable" | "replaceable" | "occupied" | "unreachable";
|
|
30
30
|
|
|
31
31
|
async function probeStatsDashboard(port: number, hostname: string): Promise<StatsDashboardProbe> {
|
|
32
32
|
const probeHostname = hostname === "0.0.0.0" ? STATS_DASHBOARD_HOSTNAME : hostname === "::" ? "::1" : hostname;
|
|
@@ -35,13 +35,22 @@ async function probeStatsDashboard(port: number, hostname: string): Promise<Stat
|
|
|
35
35
|
const response = await fetch(`http://${urlHostname}:${port}/api/stats/models`, {
|
|
36
36
|
signal: AbortSignal.timeout(STATS_PROBE_TIMEOUT_MS),
|
|
37
37
|
});
|
|
38
|
+
const dashboardVersionHeader = response.headers.get(STATS_DASHBOARD_HEADER);
|
|
39
|
+
const dashboardVersion = dashboardVersionHeader === null ? Number.NaN : Number(dashboardVersionHeader);
|
|
40
|
+
// Never replace a newer dashboard: an older CLI must not downgrade it.
|
|
41
|
+
const replaceable =
|
|
42
|
+
response.status === 200 &&
|
|
43
|
+
Number.isSafeInteger(dashboardVersion) &&
|
|
44
|
+
dashboardVersion > 0 &&
|
|
45
|
+
dashboardVersion <= Number(STATS_DASHBOARD_SECURITY_VERSION);
|
|
38
46
|
const reusable =
|
|
39
47
|
response.status === 200 &&
|
|
40
|
-
|
|
48
|
+
dashboardVersionHeader === STATS_DASHBOARD_SECURITY_VERSION &&
|
|
41
49
|
response.headers.get(STATS_DASHBOARD_HOSTNAME_HEADER) === hostname &&
|
|
42
50
|
!response.headers.has("Access-Control-Allow-Origin");
|
|
43
51
|
await response.body?.cancel();
|
|
44
|
-
|
|
52
|
+
if (reusable) return "reusable";
|
|
53
|
+
return replaceable ? "replaceable" : "occupied";
|
|
45
54
|
} catch {
|
|
46
55
|
return "unreachable";
|
|
47
56
|
}
|
|
@@ -218,7 +227,7 @@ async function terminatePortHolder(holder: PortHolder): Promise<void> {
|
|
|
218
227
|
await Bun.sleep(PROCESS_EXIT_POLL_MS);
|
|
219
228
|
}
|
|
220
229
|
|
|
221
|
-
async function reclaimStatsPort(port: number): Promise<"retry"> {
|
|
230
|
+
async function reclaimStatsPort(port: number, hasDashboardIdentity = false): Promise<"retry"> {
|
|
222
231
|
const holder = await findPortHolder(port);
|
|
223
232
|
if (!holder) {
|
|
224
233
|
throw new Error(`Port ${port} is in use, but the listening process could not be identified.`);
|
|
@@ -238,7 +247,7 @@ async function reclaimStatsPort(port: number): Promise<"retry"> {
|
|
|
238
247
|
/\/packages\/stats\/src\/index\.ts(?:["'\s]|$)/.test(normalizedCommand) ||
|
|
239
248
|
(normalizedImage === "omp" && /(?:^|\s)stats(?:\s|$)/.test(normalizedCommand)) ||
|
|
240
249
|
/(?:^|\/)omp(?:\.exe)?["'\s]+stats(?:["'\s]|$)/.test(normalizedCommand);
|
|
241
|
-
if (!STATS_RUNTIME_IMAGES[normalizedImage] || !hasStatsIdentity) {
|
|
250
|
+
if (!STATS_RUNTIME_IMAGES[normalizedImage] || (!hasStatsIdentity && !hasDashboardIdentity)) {
|
|
242
251
|
throw new Error(
|
|
243
252
|
`Port ${port} is in use by ${holder.image} (PID ${holder.pid}), which is not identifiable as an omp stats dashboard; refusing to stop it.`,
|
|
244
253
|
);
|
|
@@ -257,12 +266,14 @@ export async function prepareStatsPort(port: number, hostname = STATS_DASHBOARD_
|
|
|
257
266
|
if (port === 0) return "retry";
|
|
258
267
|
const probe = await probeStatsDashboard(port, hostname);
|
|
259
268
|
if (probe === "reusable") return "reuse";
|
|
269
|
+
if (probe === "replaceable") return reclaimStatsPort(port, true);
|
|
260
270
|
if (probe === "occupied") return reclaimStatsPort(port);
|
|
261
271
|
return "retry";
|
|
262
272
|
}
|
|
263
273
|
|
|
264
274
|
/** Reuse or reclaim a listener found after the server bind reports EADDRINUSE. */
|
|
265
275
|
export async function recoverStatsPort(port: number, hostname = STATS_DASHBOARD_HOSTNAME): Promise<"retry" | "reuse"> {
|
|
266
|
-
|
|
267
|
-
|
|
276
|
+
const probe = await probeStatsDashboard(port, hostname);
|
|
277
|
+
if (probe === "reusable") return "reuse";
|
|
278
|
+
return reclaimStatsPort(port, probe === "replaceable");
|
|
268
279
|
}
|
package/src/sync-worker.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stateless parse worker for `syncAllSessions`. The main thread owns the
|
|
3
|
-
* SQLite handle; workers receive
|
|
3
|
+
* SQLite handle; workers receive a session path, offset, and parser state, run
|
|
4
4
|
* `parseSessionFile` (which is pure I/O + CPU, no DB), and post the
|
|
5
5
|
* structured-clone-safe result back. One in-flight request per worker so
|
|
6
6
|
* the main thread can fan jobs out 1:1 with the pool size.
|
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
* for issue #1011 / PR #1027, where the worker silently failed to load).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { type ParseSessionResult, parseSessionFile } from "./parser";
|
|
14
|
+
import { type ParseSessionResult, parseSessionFile, type SessionParserState } from "./parser";
|
|
15
15
|
|
|
16
|
-
export type SyncWorkerRequest =
|
|
16
|
+
export type SyncWorkerRequest =
|
|
17
|
+
| { kind?: "parse"; sessionFile: string; fromOffset: number; parserState?: SessionParserState; replay?: boolean }
|
|
18
|
+
| { kind: "ping" };
|
|
17
19
|
|
|
18
20
|
export type SyncWorkerResponse =
|
|
19
21
|
| { ok: true; kind?: "parse"; result: ParseSessionResult }
|
|
@@ -31,7 +33,12 @@ self.onmessage = async event => {
|
|
|
31
33
|
self.postMessage({ ok: true, kind: "pong" } satisfies SyncWorkerResponse);
|
|
32
34
|
return;
|
|
33
35
|
}
|
|
34
|
-
const result = await parseSessionFile(
|
|
36
|
+
const result = await parseSessionFile(
|
|
37
|
+
request.sessionFile,
|
|
38
|
+
request.fromOffset,
|
|
39
|
+
request.parserState,
|
|
40
|
+
request.replay,
|
|
41
|
+
);
|
|
35
42
|
self.postMessage({ ok: true, result } satisfies SyncWorkerResponse);
|
|
36
43
|
} catch (err) {
|
|
37
44
|
const error = err instanceof Error ? (err.stack ?? err.message) : String(err);
|