@oh-my-pi/omp-stats 18.7.0 → 18.8.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 +96 -96
- package/dist/types/aggregator.d.ts +3 -0
- package/dist/types/client/api.d.ts +5 -1
- package/dist/types/client/data/live.d.ts +16 -11
- package/dist/types/client/data/query-store.d.ts +39 -0
- package/dist/types/client/data/query.d.ts +10 -5
- package/dist/types/client/traces/Minimap.d.ts +2 -0
- package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
- package/dist/types/client/traces/TranscriptList.d.ts +3 -1
- package/dist/types/db.d.ts +14 -2
- package/dist/types/embedded-client.d.ts +5 -0
- package/dist/types/live.d.ts +5 -3
- package/dist/types/rollup.d.ts +9 -1
- package/dist/types/trace.d.ts +18 -6
- package/package.json +4 -4
- package/src/aggregator.ts +26 -9
- package/src/client/api.ts +40 -5
- package/src/client/app/LiveChip.tsx +2 -2
- package/src/client/app/Shell.tsx +2 -2
- package/src/client/data/live.tsx +85 -45
- package/src/client/data/query-store.ts +108 -0
- package/src/client/data/query.ts +47 -56
- package/src/client/routes/CostsRoute.tsx +4 -2
- package/src/client/routes/ErrorsRoute.tsx +3 -1
- package/src/client/routes/FrustrationRoute.tsx +1 -1
- package/src/client/routes/GainRoute.tsx +3 -1
- package/src/client/routes/ModelsRoute.tsx +9 -2
- package/src/client/routes/OverviewRoute.tsx +2 -2
- package/src/client/routes/ProjectsRoute.tsx +1 -1
- package/src/client/routes/ProvidersRoute.tsx +5 -3
- package/src/client/routes/RequestsRoute.tsx +1 -1
- package/src/client/routes/ToolsRoute.tsx +1 -1
- package/src/client/routes/TracesRoute.tsx +5 -4
- package/src/client/traces/Minimap.tsx +63 -26
- package/src/client/traces/TimelineCanvas.tsx +224 -105
- package/src/client/traces/TraceView.tsx +29 -10
- package/src/client/traces/TranscriptList.tsx +17 -9
- package/src/client/traces/traces.css +26 -0
- package/src/client/ui/RequestDrawer.tsx +3 -1
- package/src/db.ts +106 -64
- package/src/embedded-client.ts +9 -0
- package/src/live.ts +36 -11
- package/src/parser.ts +1 -8
- package/src/rollup.ts +41 -20
- package/src/server.ts +38 -24
- package/src/trace.ts +54 -40
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* selection. Hand-rolled fixed-row virtualization (32px rows, spacer divs).
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
7
|
+
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
8
8
|
import { formatDurationMs } from "../data/formatters";
|
|
9
9
|
import { EmptyState } from "../ui";
|
|
10
10
|
import type { TraceMarker, TraceSpan, TraceTrack } from "../types";
|
|
@@ -25,6 +25,8 @@ export interface TranscriptListProps {
|
|
|
25
25
|
selection: string | null;
|
|
26
26
|
onSelect: (spanId: string) => void;
|
|
27
27
|
search: string;
|
|
28
|
+
/** Spans matching `search` (from the trace view); `null` when not searching. */
|
|
29
|
+
matchIds: ReadonlySet<string> | null;
|
|
28
30
|
traceStart: number;
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -49,20 +51,26 @@ export function buildTranscriptRows(tracks: TraceTrack[]): TranscriptRow[] {
|
|
|
49
51
|
return rows;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
export
|
|
54
|
+
export const TranscriptList = memo(function TranscriptList({
|
|
55
|
+
tracks,
|
|
56
|
+
selection,
|
|
57
|
+
onSelect,
|
|
58
|
+
search,
|
|
59
|
+
matchIds,
|
|
60
|
+
traceStart,
|
|
61
|
+
}: TranscriptListProps) {
|
|
53
62
|
const colors = useTraceTheme();
|
|
54
63
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
55
64
|
const [scrollTop, setScrollTop] = useState(0);
|
|
56
65
|
|
|
57
66
|
const allRows = useMemo(() => buildTranscriptRows(tracks), [tracks]);
|
|
58
67
|
const rows = useMemo(() => {
|
|
68
|
+
if (!matchIds) return allRows;
|
|
59
69
|
const needle = search.trim().toLowerCase();
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
});
|
|
65
|
-
}, [allRows, search]);
|
|
70
|
+
return allRows.filter(row =>
|
|
71
|
+
row.span ? matchIds.has(row.span.id) : (row.marker?.label ?? "").toLowerCase().includes(needle),
|
|
72
|
+
);
|
|
73
|
+
}, [allRows, search, matchIds]);
|
|
66
74
|
|
|
67
75
|
// Scroll the selected row into view when selection changes externally.
|
|
68
76
|
useEffect(() => {
|
|
@@ -135,4 +143,4 @@ export function TranscriptList({ tracks, selection, onSelect, search, traceStart
|
|
|
135
143
|
{rows.length === 0 && <EmptyState title="No matching events" />}
|
|
136
144
|
</div>
|
|
137
145
|
);
|
|
138
|
-
}
|
|
146
|
+
});
|
|
@@ -111,6 +111,32 @@
|
|
|
111
111
|
cursor: grabbing;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/* Scene canvas with the hover/selection boxes stacked on top. */
|
|
115
|
+
.traces-canvas-stack {
|
|
116
|
+
position: relative;
|
|
117
|
+
flex-shrink: 0;
|
|
118
|
+
overflow: hidden;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.traces-canvas-stack > canvas {
|
|
122
|
+
display: block;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.traces-canvas-hover,
|
|
126
|
+
.traces-canvas-selection {
|
|
127
|
+
position: absolute;
|
|
128
|
+
pointer-events: none;
|
|
129
|
+
box-sizing: border-box;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.traces-canvas-hover {
|
|
133
|
+
background: rgba(255, 255, 255, 0.16);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.traces-canvas-selection {
|
|
137
|
+
border: 1.5px solid;
|
|
138
|
+
}
|
|
139
|
+
|
|
114
140
|
.traces-chevron {
|
|
115
141
|
display: inline-flex;
|
|
116
142
|
background: none;
|
|
@@ -34,7 +34,9 @@ export interface RequestDrawerProps {
|
|
|
34
34
|
|
|
35
35
|
/** Full detail sheet for one model request: timing, tokens, cost, ids, error, and the raw session entry. */
|
|
36
36
|
export function RequestDrawer({ id, onClose }: RequestDrawerProps) {
|
|
37
|
-
const query = useQuery(["request", id], () => getRequestDetails(id ?? 0), {
|
|
37
|
+
const query = useQuery(["request", id], ({ signal }) => getRequestDetails(id ?? 0, signal), {
|
|
38
|
+
enabled: id !== null,
|
|
39
|
+
});
|
|
38
40
|
// Never show the previous request's payload under a new id.
|
|
39
41
|
const details = query.stale ? null : query.data;
|
|
40
42
|
const status = details ? REQUEST_STATUS[requestStatus(details)] : null;
|
package/src/db.ts
CHANGED
|
@@ -8,9 +8,9 @@ import {
|
|
|
8
8
|
getBundledModel,
|
|
9
9
|
} from "@oh-my-pi/pi-catalog/models";
|
|
10
10
|
import type { ModelCost } from "@oh-my-pi/pi-catalog/types";
|
|
11
|
-
import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils";
|
|
11
|
+
import { getConfigRootDir, getStatsDbPath, VERSION } from "@oh-my-pi/pi-utils";
|
|
12
12
|
import { classifyAgentType, type ParseSessionResult, type SessionParserState } from "./parser";
|
|
13
|
-
import { ensureRollupSchema } from "./rollup";
|
|
13
|
+
import { ensureRollupSchema, getDailyActivityFromRollup } from "./rollup";
|
|
14
14
|
import type {
|
|
15
15
|
AgentType,
|
|
16
16
|
DailyActivityPoint,
|
|
@@ -109,6 +109,13 @@ const COST_REINGEST_BACKFILL_KEY = "messages_cost_reingest_v1";
|
|
|
109
109
|
// above is already spent for them — without this one, every historical row
|
|
110
110
|
// keeps `cost_unpriced = 0` and unknown scheduled spend reports as free.
|
|
111
111
|
const COST_UNPRICED_BACKFILL_KEY = "messages_cost_unpriced_v1";
|
|
112
|
+
// Rows predating the `cost_no_cache_input` column are NULL; ingest always
|
|
113
|
+
// writes a value, so one pass settles the column for good.
|
|
114
|
+
const NO_CACHE_INPUT_BACKFILL_KEY = "cost_no_cache_input_v1";
|
|
115
|
+
// Zero-cost rows are repriced from the bundled catalog once per release (the
|
|
116
|
+
// value stored is the version that ran it), since only a catalog update can
|
|
117
|
+
// price a model it could not price before.
|
|
118
|
+
const CATALOG_COST_BACKFILL_KEY = "catalog_cost_backfill";
|
|
112
119
|
function shouldResetBackfill(value: string | undefined): boolean {
|
|
113
120
|
return value !== BACKFILL_COMPLETE && value !== BACKFILL_PENDING;
|
|
114
121
|
}
|
|
@@ -130,7 +137,7 @@ export async function initDb(): Promise<Database> {
|
|
|
130
137
|
// Whether `messages` predates this init — drives the one-time agent_type
|
|
131
138
|
// backfill below, so it must be sampled before CREATE TABLE adds the table.
|
|
132
139
|
const messagesTableExisted =
|
|
133
|
-
db.
|
|
140
|
+
db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages'").get() !== undefined;
|
|
134
141
|
|
|
135
142
|
// Create tables
|
|
136
143
|
db.run(`
|
|
@@ -245,11 +252,11 @@ export async function initDb(): Promise<Database> {
|
|
|
245
252
|
);
|
|
246
253
|
`);
|
|
247
254
|
|
|
248
|
-
const offsetColumns = db.
|
|
255
|
+
const offsetColumns = db.query("PRAGMA table_info(file_offsets)").all() as { name: string }[];
|
|
249
256
|
if (!offsetColumns.some(column => column.name === "parser_state")) {
|
|
250
257
|
db.run("ALTER TABLE file_offsets ADD COLUMN parser_state TEXT");
|
|
251
258
|
}
|
|
252
|
-
const messageColumns = db.
|
|
259
|
+
const messageColumns = db.query("PRAGMA table_info(messages)").all() as { name: string }[];
|
|
253
260
|
if (!messageColumns.some(column => column.name === "premium_requests")) {
|
|
254
261
|
db.run("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
|
|
255
262
|
}
|
|
@@ -266,7 +273,6 @@ export async function initDb(): Promise<Database> {
|
|
|
266
273
|
if (!messageColumns.some(column => column.name === "cost_unpriced")) {
|
|
267
274
|
db.run("ALTER TABLE messages ADD COLUMN cost_unpriced INTEGER NOT NULL DEFAULT 0");
|
|
268
275
|
}
|
|
269
|
-
db.run("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
|
|
270
276
|
// Token-usage-by-agent: each message is classified main / subagent / advisor
|
|
271
277
|
// from its transcript path. A brand-new table gets the column from CREATE
|
|
272
278
|
// TABLE and the parser labels rows at insert time; a pre-existing table gets
|
|
@@ -282,7 +288,7 @@ export async function initDb(): Promise<Database> {
|
|
|
282
288
|
// sentinel write (process killed in between) still reclassifies on the next
|
|
283
289
|
// init instead of silently leaving every row as the 'main' default. A
|
|
284
290
|
// brand-new empty table has nothing to reclassify, so it settles COMPLETE.
|
|
285
|
-
db.
|
|
291
|
+
db.query("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)").run(
|
|
286
292
|
AGENT_TYPE_BACKFILL_KEY,
|
|
287
293
|
messagesTableExisted ? BACKFILL_PENDING : BACKFILL_COMPLETE,
|
|
288
294
|
);
|
|
@@ -307,7 +313,7 @@ export async function initDb(): Promise<Database> {
|
|
|
307
313
|
// v7 -> v8: `no-op` compounds no longer count as negation; recovered
|
|
308
314
|
// measured false negatives: `:(` emoticons -> anguish,
|
|
309
315
|
// `why (would|did) you` -> blame, `makes no sense` -> negation.
|
|
310
|
-
const userMessageColumns = db.
|
|
316
|
+
const userMessageColumns = db.query("PRAGMA table_info(user_messages)").all() as {
|
|
311
317
|
name: string;
|
|
312
318
|
}[];
|
|
313
319
|
const hasStaleColumn =
|
|
@@ -500,17 +506,20 @@ function calculateNoCacheInputCost(
|
|
|
500
506
|
}
|
|
501
507
|
|
|
502
508
|
function backfillMissingCatalogCosts(database: Database): void {
|
|
509
|
+
const ran = database.query("SELECT value FROM meta WHERE key = ?").get(CATALOG_COST_BACKFILL_KEY) as
|
|
510
|
+
| { value: string }
|
|
511
|
+
| undefined;
|
|
512
|
+
if (ran?.value === VERSION) return;
|
|
513
|
+
const markRan = database.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
|
|
503
514
|
const rows = database
|
|
504
|
-
.
|
|
515
|
+
.query(`
|
|
505
516
|
SELECT id, provider, model, timestamp, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
|
|
506
517
|
FROM messages
|
|
507
518
|
WHERE cost_total = 0 AND total_tokens > 0
|
|
508
519
|
`)
|
|
509
520
|
.all() as CostBackfillRow[];
|
|
510
521
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
const update = database.prepare(`
|
|
522
|
+
const update = database.query(`
|
|
514
523
|
UPDATE messages
|
|
515
524
|
SET cost_input = ?, cost_output = ?, cost_cache_read = ?, cost_cache_write = ?, cost_total = ?
|
|
516
525
|
WHERE id = ?
|
|
@@ -541,22 +550,27 @@ function backfillMissingCatalogCosts(database: Database): void {
|
|
|
541
550
|
|
|
542
551
|
update.run(cost.input, cost.output, cost.cacheRead, cost.cacheWrite, cost.total, row.id);
|
|
543
552
|
}
|
|
553
|
+
markRan.run(CATALOG_COST_BACKFILL_KEY, VERSION);
|
|
544
554
|
});
|
|
545
555
|
|
|
546
556
|
applyBackfill();
|
|
547
557
|
}
|
|
548
558
|
|
|
549
559
|
function backfillNoCacheInputCosts(database: Database): void {
|
|
560
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(NO_CACHE_INPUT_BACKFILL_KEY) as
|
|
561
|
+
| { value: string }
|
|
562
|
+
| undefined;
|
|
563
|
+
if (row?.value === BACKFILL_COMPLETE) return;
|
|
564
|
+
const markComplete = database.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
|
|
550
565
|
const rows = database
|
|
551
|
-
.
|
|
566
|
+
.query(`
|
|
552
567
|
SELECT id, provider, model, timestamp, input_tokens, cache_read_tokens, cache_write_tokens
|
|
553
568
|
FROM messages
|
|
554
569
|
WHERE cost_no_cache_input IS NULL
|
|
555
570
|
`)
|
|
556
571
|
.all() as NoCacheInputCostBackfillRow[];
|
|
557
|
-
if (rows.length === 0) return;
|
|
558
572
|
|
|
559
|
-
const update = database.
|
|
573
|
+
const update = database.query("UPDATE messages SET cost_no_cache_input = ? WHERE id = ?");
|
|
560
574
|
const applyBackfill = database.transaction(() => {
|
|
561
575
|
for (const row of rows) {
|
|
562
576
|
const cost = calculateNoCacheInputCost(
|
|
@@ -572,6 +586,7 @@ function backfillNoCacheInputCosts(database: Database): void {
|
|
|
572
586
|
);
|
|
573
587
|
update.run(cost ?? 0, row.id);
|
|
574
588
|
}
|
|
589
|
+
markComplete.run(NO_CACHE_INPUT_BACKFILL_KEY, BACKFILL_COMPLETE);
|
|
575
590
|
});
|
|
576
591
|
applyBackfill();
|
|
577
592
|
}
|
|
@@ -662,18 +677,24 @@ function* parsedRows<T>(
|
|
|
662
677
|
for (const result of results) yield* select(result);
|
|
663
678
|
}
|
|
664
679
|
|
|
665
|
-
/**
|
|
680
|
+
/**
|
|
681
|
+
* Commit distinct transcripts' rows, reconciliation state, and cursors together; failure rolls back the batch.
|
|
682
|
+
* `changes` counts stored rows inserted, updated or deleted (cursor bookkeeping excluded), so callers can
|
|
683
|
+
* tell a batch that changed nothing.
|
|
684
|
+
*/
|
|
666
685
|
export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
667
686
|
processed: number;
|
|
668
687
|
files: number;
|
|
669
688
|
reconcile: boolean;
|
|
689
|
+
changes: number;
|
|
670
690
|
} {
|
|
671
|
-
if (!db) return { processed: 0, files: 0, reconcile: false };
|
|
691
|
+
if (!db) return { processed: 0, files: 0, reconcile: false, changes: 0 };
|
|
672
692
|
const database = db;
|
|
673
693
|
return database.transaction(() => {
|
|
674
694
|
let processed = 0;
|
|
675
695
|
let files = 0;
|
|
676
696
|
let reconcile = false;
|
|
697
|
+
let changes = 0;
|
|
677
698
|
const writes: ParseSessionResult[] = [];
|
|
678
699
|
for (const { sessionFile, result, rebuild, replay } of sessions) {
|
|
679
700
|
const parserState = result.parserState;
|
|
@@ -758,27 +779,34 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
|
758
779
|
if (absentMessages.size > 0 || absentUsers.size > 0 || absentTools.size > 0) {
|
|
759
780
|
reconcile = true;
|
|
760
781
|
for (const id of absentMessages) {
|
|
761
|
-
database
|
|
782
|
+
changes += database
|
|
762
783
|
.query("DELETE FROM messages WHERE session_file = ? AND entry_id = ?")
|
|
763
|
-
.run(sessionFile, id);
|
|
784
|
+
.run(sessionFile, id).changes;
|
|
764
785
|
}
|
|
765
786
|
for (const id of absentUsers) {
|
|
766
|
-
database
|
|
787
|
+
changes += database
|
|
767
788
|
.query("DELETE FROM user_messages WHERE session_file = ? AND entry_id = ?")
|
|
768
|
-
.run(sessionFile, id);
|
|
789
|
+
.run(sessionFile, id).changes;
|
|
769
790
|
}
|
|
770
791
|
for (const id of absentTools) {
|
|
771
|
-
database
|
|
792
|
+
changes += database
|
|
772
793
|
.query("DELETE FROM tool_calls WHERE session_file = ? AND tool_call_id = ?")
|
|
773
|
-
.run(sessionFile, id);
|
|
794
|
+
.run(sessionFile, id).changes;
|
|
774
795
|
}
|
|
775
796
|
}
|
|
776
797
|
}
|
|
777
798
|
if (replace) {
|
|
778
|
-
if (messages.length > 0)
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
if (
|
|
799
|
+
if (messages.length > 0) {
|
|
800
|
+
changes += database.query("DELETE FROM messages WHERE session_file = ?").run(sessionFile).changes;
|
|
801
|
+
}
|
|
802
|
+
if (users.length > 0) {
|
|
803
|
+
changes += database
|
|
804
|
+
.query("DELETE FROM user_messages WHERE session_file = ?")
|
|
805
|
+
.run(sessionFile).changes;
|
|
806
|
+
}
|
|
807
|
+
if (tools.length > 0) {
|
|
808
|
+
changes += database.query("DELETE FROM tool_calls WHERE session_file = ?").run(sessionFile).changes;
|
|
809
|
+
}
|
|
782
810
|
}
|
|
783
811
|
}
|
|
784
812
|
writes.push(rows);
|
|
@@ -787,11 +815,12 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
|
787
815
|
if (count > 0) files++;
|
|
788
816
|
}
|
|
789
817
|
// Remove replaced rows before choosing new fork owners, then write each table once per batch.
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
818
|
+
changes +=
|
|
819
|
+
insertMessageStats(parsedRows(writes, result => result.stats)) +
|
|
820
|
+
insertUserMessageStats(parsedRows(writes, result => result.userStats)) +
|
|
821
|
+
updateUserMessageLinks(parsedRows(writes, result => result.userLinks)) +
|
|
822
|
+
insertToolCalls(parsedRows(writes, result => result.toolCalls)) +
|
|
823
|
+
updateToolResults(parsedRows(writes, result => result.toolResults));
|
|
795
824
|
for (const { sessionFile, result } of sessions) {
|
|
796
825
|
if (result.parserState) {
|
|
797
826
|
setFileOffset(sessionFile, result.newOffset, result.parserState.mtimeMs, result.parserState);
|
|
@@ -800,16 +829,26 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
|
800
829
|
if (reconcile) {
|
|
801
830
|
database.query("INSERT OR REPLACE INTO meta (key, value) VALUES ('session_reconciliation', 'pending')").run();
|
|
802
831
|
}
|
|
803
|
-
return { processed, files, reconcile };
|
|
832
|
+
return { processed, files, reconcile, changes };
|
|
804
833
|
})();
|
|
805
834
|
}
|
|
806
835
|
|
|
836
|
+
/**
|
|
837
|
+
* SQLite `PRAGMA data_version` of the open connection, or null before
|
|
838
|
+
* {@link initDb}. It moves only when another connection (another omp process)
|
|
839
|
+
* commits, so a caller can detect foreign writes without scanning.
|
|
840
|
+
*/
|
|
841
|
+
export function getDataVersion(): number | null {
|
|
842
|
+
if (!db) return null;
|
|
843
|
+
return db.query<{ data_version: number }, []>("PRAGMA data_version").get()?.data_version ?? null;
|
|
844
|
+
}
|
|
845
|
+
|
|
807
846
|
export function prepareSessionSync(): boolean {
|
|
808
|
-
return Boolean(db?.
|
|
847
|
+
return Boolean(db?.query("SELECT 1 FROM meta WHERE key = 'session_reconciliation'").get());
|
|
809
848
|
}
|
|
810
849
|
|
|
811
850
|
export function completeSessionSync(reconcile: boolean): void {
|
|
812
|
-
if (!reconcile) db?.
|
|
851
|
+
if (!reconcile) db?.query("DELETE FROM meta WHERE key = 'session_reconciliation'").run();
|
|
813
852
|
}
|
|
814
853
|
|
|
815
854
|
/**
|
|
@@ -907,7 +946,7 @@ export function insertMessageStats(stats: Iterable<MessageStatsInput>): number {
|
|
|
907
946
|
*/
|
|
908
947
|
export function getMessageCount(): number {
|
|
909
948
|
if (!db) return 0;
|
|
910
|
-
const stmt = db.
|
|
949
|
+
const stmt = db.query("SELECT COUNT(*) as count FROM messages");
|
|
911
950
|
const row = stmt.get() as { count: number };
|
|
912
951
|
return row.count;
|
|
913
952
|
}
|
|
@@ -959,7 +998,7 @@ function rowToMessageStats(row: any): MessageStats {
|
|
|
959
998
|
|
|
960
999
|
export function getRecentRequests(limit = 100): MessageStats[] {
|
|
961
1000
|
if (!db) return [];
|
|
962
|
-
const stmt = db.
|
|
1001
|
+
const stmt = db.query(`
|
|
963
1002
|
SELECT * FROM messages
|
|
964
1003
|
ORDER BY timestamp DESC
|
|
965
1004
|
LIMIT ?
|
|
@@ -970,7 +1009,7 @@ export function getRecentRequests(limit = 100): MessageStats[] {
|
|
|
970
1009
|
export function getRecentErrors(limit = 100, cutoff?: number | null): MessageStats[] {
|
|
971
1010
|
if (!db) return [];
|
|
972
1011
|
const hasCutoff = cutoff !== undefined && cutoff !== null;
|
|
973
|
-
const stmt = db.
|
|
1012
|
+
const stmt = db.query(`
|
|
974
1013
|
SELECT * FROM messages
|
|
975
1014
|
WHERE stop_reason = 'error'
|
|
976
1015
|
${hasCutoff ? "AND timestamp >= ?" : ""}
|
|
@@ -983,7 +1022,7 @@ export function getRecentErrors(limit = 100, cutoff?: number | null): MessageSta
|
|
|
983
1022
|
|
|
984
1023
|
export function getMessageById(id: number): MessageStats | null {
|
|
985
1024
|
if (!db) return null;
|
|
986
|
-
const stmt = db.
|
|
1025
|
+
const stmt = db.query("SELECT * FROM messages WHERE id = ?");
|
|
987
1026
|
const row = stmt.get(id);
|
|
988
1027
|
return row ? rowToMessageStats(row) : null;
|
|
989
1028
|
}
|
|
@@ -992,12 +1031,15 @@ export function getMessageById(id: number): MessageStats | null {
|
|
|
992
1031
|
* Self-initializing (opens the stats DB on first use) so the coding-agent TUI
|
|
993
1032
|
* can query without the dashboard server's init flow. Days use the machine's
|
|
994
1033
|
* timezone — this is a localhost tool, same rationale as
|
|
995
|
-
* {@link getProviderHourlyBurn}.
|
|
1034
|
+
* {@link getProviderHourlyBurn}. Reads the hourly rollup, falling back to the
|
|
1035
|
+
* raw rows while its rebuild backlog is too large for exact reads.
|
|
996
1036
|
*/
|
|
997
1037
|
export async function getDailyActivity(days = 371): Promise<DailyActivityPoint[]> {
|
|
998
1038
|
const database = await initDb();
|
|
999
1039
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
1000
|
-
const
|
|
1040
|
+
const rolled = getDailyActivityFromRollup(cutoff);
|
|
1041
|
+
if (rolled) return rolled;
|
|
1042
|
+
const stmt = database.query(`
|
|
1001
1043
|
SELECT
|
|
1002
1044
|
date(timestamp / 1000, 'unixepoch', 'localtime') as day,
|
|
1003
1045
|
SUM(cost_total) as cost,
|
|
@@ -1065,7 +1107,7 @@ export async function getDailyActivity(days = 371): Promise<DailyActivityPoint[]
|
|
|
1065
1107
|
* Existing `messages` rows are unaffected - `INSERT OR IGNORE` keeps them.
|
|
1066
1108
|
*/
|
|
1067
1109
|
function backfillUserMessages(database: Database): void {
|
|
1068
|
-
const row = database.
|
|
1110
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGES_BACKFILL_KEY) as
|
|
1069
1111
|
| { value: string }
|
|
1070
1112
|
| undefined;
|
|
1071
1113
|
if (!shouldResetBackfill(row?.value)) return;
|
|
@@ -1073,7 +1115,7 @@ function backfillUserMessages(database: Database): void {
|
|
|
1073
1115
|
database.run("DELETE FROM user_messages");
|
|
1074
1116
|
database.run("DELETE FROM file_offsets");
|
|
1075
1117
|
database
|
|
1076
|
-
.
|
|
1118
|
+
.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
1077
1119
|
.run(USER_MESSAGES_BACKFILL_KEY, BACKFILL_PENDING);
|
|
1078
1120
|
}
|
|
1079
1121
|
|
|
@@ -1086,7 +1128,7 @@ function backfillUserMessages(database: Database): void {
|
|
|
1086
1128
|
* written here prevents re-wiping on subsequent inits.
|
|
1087
1129
|
*/
|
|
1088
1130
|
function backfillToolCalls(database: Database): void {
|
|
1089
|
-
const row = database.
|
|
1131
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(TOOL_CALLS_BACKFILL_KEY) as
|
|
1090
1132
|
| { value: string }
|
|
1091
1133
|
| undefined;
|
|
1092
1134
|
if (!shouldResetBackfill(row?.value)) return;
|
|
@@ -1094,7 +1136,7 @@ function backfillToolCalls(database: Database): void {
|
|
|
1094
1136
|
database.run("DELETE FROM tool_calls");
|
|
1095
1137
|
database.run("DELETE FROM file_offsets");
|
|
1096
1138
|
database
|
|
1097
|
-
.
|
|
1139
|
+
.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
1098
1140
|
.run(TOOL_CALLS_BACKFILL_KEY, BACKFILL_PENDING);
|
|
1099
1141
|
}
|
|
1100
1142
|
|
|
@@ -1110,14 +1152,14 @@ function backfillToolCalls(database: Database): void {
|
|
|
1110
1152
|
* offset reset is safe. Same sentinel protocol as {@link backfillToolCalls}.
|
|
1111
1153
|
*/
|
|
1112
1154
|
function backfillReingestCosts(database: Database): void {
|
|
1113
|
-
const row = database.
|
|
1155
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(COST_REINGEST_BACKFILL_KEY) as
|
|
1114
1156
|
| { value: string }
|
|
1115
1157
|
| undefined;
|
|
1116
1158
|
if (!shouldResetBackfill(row?.value)) return;
|
|
1117
1159
|
|
|
1118
1160
|
database.run("DELETE FROM file_offsets");
|
|
1119
1161
|
database
|
|
1120
|
-
.
|
|
1162
|
+
.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
1121
1163
|
.run(COST_REINGEST_BACKFILL_KEY, BACKFILL_PENDING);
|
|
1122
1164
|
}
|
|
1123
1165
|
|
|
@@ -1131,14 +1173,14 @@ function backfillReingestCosts(database: Database): void {
|
|
|
1131
1173
|
* absence as absence. Same sentinel protocol as {@link backfillReingestCosts}.
|
|
1132
1174
|
*/
|
|
1133
1175
|
function backfillUnpricedCosts(database: Database): void {
|
|
1134
|
-
const row = database.
|
|
1176
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(COST_UNPRICED_BACKFILL_KEY) as
|
|
1135
1177
|
| { value: string }
|
|
1136
1178
|
| undefined;
|
|
1137
1179
|
if (!shouldResetBackfill(row?.value)) return;
|
|
1138
1180
|
|
|
1139
1181
|
database.run("DELETE FROM file_offsets");
|
|
1140
1182
|
database
|
|
1141
|
-
.
|
|
1183
|
+
.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
1142
1184
|
.run(COST_UNPRICED_BACKFILL_KEY, BACKFILL_PENDING);
|
|
1143
1185
|
}
|
|
1144
1186
|
|
|
@@ -1152,16 +1194,16 @@ function backfillUnpricedCosts(database: Database): void {
|
|
|
1152
1194
|
* interrupted run rolls back and retries on the next init.
|
|
1153
1195
|
*/
|
|
1154
1196
|
function backfillAgentType(database: Database): void {
|
|
1155
|
-
const row = database.
|
|
1197
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(AGENT_TYPE_BACKFILL_KEY) as
|
|
1156
1198
|
| { value: string }
|
|
1157
1199
|
| undefined;
|
|
1158
1200
|
if (row?.value !== BACKFILL_PENDING) return;
|
|
1159
1201
|
|
|
1160
|
-
const sessionFiles = database.
|
|
1202
|
+
const sessionFiles = database.query("SELECT DISTINCT session_file FROM messages").all() as {
|
|
1161
1203
|
session_file: string;
|
|
1162
1204
|
}[];
|
|
1163
|
-
const update = database.
|
|
1164
|
-
const markComplete = database.
|
|
1205
|
+
const update = database.query("UPDATE messages SET agent_type = ? WHERE session_file = ?");
|
|
1206
|
+
const markComplete = database.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
|
|
1165
1207
|
const apply = database.transaction(() => {
|
|
1166
1208
|
for (const { session_file } of sessionFiles) {
|
|
1167
1209
|
const agentType = classifyAgentType(session_file);
|
|
@@ -1189,12 +1231,12 @@ function backfillAgentType(database: Database): void {
|
|
|
1189
1231
|
* retries on the next init.
|
|
1190
1232
|
*/
|
|
1191
1233
|
function backfillForkDuplicates(database: Database): void {
|
|
1192
|
-
const row = database.
|
|
1234
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(FORK_DEDUPE_KEY) as
|
|
1193
1235
|
| { value: string }
|
|
1194
1236
|
| undefined;
|
|
1195
1237
|
if (row?.value === BACKFILL_COMPLETE) return;
|
|
1196
1238
|
|
|
1197
|
-
const markComplete = database.
|
|
1239
|
+
const markComplete = database.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
|
|
1198
1240
|
const apply = database.transaction(() => {
|
|
1199
1241
|
database.run(`
|
|
1200
1242
|
DELETE FROM messages
|
|
@@ -1222,14 +1264,14 @@ function backfillForkDuplicates(database: Database): void {
|
|
|
1222
1264
|
* sentinel row in `meta`.
|
|
1223
1265
|
*/
|
|
1224
1266
|
function repairUserMessageLinks(database: Database): void {
|
|
1225
|
-
const row = database.
|
|
1267
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGE_LINKS_REPAIR_KEY) as
|
|
1226
1268
|
| { value: string }
|
|
1227
1269
|
| undefined;
|
|
1228
1270
|
if (!shouldResetBackfill(row?.value)) return;
|
|
1229
1271
|
|
|
1230
1272
|
database.run("DELETE FROM file_offsets");
|
|
1231
1273
|
database
|
|
1232
|
-
.
|
|
1274
|
+
.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
1233
1275
|
.run(USER_MESSAGE_LINKS_REPAIR_KEY, BACKFILL_PENDING);
|
|
1234
1276
|
}
|
|
1235
1277
|
|
|
@@ -1244,14 +1286,14 @@ function repairUserMessageLinks(database: Database): void {
|
|
|
1244
1286
|
* column. Idempotent: gated by a sentinel row in `meta`.
|
|
1245
1287
|
*/
|
|
1246
1288
|
function backfillPriorityPremiumRequests(database: Database): void {
|
|
1247
|
-
const row = database.
|
|
1289
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY) as
|
|
1248
1290
|
| { value: string }
|
|
1249
1291
|
| undefined;
|
|
1250
1292
|
if (!shouldResetBackfill(row?.value)) return;
|
|
1251
1293
|
|
|
1252
1294
|
database.run("DELETE FROM file_offsets");
|
|
1253
1295
|
database
|
|
1254
|
-
.
|
|
1296
|
+
.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
1255
1297
|
.run(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY, BACKFILL_PENDING);
|
|
1256
1298
|
}
|
|
1257
1299
|
|
|
@@ -1260,7 +1302,7 @@ function backfillPriorityPremiumRequests(database: Database): void {
|
|
|
1260
1302
|
*/
|
|
1261
1303
|
export function markSessionBackfillsComplete(): void {
|
|
1262
1304
|
if (!db) return;
|
|
1263
|
-
const markComplete = db.
|
|
1305
|
+
const markComplete = db.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
|
|
1264
1306
|
const apply = db.transaction(() => {
|
|
1265
1307
|
for (const key of [
|
|
1266
1308
|
USER_MESSAGES_BACKFILL_KEY,
|
|
@@ -1437,7 +1479,7 @@ function toFrustrationCounts(row: FrustrationCountsRow | undefined): Frustration
|
|
|
1437
1479
|
export function getFrustrationOverall(cutoff?: number | null): FrustrationCounts {
|
|
1438
1480
|
if (!db) return toFrustrationCounts(undefined);
|
|
1439
1481
|
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1440
|
-
const stmt = db.
|
|
1482
|
+
const stmt = db.query(`
|
|
1441
1483
|
SELECT ${FRUSTRATION_COUNTS_SQL}
|
|
1442
1484
|
FROM user_messages u
|
|
1443
1485
|
LEFT JOIN frustration_verdicts v ON v.prose_hash = u.prose_hash
|
|
@@ -1454,7 +1496,7 @@ export function getFrustrationOverall(cutoff?: number | null): FrustrationCounts
|
|
|
1454
1496
|
export function getFrustrationByModel(cutoff?: number | null): FrustrationModelRow[] {
|
|
1455
1497
|
if (!db) return [];
|
|
1456
1498
|
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1457
|
-
const stmt = db.
|
|
1499
|
+
const stmt = db.query(`
|
|
1458
1500
|
SELECT u.model AS model, u.provider AS provider, MIN(u.timestamp) AS first_seen, ${FRUSTRATION_COUNTS_SQL}
|
|
1459
1501
|
FROM user_messages u
|
|
1460
1502
|
LEFT JOIN frustration_verdicts v ON v.prose_hash = u.prose_hash
|
|
@@ -1485,7 +1527,7 @@ function pendingProseSql(hasCutoff: boolean): string {
|
|
|
1485
1527
|
export function getPendingFrustrationProse(cutoff?: number | null): PendingProse[] {
|
|
1486
1528
|
if (!db) return [];
|
|
1487
1529
|
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1488
|
-
const stmt = db.
|
|
1530
|
+
const stmt = db.query(pendingProseSql(hasCutoff));
|
|
1489
1531
|
return (hasCutoff ? stmt.all(cutoff) : stmt.all()) as PendingProse[];
|
|
1490
1532
|
}
|
|
1491
1533
|
|
|
@@ -1493,7 +1535,7 @@ export function getPendingFrustrationProse(cutoff?: number | null): PendingProse
|
|
|
1493
1535
|
export function getPendingFrustrationTotals(cutoff?: number | null): { messages: number; chars: number } {
|
|
1494
1536
|
if (!db) return { messages: 0, chars: 0 };
|
|
1495
1537
|
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1496
|
-
const stmt = db.
|
|
1538
|
+
const stmt = db.query(
|
|
1497
1539
|
`SELECT COUNT(*) AS messages, COALESCE(SUM(LENGTH(prose)), 0) AS chars FROM (${pendingProseSql(hasCutoff)})`,
|
|
1498
1540
|
);
|
|
1499
1541
|
const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as { messages: number; chars: number } | undefined;
|
package/src/embedded-client.ts
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
* keeps building the dashboard from source.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Cheap check whether the generated text carries a gzip archive: sniffs the
|
|
13
|
+
* base64 of the gzip magic (`1f 8b 08`) without decoding megabytes.
|
|
14
|
+
*/
|
|
15
|
+
export function hasEmbeddedClientArchive(txt: string): boolean {
|
|
16
|
+
return /^\s*H4s/.test(txt);
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
/**
|
|
12
20
|
* Decode the generated archive text.
|
|
13
21
|
*
|
|
@@ -17,6 +25,7 @@
|
|
|
17
25
|
* rather than decoded into garbage bytes.
|
|
18
26
|
*/
|
|
19
27
|
export function decodeEmbeddedClientArchive(txt: string): Buffer | null {
|
|
28
|
+
if (!hasEmbeddedClientArchive(txt)) return null;
|
|
20
29
|
const normalized = txt.replaceAll(/\s+/g, "");
|
|
21
30
|
if (!normalized) return null;
|
|
22
31
|
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) return null;
|