@oh-my-pi/omp-stats 18.16.0 → 18.17.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 +7 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.js +102 -99
- package/dist/types/client/api.d.ts +4 -1
- package/dist/types/client/app/nav.d.ts +1 -1
- package/dist/types/client/charts/Chart.d.ts +16 -8
- package/dist/types/client/charts/DotWhisker.d.ts +50 -0
- package/dist/types/client/charts/Sparkline.d.ts +1 -1
- package/dist/types/client/charts/Waterfall.d.ts +26 -0
- package/dist/types/client/charts/index.d.ts +2 -0
- package/dist/types/client/charts/types.d.ts +8 -2
- package/dist/types/client/routes/SpotlightRoute.d.ts +7 -0
- package/dist/types/client/routes/index.d.ts +1 -0
- package/dist/types/client/spotlight/Compounding.d.ts +8 -0
- package/dist/types/client/spotlight/Coverage.d.ts +6 -0
- package/dist/types/client/spotlight/Economics.d.ts +10 -0
- package/dist/types/client/spotlight/FetchBack.d.ts +9 -0
- package/dist/types/client/spotlight/Hero.d.ts +12 -0
- package/dist/types/client/spotlight/Overhead.d.ts +6 -0
- package/dist/types/client/spotlight/Ranking.d.ts +6 -0
- package/dist/types/client/spotlight/ResultsFeed.d.ts +6 -0
- package/dist/types/client/spotlight/Savings.d.ts +10 -0
- package/dist/types/client/spotlight/ShareTrend.d.ts +8 -0
- package/dist/types/client/spotlight/Steps.d.ts +6 -0
- package/dist/types/client/spotlight/TimeCost.d.ts +6 -0
- package/dist/types/client/spotlight/chips.d.ts +16 -0
- package/dist/types/client/spotlight/meta.d.ts +64 -0
- package/dist/types/db.d.ts +26 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/parser.d.ts +4 -0
- package/dist/types/rollup.d.ts +22 -1
- package/dist/types/shared-types.d.ts +419 -0
- package/dist/types/spotlight-aggregator.d.ts +189 -0
- package/dist/types/spotlight-bootstrap.d.ts +32 -0
- package/dist/types/spotlight-ingest.d.ts +258 -0
- package/dist/types/spotlight-measures.d.ts +51 -0
- package/package.json +5 -5
- package/src/aggregator.ts +3 -1
- package/src/client/App.tsx +3 -0
- package/src/client/api.ts +21 -0
- package/src/client/app/nav.ts +4 -1
- package/src/client/charts/Chart.tsx +105 -22
- package/src/client/charts/DotWhisker.tsx +149 -0
- package/src/client/charts/Sparkline.tsx +3 -2
- package/src/client/charts/Waterfall.tsx +86 -0
- package/src/client/charts/index.ts +2 -0
- package/src/client/charts/types.ts +9 -2
- package/src/client/react-css.d.ts +1 -1
- package/src/client/routes/SpotlightRoute.tsx +138 -0
- package/src/client/routes/index.ts +1 -0
- package/src/client/spotlight/Compounding.tsx +222 -0
- package/src/client/spotlight/Coverage.tsx +96 -0
- package/src/client/spotlight/Economics.tsx +167 -0
- package/src/client/spotlight/FetchBack.tsx +222 -0
- package/src/client/spotlight/Hero.tsx +177 -0
- package/src/client/spotlight/Overhead.tsx +135 -0
- package/src/client/spotlight/Ranking.tsx +248 -0
- package/src/client/spotlight/ResultsFeed.tsx +354 -0
- package/src/client/spotlight/Savings.tsx +355 -0
- package/src/client/spotlight/ShareTrend.tsx +103 -0
- package/src/client/spotlight/Steps.tsx +225 -0
- package/src/client/spotlight/TimeCost.tsx +64 -0
- package/src/client/spotlight/chips.tsx +63 -0
- package/src/client/spotlight/meta.ts +206 -0
- package/src/client/spotlight/spotlight.css +1387 -0
- package/src/client/styles.css +289 -1
- package/src/db.ts +480 -2
- package/src/index.ts +10 -1
- package/src/live.ts +21 -9
- package/src/parser.ts +44 -8
- package/src/rollup.ts +203 -10
- package/src/server.ts +42 -0
- package/src/shared-types.ts +453 -0
- package/src/spotlight-aggregator.ts +1738 -0
- package/src/spotlight-bootstrap.ts +110 -0
- package/src/spotlight-ingest.ts +981 -0
- package/src/spotlight-measures.ts +667 -0
package/src/parser.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type AssistantMessage,
|
|
6
6
|
coerceServiceTierByFamily,
|
|
7
7
|
getPremiumServiceTierRequests,
|
|
8
|
+
type MessageAttribution,
|
|
8
9
|
parseServiceTier,
|
|
9
10
|
resolveModelServiceTier,
|
|
10
11
|
type ServiceTierByFamily,
|
|
@@ -26,6 +27,13 @@ import type {
|
|
|
26
27
|
UserMessageLink,
|
|
27
28
|
UserMessageStats,
|
|
28
29
|
} from "./types";
|
|
30
|
+
import {
|
|
31
|
+
emptySpotlightRows,
|
|
32
|
+
emptySpotlightState,
|
|
33
|
+
SpotlightCollector,
|
|
34
|
+
type SpotlightParseRows,
|
|
35
|
+
type SpotlightParserState,
|
|
36
|
+
} from "./spotlight-ingest";
|
|
29
37
|
import { computeUserMessageMetrics, judgeProse } from "./user-metrics";
|
|
30
38
|
|
|
31
39
|
/** Basename of an advisor agent's transcript inside a session artifacts dir. */
|
|
@@ -128,11 +136,16 @@ function extractUserText(content: unknown): string {
|
|
|
128
136
|
}
|
|
129
137
|
|
|
130
138
|
/**
|
|
131
|
-
* Build user-message stats from an entry. Returns null for empty
|
|
139
|
+
* Build user-message stats from an entry. Returns null for empty, synthetic, or agent-attributed content.
|
|
132
140
|
*/
|
|
133
141
|
function extractUserStats(sessionFile: string, folder: string, entry: SessionMessageEntry): UserMessageStats | null {
|
|
134
|
-
const msg = entry.message as {
|
|
135
|
-
|
|
142
|
+
const msg = entry.message as {
|
|
143
|
+
role: "user";
|
|
144
|
+
content?: unknown;
|
|
145
|
+
synthetic?: boolean;
|
|
146
|
+
attribution?: MessageAttribution;
|
|
147
|
+
};
|
|
148
|
+
if (msg.role !== "user" || msg.synthetic || msg.attribution === "agent") return null;
|
|
136
149
|
const text = extractUserText(msg.content);
|
|
137
150
|
if (!text.trim()) return null;
|
|
138
151
|
const metrics = computeUserMessageMetrics(text);
|
|
@@ -490,10 +503,12 @@ export function parseAllSessionEntries(bytes: Uint8Array): SessionEntry[] {
|
|
|
490
503
|
return entries;
|
|
491
504
|
}
|
|
492
505
|
|
|
493
|
-
|
|
506
|
+
/** Rebuild the cursor state of `bytes` (a transcript prefix): the last service tier and the Spotlight state. */
|
|
507
|
+
function scanPrefixState(bytes: Uint8Array, spotlight: SpotlightCollector): ServiceTierByFamily | undefined {
|
|
494
508
|
let currentServiceTier: ServiceTierByFamily | undefined;
|
|
495
509
|
visitSessionEntriesLenient(bytes, entry => {
|
|
496
510
|
if (isServiceTierChange(entry)) currentServiceTier = coerceServiceTierByFamily(entry.serviceTier);
|
|
511
|
+
else spotlight.visit(entry);
|
|
497
512
|
});
|
|
498
513
|
return currentServiceTier;
|
|
499
514
|
}
|
|
@@ -507,6 +522,8 @@ export interface SessionParserState {
|
|
|
507
522
|
mtimeMs: number;
|
|
508
523
|
checkpoint: string;
|
|
509
524
|
serviceTier: ServiceTierByFamily | null;
|
|
525
|
+
/** Absent in cursors persisted before the Spotlight tables; such a tail parse starts from an empty state. */
|
|
526
|
+
spotlight?: SpotlightParserState;
|
|
510
527
|
}
|
|
511
528
|
|
|
512
529
|
export interface ParseSessionResult {
|
|
@@ -515,6 +532,7 @@ export interface ParseSessionResult {
|
|
|
515
532
|
userLinks: UserMessageLink[];
|
|
516
533
|
toolCalls: ToolCallStats[];
|
|
517
534
|
toolResults: ToolResultLink[];
|
|
535
|
+
spotlight: SpotlightParseRows;
|
|
518
536
|
newOffset: number;
|
|
519
537
|
parserState?: SessionParserState;
|
|
520
538
|
reset?: boolean;
|
|
@@ -562,9 +580,11 @@ export async function parseSessionFile(
|
|
|
562
580
|
const toolResults: ToolResultLink[] = [];
|
|
563
581
|
// First call per id: the row `INSERT OR IGNORE` keeps, so it can be written with its result.
|
|
564
582
|
const callsById = new Map<string, ToolCallStats>();
|
|
583
|
+
let spotlight = new SpotlightCollector(sessionPath, folder, emptySpotlightState());
|
|
565
584
|
|
|
566
585
|
// Reduce each entry immediately so full replays do not retain tool-output/message bodies.
|
|
567
586
|
const visit = (entry: SessionEntry): void => {
|
|
587
|
+
spotlight.visit(entry);
|
|
568
588
|
if (isServiceTierChange(entry)) {
|
|
569
589
|
currentServiceTier = coerceServiceTierByFamily(entry.serviceTier);
|
|
570
590
|
return;
|
|
@@ -633,9 +653,14 @@ export async function parseSessionFile(
|
|
|
633
653
|
start = reset || replay ? 0 : Math.max(0, fromOffset);
|
|
634
654
|
const readStart = resume ? start : 0;
|
|
635
655
|
bytes = await file.slice(readStart, info.size).bytes();
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
656
|
+
if (resume) {
|
|
657
|
+
currentServiceTier = state?.serviceTier ?? undefined;
|
|
658
|
+
spotlight = new SpotlightCollector(sessionPath, folder, state?.spotlight ?? emptySpotlightState());
|
|
659
|
+
} else {
|
|
660
|
+
const prefix = new SpotlightCollector(sessionPath, folder, emptySpotlightState(), false);
|
|
661
|
+
currentServiceTier = scanPrefixState(bytes.subarray(0, start), prefix);
|
|
662
|
+
spotlight = new SpotlightCollector(sessionPath, folder, prefix.state);
|
|
663
|
+
}
|
|
639
664
|
read = visitSessionEntriesLenient(bytes.subarray(start - readStart), visit);
|
|
640
665
|
const newOffset = start + read;
|
|
641
666
|
const checkpointStart = Math.max(0, newOffset - CHECKPOINT_BYTES);
|
|
@@ -649,10 +674,19 @@ export async function parseSessionFile(
|
|
|
649
674
|
}
|
|
650
675
|
} catch (err) {
|
|
651
676
|
if (isEnoent(err))
|
|
652
|
-
return {
|
|
677
|
+
return {
|
|
678
|
+
stats: [],
|
|
679
|
+
userStats: [],
|
|
680
|
+
userLinks: [],
|
|
681
|
+
toolCalls: [],
|
|
682
|
+
toolResults: [],
|
|
683
|
+
spotlight: emptySpotlightRows(),
|
|
684
|
+
newOffset: fromOffset,
|
|
685
|
+
};
|
|
653
686
|
throw err;
|
|
654
687
|
}
|
|
655
688
|
|
|
689
|
+
await spotlight.finish();
|
|
656
690
|
const newOffset = start + read;
|
|
657
691
|
return {
|
|
658
692
|
stats,
|
|
@@ -660,6 +694,7 @@ export async function parseSessionFile(
|
|
|
660
694
|
userLinks,
|
|
661
695
|
toolCalls,
|
|
662
696
|
toolResults,
|
|
697
|
+
spotlight: spotlight.rows,
|
|
663
698
|
newOffset,
|
|
664
699
|
reset,
|
|
665
700
|
parserState: {
|
|
@@ -672,6 +707,7 @@ export async function parseSessionFile(
|
|
|
672
707
|
mtimeMs: info.mtimeMs,
|
|
673
708
|
checkpoint,
|
|
674
709
|
serviceTier: currentServiceTier ?? null,
|
|
710
|
+
spotlight: spotlight.state,
|
|
675
711
|
},
|
|
676
712
|
};
|
|
677
713
|
}
|
package/src/rollup.ts
CHANGED
|
@@ -56,8 +56,17 @@ const DAY_MS = 24 * HOUR_MS;
|
|
|
56
56
|
const ROLLUP_VERSION = "2";
|
|
57
57
|
const ROLLUP_VERSION_KEY = "rollup_version";
|
|
58
58
|
/** Bump when the dirty-marking triggers change; they are then recreated in place. */
|
|
59
|
-
const TRIGGER_VERSION = "
|
|
59
|
+
const TRIGGER_VERSION = "4";
|
|
60
60
|
const TRIGGER_VERSION_KEY = "rollup_triggers_version";
|
|
61
|
+
/**
|
|
62
|
+
* `tool_agent_rollup` (tool-result characters per hour and agent type) is built
|
|
63
|
+
* on its own dirty set, so adding it never rebuilds the other rollups. Bump to
|
|
64
|
+
* rebuild just this table.
|
|
65
|
+
*/
|
|
66
|
+
const TOOL_AGENT_VERSION = "1";
|
|
67
|
+
const TOOL_AGENT_VERSION_KEY = "tool_agent_rollup_version";
|
|
68
|
+
/** Before this, timestamps are sentinels (0) or garbage: their hours are seeded one by one, not as a range. */
|
|
69
|
+
const SANE_TIMESTAMP_MS = Date.UTC(2000, 0, 1);
|
|
61
70
|
|
|
62
71
|
/** Above this many dirty hours, reads use stale rollup rows instead of scanning dirty hours raw. */
|
|
63
72
|
const EXACT_DIRTY_LIMIT = 96;
|
|
@@ -142,11 +151,22 @@ function hourOf(column: string): string {
|
|
|
142
151
|
* Trigger statements marking a row's hour and transcript dirty (`row` is
|
|
143
152
|
* `NEW` or `OLD`), without any conflict clause.
|
|
144
153
|
*/
|
|
145
|
-
function markDirty(row: "NEW" | "OLD"): string {
|
|
154
|
+
function markDirty(row: "NEW" | "OLD", table: "messages" | "tool_calls"): string {
|
|
155
|
+
const toolAgent =
|
|
156
|
+
table === "tool_calls"
|
|
157
|
+
? `INSERT INTO tool_agent_dirty (bucket)
|
|
158
|
+
SELECT h FROM (SELECT ${hourOf(`${row}.timestamp`)} AS h) WHERE h NOT IN (SELECT bucket FROM tool_agent_dirty);`
|
|
159
|
+
: "";
|
|
146
160
|
return `INSERT INTO rollup_dirty (bucket)
|
|
147
161
|
SELECT h FROM (SELECT ${hourOf(`${row}.timestamp`)} AS h) WHERE h NOT IN (SELECT bucket FROM rollup_dirty);
|
|
148
162
|
INSERT INTO session_dirty (session_file)
|
|
149
|
-
SELECT ${row}.session_file WHERE ${row}.session_file NOT IN (SELECT session_file FROM session_dirty)
|
|
163
|
+
SELECT ${row}.session_file WHERE ${row}.session_file NOT IN (SELECT session_file FROM session_dirty);
|
|
164
|
+
${toolAgent}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Additive per-bucket tool-result facts per agent type (columns of `tool_calls`). */
|
|
168
|
+
function toolAgentFacts(bucketExpr: string): string {
|
|
169
|
+
return `${bucketExpr} AS bucket, agent_type, SUM(COALESCE(result_chars, 0)) AS result_chars`;
|
|
150
170
|
}
|
|
151
171
|
|
|
152
172
|
/** Per-transcript facts over `messages` (qualified by `p`); tool calls counted by subquery. */
|
|
@@ -182,9 +202,10 @@ export function ensureRollupSchema(database: Database): void {
|
|
|
182
202
|
const readMeta = (key: string) =>
|
|
183
203
|
(database.query("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | undefined)?.value;
|
|
184
204
|
const fresh = readMeta(ROLLUP_VERSION_KEY) !== ROLLUP_VERSION;
|
|
205
|
+
const toolAgentFresh = fresh || readMeta(TOOL_AGENT_VERSION_KEY) !== TOOL_AGENT_VERSION;
|
|
185
206
|
// Steady state touches no schema: DDL on every open would contend with
|
|
186
207
|
// other omp processes writing the same database.
|
|
187
|
-
if (!fresh && readMeta(TRIGGER_VERSION_KEY) === TRIGGER_VERSION) return;
|
|
208
|
+
if (!fresh && !toolAgentFresh && readMeta(TRIGGER_VERSION_KEY) === TRIGGER_VERSION) return;
|
|
188
209
|
database
|
|
189
210
|
.transaction(() => {
|
|
190
211
|
if (fresh) {
|
|
@@ -194,6 +215,10 @@ export function ensureRollupSchema(database: Database): void {
|
|
|
194
215
|
database.run("DROP TABLE IF EXISTS session_rollup");
|
|
195
216
|
database.run("DROP TABLE IF EXISTS session_dirty");
|
|
196
217
|
}
|
|
218
|
+
if (toolAgentFresh) {
|
|
219
|
+
database.run("DROP TABLE IF EXISTS tool_agent_rollup");
|
|
220
|
+
database.run("DROP TABLE IF EXISTS tool_agent_dirty");
|
|
221
|
+
}
|
|
197
222
|
database.run(`
|
|
198
223
|
CREATE TABLE IF NOT EXISTS message_rollup (
|
|
199
224
|
bucket INTEGER NOT NULL,
|
|
@@ -256,17 +281,24 @@ export function ensureRollupSchema(database: Database): void {
|
|
|
256
281
|
tool_calls INTEGER NOT NULL
|
|
257
282
|
) WITHOUT ROWID;
|
|
258
283
|
CREATE TABLE IF NOT EXISTS session_dirty (session_file TEXT PRIMARY KEY) WITHOUT ROWID;
|
|
284
|
+
CREATE TABLE IF NOT EXISTS tool_agent_rollup (
|
|
285
|
+
bucket INTEGER NOT NULL,
|
|
286
|
+
agent_type TEXT NOT NULL,
|
|
287
|
+
result_chars INTEGER NOT NULL
|
|
288
|
+
);
|
|
289
|
+
CREATE INDEX IF NOT EXISTS idx_tool_agent_rollup_bucket ON tool_agent_rollup(bucket);
|
|
290
|
+
CREATE TABLE IF NOT EXISTS tool_agent_dirty (bucket INTEGER PRIMARY KEY) WITHOUT ROWID;
|
|
259
291
|
`);
|
|
260
292
|
// A tool share reads its assistant message, so message changes dirty the
|
|
261
293
|
// hour for both rollups (tool calls share their message's timestamp).
|
|
262
294
|
// Triggers must not use `OR IGNORE`: a trigger statement inherits the
|
|
263
295
|
// outer statement's conflict policy, and the UPSERTs in
|
|
264
296
|
// `insertMessageStats` would turn a duplicate dirty mark into a failed ingest.
|
|
265
|
-
for (const table of ["messages", "tool_calls"]) {
|
|
297
|
+
for (const table of ["messages", "tool_calls"] as const) {
|
|
266
298
|
const triggers = {
|
|
267
|
-
insert: `AFTER INSERT ON ${table} BEGIN ${markDirty("NEW")} END`,
|
|
268
|
-
delete: `AFTER DELETE ON ${table} BEGIN ${markDirty("OLD")} END`,
|
|
269
|
-
update: `AFTER UPDATE ON ${table} BEGIN ${markDirty("OLD")} ${markDirty("NEW")} END`,
|
|
299
|
+
insert: `AFTER INSERT ON ${table} BEGIN ${markDirty("NEW", table)} END`,
|
|
300
|
+
delete: `AFTER DELETE ON ${table} BEGIN ${markDirty("OLD", table)} END`,
|
|
301
|
+
update: `AFTER UPDATE ON ${table} BEGIN ${markDirty("OLD", table)} ${markDirty("NEW", table)} END`,
|
|
270
302
|
};
|
|
271
303
|
for (const [event, body] of Object.entries(triggers)) {
|
|
272
304
|
database.run(`DROP TRIGGER IF EXISTS rollup_dirty_${table}_${event}`);
|
|
@@ -290,6 +322,26 @@ export function ensureRollupSchema(database: Database): void {
|
|
|
290
322
|
.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
291
323
|
.run(ROLLUP_VERSION_KEY, ROLLUP_VERSION);
|
|
292
324
|
}
|
|
325
|
+
if (toolAgentFresh) {
|
|
326
|
+
// Every hour between the first and last call, seeded from two index lookups
|
|
327
|
+
// instead of a scan; hours without calls refresh to nothing.
|
|
328
|
+
database.run(`
|
|
329
|
+
WITH RECURSIVE h(bucket) AS (
|
|
330
|
+
SELECT ${hourOf("MIN(timestamp)")} FROM tool_calls WHERE timestamp >= ${SANE_TIMESTAMP_MS}
|
|
331
|
+
UNION ALL
|
|
332
|
+
SELECT bucket + ${HOUR_MS} FROM h
|
|
333
|
+
WHERE bucket + ${HOUR_MS} <= (SELECT MAX(timestamp) FROM tool_calls)
|
|
334
|
+
)
|
|
335
|
+
INSERT OR IGNORE INTO tool_agent_dirty (bucket) SELECT bucket FROM h WHERE bucket IS NOT NULL
|
|
336
|
+
`);
|
|
337
|
+
database.run(`
|
|
338
|
+
INSERT OR IGNORE INTO tool_agent_dirty (bucket)
|
|
339
|
+
SELECT DISTINCT ${hourOf("timestamp")} FROM tool_calls WHERE timestamp < ${SANE_TIMESTAMP_MS}
|
|
340
|
+
`);
|
|
341
|
+
database
|
|
342
|
+
.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
343
|
+
.run(TOOL_AGENT_VERSION_KEY, TOOL_AGENT_VERSION);
|
|
344
|
+
}
|
|
293
345
|
})
|
|
294
346
|
.immediate();
|
|
295
347
|
}
|
|
@@ -309,7 +361,8 @@ export function getRollupStatus(): RollupStatus {
|
|
|
309
361
|
if (!database) return { dirtyHours: 0, dirtySessions: 0 };
|
|
310
362
|
return database
|
|
311
363
|
.query(
|
|
312
|
-
|
|
364
|
+
`SELECT MAX((SELECT COUNT(*) FROM rollup_dirty), (SELECT COUNT(*) FROM tool_agent_dirty)) AS dirtyHours,
|
|
365
|
+
(SELECT COUNT(*) FROM session_dirty) AS dirtySessions`,
|
|
313
366
|
)
|
|
314
367
|
.get() as RollupStatus;
|
|
315
368
|
}
|
|
@@ -350,6 +403,23 @@ export function refreshRollupBatch(limit: number): number {
|
|
|
350
403
|
clean.run(bucket);
|
|
351
404
|
}
|
|
352
405
|
|
|
406
|
+
const agentBuckets = database
|
|
407
|
+
.query("SELECT bucket FROM tool_agent_dirty ORDER BY bucket DESC LIMIT ?")
|
|
408
|
+
.all(limit) as { bucket: number }[];
|
|
409
|
+
const clearAgent = database.query("DELETE FROM tool_agent_rollup WHERE bucket = ?");
|
|
410
|
+
const fillAgent = database.query(`
|
|
411
|
+
INSERT INTO tool_agent_rollup (bucket, agent_type, result_chars)
|
|
412
|
+
SELECT ${toolAgentFacts("?1")}
|
|
413
|
+
FROM tool_calls WHERE timestamp >= ?1 AND timestamp < ?1 + ${HOUR_MS}
|
|
414
|
+
GROUP BY agent_type
|
|
415
|
+
`);
|
|
416
|
+
const cleanAgent = database.query("DELETE FROM tool_agent_dirty WHERE bucket = ?");
|
|
417
|
+
for (const { bucket } of agentBuckets) {
|
|
418
|
+
clearAgent.run(bucket);
|
|
419
|
+
fillAgent.run(bucket);
|
|
420
|
+
cleanAgent.run(bucket);
|
|
421
|
+
}
|
|
422
|
+
|
|
353
423
|
// Transcripts are cheap (indexed by session_file); roll many per hour-batch.
|
|
354
424
|
const sessions = database
|
|
355
425
|
.query("SELECT session_file FROM session_dirty LIMIT ?")
|
|
@@ -367,7 +437,7 @@ export function refreshRollupBatch(limit: number): number {
|
|
|
367
437
|
fillSession.run(session_file);
|
|
368
438
|
cleanSession.run(session_file);
|
|
369
439
|
}
|
|
370
|
-
return buckets.length + sessions.length;
|
|
440
|
+
return buckets.length + agentBuckets.length + sessions.length;
|
|
371
441
|
});
|
|
372
442
|
return run.immediate();
|
|
373
443
|
}
|
|
@@ -970,3 +1040,126 @@ export function getToolTimeSeries({ cutoff, bucketMs }: RangeWindow): ToolTimeSe
|
|
|
970
1040
|
"GROUP BY 1, f.tool_name ORDER BY 1",
|
|
971
1041
|
).map(row => ({ timestamp: row.bucket, tool: row.tool_name, calls: row.calls, errors: row.errors }));
|
|
972
1042
|
}
|
|
1043
|
+
|
|
1044
|
+
// ---------------------------------------------------------------------------
|
|
1045
|
+
// Context facts (Spotlight tool-output share)
|
|
1046
|
+
// ---------------------------------------------------------------------------
|
|
1047
|
+
|
|
1048
|
+
/** Per-bucket context facts of every transcript of one agent type: tool-result characters and output tokens. */
|
|
1049
|
+
export interface ContextFactPoint {
|
|
1050
|
+
bucket: number;
|
|
1051
|
+
agentType: AgentType;
|
|
1052
|
+
resultChars: number;
|
|
1053
|
+
outputTokens: number;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** Tool-result characters per hour and agent type over `[cutoff, now]`; same union shape as {@link toolSource}. */
|
|
1057
|
+
function toolAgentSource(database: Database, cutoff: number | null, fine: boolean): Source {
|
|
1058
|
+
if (fine) {
|
|
1059
|
+
return {
|
|
1060
|
+
sql: `SELECT ${toolAgentFacts("(timestamp / 300000) * 300000")} FROM tool_calls WHERE timestamp >= ? GROUP BY 1, agent_type`,
|
|
1061
|
+
params: [cutoff ?? 0],
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
const hi = cutoff === null ? null : Math.ceil(cutoff / HOUR_MS) * HOUR_MS;
|
|
1065
|
+
const { n } = database
|
|
1066
|
+
.query(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM tool_agent_dirty LIMIT ${EXACT_DIRTY_LIMIT + 1})`)
|
|
1067
|
+
.get() as { n: number };
|
|
1068
|
+
const exact = n <= EXACT_DIRTY_LIMIT;
|
|
1069
|
+
const parts: string[] = [];
|
|
1070
|
+
const params: number[] = [];
|
|
1071
|
+
parts.push(
|
|
1072
|
+
`SELECT bucket, agent_type, result_chars FROM tool_agent_rollup
|
|
1073
|
+
WHERE ${exact ? "bucket NOT IN (SELECT bucket FROM tool_agent_dirty)" : "1"}${hi !== null ? " AND bucket >= ?" : ""}`,
|
|
1074
|
+
);
|
|
1075
|
+
if (hi !== null) params.push(hi);
|
|
1076
|
+
if (cutoff !== null && hi !== null && cutoff < hi) {
|
|
1077
|
+
parts.push(
|
|
1078
|
+
`SELECT ${toolAgentFacts(hourOf("timestamp"))} FROM tool_calls
|
|
1079
|
+
WHERE timestamp >= ? AND timestamp < ? GROUP BY 1, agent_type`,
|
|
1080
|
+
);
|
|
1081
|
+
params.push(cutoff, hi);
|
|
1082
|
+
}
|
|
1083
|
+
if (exact) {
|
|
1084
|
+
parts.push(
|
|
1085
|
+
`SELECT d.bucket AS bucket, t.agent_type AS agent_type, SUM(COALESCE(t.result_chars, 0)) AS result_chars
|
|
1086
|
+
FROM tool_agent_dirty d JOIN tool_calls t ON t.timestamp >= d.bucket AND t.timestamp < d.bucket + ${HOUR_MS}
|
|
1087
|
+
${hi !== null ? "WHERE d.bucket >= ?" : ""}
|
|
1088
|
+
GROUP BY d.bucket, t.agent_type`,
|
|
1089
|
+
);
|
|
1090
|
+
if (hi !== null) params.push(hi);
|
|
1091
|
+
}
|
|
1092
|
+
return { sql: parts.join(" UNION ALL "), params };
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/** {@link ContextFactPoint}s over a window, from the rollups (exact while few hours are dirty). */
|
|
1096
|
+
export function getContextFactSeries(database: Database, { cutoff, bucketMs }: RangeWindow): ContextFactPoint[] {
|
|
1097
|
+
const fine = bucketMs < HOUR_MS;
|
|
1098
|
+
const normalized = normalizeCutoff(cutoff);
|
|
1099
|
+
const tools = toolAgentSource(database, normalized, fine);
|
|
1100
|
+
const messages = messageSource(database, normalized, fine);
|
|
1101
|
+
const points = new Map<string, ContextFactPoint>();
|
|
1102
|
+
const point = (bucket: number, agentType: AgentType) => {
|
|
1103
|
+
const key = `${bucket}:${agentType}`;
|
|
1104
|
+
let p = points.get(key);
|
|
1105
|
+
if (!p) {
|
|
1106
|
+
p = { bucket, agentType, resultChars: 0, outputTokens: 0 };
|
|
1107
|
+
points.set(key, p);
|
|
1108
|
+
}
|
|
1109
|
+
return p;
|
|
1110
|
+
};
|
|
1111
|
+
type Row = { bucket: number; agent_type: AgentType; n: number | null };
|
|
1112
|
+
const toolRows = database
|
|
1113
|
+
.query(
|
|
1114
|
+
`SELECT ${seriesBucket(bucketMs)} AS bucket, f.agent_type AS agent_type, SUM(f.result_chars) AS n
|
|
1115
|
+
FROM (${tools.sql}) f GROUP BY 1, 2`,
|
|
1116
|
+
)
|
|
1117
|
+
.all(...tools.params) as Row[];
|
|
1118
|
+
for (const row of toolRows) point(row.bucket, row.agent_type).resultChars += row.n ?? 0;
|
|
1119
|
+
const messageRows = database
|
|
1120
|
+
.query(
|
|
1121
|
+
`SELECT ${seriesBucket(bucketMs)} AS bucket, f.agent_type AS agent_type, SUM(f.output_tokens) AS n
|
|
1122
|
+
FROM (${messages.sql}) f GROUP BY 1, 2`,
|
|
1123
|
+
)
|
|
1124
|
+
.all(...messages.params) as Row[];
|
|
1125
|
+
for (const row of messageRows) point(row.bucket, row.agent_type).outputTokens += row.n ?? 0;
|
|
1126
|
+
return [...points.values()].sort((a, b) => a.bucket - b.bucket);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** A transcript's request span and its first request's prompt (input + cache read + cache write). */
|
|
1130
|
+
export interface SessionSpan {
|
|
1131
|
+
sessionFile: string;
|
|
1132
|
+
startedAt: number;
|
|
1133
|
+
endedAt: number;
|
|
1134
|
+
firstPrompt: number;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Spans of the transcripts with a request at or after `endedAfter` (all when null), from `session_rollup` with
|
|
1139
|
+
* the same exactness rule as {@link getSessionRollups}.
|
|
1140
|
+
*/
|
|
1141
|
+
export function getSessionSpans(database: Database, endedAfter: number | null): SessionSpan[] {
|
|
1142
|
+
const { n } = database
|
|
1143
|
+
.query(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM session_dirty LIMIT ${EXACT_DIRTY_SESSIONS + 1})`)
|
|
1144
|
+
.get() as { n: number };
|
|
1145
|
+
const exact = n <= EXACT_DIRTY_SESSIONS;
|
|
1146
|
+
const parts = [
|
|
1147
|
+
`SELECT session_file, started_at, ended_at FROM session_rollup
|
|
1148
|
+
WHERE ended_at >= ?1${exact ? " AND session_file NOT IN (SELECT session_file FROM session_dirty)" : ""}`,
|
|
1149
|
+
];
|
|
1150
|
+
if (exact) {
|
|
1151
|
+
parts.push(
|
|
1152
|
+
`SELECT m.session_file, MIN(m.timestamp), MAX(m.timestamp + COALESCE(m.duration, 0))
|
|
1153
|
+
FROM session_dirty d JOIN messages m ON m.session_file = d.session_file
|
|
1154
|
+
GROUP BY m.session_file HAVING MAX(m.timestamp + COALESCE(m.duration, 0)) >= ?1`,
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
return database
|
|
1158
|
+
.query(
|
|
1159
|
+
`SELECT s.session_file AS sessionFile, s.started_at AS startedAt, s.ended_at AS endedAt,
|
|
1160
|
+
COALESCE((SELECT m.input_tokens + m.cache_read_tokens + m.cache_write_tokens FROM messages m
|
|
1161
|
+
WHERE m.timestamp = s.started_at AND m.session_file = s.session_file LIMIT 1), 0) AS firstPrompt
|
|
1162
|
+
FROM (${parts.join(" UNION ALL ")}) s`,
|
|
1163
|
+
)
|
|
1164
|
+
.all(endedAfter ?? 0) as SessionSpan[];
|
|
1165
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
STATS_DASHBOARD_SECURITY_VERSION,
|
|
40
40
|
} from "./port-conflict";
|
|
41
41
|
import { getStatsByModel, getTimeSeries } from "./rollup";
|
|
42
|
+
import { getSpotlightDashboardStats, getSpotlightSessionContext } from "./spotlight-aggregator";
|
|
42
43
|
import {
|
|
43
44
|
buildSessionTrace,
|
|
44
45
|
getTraceEntry,
|
|
@@ -166,6 +167,33 @@ const STATS_ACTION_HEADER = "X-Omp-Stats-Action";
|
|
|
166
167
|
/** What the page's Close calls; unset, the page offers none. */
|
|
167
168
|
let closeHandler: (() => void) | undefined;
|
|
168
169
|
|
|
170
|
+
/** Longest a Spotlight build serves later requests of its data version: its time range moves with the clock. */
|
|
171
|
+
const SPOTLIGHT_VIEW_TTL_MS = 60_000;
|
|
172
|
+
|
|
173
|
+
/** Spotlight payloads (JSON) of one live data version by `range\0agent`, with when each build started. */
|
|
174
|
+
let spotlightViews: { version: number; views: Map<string, { at: number; json: Promise<string> }> } | null = null;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The Spotlight dashboard as JSON, built once per data version and view. Every open page refetches on each version
|
|
178
|
+
* bump and a build takes seconds on the thread that also ingests, so pages showing the same view share one build.
|
|
179
|
+
*/
|
|
180
|
+
function spotlightView(range: string | null, agent: string | null): Promise<string> {
|
|
181
|
+
const version = statsLive().status().version;
|
|
182
|
+
if (spotlightViews?.version !== version) spotlightViews = { version, views: new Map() };
|
|
183
|
+
const views = spotlightViews.views;
|
|
184
|
+
const key = `${range ?? ""}\0${agent ?? ""}`;
|
|
185
|
+
const cached = views.get(key);
|
|
186
|
+
if (cached && Date.now() - cached.at < SPOTLIGHT_VIEW_TTL_MS) return cached.json;
|
|
187
|
+
const json = getSpotlightDashboardStats(range, agent).then(stats => JSON.stringify(stats));
|
|
188
|
+
const entry = { at: Date.now(), json };
|
|
189
|
+
views.set(key, entry);
|
|
190
|
+
// A failed build is not reused.
|
|
191
|
+
json.catch(() => {
|
|
192
|
+
if (views.get(key) === entry) views.delete(key);
|
|
193
|
+
});
|
|
194
|
+
return json;
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
/**
|
|
170
198
|
* Let the page close the dashboard: while `close` is set, it shows a Close
|
|
171
199
|
* button (and takes Esc and Ctrl+C) that calls it. `omp stats` sets it while
|
|
@@ -303,6 +331,20 @@ export async function handleApi(req: Request): Promise<Response> {
|
|
|
303
331
|
const stats = await getGainDashboardStats(range, project);
|
|
304
332
|
return Response.json(stats);
|
|
305
333
|
}
|
|
334
|
+
if (path === "/api/stats/spotlight") {
|
|
335
|
+
return new Response(await spotlightView(range, url.searchParams.get("agent")), {
|
|
336
|
+
headers: { "content-type": "application/json;charset=utf-8" },
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (path === "/api/stats/spotlight/session") {
|
|
341
|
+
const file = url.searchParams.get("file");
|
|
342
|
+
if (!file) return Response.json({ error: "file required" }, { status: 400 });
|
|
343
|
+
const context = await getSpotlightSessionContext(file);
|
|
344
|
+
if (!context) return Response.json({ error: "not a Spotlight experiment session" }, { status: 404 });
|
|
345
|
+
return Response.json(context);
|
|
346
|
+
}
|
|
347
|
+
|
|
306
348
|
if (path === "/api/sessions") {
|
|
307
349
|
const limitParam = Number(url.searchParams.get("limit") ?? "100");
|
|
308
350
|
const limit = Number.isFinite(limitParam) && limitParam > 0 ? Math.floor(limitParam) : 100;
|