@oh-my-pi/omp-stats 18.3.5 → 18.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +13 -11
- package/build.ts +5 -49
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/index.js +105 -126
- package/dist/types/aggregator.d.ts +25 -22
- package/dist/types/client/api.d.ts +11 -7
- package/dist/types/client/app/LiveChip.d.ts +2 -0
- package/dist/types/client/app/Shell.d.ts +16 -0
- package/dist/types/client/app/ThemeToggle.d.ts +1 -0
- package/dist/types/client/app/nav.d.ts +18 -0
- package/dist/types/client/charts/BarList.d.ts +17 -0
- package/dist/types/client/charts/Chart.d.ts +49 -0
- package/dist/types/client/charts/Legend.d.ts +16 -0
- package/dist/types/client/charts/ShareBar.d.ts +11 -0
- package/dist/types/client/charts/Sparkline.d.ts +11 -0
- package/dist/types/client/charts/TimeChart.d.ts +9 -0
- package/dist/types/client/charts/index.d.ts +7 -0
- package/dist/types/client/charts/types.d.ts +25 -0
- package/dist/types/client/charts/useWidth.d.ts +3 -0
- package/dist/types/client/data/colors.d.ts +25 -0
- package/dist/types/client/data/formatters.d.ts +9 -1
- package/dist/types/client/data/live.d.ts +29 -0
- package/dist/types/client/data/query.d.ts +35 -0
- package/dist/types/client/data/range.d.ts +31 -0
- package/dist/types/client/data/series.d.ts +28 -0
- package/dist/types/client/data/useHashRoute.d.ts +18 -4
- package/dist/types/client/data/view-models.d.ts +111 -28
- package/dist/types/client/index.d.ts +0 -1
- package/dist/types/client/routes/CostsRoute.d.ts +2 -2
- package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
- package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
- package/dist/types/client/routes/GainRoute.d.ts +1 -2
- package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
- package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
- package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
- package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
- package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
- package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
- package/dist/types/client/routes/TracesRoute.d.ts +2 -2
- package/dist/types/client/routes/index.d.ts +1 -1
- package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
- package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
- package/dist/types/client/traces/trace-colors.d.ts +18 -12
- package/dist/types/client/ui/Badge.d.ts +19 -0
- package/dist/types/client/ui/Card.d.ts +25 -0
- package/dist/types/client/ui/Drawer.d.ts +25 -0
- package/dist/types/client/ui/JsonBlock.d.ts +2 -2
- package/dist/types/client/ui/Modal.d.ts +23 -0
- package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
- package/dist/types/client/ui/SearchInput.d.ts +8 -0
- package/dist/types/client/ui/Segmented.d.ts +15 -0
- package/dist/types/client/ui/Stat.d.ts +36 -0
- package/dist/types/client/ui/States.d.ts +41 -0
- package/dist/types/client/ui/Table.d.ts +48 -0
- package/dist/types/client/ui/index.d.ts +9 -10
- package/dist/types/db.d.ts +90 -106
- package/dist/types/frustration.d.ts +70 -0
- package/dist/types/index.d.ts +3 -1
- package/dist/types/live.d.ts +30 -0
- package/dist/types/rollup.d.ts +115 -0
- package/dist/types/server.d.ts +6 -1
- package/dist/types/shared-types.d.ts +116 -56
- package/dist/types/types.d.ts +4 -0
- package/dist/types/user-metrics.d.ts +8 -0
- package/package.json +7 -13
- package/src/aggregator.ts +276 -211
- package/src/client/App.tsx +38 -80
- package/src/client/api.ts +65 -7
- package/src/client/app/LiveChip.tsx +97 -0
- package/src/client/app/Shell.tsx +181 -0
- package/src/client/app/ThemeToggle.tsx +5 -2
- package/src/client/app/nav.ts +78 -0
- package/src/client/charts/BarList.tsx +48 -0
- package/src/client/charts/Chart.tsx +388 -0
- package/src/client/charts/Legend.tsx +61 -0
- package/src/client/charts/ShareBar.tsx +26 -0
- package/src/client/charts/Sparkline.tsx +38 -0
- package/src/client/charts/TimeChart.tsx +22 -0
- package/src/client/charts/index.ts +7 -0
- package/src/client/charts/types.ts +27 -0
- package/src/client/charts/useWidth.ts +19 -0
- package/src/client/data/colors.ts +45 -0
- package/src/client/data/formatters.ts +30 -9
- package/src/client/data/live.tsx +85 -0
- package/src/client/data/query.ts +166 -0
- package/src/client/data/range.ts +82 -0
- package/src/client/data/series.ts +85 -0
- package/src/client/data/useHashRoute.ts +41 -83
- package/src/client/data/view-models.ts +347 -140
- package/src/client/index.tsx +3 -6
- package/src/client/routes/CostsRoute.tsx +394 -222
- package/src/client/routes/ErrorsRoute.tsx +401 -98
- package/src/client/routes/FrustrationRoute.tsx +924 -0
- package/src/client/routes/GainRoute.tsx +209 -206
- package/src/client/routes/ModelsRoute.tsx +540 -431
- package/src/client/routes/OverviewRoute.tsx +283 -311
- package/src/client/routes/ProjectsRoute.tsx +298 -156
- package/src/client/routes/ProvidersRoute.tsx +948 -416
- package/src/client/routes/RequestsRoute.tsx +295 -107
- package/src/client/routes/ToolsRoute.tsx +495 -398
- package/src/client/routes/TracesRoute.tsx +136 -117
- package/src/client/routes/costs.css +24 -0
- package/src/client/routes/errors.css +60 -0
- package/src/client/routes/frustration.css +123 -0
- package/src/client/routes/index.ts +1 -1
- package/src/client/routes/models.css +43 -0
- package/src/client/routes/projects.css +6 -0
- package/src/client/routes/providers.css +28 -0
- package/src/client/routes/tools.css +14 -0
- package/src/client/styles.css +1396 -1299
- package/src/client/traces/AggregatesPanel.tsx +55 -74
- package/src/client/traces/Minimap.tsx +6 -15
- package/src/client/traces/SpanDrawer.tsx +128 -194
- package/src/client/traces/SummaryStrip.tsx +21 -24
- package/src/client/traces/TimelineCanvas.tsx +27 -44
- package/src/client/traces/TraceView.tsx +175 -130
- package/src/client/traces/TranscriptList.tsx +13 -41
- package/src/client/traces/trace-colors.ts +60 -53
- package/src/client/traces/traces.css +279 -0
- package/src/client/ui/Badge.tsx +29 -0
- package/src/client/ui/Card.tsx +61 -0
- package/src/client/ui/Drawer.tsx +93 -0
- package/src/client/ui/JsonBlock.tsx +26 -44
- package/src/client/ui/Modal.tsx +106 -0
- package/src/client/ui/RequestDrawer.tsx +163 -188
- package/src/client/ui/SearchInput.tsx +25 -0
- package/src/client/ui/Segmented.tsx +61 -0
- package/src/client/ui/Stat.tsx +104 -0
- package/src/client/ui/States.tsx +81 -0
- package/src/client/ui/Table.tsx +229 -0
- package/src/client/ui/index.ts +9 -10
- package/src/client/ui/modal.css +54 -0
- package/src/client/ui/request-drawer.css +40 -0
- package/src/db.ts +379 -983
- package/src/frustration.ts +473 -0
- package/src/index.ts +22 -22
- package/src/live.ts +259 -0
- package/src/parser.ts +55 -66
- package/src/rollup.ts +951 -0
- package/src/server.ts +123 -40
- package/src/shared-types.ts +120 -56
- package/src/trace.ts +14 -7
- package/src/types.ts +4 -0
- package/src/user-metrics.ts +13 -0
- package/dist/client/styles.css +0 -1976
- package/dist/types/client/app/AppLayout.d.ts +0 -16
- package/dist/types/client/app/NavRail.d.ts +0 -7
- package/dist/types/client/app/RangeControl.d.ts +0 -7
- package/dist/types/client/app/SyncButton.d.ts +0 -14
- package/dist/types/client/app/TopBar.d.ts +0 -15
- package/dist/types/client/app/routes.d.ts +0 -12
- package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
- package/dist/types/client/components/chart-shared.d.ts +0 -178
- package/dist/types/client/components/models-table-shared.d.ts +0 -175
- package/dist/types/client/components/range-meta.d.ts +0 -21
- package/dist/types/client/data/charts.d.ts +0 -1
- package/dist/types/client/data/useResource.d.ts +0 -13
- package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
- package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
- package/dist/types/client/ui/DataTable.d.ts +0 -17
- package/dist/types/client/ui/EmptyState.d.ts +0 -7
- package/dist/types/client/ui/ErrorState.d.ts +0 -6
- package/dist/types/client/ui/MetricCluster.d.ts +0 -5
- package/dist/types/client/ui/Panel.d.ts +0 -7
- package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
- package/dist/types/client/ui/Skeleton.d.ts +0 -8
- package/dist/types/client/ui/StatusPill.d.ts +0 -7
- package/src/client/app/AppLayout.tsx +0 -93
- package/src/client/app/NavRail.tsx +0 -44
- package/src/client/app/RangeControl.tsx +0 -39
- package/src/client/app/SyncButton.tsx +0 -75
- package/src/client/app/TopBar.tsx +0 -73
- package/src/client/app/routes.ts +0 -93
- package/src/client/components/AgentTokenShare.tsx +0 -68
- package/src/client/components/chart-shared.tsx +0 -273
- package/src/client/components/models-table-shared.tsx +0 -255
- package/src/client/components/range-meta.ts +0 -73
- package/src/client/data/charts.ts +0 -14
- package/src/client/data/useResource.ts +0 -154
- package/src/client/routes/BehaviorRoute.tsx +0 -623
- package/src/client/ui/AsyncBoundary.tsx +0 -54
- package/src/client/ui/DataTable.tsx +0 -122
- package/src/client/ui/EmptyState.tsx +0 -16
- package/src/client/ui/ErrorState.tsx +0 -25
- package/src/client/ui/MetricCluster.tsx +0 -92
- package/src/client/ui/Panel.tsx +0 -24
- package/src/client/ui/SegmentedControl.tsx +0 -36
- package/src/client/ui/Skeleton.tsx +0 -17
- package/src/client/ui/StatusPill.tsx +0 -15
- package/tailwind.config.js +0 -40
package/src/db.ts
CHANGED
|
@@ -10,30 +10,15 @@ import {
|
|
|
10
10
|
import type { ModelCost } from "@oh-my-pi/pi-catalog/types";
|
|
11
11
|
import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils";
|
|
12
12
|
import { classifyAgentType, type ParseSessionResult, type SessionParserState } from "./parser";
|
|
13
|
+
import { ensureRollupSchema } from "./rollup";
|
|
13
14
|
import type {
|
|
14
15
|
AgentType,
|
|
15
|
-
AgentTypeStats,
|
|
16
|
-
AggregatedStats,
|
|
17
|
-
BehaviorModelStats,
|
|
18
|
-
BehaviorOverallStats,
|
|
19
|
-
BehaviorTimeSeriesPoint,
|
|
20
|
-
CostTimeSeriesPoint,
|
|
21
16
|
DailyActivityPoint,
|
|
22
|
-
|
|
17
|
+
FrustrationCounts,
|
|
23
18
|
MessageStats,
|
|
24
19
|
MessageStatsInput,
|
|
25
|
-
ModelPerformancePoint,
|
|
26
|
-
ModelStats,
|
|
27
|
-
ModelTimeSeriesPoint,
|
|
28
|
-
ProviderAggregate,
|
|
29
|
-
ProviderHourlyPoint,
|
|
30
|
-
ProviderTimeSeriesPoint,
|
|
31
|
-
TimeSeriesPoint,
|
|
32
20
|
ToolCallStats,
|
|
33
|
-
ToolModelStats,
|
|
34
21
|
ToolResultLink,
|
|
35
|
-
ToolTimeSeriesPoint,
|
|
36
|
-
ToolUsageStats,
|
|
37
22
|
UserMessageLink,
|
|
38
23
|
UserMessageStats,
|
|
39
24
|
} from "./types";
|
|
@@ -66,13 +51,11 @@ const ZERO_USAGE_COST: UsageCost = {
|
|
|
66
51
|
* parser's timestamp sentinel, because their zero is a real price.
|
|
67
52
|
* `prefix` qualifies the columns for queries that alias `messages`.
|
|
68
53
|
*/
|
|
69
|
-
function unpricedRequestSql(prefix = ""): string {
|
|
54
|
+
export function unpricedRequestSql(prefix = ""): string {
|
|
70
55
|
return `CASE WHEN ${prefix}total_tokens > 0 AND ${prefix}cost_total = 0
|
|
71
56
|
AND (${prefix}provider = 'xai-oauth' OR ${prefix}cost_unpriced = 1) THEN 1 ELSE 0 END`;
|
|
72
57
|
}
|
|
73
58
|
|
|
74
|
-
const UNPRICED_REQUEST_SQL = unpricedRequestSql();
|
|
75
|
-
|
|
76
59
|
interface CostBackfillRow {
|
|
77
60
|
id: number;
|
|
78
61
|
provider: string;
|
|
@@ -94,52 +77,16 @@ interface NoCacheInputCostBackfillRow {
|
|
|
94
77
|
cache_write_tokens: number;
|
|
95
78
|
}
|
|
96
79
|
|
|
97
|
-
|
|
98
|
-
total_requests: number;
|
|
99
|
-
failed_requests: number | null;
|
|
100
|
-
total_input_tokens: number | null;
|
|
101
|
-
total_output_tokens: number | null;
|
|
102
|
-
total_cache_read_tokens: number | null;
|
|
103
|
-
total_cache_write_tokens: number | null;
|
|
104
|
-
total_premium_requests: number | null;
|
|
105
|
-
total_cost: number | null;
|
|
106
|
-
unpriced_requests: number | null;
|
|
107
|
-
total_cached_prompt_cost: number | null;
|
|
108
|
-
total_no_cache_input_cost: number | null;
|
|
109
|
-
avg_duration: number | null;
|
|
110
|
-
avg_ttft: number | null;
|
|
111
|
-
avg_tokens_per_second: number | null;
|
|
112
|
-
first_timestamp: number | null;
|
|
113
|
-
last_timestamp: number | null;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
interface ModelStatsRow extends AggregatedStatsRow {
|
|
117
|
-
model: string;
|
|
118
|
-
provider: string;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
interface FolderStatsRow extends AggregatedStatsRow {
|
|
122
|
-
folder: string;
|
|
123
|
-
}
|
|
80
|
+
let db: Database | null = null;
|
|
124
81
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
provider: string;
|
|
129
|
-
cost: number | null;
|
|
130
|
-
unpriced_requests: number | null;
|
|
131
|
-
cost_input: number | null;
|
|
132
|
-
cost_output: number | null;
|
|
133
|
-
cost_cache_read: number | null;
|
|
134
|
-
cost_cache_write: number | null;
|
|
135
|
-
requests: number;
|
|
82
|
+
/** The open stats database, or null before {@link initDb}. Used by the rollup query layer. */
|
|
83
|
+
export function currentDb(): Database | null {
|
|
84
|
+
return db;
|
|
136
85
|
}
|
|
137
86
|
|
|
138
|
-
let db: Database | null = null;
|
|
139
|
-
|
|
140
87
|
const BACKFILL_COMPLETE = "complete";
|
|
141
88
|
const BACKFILL_PENDING = "pending";
|
|
142
|
-
const USER_MESSAGES_BACKFILL_KEY = "
|
|
89
|
+
const USER_MESSAGES_BACKFILL_KEY = "user_messages_v9";
|
|
143
90
|
const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
|
|
144
91
|
const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v1";
|
|
145
92
|
const AGENT_TYPE_BACKFILL_KEY = "agent_type_v1";
|
|
@@ -246,6 +193,8 @@ export async function initDb(): Promise<Database> {
|
|
|
246
193
|
negation INTEGER NOT NULL DEFAULT 0,
|
|
247
194
|
repetition INTEGER NOT NULL DEFAULT 0,
|
|
248
195
|
blame INTEGER NOT NULL DEFAULT 0,
|
|
196
|
+
prose TEXT NOT NULL DEFAULT '',
|
|
197
|
+
prose_hash TEXT NOT NULL DEFAULT '',
|
|
249
198
|
UNIQUE(session_file, entry_id)
|
|
250
199
|
);
|
|
251
200
|
|
|
@@ -253,6 +202,17 @@ export async function initDb(): Promise<Database> {
|
|
|
253
202
|
CREATE INDEX IF NOT EXISTS idx_user_messages_entry_timestamp ON user_messages(entry_id, timestamp);
|
|
254
203
|
CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
|
|
255
204
|
|
|
205
|
+
-- Judge verdicts keyed by prose hash: identical messages share one verdict,
|
|
206
|
+
-- and verdicts outlive user_messages re-parses. No backfill wipes this table.
|
|
207
|
+
CREATE TABLE IF NOT EXISTS frustration_verdicts (
|
|
208
|
+
prose_hash TEXT PRIMARY KEY,
|
|
209
|
+
p_annoyed REAL NOT NULL,
|
|
210
|
+
p_angry REAL NOT NULL,
|
|
211
|
+
target TEXT NOT NULL,
|
|
212
|
+
judge TEXT NOT NULL,
|
|
213
|
+
judged_at INTEGER NOT NULL
|
|
214
|
+
);
|
|
215
|
+
|
|
256
216
|
CREATE TABLE IF NOT EXISTS tool_calls (
|
|
257
217
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
258
218
|
session_file TEXT NOT NULL,
|
|
@@ -367,12 +327,20 @@ export async function initDb(): Promise<Database> {
|
|
|
367
327
|
negation INTEGER NOT NULL DEFAULT 0,
|
|
368
328
|
repetition INTEGER NOT NULL DEFAULT 0,
|
|
369
329
|
blame INTEGER NOT NULL DEFAULT 0,
|
|
330
|
+
prose TEXT NOT NULL DEFAULT '',
|
|
331
|
+
prose_hash TEXT NOT NULL DEFAULT '',
|
|
370
332
|
UNIQUE(session_file, entry_id)
|
|
371
333
|
);
|
|
372
334
|
CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
|
|
373
335
|
CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
|
|
374
336
|
`);
|
|
337
|
+
} else if (!userMessageColumns.some(column => column.name === "prose")) {
|
|
338
|
+
// v9 judge prose: the backfill below clears the rows, so defaults are
|
|
339
|
+
// only placeholders until the re-parse repopulates them.
|
|
340
|
+
db.run("ALTER TABLE user_messages ADD COLUMN prose TEXT NOT NULL DEFAULT ''");
|
|
341
|
+
db.run("ALTER TABLE user_messages ADD COLUMN prose_hash TEXT NOT NULL DEFAULT ''");
|
|
375
342
|
}
|
|
343
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_user_messages_prose_hash ON user_messages(prose_hash)");
|
|
376
344
|
backfillUserMessages(db);
|
|
377
345
|
backfillToolCalls(db);
|
|
378
346
|
backfillReingestCosts(db);
|
|
@@ -383,6 +351,7 @@ export async function initDb(): Promise<Database> {
|
|
|
383
351
|
backfillMissingCatalogCosts(db);
|
|
384
352
|
backfillNoCacheInputCosts(db);
|
|
385
353
|
backfillForkDuplicates(db);
|
|
354
|
+
ensureRollupSchema(db);
|
|
386
355
|
return db;
|
|
387
356
|
}
|
|
388
357
|
|
|
@@ -598,23 +567,25 @@ function backfillNoCacheInputCosts(database: Database): void {
|
|
|
598
567
|
applyBackfill();
|
|
599
568
|
}
|
|
600
569
|
|
|
601
|
-
/**
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
if (!db) return null;
|
|
570
|
+
/** Persisted transcript identity and parser cursor used to resume stats ingestion. */
|
|
571
|
+
export interface FileOffset {
|
|
572
|
+
offset: number;
|
|
573
|
+
lastModified: number;
|
|
574
|
+
parserState?: SessionParserState;
|
|
575
|
+
}
|
|
608
576
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
577
|
+
interface FileOffsetRow {
|
|
578
|
+
session_file: string;
|
|
579
|
+
offset: number;
|
|
580
|
+
last_modified: number;
|
|
581
|
+
parser_state: string | null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function decodeFileOffset(row: FileOffsetRow): FileOffset {
|
|
614
585
|
let parserState: SessionParserState | undefined;
|
|
615
586
|
if (row.parser_state) {
|
|
616
587
|
try {
|
|
617
|
-
const state = JSON.parse(row.parser_state)
|
|
588
|
+
const state: SessionParserState = JSON.parse(row.parser_state);
|
|
618
589
|
if (state?.version === 1 && state.offset === row.offset) parserState = state;
|
|
619
590
|
} catch {
|
|
620
591
|
/* A missing cursor is reconstructed from the transcript. */
|
|
@@ -623,6 +594,31 @@ export function getFileOffset(
|
|
|
623
594
|
return { offset: row.offset, lastModified: row.last_modified, parserState };
|
|
624
595
|
}
|
|
625
596
|
|
|
597
|
+
/** Read one persisted cursor for callers inspecting an individual transcript. */
|
|
598
|
+
export function getFileOffset(sessionFile: string): FileOffset | null {
|
|
599
|
+
if (!db) return null;
|
|
600
|
+
const row = db
|
|
601
|
+
.query<FileOffsetRow, [string]>(
|
|
602
|
+
"SELECT session_file, offset, last_modified, parser_state FROM file_offsets WHERE session_file = ?",
|
|
603
|
+
)
|
|
604
|
+
.get(sessionFile);
|
|
605
|
+
return row ? decodeFileOffset(row) : null;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Read a bounded sync work set's cursors with one SQLite snapshot instead of one lookup per file. */
|
|
609
|
+
export function getFileOffsets(sessionFiles: string[]): Map<string, FileOffset> {
|
|
610
|
+
const offsets = new Map<string, FileOffset>();
|
|
611
|
+
if (!db || sessionFiles.length === 0) return offsets;
|
|
612
|
+
const placeholders = sessionFiles.map(() => "?").join(",");
|
|
613
|
+
const rows = db
|
|
614
|
+
.query<FileOffsetRow, string[]>(
|
|
615
|
+
`SELECT session_file, offset, last_modified, parser_state FROM file_offsets WHERE session_file IN (${placeholders})`,
|
|
616
|
+
)
|
|
617
|
+
.all(...sessionFiles);
|
|
618
|
+
for (const row of rows) offsets.set(row.session_file, decodeFileOffset(row));
|
|
619
|
+
return offsets;
|
|
620
|
+
}
|
|
621
|
+
|
|
626
622
|
/**
|
|
627
623
|
* Update the stored offset for a session file.
|
|
628
624
|
*/
|
|
@@ -634,62 +630,168 @@ export function setFileOffset(
|
|
|
634
630
|
): void {
|
|
635
631
|
if (!db) return;
|
|
636
632
|
|
|
637
|
-
const stmt = db.
|
|
633
|
+
const stmt = db.query(`
|
|
638
634
|
INSERT OR REPLACE INTO file_offsets (session_file, offset, last_modified, parser_state)
|
|
639
635
|
VALUES (?, ?, ?, ?)
|
|
640
636
|
`);
|
|
641
637
|
stmt.run(sessionFile, offset, lastModified, parserState ? JSON.stringify(parserState) : null);
|
|
642
638
|
}
|
|
643
639
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
640
|
+
/** Parsed transcript queued by the sync loop for an atomic database batch. */
|
|
641
|
+
export interface ParsedSession {
|
|
642
|
+
sessionFile: string;
|
|
643
|
+
result: ParseSessionResult;
|
|
644
|
+
rebuild: boolean;
|
|
645
|
+
/** Recover missing rows from a full-history scan without rewriting unchanged records. */
|
|
646
|
+
replay: boolean;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function* parsedRows<T>(
|
|
650
|
+
results: ParseSessionResult[],
|
|
651
|
+
select: (result: ParseSessionResult) => Iterable<T>,
|
|
652
|
+
): Generator<T> {
|
|
653
|
+
for (const result of results) yield* select(result);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Commit distinct transcripts' rows, reconciliation state, and cursors together; failure rolls back the batch. */
|
|
657
|
+
export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
658
|
+
processed: number;
|
|
659
|
+
files: number;
|
|
660
|
+
reconcile: boolean;
|
|
661
|
+
} {
|
|
662
|
+
if (!db) return { processed: 0, files: 0, reconcile: false };
|
|
651
663
|
const database = db;
|
|
652
664
|
return database.transaction(() => {
|
|
653
|
-
let
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
);
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
665
|
+
let processed = 0;
|
|
666
|
+
let files = 0;
|
|
667
|
+
let reconcile = false;
|
|
668
|
+
const writes: ParseSessionResult[] = [];
|
|
669
|
+
for (const { sessionFile, result, rebuild, replay } of sessions) {
|
|
670
|
+
const parserState = result.parserState;
|
|
671
|
+
if (!parserState) continue;
|
|
672
|
+
let rows = result;
|
|
673
|
+
if (result.reset || rebuild || replay) {
|
|
674
|
+
const messages = database
|
|
675
|
+
.query<{ entry_id: string; timestamp: number }, [string]>(
|
|
676
|
+
"SELECT entry_id, timestamp FROM messages WHERE session_file = ?",
|
|
677
|
+
)
|
|
678
|
+
.all(sessionFile);
|
|
679
|
+
const users = database
|
|
680
|
+
.query<{ entry_id: string; timestamp: number; model: string | null }, [string]>(
|
|
681
|
+
"SELECT entry_id, timestamp, model FROM user_messages WHERE session_file = ?",
|
|
682
|
+
)
|
|
683
|
+
.all(sessionFile);
|
|
684
|
+
const tools = database
|
|
685
|
+
.query<
|
|
686
|
+
{ entry_id: string; timestamp: number; tool_call_id: string; result_chars: number | null },
|
|
687
|
+
[string]
|
|
688
|
+
>("SELECT entry_id, timestamp, tool_call_id, result_chars FROM tool_calls WHERE session_file = ?")
|
|
689
|
+
.all(sessionFile);
|
|
690
|
+
const replace = result.reset || rebuild;
|
|
691
|
+
if (replace) {
|
|
692
|
+
// Only removed owners require another full replay, not an identity-only file replacement.
|
|
693
|
+
if (!reconcile && (messages.length > 0 || users.length > 0 || tools.length > 0)) {
|
|
694
|
+
const retainedMessages = new Set(
|
|
695
|
+
result.stats.map(row => JSON.stringify([row.entryId, row.timestamp])),
|
|
696
|
+
);
|
|
697
|
+
const retainedUsers = new Set(
|
|
698
|
+
result.userStats.map(row => JSON.stringify([row.entryId, row.timestamp])),
|
|
699
|
+
);
|
|
700
|
+
const retainedTools = new Set(
|
|
701
|
+
result.toolCalls.map(row => JSON.stringify([row.entryId, row.timestamp, row.toolCallId])),
|
|
702
|
+
);
|
|
703
|
+
reconcile =
|
|
704
|
+
messages.some(row => !retainedMessages.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
|
|
705
|
+
users.some(row => !retainedUsers.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
|
|
706
|
+
tools.some(
|
|
707
|
+
row => !retainedTools.has(JSON.stringify([row.entry_id, row.timestamp, row.tool_call_id])),
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
} else {
|
|
711
|
+
// A reconciliation replay only needs missing rows and unfinished links. Keep stable request
|
|
712
|
+
// IDs and avoid rewriting every table/index for transcripts whose contents did not change.
|
|
713
|
+
const messageById = new Map(messages.map(row => [row.entry_id, row.timestamp]));
|
|
714
|
+
const userById = new Map(users.map(row => [row.entry_id, row]));
|
|
715
|
+
const toolById = new Map(tools.map(row => [row.tool_call_id, row]));
|
|
716
|
+
const absentMessages = new Set(messageById.keys());
|
|
717
|
+
const absentUsers = new Set(userById.keys());
|
|
718
|
+
const absentTools = new Set(toolById.keys());
|
|
719
|
+
const stats = result.stats.filter(row => {
|
|
720
|
+
if (messageById.get(row.entryId) !== row.timestamp) return true;
|
|
721
|
+
absentMessages.delete(row.entryId);
|
|
722
|
+
return false;
|
|
723
|
+
});
|
|
724
|
+
const userStats = result.userStats.filter(row => {
|
|
725
|
+
if (userById.get(row.entryId)?.timestamp !== row.timestamp) return true;
|
|
726
|
+
absentUsers.delete(row.entryId);
|
|
727
|
+
return false;
|
|
728
|
+
});
|
|
729
|
+
const toolCalls = result.toolCalls.filter(row => {
|
|
730
|
+
const stored = toolById.get(row.toolCallId);
|
|
731
|
+
if (stored?.entry_id !== row.entryId || stored.timestamp !== row.timestamp) return true;
|
|
732
|
+
absentTools.delete(row.toolCallId);
|
|
733
|
+
return false;
|
|
734
|
+
});
|
|
735
|
+
rows = {
|
|
736
|
+
...result,
|
|
737
|
+
stats,
|
|
738
|
+
userStats,
|
|
739
|
+
toolCalls,
|
|
740
|
+
// A reused ID needs fresh linkage after its old identity is removed.
|
|
741
|
+
userLinks: result.userLinks.filter(
|
|
742
|
+
row => absentUsers.has(row.entryId) || userById.get(row.entryId)?.model == null,
|
|
743
|
+
),
|
|
744
|
+
toolResults: result.toolResults.filter(
|
|
745
|
+
row => absentTools.has(row.toolCallId) || toolById.get(row.toolCallId)?.result_chars == null,
|
|
746
|
+
),
|
|
747
|
+
};
|
|
748
|
+
// Repair stale owners without invalidating IDs of records still present in the transcript.
|
|
749
|
+
if (absentMessages.size > 0 || absentUsers.size > 0 || absentTools.size > 0) {
|
|
750
|
+
reconcile = true;
|
|
751
|
+
for (const id of absentMessages) {
|
|
752
|
+
database
|
|
753
|
+
.query("DELETE FROM messages WHERE session_file = ? AND entry_id = ?")
|
|
754
|
+
.run(sessionFile, id);
|
|
755
|
+
}
|
|
756
|
+
for (const id of absentUsers) {
|
|
757
|
+
database
|
|
758
|
+
.query("DELETE FROM user_messages WHERE session_file = ? AND entry_id = ?")
|
|
759
|
+
.run(sessionFile, id);
|
|
760
|
+
}
|
|
761
|
+
for (const id of absentTools) {
|
|
762
|
+
database
|
|
763
|
+
.query("DELETE FROM tool_calls WHERE session_file = ? AND tool_call_id = ?")
|
|
764
|
+
.run(sessionFile, id);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (replace) {
|
|
769
|
+
if (messages.length > 0) database.query("DELETE FROM messages WHERE session_file = ?").run(sessionFile);
|
|
770
|
+
if (users.length > 0)
|
|
771
|
+
database.query("DELETE FROM user_messages WHERE session_file = ?").run(sessionFile);
|
|
772
|
+
if (tools.length > 0) database.query("DELETE FROM tool_calls WHERE session_file = ?").run(sessionFile);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
writes.push(rows);
|
|
776
|
+
const count = rows.stats.length + rows.userStats.length;
|
|
777
|
+
processed += count;
|
|
778
|
+
if (count > 0) files++;
|
|
779
|
+
}
|
|
780
|
+
// Remove replaced rows before choosing new fork owners, then write each table once per batch.
|
|
781
|
+
insertMessageStats(parsedRows(writes, result => result.stats));
|
|
782
|
+
insertUserMessageStats(parsedRows(writes, result => result.userStats));
|
|
783
|
+
updateUserMessageLinks(parsedRows(writes, result => result.userLinks));
|
|
784
|
+
insertToolCalls(parsedRows(writes, result => result.toolCalls));
|
|
785
|
+
updateToolResults(parsedRows(writes, result => result.toolResults));
|
|
786
|
+
for (const { sessionFile, result } of sessions) {
|
|
787
|
+
if (result.parserState) {
|
|
788
|
+
setFileOffset(sessionFile, result.newOffset, result.parserState.mtimeMs, result.parserState);
|
|
789
|
+
}
|
|
680
790
|
}
|
|
681
791
|
if (reconcile) {
|
|
682
|
-
database
|
|
683
|
-
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('session_reconciliation', 'pending')")
|
|
684
|
-
.run();
|
|
792
|
+
database.query("INSERT OR REPLACE INTO meta (key, value) VALUES ('session_reconciliation', 'pending')").run();
|
|
685
793
|
}
|
|
686
|
-
|
|
687
|
-
if (result.userStats.length > 0) insertUserMessageStats(result.userStats);
|
|
688
|
-
if (result.userLinks.length > 0) updateUserMessageLinks(result.userLinks);
|
|
689
|
-
if (result.toolCalls.length > 0) insertToolCalls(result.toolCalls);
|
|
690
|
-
if (result.toolResults.length > 0) updateToolResults(result.toolResults);
|
|
691
|
-
setFileOffset(sessionFile, result.newOffset, parserState.mtimeMs, parserState);
|
|
692
|
-
return { processed: result.stats.length + result.userStats.length, reconcile };
|
|
794
|
+
return { processed, files, reconcile };
|
|
693
795
|
})();
|
|
694
796
|
}
|
|
695
797
|
|
|
@@ -717,10 +819,10 @@ export function completeSessionSync(reconcile: boolean): void {
|
|
|
717
819
|
* stored cost (orchestration-aware) and keeps `premium_requests` monotonic, so
|
|
718
820
|
* a forced re-parse repairs historical `premium_requests` and cost fix-ups.
|
|
719
821
|
*/
|
|
720
|
-
export function insertMessageStats(stats: MessageStatsInput
|
|
721
|
-
if (!db
|
|
822
|
+
export function insertMessageStats(stats: Iterable<MessageStatsInput>): number {
|
|
823
|
+
if (!db) return 0;
|
|
722
824
|
|
|
723
|
-
const stmt = db.
|
|
825
|
+
const stmt = db.query(`
|
|
724
826
|
INSERT INTO messages (
|
|
725
827
|
session_file, entry_id, folder, model, provider, api, timestamp,
|
|
726
828
|
duration, ttft, stop_reason, error_message,
|
|
@@ -789,483 +891,6 @@ export function insertMessageStats(stats: MessageStatsInput[]): number {
|
|
|
789
891
|
return inserted;
|
|
790
892
|
}
|
|
791
893
|
|
|
792
|
-
/**
|
|
793
|
-
* Build aggregated stats from query results.
|
|
794
|
-
*/
|
|
795
|
-
function buildAggregatedStats(rows: AggregatedStatsRow[]): AggregatedStats {
|
|
796
|
-
if (rows.length === 0) {
|
|
797
|
-
return {
|
|
798
|
-
totalRequests: 0,
|
|
799
|
-
successfulRequests: 0,
|
|
800
|
-
failedRequests: 0,
|
|
801
|
-
errorRate: 0,
|
|
802
|
-
totalInputTokens: 0,
|
|
803
|
-
totalOutputTokens: 0,
|
|
804
|
-
totalCacheReadTokens: 0,
|
|
805
|
-
totalCacheWriteTokens: 0,
|
|
806
|
-
cacheRate: 0,
|
|
807
|
-
cacheSavings: 0,
|
|
808
|
-
totalCost: 0,
|
|
809
|
-
unpricedRequests: 0,
|
|
810
|
-
totalPremiumRequests: 0,
|
|
811
|
-
avgDuration: null,
|
|
812
|
-
avgTtft: null,
|
|
813
|
-
avgTokensPerSecond: null,
|
|
814
|
-
firstTimestamp: 0,
|
|
815
|
-
lastTimestamp: 0,
|
|
816
|
-
};
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
const row = rows[0];
|
|
820
|
-
const totalRequests = row.total_requests || 0;
|
|
821
|
-
const failedRequests = row.failed_requests || 0;
|
|
822
|
-
const successfulRequests = totalRequests - failedRequests;
|
|
823
|
-
const totalInputTokens = row.total_input_tokens || 0;
|
|
824
|
-
const totalCacheReadTokens = row.total_cache_read_tokens || 0;
|
|
825
|
-
const totalPremiumRequests = row.total_premium_requests || 0;
|
|
826
|
-
const noCacheInputCost = row.total_no_cache_input_cost || 0;
|
|
827
|
-
const cachedPromptCost = row.total_cached_prompt_cost || 0;
|
|
828
|
-
|
|
829
|
-
return {
|
|
830
|
-
totalRequests,
|
|
831
|
-
successfulRequests,
|
|
832
|
-
failedRequests,
|
|
833
|
-
errorRate: totalRequests > 0 ? failedRequests / totalRequests : 0,
|
|
834
|
-
totalInputTokens,
|
|
835
|
-
totalOutputTokens: row.total_output_tokens || 0,
|
|
836
|
-
totalCacheReadTokens,
|
|
837
|
-
totalCacheWriteTokens: row.total_cache_write_tokens || 0,
|
|
838
|
-
cacheRate:
|
|
839
|
-
totalInputTokens + totalCacheReadTokens > 0
|
|
840
|
-
? totalCacheReadTokens / (totalInputTokens + totalCacheReadTokens)
|
|
841
|
-
: 0,
|
|
842
|
-
cacheSavings: noCacheInputCost > 0 ? (noCacheInputCost - cachedPromptCost) / noCacheInputCost : 0,
|
|
843
|
-
totalCost: row.total_cost || 0,
|
|
844
|
-
unpricedRequests: row.unpriced_requests || 0,
|
|
845
|
-
totalPremiumRequests,
|
|
846
|
-
avgDuration: row.avg_duration,
|
|
847
|
-
avgTtft: row.avg_ttft,
|
|
848
|
-
avgTokensPerSecond: row.avg_tokens_per_second,
|
|
849
|
-
firstTimestamp: row.first_timestamp || 0,
|
|
850
|
-
lastTimestamp: row.last_timestamp || 0,
|
|
851
|
-
};
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
/**
|
|
855
|
-
* Get overall aggregated stats.
|
|
856
|
-
*/
|
|
857
|
-
export function getOverallStats(cutoff?: number): AggregatedStats {
|
|
858
|
-
if (!db) return buildAggregatedStats([]);
|
|
859
|
-
|
|
860
|
-
const hasCutoff = cutoff !== undefined && cutoff > 0;
|
|
861
|
-
const stmt = db.prepare(`
|
|
862
|
-
SELECT
|
|
863
|
-
COUNT(*) as total_requests,
|
|
864
|
-
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
|
|
865
|
-
SUM(input_tokens) as total_input_tokens,
|
|
866
|
-
SUM(output_tokens) as total_output_tokens,
|
|
867
|
-
SUM(cache_read_tokens) as total_cache_read_tokens,
|
|
868
|
-
SUM(cache_write_tokens) as total_cache_write_tokens,
|
|
869
|
-
SUM(premium_requests) as total_premium_requests,
|
|
870
|
-
SUM(cost_total) as total_cost,
|
|
871
|
-
SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
|
|
872
|
-
SUM(CASE WHEN cost_no_cache_input > 0
|
|
873
|
-
THEN cost_input + cost_cache_read + cost_cache_write
|
|
874
|
-
ELSE 0 END) as total_cached_prompt_cost,
|
|
875
|
-
SUM(cost_no_cache_input) as total_no_cache_input_cost,
|
|
876
|
-
AVG(duration) as avg_duration,
|
|
877
|
-
AVG(ttft) as avg_ttft,
|
|
878
|
-
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
|
|
879
|
-
MIN(timestamp) as first_timestamp,
|
|
880
|
-
MAX(timestamp) as last_timestamp
|
|
881
|
-
FROM messages
|
|
882
|
-
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
883
|
-
`);
|
|
884
|
-
|
|
885
|
-
const rows = hasCutoff ? stmt.all(cutoff) : stmt.all();
|
|
886
|
-
return buildAggregatedStats(rows as AggregatedStatsRow[]);
|
|
887
|
-
}
|
|
888
|
-
/**
|
|
889
|
-
* Get stats grouped by model.
|
|
890
|
-
*/
|
|
891
|
-
export function getStatsByModel(cutoff?: number): ModelStats[] {
|
|
892
|
-
if (!db) return [];
|
|
893
|
-
|
|
894
|
-
const hasCutoff = cutoff !== undefined && cutoff > 0;
|
|
895
|
-
const stmt = db.prepare(`
|
|
896
|
-
SELECT
|
|
897
|
-
model,
|
|
898
|
-
provider,
|
|
899
|
-
COUNT(*) as total_requests,
|
|
900
|
-
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
|
|
901
|
-
SUM(input_tokens) as total_input_tokens,
|
|
902
|
-
SUM(output_tokens) as total_output_tokens,
|
|
903
|
-
SUM(cache_read_tokens) as total_cache_read_tokens,
|
|
904
|
-
SUM(cache_write_tokens) as total_cache_write_tokens,
|
|
905
|
-
SUM(premium_requests) as total_premium_requests,
|
|
906
|
-
SUM(cost_total) as total_cost,
|
|
907
|
-
SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
|
|
908
|
-
SUM(CASE WHEN cost_no_cache_input > 0
|
|
909
|
-
THEN cost_input + cost_cache_read + cost_cache_write
|
|
910
|
-
ELSE 0 END) as total_cached_prompt_cost,
|
|
911
|
-
SUM(cost_no_cache_input) as total_no_cache_input_cost,
|
|
912
|
-
AVG(duration) as avg_duration,
|
|
913
|
-
AVG(ttft) as avg_ttft,
|
|
914
|
-
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
|
|
915
|
-
MIN(timestamp) as first_timestamp,
|
|
916
|
-
MAX(timestamp) as last_timestamp
|
|
917
|
-
FROM messages
|
|
918
|
-
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
919
|
-
GROUP BY model, provider
|
|
920
|
-
ORDER BY total_requests DESC
|
|
921
|
-
`);
|
|
922
|
-
|
|
923
|
-
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as ModelStatsRow[];
|
|
924
|
-
return rows.map(row => ({
|
|
925
|
-
model: row.model,
|
|
926
|
-
provider: row.provider,
|
|
927
|
-
...buildAggregatedStats([row]),
|
|
928
|
-
}));
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
/**
|
|
932
|
-
* Get stats grouped by folder.
|
|
933
|
-
*/
|
|
934
|
-
export function getStatsByFolder(cutoff?: number): FolderStats[] {
|
|
935
|
-
if (!db) return [];
|
|
936
|
-
|
|
937
|
-
const hasCutoff = cutoff !== undefined && cutoff > 0;
|
|
938
|
-
const stmt = db.prepare(`
|
|
939
|
-
SELECT
|
|
940
|
-
folder,
|
|
941
|
-
COUNT(*) as total_requests,
|
|
942
|
-
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
|
|
943
|
-
SUM(input_tokens) as total_input_tokens,
|
|
944
|
-
SUM(output_tokens) as total_output_tokens,
|
|
945
|
-
SUM(cache_read_tokens) as total_cache_read_tokens,
|
|
946
|
-
SUM(cache_write_tokens) as total_cache_write_tokens,
|
|
947
|
-
SUM(premium_requests) as total_premium_requests,
|
|
948
|
-
SUM(cost_total) as total_cost,
|
|
949
|
-
SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
|
|
950
|
-
SUM(CASE WHEN cost_no_cache_input > 0
|
|
951
|
-
THEN cost_input + cost_cache_read + cost_cache_write
|
|
952
|
-
ELSE 0 END) as total_cached_prompt_cost,
|
|
953
|
-
SUM(cost_no_cache_input) as total_no_cache_input_cost,
|
|
954
|
-
AVG(duration) as avg_duration,
|
|
955
|
-
AVG(ttft) as avg_ttft,
|
|
956
|
-
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
|
|
957
|
-
MIN(timestamp) as first_timestamp,
|
|
958
|
-
MAX(timestamp) as last_timestamp
|
|
959
|
-
FROM messages
|
|
960
|
-
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
961
|
-
GROUP BY folder
|
|
962
|
-
ORDER BY total_requests DESC
|
|
963
|
-
`);
|
|
964
|
-
|
|
965
|
-
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as FolderStatsRow[];
|
|
966
|
-
return rows.map(row => ({
|
|
967
|
-
folder: row.folder,
|
|
968
|
-
...buildAggregatedStats([row]),
|
|
969
|
-
}));
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
/**
|
|
973
|
-
* Get token usage grouped by agent type (main agent, task subagents, advisor).
|
|
974
|
-
* Token columns are explicit so the dashboard's share denominator matches the
|
|
975
|
-
* counts it renders. Rows missing `agent_type` (defensive) fall back to "main".
|
|
976
|
-
*/
|
|
977
|
-
export function getStatsByAgentType(cutoff?: number): AgentTypeStats[] {
|
|
978
|
-
if (!db) return [];
|
|
979
|
-
|
|
980
|
-
const hasCutoff = cutoff !== undefined && cutoff > 0;
|
|
981
|
-
const stmt = db.prepare(`
|
|
982
|
-
SELECT
|
|
983
|
-
agent_type,
|
|
984
|
-
COUNT(*) as total_requests,
|
|
985
|
-
SUM(input_tokens) as total_input_tokens,
|
|
986
|
-
SUM(output_tokens) as total_output_tokens,
|
|
987
|
-
SUM(cache_read_tokens) as total_cache_read_tokens,
|
|
988
|
-
SUM(cache_write_tokens) as total_cache_write_tokens,
|
|
989
|
-
SUM(cost_total) as total_cost
|
|
990
|
-
FROM messages
|
|
991
|
-
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
992
|
-
GROUP BY agent_type
|
|
993
|
-
`);
|
|
994
|
-
|
|
995
|
-
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
|
|
996
|
-
return rows.map(row => ({
|
|
997
|
-
agentType: (row.agent_type as AgentType) ?? "main",
|
|
998
|
-
totalRequests: row.total_requests || 0,
|
|
999
|
-
totalInputTokens: row.total_input_tokens || 0,
|
|
1000
|
-
totalOutputTokens: row.total_output_tokens || 0,
|
|
1001
|
-
totalCacheReadTokens: row.total_cache_read_tokens || 0,
|
|
1002
|
-
totalCacheWriteTokens: row.total_cache_write_tokens || 0,
|
|
1003
|
-
totalCost: row.total_cost || 0,
|
|
1004
|
-
}));
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
/**
|
|
1008
|
-
* Get time series data.
|
|
1009
|
-
*/
|
|
1010
|
-
export function getTimeSeries(hours = 24, cutoff?: number | null, bucketMs = 60 * 60 * 1000): TimeSeriesPoint[] {
|
|
1011
|
-
if (!db) return [];
|
|
1012
|
-
|
|
1013
|
-
const hasCutoff = cutoff !== null;
|
|
1014
|
-
const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - hours * 60 * 60 * 1000) : 0;
|
|
1015
|
-
|
|
1016
|
-
const stmt = db.prepare(`
|
|
1017
|
-
SELECT
|
|
1018
|
-
(timestamp / ?) * ? as bucket,
|
|
1019
|
-
COUNT(*) as requests,
|
|
1020
|
-
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as errors,
|
|
1021
|
-
SUM(total_tokens) as tokens,
|
|
1022
|
-
SUM(cost_total) as cost
|
|
1023
|
-
FROM messages
|
|
1024
|
-
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
1025
|
-
GROUP BY bucket
|
|
1026
|
-
ORDER BY bucket ASC
|
|
1027
|
-
`);
|
|
1028
|
-
|
|
1029
|
-
const rows = hasCutoff
|
|
1030
|
-
? (stmt.all(bucketMs, bucketMs, seriesCutoff) as any[])
|
|
1031
|
-
: (stmt.all(bucketMs, bucketMs) as any[]);
|
|
1032
|
-
return rows.map(row => ({
|
|
1033
|
-
timestamp: row.bucket,
|
|
1034
|
-
requests: row.requests,
|
|
1035
|
-
errors: row.errors,
|
|
1036
|
-
tokens: row.tokens,
|
|
1037
|
-
cost: row.cost,
|
|
1038
|
-
}));
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
/**
|
|
1042
|
-
* Get daily performance time series data for the last N days.
|
|
1043
|
-
*/
|
|
1044
|
-
/**
|
|
1045
|
-
* Get daily model usage time series data for the last N days.
|
|
1046
|
-
*/
|
|
1047
|
-
export function getModelTimeSeries(
|
|
1048
|
-
days = 14,
|
|
1049
|
-
cutoff?: number | null,
|
|
1050
|
-
bucketMs = 24 * 60 * 60 * 1000,
|
|
1051
|
-
): ModelTimeSeriesPoint[] {
|
|
1052
|
-
if (!db) return [];
|
|
1053
|
-
|
|
1054
|
-
const hasCutoff = cutoff !== null;
|
|
1055
|
-
const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
|
|
1056
|
-
|
|
1057
|
-
const stmt = db.prepare(`
|
|
1058
|
-
SELECT
|
|
1059
|
-
(timestamp / ?) * ? as bucket,
|
|
1060
|
-
model,
|
|
1061
|
-
provider,
|
|
1062
|
-
COUNT(*) as requests
|
|
1063
|
-
FROM messages
|
|
1064
|
-
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
1065
|
-
GROUP BY bucket, model, provider
|
|
1066
|
-
ORDER BY bucket ASC
|
|
1067
|
-
`);
|
|
1068
|
-
|
|
1069
|
-
const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
|
|
1070
|
-
const rows = rowsRaw as Array<{ bucket: number; model: string; provider: string; requests: number }>;
|
|
1071
|
-
return rows.map(row => ({
|
|
1072
|
-
timestamp: row.bucket,
|
|
1073
|
-
model: row.model,
|
|
1074
|
-
provider: row.provider,
|
|
1075
|
-
requests: row.requests,
|
|
1076
|
-
}));
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
/**
|
|
1080
|
-
* Get request/token/cost totals grouped by provider.
|
|
1081
|
-
*/
|
|
1082
|
-
export function getStatsByProvider(cutoff?: number | null): ProviderAggregate[] {
|
|
1083
|
-
if (!db) return [];
|
|
1084
|
-
|
|
1085
|
-
const hasCutoff = cutoff !== undefined && cutoff !== null && cutoff > 0;
|
|
1086
|
-
const stmt = db.prepare(`
|
|
1087
|
-
SELECT
|
|
1088
|
-
provider,
|
|
1089
|
-
COUNT(*) as total_requests,
|
|
1090
|
-
SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
|
|
1091
|
-
COUNT(DISTINCT model) as models,
|
|
1092
|
-
SUM(input_tokens) as total_input_tokens,
|
|
1093
|
-
SUM(output_tokens) as total_output_tokens,
|
|
1094
|
-
SUM(cache_read_tokens) as total_cache_read_tokens,
|
|
1095
|
-
SUM(cache_write_tokens) as total_cache_write_tokens,
|
|
1096
|
-
SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
|
|
1097
|
-
SUM(cost_total) as total_cost,
|
|
1098
|
-
SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
|
|
1099
|
-
SUM(premium_requests) as total_premium_requests,
|
|
1100
|
-
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
|
|
1101
|
-
FROM messages
|
|
1102
|
-
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
1103
|
-
GROUP BY provider
|
|
1104
|
-
ORDER BY total_tokens DESC
|
|
1105
|
-
`);
|
|
1106
|
-
|
|
1107
|
-
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as Array<{
|
|
1108
|
-
provider: string;
|
|
1109
|
-
total_requests: number;
|
|
1110
|
-
failed_requests: number;
|
|
1111
|
-
models: number;
|
|
1112
|
-
total_input_tokens: number | null;
|
|
1113
|
-
total_output_tokens: number | null;
|
|
1114
|
-
total_cache_read_tokens: number | null;
|
|
1115
|
-
total_cache_write_tokens: number | null;
|
|
1116
|
-
total_tokens: number | null;
|
|
1117
|
-
total_cost: number | null;
|
|
1118
|
-
unpriced_requests: number | null;
|
|
1119
|
-
total_premium_requests: number | null;
|
|
1120
|
-
avg_tokens_per_second: number | null;
|
|
1121
|
-
}>;
|
|
1122
|
-
return rows.map(row => ({
|
|
1123
|
-
provider: row.provider,
|
|
1124
|
-
totalRequests: row.total_requests,
|
|
1125
|
-
failedRequests: row.failed_requests,
|
|
1126
|
-
models: row.models,
|
|
1127
|
-
totalInputTokens: row.total_input_tokens ?? 0,
|
|
1128
|
-
totalOutputTokens: row.total_output_tokens ?? 0,
|
|
1129
|
-
totalCacheReadTokens: row.total_cache_read_tokens ?? 0,
|
|
1130
|
-
totalCacheWriteTokens: row.total_cache_write_tokens ?? 0,
|
|
1131
|
-
totalTokens: row.total_tokens ?? 0,
|
|
1132
|
-
totalCost: row.total_cost ?? 0,
|
|
1133
|
-
unpricedRequests: row.unpriced_requests ?? 0,
|
|
1134
|
-
totalPremiumRequests: row.total_premium_requests ?? 0,
|
|
1135
|
-
avgTokensPerSecond: row.avg_tokens_per_second,
|
|
1136
|
-
}));
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
/**
|
|
1140
|
-
* Get token burn grouped by provider and local hour of day (0-23).
|
|
1141
|
-
* Hours use the server's timezone — the dashboard is a localhost tool, so
|
|
1142
|
-
* server-local and viewer-local time coincide.
|
|
1143
|
-
*/
|
|
1144
|
-
export function getProviderHourlyBurn(cutoff?: number | null): ProviderHourlyPoint[] {
|
|
1145
|
-
if (!db) return [];
|
|
1146
|
-
|
|
1147
|
-
const hasCutoff = cutoff !== undefined && cutoff !== null && cutoff > 0;
|
|
1148
|
-
const stmt = db.prepare(`
|
|
1149
|
-
SELECT
|
|
1150
|
-
provider,
|
|
1151
|
-
CAST(strftime('%H', timestamp / 1000, 'unixepoch', 'localtime') AS INTEGER) as hour,
|
|
1152
|
-
SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
|
|
1153
|
-
SUM(output_tokens) as output_tokens,
|
|
1154
|
-
COUNT(*) as requests
|
|
1155
|
-
FROM messages
|
|
1156
|
-
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
1157
|
-
GROUP BY provider, hour
|
|
1158
|
-
ORDER BY provider, hour
|
|
1159
|
-
`);
|
|
1160
|
-
|
|
1161
|
-
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as Array<{
|
|
1162
|
-
provider: string;
|
|
1163
|
-
hour: number;
|
|
1164
|
-
total_tokens: number | null;
|
|
1165
|
-
output_tokens: number | null;
|
|
1166
|
-
requests: number;
|
|
1167
|
-
}>;
|
|
1168
|
-
return rows.map(row => ({
|
|
1169
|
-
provider: row.provider,
|
|
1170
|
-
hour: row.hour,
|
|
1171
|
-
totalTokens: row.total_tokens ?? 0,
|
|
1172
|
-
outputTokens: row.output_tokens ?? 0,
|
|
1173
|
-
requests: row.requests,
|
|
1174
|
-
}));
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
/**
|
|
1178
|
-
* Get token/cost time series grouped by provider (bucketed like the model series).
|
|
1179
|
-
*/
|
|
1180
|
-
export function getProviderTimeSeries(
|
|
1181
|
-
days = 14,
|
|
1182
|
-
cutoff?: number | null,
|
|
1183
|
-
bucketMs = 24 * 60 * 60 * 1000,
|
|
1184
|
-
): ProviderTimeSeriesPoint[] {
|
|
1185
|
-
if (!db) return [];
|
|
1186
|
-
|
|
1187
|
-
const hasCutoff = cutoff !== null;
|
|
1188
|
-
const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
|
|
1189
|
-
|
|
1190
|
-
const stmt = db.prepare(`
|
|
1191
|
-
SELECT
|
|
1192
|
-
(timestamp / ?) * ? as bucket,
|
|
1193
|
-
provider,
|
|
1194
|
-
SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) as total_tokens,
|
|
1195
|
-
SUM(cost_total) as cost,
|
|
1196
|
-
SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
|
|
1197
|
-
COUNT(*) as requests
|
|
1198
|
-
FROM messages
|
|
1199
|
-
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
1200
|
-
GROUP BY bucket, provider
|
|
1201
|
-
ORDER BY bucket ASC
|
|
1202
|
-
`);
|
|
1203
|
-
|
|
1204
|
-
const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
|
|
1205
|
-
const rows = rowsRaw as Array<{
|
|
1206
|
-
bucket: number;
|
|
1207
|
-
provider: string;
|
|
1208
|
-
total_tokens: number | null;
|
|
1209
|
-
cost: number | null;
|
|
1210
|
-
unpriced_requests: number | null;
|
|
1211
|
-
requests: number;
|
|
1212
|
-
}>;
|
|
1213
|
-
return rows.map(row => ({
|
|
1214
|
-
timestamp: row.bucket,
|
|
1215
|
-
provider: row.provider,
|
|
1216
|
-
totalTokens: row.total_tokens ?? 0,
|
|
1217
|
-
cost: row.cost ?? 0,
|
|
1218
|
-
unpricedRequests: row.unpriced_requests ?? 0,
|
|
1219
|
-
requests: row.requests,
|
|
1220
|
-
}));
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
/**
|
|
1224
|
-
* Get daily model performance time series data for the last N days.
|
|
1225
|
-
*/
|
|
1226
|
-
export function getModelPerformanceSeries(
|
|
1227
|
-
days = 14,
|
|
1228
|
-
cutoff?: number | null,
|
|
1229
|
-
bucketMs = 24 * 60 * 60 * 1000,
|
|
1230
|
-
): ModelPerformancePoint[] {
|
|
1231
|
-
if (!db) return [];
|
|
1232
|
-
|
|
1233
|
-
const hasCutoff = cutoff !== null;
|
|
1234
|
-
const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
|
|
1235
|
-
|
|
1236
|
-
const stmt = db.prepare(`
|
|
1237
|
-
SELECT
|
|
1238
|
-
(timestamp / ?) * ? as bucket,
|
|
1239
|
-
model,
|
|
1240
|
-
provider,
|
|
1241
|
-
COUNT(*) as requests,
|
|
1242
|
-
AVG(ttft) as avg_ttft,
|
|
1243
|
-
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
|
|
1244
|
-
FROM messages
|
|
1245
|
-
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
1246
|
-
GROUP BY bucket, model, provider
|
|
1247
|
-
ORDER BY bucket ASC
|
|
1248
|
-
`);
|
|
1249
|
-
|
|
1250
|
-
const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
|
|
1251
|
-
const rows = rowsRaw as Array<{
|
|
1252
|
-
bucket: number;
|
|
1253
|
-
model: string;
|
|
1254
|
-
provider: string;
|
|
1255
|
-
requests: number;
|
|
1256
|
-
avg_ttft: number | null;
|
|
1257
|
-
avg_tokens_per_second: number | null;
|
|
1258
|
-
}>;
|
|
1259
|
-
return rows.map(row => ({
|
|
1260
|
-
timestamp: row.bucket,
|
|
1261
|
-
model: row.model,
|
|
1262
|
-
provider: row.provider,
|
|
1263
|
-
requests: row.requests,
|
|
1264
|
-
avgTtft: row.avg_ttft,
|
|
1265
|
-
avgTokensPerSecond: row.avg_tokens_per_second,
|
|
1266
|
-
}));
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
894
|
/**
|
|
1270
895
|
* Get total message count.
|
|
1271
896
|
*/
|
|
@@ -1350,92 +975,6 @@ export function getMessageById(id: number): MessageStats | null {
|
|
|
1350
975
|
const row = stmt.get(id);
|
|
1351
976
|
return row ? rowToMessageStats(row) : null;
|
|
1352
977
|
}
|
|
1353
|
-
/** Per-transcript-file rollup for the Traces session list. */
|
|
1354
|
-
export interface SessionRollupRow {
|
|
1355
|
-
sessionFile: string;
|
|
1356
|
-
requests: number;
|
|
1357
|
-
startedAt: number;
|
|
1358
|
-
endedAt: number;
|
|
1359
|
-
totalTokens: number;
|
|
1360
|
-
costTotal: number;
|
|
1361
|
-
unpricedRequests: number;
|
|
1362
|
-
/** Comma-joined DISTINCT models. */
|
|
1363
|
-
models: string;
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
/** Aggregate every synced transcript file into one row (subagents unfolded). */
|
|
1367
|
-
export function getSessionRollups(): SessionRollupRow[] {
|
|
1368
|
-
if (!db) return [];
|
|
1369
|
-
const stmt = db.prepare(`
|
|
1370
|
-
SELECT session_file AS sessionFile,
|
|
1371
|
-
COUNT(*) AS requests,
|
|
1372
|
-
MIN(timestamp) AS startedAt,
|
|
1373
|
-
MAX(timestamp + COALESCE(duration, 0)) AS endedAt,
|
|
1374
|
-
SUM(total_tokens) AS totalTokens,
|
|
1375
|
-
SUM(cost_total) AS costTotal,
|
|
1376
|
-
SUM(${UNPRICED_REQUEST_SQL}) AS unpricedRequests,
|
|
1377
|
-
GROUP_CONCAT(DISTINCT model) AS models
|
|
1378
|
-
FROM messages
|
|
1379
|
-
GROUP BY session_file
|
|
1380
|
-
`);
|
|
1381
|
-
return stmt.all() as SessionRollupRow[];
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
/** Tool-call counts keyed by transcript file, for the Traces session list. */
|
|
1385
|
-
export function getToolCallCountsBySession(): Map<string, number> {
|
|
1386
|
-
const counts = new Map<string, number>();
|
|
1387
|
-
if (!db) return counts;
|
|
1388
|
-
const stmt = db.prepare(
|
|
1389
|
-
"SELECT session_file AS sessionFile, COUNT(*) AS calls FROM tool_calls GROUP BY session_file",
|
|
1390
|
-
);
|
|
1391
|
-
for (const row of stmt.all() as Array<{ sessionFile: string; calls: number }>) {
|
|
1392
|
-
counts.set(row.sessionFile, row.calls);
|
|
1393
|
-
}
|
|
1394
|
-
return counts;
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
/**
|
|
1398
|
-
* Get daily cost time series data for the last N days, broken down by model.
|
|
1399
|
-
*/
|
|
1400
|
-
export function getCostTimeSeries(days = 90, cutoff?: number | null): CostTimeSeriesPoint[] {
|
|
1401
|
-
if (!db) return [];
|
|
1402
|
-
|
|
1403
|
-
const hasCutoff = cutoff !== null;
|
|
1404
|
-
const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
|
|
1405
|
-
|
|
1406
|
-
const stmt = db.prepare(`
|
|
1407
|
-
SELECT
|
|
1408
|
-
(timestamp / 86400000) * 86400000 as bucket,
|
|
1409
|
-
model,
|
|
1410
|
-
provider,
|
|
1411
|
-
SUM(cost_total) as cost,
|
|
1412
|
-
SUM(${UNPRICED_REQUEST_SQL}) as unpriced_requests,
|
|
1413
|
-
SUM(cost_input) as cost_input,
|
|
1414
|
-
SUM(cost_output) as cost_output,
|
|
1415
|
-
SUM(cost_cache_read) as cost_cache_read,
|
|
1416
|
-
SUM(cost_cache_write) as cost_cache_write,
|
|
1417
|
-
COUNT(*) as requests
|
|
1418
|
-
FROM messages
|
|
1419
|
-
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
1420
|
-
GROUP BY bucket, model, provider
|
|
1421
|
-
ORDER BY bucket ASC
|
|
1422
|
-
`);
|
|
1423
|
-
|
|
1424
|
-
const rows = (hasCutoff ? stmt.all(seriesCutoff) : stmt.all()) as CostTimeSeriesRow[];
|
|
1425
|
-
return rows.map(row => ({
|
|
1426
|
-
timestamp: row.bucket,
|
|
1427
|
-
model: row.model,
|
|
1428
|
-
provider: row.provider,
|
|
1429
|
-
cost: row.cost ?? 0,
|
|
1430
|
-
unpricedRequests: row.unpriced_requests ?? 0,
|
|
1431
|
-
costInput: row.cost_input ?? 0,
|
|
1432
|
-
costOutput: row.cost_output ?? 0,
|
|
1433
|
-
costCacheRead: row.cost_cache_read ?? 0,
|
|
1434
|
-
costCacheWrite: row.cost_cache_write ?? 0,
|
|
1435
|
-
requests: row.requests,
|
|
1436
|
-
}));
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
978
|
/**
|
|
1440
979
|
* Per-local-day activity aggregates for the last `days` days, oldest first.
|
|
1441
980
|
* Self-initializing (opens the stats DB on first use) so the coding-agent TUI
|
|
@@ -1507,6 +1046,9 @@ export async function getDailyActivity(days = 371): Promise<DailyActivityPoint[]
|
|
|
1507
1046
|
* you` scores blame, `makes (no|zero) sense` scores negation. v7
|
|
1508
1047
|
* shipped briefly without these, so any database that completed the v7
|
|
1509
1048
|
* backfill needs one more re-derive.
|
|
1049
|
+
* - v9: stored judge prose (`prose` / `prose_hash`) for the frustration
|
|
1050
|
+
* judge, so every session re-parses once to populate it. Verdicts live in
|
|
1051
|
+
* `frustration_verdicts`, keyed by prose hash, which this reset never touches.
|
|
1510
1052
|
*
|
|
1511
1053
|
* Existing `messages` rows are unaffected - `INSERT OR IGNORE` keeps them.
|
|
1512
1054
|
*/
|
|
@@ -1728,16 +1270,16 @@ export function markSessionBackfillsComplete(): void {
|
|
|
1728
1270
|
* copy user entries verbatim into the child JSONL, so the same
|
|
1729
1271
|
* `(entry_id, timestamp)` must not land twice across different session files.
|
|
1730
1272
|
*/
|
|
1731
|
-
export function insertUserMessageStats(stats: UserMessageStats
|
|
1732
|
-
if (!db
|
|
1273
|
+
export function insertUserMessageStats(stats: Iterable<UserMessageStats>): number {
|
|
1274
|
+
if (!db) return 0;
|
|
1733
1275
|
|
|
1734
|
-
const stmt = db.
|
|
1276
|
+
const stmt = db.query(`
|
|
1735
1277
|
INSERT OR IGNORE INTO user_messages (
|
|
1736
1278
|
session_file, entry_id, folder, timestamp, model, provider,
|
|
1737
1279
|
chars, words, yelling, profanity, anguish,
|
|
1738
|
-
negation, repetition, blame
|
|
1280
|
+
negation, repetition, blame, prose, prose_hash
|
|
1739
1281
|
)
|
|
1740
|
-
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
1282
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
1741
1283
|
WHERE NOT EXISTS (
|
|
1742
1284
|
SELECT 1 FROM user_messages
|
|
1743
1285
|
WHERE entry_id = ? AND timestamp = ? AND session_file <> ?
|
|
@@ -1762,6 +1304,8 @@ export function insertUserMessageStats(stats: UserMessageStats[]): number {
|
|
|
1762
1304
|
s.negation,
|
|
1763
1305
|
s.repetition,
|
|
1764
1306
|
s.blame,
|
|
1307
|
+
s.prose,
|
|
1308
|
+
s.proseHash,
|
|
1765
1309
|
// `WHERE NOT EXISTS` binds: skip when a different session_file
|
|
1766
1310
|
// already holds this (entry_id, timestamp).
|
|
1767
1311
|
s.entryId,
|
|
@@ -1777,17 +1321,16 @@ export function insertUserMessageStats(stats: UserMessageStats[]): number {
|
|
|
1777
1321
|
|
|
1778
1322
|
/**
|
|
1779
1323
|
* Backfill the responding `model`/`provider` on user-message rows that were
|
|
1780
|
-
* persisted before their assistant reply was parsed
|
|
1781
|
-
*
|
|
1782
|
-
*
|
|
1783
|
-
* once because the `model IS NULL` guard short-circuits subsequent passes.
|
|
1324
|
+
* persisted before their assistant reply was parsed by an incremental tail
|
|
1325
|
+
* read. Each row is updated at most once because the `model IS NULL` guard
|
|
1326
|
+
* short-circuits subsequent passes.
|
|
1784
1327
|
*
|
|
1785
1328
|
* Returns the number of rows actually updated.
|
|
1786
1329
|
*/
|
|
1787
|
-
export function updateUserMessageLinks(links: UserMessageLink
|
|
1788
|
-
if (!db
|
|
1330
|
+
export function updateUserMessageLinks(links: Iterable<UserMessageLink>): number {
|
|
1331
|
+
if (!db) return 0;
|
|
1789
1332
|
|
|
1790
|
-
const stmt = db.
|
|
1333
|
+
const stmt = db.query(`
|
|
1791
1334
|
UPDATE user_messages
|
|
1792
1335
|
SET model = ?, provider = ?
|
|
1793
1336
|
WHERE session_file = ? AND entry_id = ? AND model IS NULL
|
|
@@ -1804,181 +1347,162 @@ export function updateUserMessageLinks(links: UserMessageLink[]): number {
|
|
|
1804
1347
|
return updated;
|
|
1805
1348
|
}
|
|
1806
1349
|
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
interface BehaviorSeriesRow {
|
|
1810
|
-
bucket: number;
|
|
1350
|
+
/** Frustration tallies of one responding (model, provider) pair, before identity merging. */
|
|
1351
|
+
export interface FrustrationModelRow extends FrustrationCounts {
|
|
1811
1352
|
model: string;
|
|
1812
|
-
provider: string;
|
|
1353
|
+
provider: string | null;
|
|
1354
|
+
/** Earliest message timestamp (ms) in range. */
|
|
1355
|
+
firstSeen: number;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** One unique unjudged prose text: identical messages share a hash and one verdict. */
|
|
1359
|
+
export interface PendingProse {
|
|
1360
|
+
hash: string;
|
|
1361
|
+
prose: string;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/** Cached judge verdict for one prose hash. */
|
|
1365
|
+
export interface FrustrationVerdict {
|
|
1366
|
+
proseHash: string;
|
|
1367
|
+
/** P(clearly annoyed) + P(angry) on the `annoyed` score question. */
|
|
1368
|
+
pAnnoyed: number;
|
|
1369
|
+
/** P(angry, hostile, or swearing). */
|
|
1370
|
+
pAngry: number;
|
|
1371
|
+
/** Most likely `target` choice: `assistant`, `other`, or `none`. */
|
|
1372
|
+
target: string;
|
|
1373
|
+
/** `provider/model` that produced the verdict. */
|
|
1374
|
+
judge: string;
|
|
1375
|
+
judgedAt: number;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
interface FrustrationCountsRow {
|
|
1813
1379
|
messages: number;
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1380
|
+
judged: number;
|
|
1381
|
+
annoyed: number;
|
|
1382
|
+
at_assistant: number;
|
|
1383
|
+
angry: number;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
interface FrustrationModelSqlRow extends FrustrationCountsRow {
|
|
1387
|
+
model: string;
|
|
1388
|
+
provider: string | null;
|
|
1389
|
+
first_seen: number;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Each message is classified once: by its cached verdict when `v` joined,
|
|
1393
|
+
// else by the regex signals stored at ingest. Keep in sync with the rules
|
|
1394
|
+
// documented on `FrustrationCounts` / `frustration.ts`.
|
|
1395
|
+
const JUDGED_SQL = "v.prose_hash IS NOT NULL";
|
|
1396
|
+
const JUDGED_ANNOYED_SQL = "v.p_annoyed >= 0.5";
|
|
1397
|
+
const JUDGED_AT_ASSISTANT_SQL = `${JUDGED_ANNOYED_SQL} AND v.target = 'assistant'`;
|
|
1398
|
+
const REGEX_AT_ASSISTANT_SQL = "u.negation + u.repetition + u.blame > 0";
|
|
1399
|
+
const FRUSTRATION_COUNTS_SQL = `
|
|
1400
|
+
COUNT(*) AS messages,
|
|
1401
|
+
COALESCE(SUM(${JUDGED_SQL}), 0) AS judged,
|
|
1402
|
+
COALESCE(SUM(CASE WHEN ${JUDGED_SQL} THEN ${JUDGED_ANNOYED_SQL}
|
|
1403
|
+
ELSE u.yelling + u.profanity + u.anguish + u.negation + u.repetition + u.blame > 0 END), 0) AS annoyed,
|
|
1404
|
+
COALESCE(SUM(CASE WHEN ${JUDGED_SQL} THEN ${JUDGED_AT_ASSISTANT_SQL}
|
|
1405
|
+
ELSE ${REGEX_AT_ASSISTANT_SQL} END), 0) AS at_assistant,
|
|
1406
|
+
COALESCE(SUM(CASE WHEN ${JUDGED_SQL} THEN ${JUDGED_AT_ASSISTANT_SQL} AND v.p_angry >= 0.5
|
|
1407
|
+
ELSE ${REGEX_AT_ASSISTANT_SQL} AND (u.profanity > 0 OR u.yelling > 0) END), 0) AS angry
|
|
1408
|
+
`;
|
|
1409
|
+
|
|
1410
|
+
function hasRangeCutoff(cutoff: number | null | undefined): cutoff is number {
|
|
1411
|
+
return cutoff !== null && cutoff !== undefined && cutoff > 0;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function toFrustrationCounts(row: FrustrationCountsRow | undefined): FrustrationCounts {
|
|
1415
|
+
return {
|
|
1416
|
+
messages: row?.messages ?? 0,
|
|
1417
|
+
judged: row?.judged ?? 0,
|
|
1418
|
+
annoyed: row?.annoyed ?? 0,
|
|
1419
|
+
atAssistant: row?.at_assistant ?? 0,
|
|
1420
|
+
angry: row?.angry ?? 0,
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/** Frustration tallies over every user message with prose in range, linked to a model or not. */
|
|
1425
|
+
export function getFrustrationOverall(cutoff?: number | null): FrustrationCounts {
|
|
1426
|
+
if (!db) return toFrustrationCounts(undefined);
|
|
1427
|
+
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1428
|
+
const stmt = db.prepare(`
|
|
1429
|
+
SELECT ${FRUSTRATION_COUNTS_SQL}
|
|
1430
|
+
FROM user_messages u
|
|
1431
|
+
LEFT JOIN frustration_verdicts v ON v.prose_hash = u.prose_hash
|
|
1432
|
+
WHERE u.prose != ''${hasCutoff ? " AND u.timestamp >= ?" : ""}
|
|
1433
|
+
`);
|
|
1434
|
+
const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as FrustrationCountsRow | undefined;
|
|
1435
|
+
return toFrustrationCounts(row);
|
|
1821
1436
|
}
|
|
1822
1437
|
|
|
1823
1438
|
/**
|
|
1824
|
-
*
|
|
1439
|
+
* Frustration tallies per responding (model, provider) over the range. User
|
|
1440
|
+
* messages that never got a reply (null model) are only in the overall tally.
|
|
1825
1441
|
*/
|
|
1826
|
-
export function
|
|
1442
|
+
export function getFrustrationByModel(cutoff?: number | null): FrustrationModelRow[] {
|
|
1827
1443
|
if (!db) return [];
|
|
1828
|
-
const hasCutoff = cutoff
|
|
1444
|
+
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1829
1445
|
const stmt = db.prepare(`
|
|
1830
|
-
SELECT
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
SUM(yelling) as yelling,
|
|
1836
|
-
SUM(profanity) as profanity,
|
|
1837
|
-
SUM(anguish) as anguish,
|
|
1838
|
-
SUM(negation) as negation,
|
|
1839
|
-
SUM(repetition) as repetition,
|
|
1840
|
-
SUM(blame) as blame,
|
|
1841
|
-
SUM(chars) as chars
|
|
1842
|
-
FROM user_messages
|
|
1843
|
-
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
1844
|
-
GROUP BY bucket, model, provider
|
|
1845
|
-
ORDER BY bucket ASC
|
|
1446
|
+
SELECT u.model AS model, u.provider AS provider, MIN(u.timestamp) AS first_seen, ${FRUSTRATION_COUNTS_SQL}
|
|
1447
|
+
FROM user_messages u
|
|
1448
|
+
LEFT JOIN frustration_verdicts v ON v.prose_hash = u.prose_hash
|
|
1449
|
+
WHERE u.prose != '' AND u.model IS NOT NULL${hasCutoff ? " AND u.timestamp >= ?" : ""}
|
|
1450
|
+
GROUP BY u.model, u.provider
|
|
1846
1451
|
`);
|
|
1847
|
-
const rows = (
|
|
1848
|
-
hasCutoff ? stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL, cutoff) : stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL)
|
|
1849
|
-
) as BehaviorSeriesRow[];
|
|
1452
|
+
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as FrustrationModelSqlRow[];
|
|
1850
1453
|
return rows.map(row => ({
|
|
1851
|
-
timestamp: row.bucket,
|
|
1852
1454
|
model: row.model,
|
|
1853
1455
|
provider: row.provider,
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
profanity: row.profanity ?? 0,
|
|
1857
|
-
anguish: row.anguish ?? 0,
|
|
1858
|
-
negation: row.negation ?? 0,
|
|
1859
|
-
repetition: row.repetition ?? 0,
|
|
1860
|
-
blame: row.blame ?? 0,
|
|
1861
|
-
chars: row.chars ?? 0,
|
|
1456
|
+
firstSeen: row.first_seen,
|
|
1457
|
+
...toFrustrationCounts(row),
|
|
1862
1458
|
}));
|
|
1863
1459
|
}
|
|
1864
1460
|
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
first_timestamp: number | null;
|
|
1875
|
-
last_timestamp: number | null;
|
|
1461
|
+
/** Unique unjudged prose in range, one row per prose hash. */
|
|
1462
|
+
function pendingProseSql(hasCutoff: boolean): string {
|
|
1463
|
+
return `
|
|
1464
|
+
SELECT u.prose_hash AS hash, MIN(u.prose) AS prose
|
|
1465
|
+
FROM user_messages u
|
|
1466
|
+
WHERE u.prose != ''${hasCutoff ? " AND u.timestamp >= ?" : ""}
|
|
1467
|
+
AND NOT EXISTS (SELECT 1 FROM frustration_verdicts v WHERE v.prose_hash = u.prose_hash)
|
|
1468
|
+
GROUP BY u.prose_hash
|
|
1469
|
+
`;
|
|
1876
1470
|
}
|
|
1877
1471
|
|
|
1878
|
-
/**
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
const
|
|
1883
|
-
|
|
1884
|
-
totalYelling: 0,
|
|
1885
|
-
totalProfanity: 0,
|
|
1886
|
-
totalAnguish: 0,
|
|
1887
|
-
totalNegation: 0,
|
|
1888
|
-
totalRepetition: 0,
|
|
1889
|
-
totalBlame: 0,
|
|
1890
|
-
totalChars: 0,
|
|
1891
|
-
firstTimestamp: 0,
|
|
1892
|
-
lastTimestamp: 0,
|
|
1893
|
-
};
|
|
1894
|
-
if (!db) return empty;
|
|
1895
|
-
const hasCutoff = cutoff !== null && cutoff !== undefined && cutoff > 0;
|
|
1896
|
-
const stmt = db.prepare(`
|
|
1897
|
-
SELECT
|
|
1898
|
-
COUNT(*) as total_messages,
|
|
1899
|
-
SUM(yelling) as total_yelling,
|
|
1900
|
-
SUM(profanity) as total_profanity,
|
|
1901
|
-
SUM(anguish) as total_anguish,
|
|
1902
|
-
SUM(negation) as total_negation,
|
|
1903
|
-
SUM(repetition) as total_repetition,
|
|
1904
|
-
SUM(blame) as total_blame,
|
|
1905
|
-
SUM(chars) as total_chars,
|
|
1906
|
-
MIN(timestamp) as first_timestamp,
|
|
1907
|
-
MAX(timestamp) as last_timestamp
|
|
1908
|
-
FROM user_messages
|
|
1909
|
-
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
1910
|
-
`);
|
|
1911
|
-
const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as BehaviorOverallRow | undefined;
|
|
1912
|
-
if (!row?.total_messages) return empty;
|
|
1913
|
-
return {
|
|
1914
|
-
totalMessages: row.total_messages,
|
|
1915
|
-
totalYelling: row.total_yelling ?? 0,
|
|
1916
|
-
totalProfanity: row.total_profanity ?? 0,
|
|
1917
|
-
totalAnguish: row.total_anguish ?? 0,
|
|
1918
|
-
totalNegation: row.total_negation ?? 0,
|
|
1919
|
-
totalRepetition: row.total_repetition ?? 0,
|
|
1920
|
-
totalBlame: row.total_blame ?? 0,
|
|
1921
|
-
totalChars: row.total_chars ?? 0,
|
|
1922
|
-
firstTimestamp: row.first_timestamp ?? 0,
|
|
1923
|
-
lastTimestamp: row.last_timestamp ?? 0,
|
|
1924
|
-
};
|
|
1472
|
+
/** Unique prose texts in range that have no cached verdict yet. */
|
|
1473
|
+
export function getPendingFrustrationProse(cutoff?: number | null): PendingProse[] {
|
|
1474
|
+
if (!db) return [];
|
|
1475
|
+
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1476
|
+
const stmt = db.prepare(pendingProseSql(hasCutoff));
|
|
1477
|
+
return (hasCutoff ? stmt.all(cutoff) : stmt.all()) as PendingProse[];
|
|
1925
1478
|
}
|
|
1926
1479
|
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
total_blame: number | null;
|
|
1937
|
-
total_chars: number | null;
|
|
1938
|
-
last_timestamp: number | null;
|
|
1480
|
+
/** Count and total characters of {@link getPendingFrustrationProse} without loading the text. */
|
|
1481
|
+
export function getPendingFrustrationTotals(cutoff?: number | null): { messages: number; chars: number } {
|
|
1482
|
+
if (!db) return { messages: 0, chars: 0 };
|
|
1483
|
+
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1484
|
+
const stmt = db.prepare(
|
|
1485
|
+
`SELECT COUNT(*) AS messages, COALESCE(SUM(LENGTH(prose)), 0) AS chars FROM (${pendingProseSql(hasCutoff)})`,
|
|
1486
|
+
);
|
|
1487
|
+
const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as { messages: number; chars: number } | undefined;
|
|
1488
|
+
return { messages: row?.messages ?? 0, chars: row?.chars ?? 0 };
|
|
1939
1489
|
}
|
|
1940
1490
|
|
|
1941
1491
|
/**
|
|
1942
|
-
*
|
|
1943
|
-
*
|
|
1492
|
+
* Cache (or replace) verdicts, all in one transaction: a judge run lands
|
|
1493
|
+
* dozens per second, and a commit per verdict would contend with ingest for
|
|
1494
|
+
* the write lock every time.
|
|
1944
1495
|
*/
|
|
1945
|
-
export function
|
|
1946
|
-
if (!db) return
|
|
1947
|
-
const
|
|
1948
|
-
const stmt =
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
SUM(anguish) as total_anguish,
|
|
1956
|
-
SUM(negation) as total_negation,
|
|
1957
|
-
SUM(repetition) as total_repetition,
|
|
1958
|
-
SUM(blame) as total_blame,
|
|
1959
|
-
SUM(chars) as total_chars,
|
|
1960
|
-
MAX(timestamp) as last_timestamp
|
|
1961
|
-
FROM user_messages
|
|
1962
|
-
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
1963
|
-
GROUP BY model, provider
|
|
1964
|
-
ORDER BY total_messages DESC
|
|
1965
|
-
`);
|
|
1966
|
-
const rows = (
|
|
1967
|
-
hasCutoff ? stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL, cutoff) : stmt.all(UNKNOWN_MODEL, UNKNOWN_MODEL)
|
|
1968
|
-
) as BehaviorByModelRow[];
|
|
1969
|
-
return rows.map(row => ({
|
|
1970
|
-
model: row.model,
|
|
1971
|
-
provider: row.provider,
|
|
1972
|
-
totalMessages: row.total_messages,
|
|
1973
|
-
totalYelling: row.total_yelling ?? 0,
|
|
1974
|
-
totalProfanity: row.total_profanity ?? 0,
|
|
1975
|
-
totalAnguish: row.total_anguish ?? 0,
|
|
1976
|
-
totalNegation: row.total_negation ?? 0,
|
|
1977
|
-
totalRepetition: row.total_repetition ?? 0,
|
|
1978
|
-
totalBlame: row.total_blame ?? 0,
|
|
1979
|
-
totalChars: row.total_chars ?? 0,
|
|
1980
|
-
lastTimestamp: row.last_timestamp ?? 0,
|
|
1981
|
-
}));
|
|
1496
|
+
export function upsertFrustrationVerdicts(verdicts: readonly FrustrationVerdict[]): void {
|
|
1497
|
+
if (!db || verdicts.length === 0) return;
|
|
1498
|
+
const database = db;
|
|
1499
|
+
const stmt = database.query(
|
|
1500
|
+
`INSERT OR REPLACE INTO frustration_verdicts (prose_hash, p_annoyed, p_angry, target, judge, judged_at)
|
|
1501
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
1502
|
+
);
|
|
1503
|
+
database.transaction(() => {
|
|
1504
|
+
for (const v of verdicts) stmt.run(v.proseHash, v.pAnnoyed, v.pAngry, v.target, v.judge, v.judgedAt);
|
|
1505
|
+
})();
|
|
1982
1506
|
}
|
|
1983
1507
|
|
|
1984
1508
|
/**
|
|
@@ -1990,10 +1514,10 @@ export function getBehaviorByModel(cutoff?: number | null): BehaviorModelStats[]
|
|
|
1990
1514
|
* identity, not the call id alone — provider call ids are not a global
|
|
1991
1515
|
* namespace across unrelated sessions.
|
|
1992
1516
|
*/
|
|
1993
|
-
export function insertToolCalls(calls: ToolCallStats
|
|
1994
|
-
if (!db
|
|
1517
|
+
export function insertToolCalls(calls: Iterable<ToolCallStats>): number {
|
|
1518
|
+
if (!db) return 0;
|
|
1995
1519
|
|
|
1996
|
-
const stmt = db.
|
|
1520
|
+
const stmt = db.query(`
|
|
1997
1521
|
INSERT OR IGNORE INTO tool_calls (
|
|
1998
1522
|
session_file, entry_id, tool_call_id, folder, tool_name,
|
|
1999
1523
|
model, provider, timestamp, agent_type, calls_in_turn, args_chars
|
|
@@ -2041,10 +1565,10 @@ export function insertToolCalls(calls: ToolCallStats[]): number {
|
|
|
2041
1565
|
* guard makes re-syncs idempotent; rows skipped by the fork guard simply
|
|
2042
1566
|
* never match.
|
|
2043
1567
|
*/
|
|
2044
|
-
export function updateToolResults(links: ToolResultLink
|
|
2045
|
-
if (!db
|
|
1568
|
+
export function updateToolResults(links: Iterable<ToolResultLink>): number {
|
|
1569
|
+
if (!db) return 0;
|
|
2046
1570
|
|
|
2047
|
-
const stmt = db.
|
|
1571
|
+
const stmt = db.query(`
|
|
2048
1572
|
UPDATE tool_calls
|
|
2049
1573
|
SET result_chars = ?, is_error = ?
|
|
2050
1574
|
WHERE session_file = ? AND tool_call_id = ? AND result_chars IS NULL
|
|
@@ -2060,131 +1584,3 @@ export function updateToolResults(links: ToolResultLink[]): number {
|
|
|
2060
1584
|
apply();
|
|
2061
1585
|
return updated;
|
|
2062
1586
|
}
|
|
2063
|
-
|
|
2064
|
-
/**
|
|
2065
|
-
* Shared SELECT list for tool aggregates. Real provider usage comes from the
|
|
2066
|
-
* invoking assistant turn (`messages` join) divided by `calls_in_turn`, so
|
|
2067
|
-
* per-tool token/cost shares stay additive across tools. The unpriced share
|
|
2068
|
-
* reuses the request predicate against the joined message, whose stored cost
|
|
2069
|
-
* it attributes.
|
|
2070
|
-
*/
|
|
2071
|
-
const TOOL_AGGREGATE_COLUMNS = `
|
|
2072
|
-
COUNT(*) as calls,
|
|
2073
|
-
SUM(CASE WHEN t.is_error = 1 THEN 1 ELSE 0 END) as errors,
|
|
2074
|
-
SUM(t.args_chars) as args_chars,
|
|
2075
|
-
SUM(COALESCE(t.result_chars, 0)) as result_chars,
|
|
2076
|
-
SUM(COALESCE(m.total_tokens, 0) * 1.0 / t.calls_in_turn) as total_tokens_share,
|
|
2077
|
-
SUM(COALESCE(m.output_tokens, 0) * 1.0 / t.calls_in_turn) as output_tokens_share,
|
|
2078
|
-
SUM(COALESCE(m.cost_total, 0) / t.calls_in_turn) as cost_share,
|
|
2079
|
-
SUM(${unpricedRequestSql("m.")} * 1.0 / t.calls_in_turn) as unpriced_requests_share,
|
|
2080
|
-
MAX(t.timestamp) as last_used
|
|
2081
|
-
`;
|
|
2082
|
-
|
|
2083
|
-
interface ToolAggregateRow {
|
|
2084
|
-
tool_name: string;
|
|
2085
|
-
model?: string;
|
|
2086
|
-
provider?: string;
|
|
2087
|
-
calls: number;
|
|
2088
|
-
errors: number;
|
|
2089
|
-
args_chars: number | null;
|
|
2090
|
-
result_chars: number | null;
|
|
2091
|
-
total_tokens_share: number | null;
|
|
2092
|
-
output_tokens_share: number | null;
|
|
2093
|
-
cost_share: number | null;
|
|
2094
|
-
unpriced_requests_share: number | null;
|
|
2095
|
-
last_used: number;
|
|
2096
|
-
}
|
|
2097
|
-
|
|
2098
|
-
function rowToToolUsage(row: ToolAggregateRow): ToolUsageStats {
|
|
2099
|
-
return {
|
|
2100
|
-
tool: row.tool_name,
|
|
2101
|
-
calls: row.calls,
|
|
2102
|
-
errors: row.errors,
|
|
2103
|
-
argsChars: row.args_chars ?? 0,
|
|
2104
|
-
resultChars: row.result_chars ?? 0,
|
|
2105
|
-
totalTokensShare: row.total_tokens_share ?? 0,
|
|
2106
|
-
outputTokensShare: row.output_tokens_share ?? 0,
|
|
2107
|
-
costShare: row.cost_share ?? 0,
|
|
2108
|
-
unpricedRequestsShare: row.unpriced_requests_share ?? 0,
|
|
2109
|
-
lastUsed: row.last_used,
|
|
2110
|
-
};
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
/**
|
|
2114
|
-
* Get tool usage aggregated by tool name.
|
|
2115
|
-
*/
|
|
2116
|
-
export function getToolStats(cutoff?: number): ToolUsageStats[] {
|
|
2117
|
-
if (!db) return [];
|
|
2118
|
-
|
|
2119
|
-
const hasCutoff = cutoff !== undefined && cutoff > 0;
|
|
2120
|
-
const stmt = db.prepare(`
|
|
2121
|
-
SELECT t.tool_name, ${TOOL_AGGREGATE_COLUMNS}
|
|
2122
|
-
FROM tool_calls t
|
|
2123
|
-
LEFT JOIN messages m ON m.session_file = t.session_file AND m.entry_id = t.entry_id
|
|
2124
|
-
${hasCutoff ? "WHERE t.timestamp >= ?" : ""}
|
|
2125
|
-
GROUP BY t.tool_name
|
|
2126
|
-
ORDER BY calls DESC
|
|
2127
|
-
`);
|
|
2128
|
-
|
|
2129
|
-
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as ToolAggregateRow[];
|
|
2130
|
-
return rows.map(rowToToolUsage);
|
|
2131
|
-
}
|
|
2132
|
-
|
|
2133
|
-
/**
|
|
2134
|
-
* Get tool usage aggregated by (tool, model, provider).
|
|
2135
|
-
*/
|
|
2136
|
-
export function getToolStatsByModel(cutoff?: number): ToolModelStats[] {
|
|
2137
|
-
if (!db) return [];
|
|
2138
|
-
|
|
2139
|
-
const hasCutoff = cutoff !== undefined && cutoff > 0;
|
|
2140
|
-
const stmt = db.prepare(`
|
|
2141
|
-
SELECT t.tool_name, t.model, t.provider, ${TOOL_AGGREGATE_COLUMNS}
|
|
2142
|
-
FROM tool_calls t
|
|
2143
|
-
LEFT JOIN messages m ON m.session_file = t.session_file AND m.entry_id = t.entry_id
|
|
2144
|
-
${hasCutoff ? "WHERE t.timestamp >= ?" : ""}
|
|
2145
|
-
GROUP BY t.tool_name, t.model, t.provider
|
|
2146
|
-
ORDER BY calls DESC
|
|
2147
|
-
`);
|
|
2148
|
-
|
|
2149
|
-
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as ToolAggregateRow[];
|
|
2150
|
-
return rows.map(row => ({
|
|
2151
|
-
...rowToToolUsage(row),
|
|
2152
|
-
model: row.model ?? "",
|
|
2153
|
-
provider: row.provider ?? "",
|
|
2154
|
-
}));
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
|
-
/**
|
|
2158
|
-
* Get tool-call time series (one point per bucket per tool).
|
|
2159
|
-
*/
|
|
2160
|
-
export function getToolTimeSeries(
|
|
2161
|
-
days = 14,
|
|
2162
|
-
cutoff?: number | null,
|
|
2163
|
-
bucketMs = 24 * 60 * 60 * 1000,
|
|
2164
|
-
): ToolTimeSeriesPoint[] {
|
|
2165
|
-
if (!db) return [];
|
|
2166
|
-
|
|
2167
|
-
const hasCutoff = cutoff !== null;
|
|
2168
|
-
const seriesCutoff = hasCutoff ? (cutoff ?? Date.now() - days * 24 * 60 * 60 * 1000) : 0;
|
|
2169
|
-
|
|
2170
|
-
const stmt = db.prepare(`
|
|
2171
|
-
SELECT
|
|
2172
|
-
(timestamp / ?) * ? as bucket,
|
|
2173
|
-
tool_name,
|
|
2174
|
-
COUNT(*) as calls,
|
|
2175
|
-
SUM(CASE WHEN is_error = 1 THEN 1 ELSE 0 END) as errors
|
|
2176
|
-
FROM tool_calls
|
|
2177
|
-
${hasCutoff ? "WHERE timestamp >= ?" : ""}
|
|
2178
|
-
GROUP BY bucket, tool_name
|
|
2179
|
-
ORDER BY bucket ASC
|
|
2180
|
-
`);
|
|
2181
|
-
|
|
2182
|
-
const rowsRaw = hasCutoff ? stmt.all(bucketMs, bucketMs, seriesCutoff) : stmt.all(bucketMs, bucketMs);
|
|
2183
|
-
const rows = rowsRaw as Array<{ bucket: number; tool_name: string; calls: number; errors: number }>;
|
|
2184
|
-
return rows.map(row => ({
|
|
2185
|
-
timestamp: row.bucket,
|
|
2186
|
-
tool: row.tool_name,
|
|
2187
|
-
calls: row.calls,
|
|
2188
|
-
errors: row.errors,
|
|
2189
|
-
}));
|
|
2190
|
-
}
|