@linxiraos/pi-stats 1.1.26 → 1.2.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 +12 -5
- package/build.ts +4 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +1 -0
- package/dist/client/index.js +96 -96
- package/dist/types/aggregator.d.ts +4 -1
- package/dist/types/client/api.d.ts +5 -1
- package/dist/types/client/app/nav.d.ts +5 -2
- package/dist/types/client/data/formatters.d.ts +7 -2
- 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/data/range.d.ts +4 -3
- package/dist/types/client/i18n/format.d.ts +9 -0
- package/dist/types/client/i18n/index.d.ts +18 -0
- package/dist/types/client/i18n/messages/charts.d.ts +8 -0
- package/dist/types/client/i18n/messages/common.d.ts +153 -0
- package/dist/types/client/i18n/messages/costs.d.ts +92 -0
- package/dist/types/client/i18n/messages/en.d.ts +1255 -0
- package/dist/types/client/i18n/messages/errors.d.ts +78 -0
- package/dist/types/client/i18n/messages/frustration.d.ts +191 -0
- package/dist/types/client/i18n/messages/gain.d.ts +54 -0
- package/dist/types/client/i18n/messages/models.d.ts +94 -0
- package/dist/types/client/i18n/messages/nav.d.ts +46 -0
- package/dist/types/client/i18n/messages/overview.d.ts +80 -0
- package/dist/types/client/i18n/messages/projects.d.ts +84 -0
- package/dist/types/client/i18n/messages/providers.d.ts +142 -0
- package/dist/types/client/i18n/messages/requests.d.ts +72 -0
- package/dist/types/client/i18n/messages/tools.d.ts +119 -0
- package/dist/types/client/i18n/messages/traces.d.ts +144 -0
- package/dist/types/client/i18n/messages/ui.d.ts +44 -0
- package/dist/types/client/i18n/messages/zh-CN.d.ts +13 -0
- package/dist/types/client/i18n/types.d.ts +15 -0
- 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 -21
- package/dist/types/embedded-client.d.ts +5 -0
- package/dist/types/frustration.d.ts +18 -9
- package/dist/types/live.d.ts +5 -3
- package/dist/types/locale.d.ts +16 -0
- package/dist/types/parser.d.ts +0 -17
- package/dist/types/rollup.d.ts +9 -1
- package/dist/types/server.d.ts +6 -0
- package/dist/types/shared-types.d.ts +7 -0
- package/dist/types/trace.d.ts +18 -6
- package/dist/types/types.d.ts +13 -1
- package/dist/types/user-metrics.d.ts +23 -1
- package/package.json +5 -5
- package/src/aggregator.ts +31 -11
- package/src/client/api.ts +40 -5
- package/src/client/app/LiveChip.tsx +14 -13
- package/src/client/app/Shell.tsx +19 -13
- package/src/client/app/ThemeToggle.tsx +4 -3
- package/src/client/app/nav.ts +19 -16
- package/src/client/data/formatters.ts +80 -13
- 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/data/range.ts +34 -14
- package/src/client/data/series.ts +7 -1
- package/src/client/favicon.ico +0 -0
- package/src/client/i18n/format.ts +28 -0
- package/src/client/i18n/index.ts +53 -0
- package/src/client/i18n/messages/charts.ts +8 -0
- package/src/client/i18n/messages/common.ts +157 -0
- package/src/client/i18n/messages/costs.ts +104 -0
- package/src/client/i18n/messages/en.ts +40 -0
- package/src/client/i18n/messages/errors.ts +80 -0
- package/src/client/i18n/messages/frustration.ts +198 -0
- package/src/client/i18n/messages/gain.ts +54 -0
- package/src/client/i18n/messages/models.ts +94 -0
- package/src/client/i18n/messages/nav.ts +46 -0
- package/src/client/i18n/messages/overview.ts +90 -0
- package/src/client/i18n/messages/projects.ts +84 -0
- package/src/client/i18n/messages/providers.ts +165 -0
- package/src/client/i18n/messages/requests.ts +73 -0
- package/src/client/i18n/messages/tools.ts +120 -0
- package/src/client/i18n/messages/traces.ts +160 -0
- package/src/client/i18n/messages/ui.ts +46 -0
- package/src/client/i18n/messages/zh-CN.ts +43 -0
- package/src/client/i18n/types.ts +19 -0
- package/src/client/index.tsx +4 -0
- package/src/client/routes/CostsRoute.tsx +61 -48
- package/src/client/routes/ErrorsRoute.tsx +71 -47
- package/src/client/routes/FrustrationRoute.tsx +131 -116
- package/src/client/routes/GainRoute.tsx +42 -30
- package/src/client/routes/ModelsRoute.tsx +90 -73
- package/src/client/routes/OverviewRoute.tsx +109 -63
- package/src/client/routes/ProjectsRoute.tsx +73 -46
- package/src/client/routes/ProvidersRoute.tsx +151 -114
- package/src/client/routes/RequestsRoute.tsx +69 -46
- package/src/client/routes/ToolsRoute.tsx +108 -94
- package/src/client/routes/TracesRoute.tsx +25 -23
- package/src/client/styles.css +5 -2
- package/src/client/traces/AggregatesPanel.tsx +19 -8
- package/src/client/traces/Minimap.tsx +65 -27
- package/src/client/traces/SpanDrawer.tsx +46 -21
- package/src/client/traces/SummaryStrip.tsx +27 -11
- package/src/client/traces/TimelineCanvas.tsx +245 -116
- package/src/client/traces/TraceView.tsx +51 -36
- package/src/client/traces/TranscriptList.tsx +20 -11
- package/src/client/traces/traces.css +26 -0
- package/src/client/ui/JsonBlock.tsx +3 -2
- package/src/client/ui/Modal.tsx +3 -2
- package/src/client/ui/RequestDrawer.tsx +33 -30
- package/src/client/ui/SearchInput.tsx +2 -1
- package/src/client/ui/States.tsx +3 -2
- package/src/client/ui/Table.tsx +7 -3
- package/src/db.ts +150 -135
- package/src/embedded-client.ts +9 -0
- package/src/frustration.ts +9 -30
- package/src/index.ts +2 -1
- package/src/live.ts +36 -11
- package/src/locale.ts +29 -0
- package/src/parser.ts +37 -72
- package/src/port-conflict.ts +1 -1
- package/src/prompts/judge/frustration.json +19 -0
- package/src/rollup.ts +41 -20
- package/src/server.ts +67 -27
- package/src/shared-types.ts +7 -0
- package/src/trace.ts +54 -40
- package/src/types.ts +13 -1
- package/src/user-metrics.ts +220 -6
package/src/db.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
|
-
import type { Usage } from "@linxiraos/pi-ai";
|
|
3
|
+
import type { ServiceTier, Usage } from "@linxiraos/pi-ai";
|
|
4
4
|
import {
|
|
5
5
|
calculateUncachedInputCost,
|
|
6
6
|
calculateUsageCost,
|
|
@@ -8,9 +8,9 @@ import {
|
|
|
8
8
|
getBundledModel,
|
|
9
9
|
} from "@linxiraos/pi-catalog/models";
|
|
10
10
|
import type { ModelCost } from "@linxiraos/pi-catalog/types";
|
|
11
|
-
import { getConfigRootDir, getStatsDbPath } from "@linxiraos/pi-utils";
|
|
11
|
+
import { getConfigRootDir, getStatsDbPath, VERSION } from "@linxiraos/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,
|
|
@@ -88,7 +88,10 @@ const BACKFILL_COMPLETE = "complete";
|
|
|
88
88
|
const BACKFILL_PENDING = "pending";
|
|
89
89
|
const USER_MESSAGES_BACKFILL_KEY = "user_messages_v9";
|
|
90
90
|
const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
|
|
91
|
-
|
|
91
|
+
// v2: the parser also records the served service tier per message, so a full
|
|
92
|
+
// re-parse fills `service_tier` and re-derives ultrafast premium counts that the
|
|
93
|
+
// v1 pass (priority only) left at zero.
|
|
94
|
+
const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v2";
|
|
92
95
|
const AGENT_TYPE_BACKFILL_KEY = "agent_type_v1";
|
|
93
96
|
const FORK_DEDUPE_KEY = "fork_dedupe_v1";
|
|
94
97
|
// v2: tool-name sanitization at ingest (see `sanitizeToolName` in parser.ts)
|
|
@@ -106,6 +109,13 @@ const COST_REINGEST_BACKFILL_KEY = "messages_cost_reingest_v1";
|
|
|
106
109
|
// above is already spent for them — without this one, every historical row
|
|
107
110
|
// keeps `cost_unpriced = 0` and unknown scheduled spend reports as free.
|
|
108
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";
|
|
109
119
|
function shouldResetBackfill(value: string | undefined): boolean {
|
|
110
120
|
return value !== BACKFILL_COMPLETE && value !== BACKFILL_PENDING;
|
|
111
121
|
}
|
|
@@ -127,7 +137,7 @@ export async function initDb(): Promise<Database> {
|
|
|
127
137
|
// Whether `messages` predates this init — drives the one-time agent_type
|
|
128
138
|
// backfill below, so it must be sampled before CREATE TABLE adds the table.
|
|
129
139
|
const messagesTableExisted =
|
|
130
|
-
db.
|
|
140
|
+
db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages'").get() !== undefined;
|
|
131
141
|
|
|
132
142
|
// Create tables
|
|
133
143
|
db.run(`
|
|
@@ -158,6 +168,7 @@ export async function initDb(): Promise<Database> {
|
|
|
158
168
|
cost_no_cache_input REAL,
|
|
159
169
|
cost_unpriced INTEGER NOT NULL DEFAULT 0,
|
|
160
170
|
agent_type TEXT NOT NULL DEFAULT 'main',
|
|
171
|
+
service_tier TEXT,
|
|
161
172
|
UNIQUE(session_file, entry_id)
|
|
162
173
|
);
|
|
163
174
|
|
|
@@ -193,6 +204,8 @@ export async function initDb(): Promise<Database> {
|
|
|
193
204
|
negation INTEGER NOT NULL DEFAULT 0,
|
|
194
205
|
repetition INTEGER NOT NULL DEFAULT 0,
|
|
195
206
|
blame INTEGER NOT NULL DEFAULT 0,
|
|
207
|
+
emphasis INTEGER NOT NULL DEFAULT 0,
|
|
208
|
+
unsupported_script INTEGER NOT NULL DEFAULT 0,
|
|
196
209
|
prose TEXT NOT NULL DEFAULT '',
|
|
197
210
|
prose_hash TEXT NOT NULL DEFAULT '',
|
|
198
211
|
UNIQUE(session_file, entry_id)
|
|
@@ -241,23 +254,27 @@ export async function initDb(): Promise<Database> {
|
|
|
241
254
|
);
|
|
242
255
|
`);
|
|
243
256
|
|
|
244
|
-
const offsetColumns = db.
|
|
257
|
+
const offsetColumns = db.query("PRAGMA table_info(file_offsets)").all() as { name: string }[];
|
|
245
258
|
if (!offsetColumns.some(column => column.name === "parser_state")) {
|
|
246
259
|
db.run("ALTER TABLE file_offsets ADD COLUMN parser_state TEXT");
|
|
247
260
|
}
|
|
248
|
-
const messageColumns = db.
|
|
261
|
+
const messageColumns = db.query("PRAGMA table_info(messages)").all() as { name: string }[];
|
|
249
262
|
if (!messageColumns.some(column => column.name === "premium_requests")) {
|
|
250
263
|
db.run("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
|
|
251
264
|
}
|
|
252
265
|
if (!messageColumns.some(column => column.name === "cost_no_cache_input")) {
|
|
253
266
|
db.run("ALTER TABLE messages ADD COLUMN cost_no_cache_input REAL");
|
|
254
267
|
}
|
|
268
|
+
// Rows ingested before this column existed carry no served tier; a re-parse
|
|
269
|
+
// fills them from the session's assistant messages.
|
|
270
|
+
if (!messageColumns.some(column => column.name === "service_tier")) {
|
|
271
|
+
db.run("ALTER TABLE messages ADD COLUMN service_tier TEXT");
|
|
272
|
+
}
|
|
255
273
|
// Rows ingested before this column existed default to 0 (not unpriced), so
|
|
256
274
|
// their epoch-sentinel zeros read as free until a re-parse rewrites them.
|
|
257
275
|
if (!messageColumns.some(column => column.name === "cost_unpriced")) {
|
|
258
276
|
db.run("ALTER TABLE messages ADD COLUMN cost_unpriced INTEGER NOT NULL DEFAULT 0");
|
|
259
277
|
}
|
|
260
|
-
db.run("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
|
|
261
278
|
// Token-usage-by-agent: each message is classified main / subagent / advisor
|
|
262
279
|
// from its transcript path. A brand-new table gets the column from CREATE
|
|
263
280
|
// TABLE and the parser labels rows at insert time; a pre-existing table gets
|
|
@@ -273,7 +290,7 @@ export async function initDb(): Promise<Database> {
|
|
|
273
290
|
// sentinel write (process killed in between) still reclassifies on the next
|
|
274
291
|
// init instead of silently leaving every row as the 'main' default. A
|
|
275
292
|
// brand-new empty table has nothing to reclassify, so it settles COMPLETE.
|
|
276
|
-
db.
|
|
293
|
+
db.query("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)").run(
|
|
277
294
|
AGENT_TYPE_BACKFILL_KEY,
|
|
278
295
|
messagesTableExisted ? BACKFILL_PENDING : BACKFILL_COMPLETE,
|
|
279
296
|
);
|
|
@@ -295,10 +312,15 @@ export async function initDb(): Promise<Database> {
|
|
|
295
312
|
// v6 -> v7: dropped dot runs from `anguish`, technical-collision and
|
|
296
313
|
// opinion words from the profanity list; gated yelling on
|
|
297
314
|
// multi-word caps and bare `no` on interjection use.
|
|
315
|
+
// v9 -> v10: added `emphasis` (full-width bursts, repeated Han runs and
|
|
316
|
+
// repeated denials - the shouting proxy capitalisation cannot
|
|
317
|
+
// see) and `unsupported_script` (prose whose script no lexicon
|
|
318
|
+
// covers, so its zero counts mean "unscored", not "calm"),
|
|
319
|
+
// together with the Chinese/voice-input lexicon layer.
|
|
298
320
|
// v7 -> v8: `no-op` compounds no longer count as negation; recovered
|
|
299
321
|
// measured false negatives: `:(` emoticons -> anguish,
|
|
300
322
|
// `why (would|did) you` -> blame, `makes no sense` -> negation.
|
|
301
|
-
const userMessageColumns = db.
|
|
323
|
+
const userMessageColumns = db.query("PRAGMA table_info(user_messages)").all() as {
|
|
302
324
|
name: string;
|
|
303
325
|
}[];
|
|
304
326
|
const hasStaleColumn =
|
|
@@ -307,8 +329,9 @@ export async function initDb(): Promise<Database> {
|
|
|
307
329
|
userMessageColumns.some(column => column.name === "drama_runs") ||
|
|
308
330
|
userMessageColumns.some(column => column.name === "yelling_sentences"));
|
|
309
331
|
const hasV4Columns = userMessageColumns.some(column => column.name === "negation");
|
|
332
|
+
const hasV10Columns = userMessageColumns.some(column => column.name === "emphasis");
|
|
310
333
|
const hasOldUserMessages = userMessageColumns.length > 0;
|
|
311
|
-
if (hasStaleColumn || (hasOldUserMessages && !hasV4Columns)) {
|
|
334
|
+
if (hasStaleColumn || (hasOldUserMessages && (!hasV4Columns || !hasV10Columns))) {
|
|
312
335
|
db.run("DROP TABLE user_messages");
|
|
313
336
|
db.run(`
|
|
314
337
|
CREATE TABLE user_messages (
|
|
@@ -327,6 +350,8 @@ export async function initDb(): Promise<Database> {
|
|
|
327
350
|
negation INTEGER NOT NULL DEFAULT 0,
|
|
328
351
|
repetition INTEGER NOT NULL DEFAULT 0,
|
|
329
352
|
blame INTEGER NOT NULL DEFAULT 0,
|
|
353
|
+
emphasis INTEGER NOT NULL DEFAULT 0,
|
|
354
|
+
unsupported_script INTEGER NOT NULL DEFAULT 0,
|
|
330
355
|
prose TEXT NOT NULL DEFAULT '',
|
|
331
356
|
prose_hash TEXT NOT NULL DEFAULT '',
|
|
332
357
|
UNIQUE(session_file, entry_id)
|
|
@@ -491,17 +516,20 @@ function calculateNoCacheInputCost(
|
|
|
491
516
|
}
|
|
492
517
|
|
|
493
518
|
function backfillMissingCatalogCosts(database: Database): void {
|
|
519
|
+
const ran = database.query("SELECT value FROM meta WHERE key = ?").get(CATALOG_COST_BACKFILL_KEY) as
|
|
520
|
+
| { value: string }
|
|
521
|
+
| undefined;
|
|
522
|
+
if (ran?.value === VERSION) return;
|
|
523
|
+
const markRan = database.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
|
|
494
524
|
const rows = database
|
|
495
|
-
.
|
|
525
|
+
.query(`
|
|
496
526
|
SELECT id, provider, model, timestamp, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
|
|
497
527
|
FROM messages
|
|
498
528
|
WHERE cost_total = 0 AND total_tokens > 0
|
|
499
529
|
`)
|
|
500
530
|
.all() as CostBackfillRow[];
|
|
501
531
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const update = database.prepare(`
|
|
532
|
+
const update = database.query(`
|
|
505
533
|
UPDATE messages
|
|
506
534
|
SET cost_input = ?, cost_output = ?, cost_cache_read = ?, cost_cache_write = ?, cost_total = ?
|
|
507
535
|
WHERE id = ?
|
|
@@ -532,22 +560,27 @@ function backfillMissingCatalogCosts(database: Database): void {
|
|
|
532
560
|
|
|
533
561
|
update.run(cost.input, cost.output, cost.cacheRead, cost.cacheWrite, cost.total, row.id);
|
|
534
562
|
}
|
|
563
|
+
markRan.run(CATALOG_COST_BACKFILL_KEY, VERSION);
|
|
535
564
|
});
|
|
536
565
|
|
|
537
566
|
applyBackfill();
|
|
538
567
|
}
|
|
539
568
|
|
|
540
569
|
function backfillNoCacheInputCosts(database: Database): void {
|
|
570
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(NO_CACHE_INPUT_BACKFILL_KEY) as
|
|
571
|
+
| { value: string }
|
|
572
|
+
| undefined;
|
|
573
|
+
if (row?.value === BACKFILL_COMPLETE) return;
|
|
574
|
+
const markComplete = database.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
|
|
541
575
|
const rows = database
|
|
542
|
-
.
|
|
576
|
+
.query(`
|
|
543
577
|
SELECT id, provider, model, timestamp, input_tokens, cache_read_tokens, cache_write_tokens
|
|
544
578
|
FROM messages
|
|
545
579
|
WHERE cost_no_cache_input IS NULL
|
|
546
580
|
`)
|
|
547
581
|
.all() as NoCacheInputCostBackfillRow[];
|
|
548
|
-
if (rows.length === 0) return;
|
|
549
582
|
|
|
550
|
-
const update = database.
|
|
583
|
+
const update = database.query("UPDATE messages SET cost_no_cache_input = ? WHERE id = ?");
|
|
551
584
|
const applyBackfill = database.transaction(() => {
|
|
552
585
|
for (const row of rows) {
|
|
553
586
|
const cost = calculateNoCacheInputCost(
|
|
@@ -563,6 +596,7 @@ function backfillNoCacheInputCosts(database: Database): void {
|
|
|
563
596
|
);
|
|
564
597
|
update.run(cost ?? 0, row.id);
|
|
565
598
|
}
|
|
599
|
+
markComplete.run(NO_CACHE_INPUT_BACKFILL_KEY, BACKFILL_COMPLETE);
|
|
566
600
|
});
|
|
567
601
|
applyBackfill();
|
|
568
602
|
}
|
|
@@ -653,18 +687,24 @@ function* parsedRows<T>(
|
|
|
653
687
|
for (const result of results) yield* select(result);
|
|
654
688
|
}
|
|
655
689
|
|
|
656
|
-
/**
|
|
690
|
+
/**
|
|
691
|
+
* Commit distinct transcripts' rows, reconciliation state, and cursors together; failure rolls back the batch.
|
|
692
|
+
* `changes` counts stored rows inserted, updated or deleted (cursor bookkeeping excluded), so callers can
|
|
693
|
+
* tell a batch that changed nothing.
|
|
694
|
+
*/
|
|
657
695
|
export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
658
696
|
processed: number;
|
|
659
697
|
files: number;
|
|
660
698
|
reconcile: boolean;
|
|
699
|
+
changes: number;
|
|
661
700
|
} {
|
|
662
|
-
if (!db) return { processed: 0, files: 0, reconcile: false };
|
|
701
|
+
if (!db) return { processed: 0, files: 0, reconcile: false, changes: 0 };
|
|
663
702
|
const database = db;
|
|
664
703
|
return database.transaction(() => {
|
|
665
704
|
let processed = 0;
|
|
666
705
|
let files = 0;
|
|
667
706
|
let reconcile = false;
|
|
707
|
+
let changes = 0;
|
|
668
708
|
const writes: ParseSessionResult[] = [];
|
|
669
709
|
for (const { sessionFile, result, rebuild, replay } of sessions) {
|
|
670
710
|
const parserState = result.parserState;
|
|
@@ -749,27 +789,34 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
|
749
789
|
if (absentMessages.size > 0 || absentUsers.size > 0 || absentTools.size > 0) {
|
|
750
790
|
reconcile = true;
|
|
751
791
|
for (const id of absentMessages) {
|
|
752
|
-
database
|
|
792
|
+
changes += database
|
|
753
793
|
.query("DELETE FROM messages WHERE session_file = ? AND entry_id = ?")
|
|
754
|
-
.run(sessionFile, id);
|
|
794
|
+
.run(sessionFile, id).changes;
|
|
755
795
|
}
|
|
756
796
|
for (const id of absentUsers) {
|
|
757
|
-
database
|
|
797
|
+
changes += database
|
|
758
798
|
.query("DELETE FROM user_messages WHERE session_file = ? AND entry_id = ?")
|
|
759
|
-
.run(sessionFile, id);
|
|
799
|
+
.run(sessionFile, id).changes;
|
|
760
800
|
}
|
|
761
801
|
for (const id of absentTools) {
|
|
762
|
-
database
|
|
802
|
+
changes += database
|
|
763
803
|
.query("DELETE FROM tool_calls WHERE session_file = ? AND tool_call_id = ?")
|
|
764
|
-
.run(sessionFile, id);
|
|
804
|
+
.run(sessionFile, id).changes;
|
|
765
805
|
}
|
|
766
806
|
}
|
|
767
807
|
}
|
|
768
808
|
if (replace) {
|
|
769
|
-
if (messages.length > 0)
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
if (
|
|
809
|
+
if (messages.length > 0) {
|
|
810
|
+
changes += database.query("DELETE FROM messages WHERE session_file = ?").run(sessionFile).changes;
|
|
811
|
+
}
|
|
812
|
+
if (users.length > 0) {
|
|
813
|
+
changes += database
|
|
814
|
+
.query("DELETE FROM user_messages WHERE session_file = ?")
|
|
815
|
+
.run(sessionFile).changes;
|
|
816
|
+
}
|
|
817
|
+
if (tools.length > 0) {
|
|
818
|
+
changes += database.query("DELETE FROM tool_calls WHERE session_file = ?").run(sessionFile).changes;
|
|
819
|
+
}
|
|
773
820
|
}
|
|
774
821
|
}
|
|
775
822
|
writes.push(rows);
|
|
@@ -778,11 +825,12 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
|
778
825
|
if (count > 0) files++;
|
|
779
826
|
}
|
|
780
827
|
// Remove replaced rows before choosing new fork owners, then write each table once per batch.
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
828
|
+
changes +=
|
|
829
|
+
insertMessageStats(parsedRows(writes, result => result.stats)) +
|
|
830
|
+
insertUserMessageStats(parsedRows(writes, result => result.userStats)) +
|
|
831
|
+
updateUserMessageLinks(parsedRows(writes, result => result.userLinks)) +
|
|
832
|
+
insertToolCalls(parsedRows(writes, result => result.toolCalls)) +
|
|
833
|
+
updateToolResults(parsedRows(writes, result => result.toolResults));
|
|
786
834
|
for (const { sessionFile, result } of sessions) {
|
|
787
835
|
if (result.parserState) {
|
|
788
836
|
setFileOffset(sessionFile, result.newOffset, result.parserState.mtimeMs, result.parserState);
|
|
@@ -791,16 +839,26 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
|
|
|
791
839
|
if (reconcile) {
|
|
792
840
|
database.query("INSERT OR REPLACE INTO meta (key, value) VALUES ('session_reconciliation', 'pending')").run();
|
|
793
841
|
}
|
|
794
|
-
return { processed, files, reconcile };
|
|
842
|
+
return { processed, files, reconcile, changes };
|
|
795
843
|
})();
|
|
796
844
|
}
|
|
797
845
|
|
|
846
|
+
/**
|
|
847
|
+
* SQLite `PRAGMA data_version` of the open connection, or null before
|
|
848
|
+
* {@link initDb}. It moves only when another connection (another omp process)
|
|
849
|
+
* commits, so a caller can detect foreign writes without scanning.
|
|
850
|
+
*/
|
|
851
|
+
export function getDataVersion(): number | null {
|
|
852
|
+
if (!db) return null;
|
|
853
|
+
return db.query<{ data_version: number }, []>("PRAGMA data_version").get()?.data_version ?? null;
|
|
854
|
+
}
|
|
855
|
+
|
|
798
856
|
export function prepareSessionSync(): boolean {
|
|
799
|
-
return Boolean(db?.
|
|
857
|
+
return Boolean(db?.query("SELECT 1 FROM meta WHERE key = 'session_reconciliation'").get());
|
|
800
858
|
}
|
|
801
859
|
|
|
802
860
|
export function completeSessionSync(reconcile: boolean): void {
|
|
803
|
-
if (!reconcile) db?.
|
|
861
|
+
if (!reconcile) db?.query("DELETE FROM meta WHERE key = 'session_reconciliation'").run();
|
|
804
862
|
}
|
|
805
863
|
|
|
806
864
|
/**
|
|
@@ -828,9 +886,9 @@ export function insertMessageStats(stats: Iterable<MessageStatsInput>): number {
|
|
|
828
886
|
duration, ttft, stop_reason, error_message,
|
|
829
887
|
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests,
|
|
830
888
|
cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total, cost_no_cache_input,
|
|
831
|
-
cost_unpriced, agent_type
|
|
889
|
+
cost_unpriced, agent_type, service_tier
|
|
832
890
|
)
|
|
833
|
-
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
891
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
834
892
|
WHERE NOT EXISTS (
|
|
835
893
|
SELECT 1 FROM messages
|
|
836
894
|
WHERE entry_id = ? AND timestamp = ? AND session_file <> ?
|
|
@@ -843,7 +901,8 @@ export function insertMessageStats(stats: Iterable<MessageStatsInput>): number {
|
|
|
843
901
|
cost_cache_write = excluded.cost_cache_write,
|
|
844
902
|
cost_total = excluded.cost_total,
|
|
845
903
|
cost_no_cache_input = excluded.cost_no_cache_input,
|
|
846
|
-
cost_unpriced = excluded.cost_unpriced
|
|
904
|
+
cost_unpriced = excluded.cost_unpriced,
|
|
905
|
+
service_tier = excluded.service_tier
|
|
847
906
|
`);
|
|
848
907
|
|
|
849
908
|
let inserted = 0;
|
|
@@ -877,6 +936,7 @@ export function insertMessageStats(stats: Iterable<MessageStatsInput>): number {
|
|
|
877
936
|
noCacheInputCost,
|
|
878
937
|
unpriced ? 1 : 0,
|
|
879
938
|
s.agentType,
|
|
939
|
+
s.serviceTier ?? null,
|
|
880
940
|
// `WHERE NOT EXISTS` binds: skip when a different session_file
|
|
881
941
|
// already holds this (entry_id, timestamp).
|
|
882
942
|
s.entryId,
|
|
@@ -896,7 +956,7 @@ export function insertMessageStats(stats: Iterable<MessageStatsInput>): number {
|
|
|
896
956
|
*/
|
|
897
957
|
export function getMessageCount(): number {
|
|
898
958
|
if (!db) return 0;
|
|
899
|
-
const stmt = db.
|
|
959
|
+
const stmt = db.query("SELECT COUNT(*) as count FROM messages");
|
|
900
960
|
const row = stmt.get() as { count: number };
|
|
901
961
|
return row.count;
|
|
902
962
|
}
|
|
@@ -941,13 +1001,14 @@ function rowToMessageStats(row: any): MessageStats {
|
|
|
941
1001
|
},
|
|
942
1002
|
},
|
|
943
1003
|
agentType: (row.agent_type as AgentType) ?? "main",
|
|
1004
|
+
serviceTier: (row.service_tier as ServiceTier | null) ?? null,
|
|
944
1005
|
costUnpriced: row.cost_unpriced === 1,
|
|
945
1006
|
};
|
|
946
1007
|
}
|
|
947
1008
|
|
|
948
1009
|
export function getRecentRequests(limit = 100): MessageStats[] {
|
|
949
1010
|
if (!db) return [];
|
|
950
|
-
const stmt = db.
|
|
1011
|
+
const stmt = db.query(`
|
|
951
1012
|
SELECT * FROM messages
|
|
952
1013
|
ORDER BY timestamp DESC
|
|
953
1014
|
LIMIT ?
|
|
@@ -958,7 +1019,7 @@ export function getRecentRequests(limit = 100): MessageStats[] {
|
|
|
958
1019
|
export function getRecentErrors(limit = 100, cutoff?: number | null): MessageStats[] {
|
|
959
1020
|
if (!db) return [];
|
|
960
1021
|
const hasCutoff = cutoff !== undefined && cutoff !== null;
|
|
961
|
-
const stmt = db.
|
|
1022
|
+
const stmt = db.query(`
|
|
962
1023
|
SELECT * FROM messages
|
|
963
1024
|
WHERE stop_reason = 'error'
|
|
964
1025
|
${hasCutoff ? "AND timestamp >= ?" : ""}
|
|
@@ -971,7 +1032,7 @@ export function getRecentErrors(limit = 100, cutoff?: number | null): MessageSta
|
|
|
971
1032
|
|
|
972
1033
|
export function getMessageById(id: number): MessageStats | null {
|
|
973
1034
|
if (!db) return null;
|
|
974
|
-
const stmt = db.
|
|
1035
|
+
const stmt = db.query("SELECT * FROM messages WHERE id = ?");
|
|
975
1036
|
const row = stmt.get(id);
|
|
976
1037
|
return row ? rowToMessageStats(row) : null;
|
|
977
1038
|
}
|
|
@@ -980,12 +1041,15 @@ export function getMessageById(id: number): MessageStats | null {
|
|
|
980
1041
|
* Self-initializing (opens the stats DB on first use) so the coding-agent TUI
|
|
981
1042
|
* can query without the dashboard server's init flow. Days use the machine's
|
|
982
1043
|
* timezone — this is a localhost tool, same rationale as
|
|
983
|
-
* {@link getProviderHourlyBurn}.
|
|
1044
|
+
* {@link getProviderHourlyBurn}. Reads the hourly rollup, falling back to the
|
|
1045
|
+
* raw rows while its rebuild backlog is too large for exact reads.
|
|
984
1046
|
*/
|
|
985
1047
|
export async function getDailyActivity(days = 371): Promise<DailyActivityPoint[]> {
|
|
986
1048
|
const database = await initDb();
|
|
987
1049
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
988
|
-
const
|
|
1050
|
+
const rolled = getDailyActivityFromRollup(cutoff);
|
|
1051
|
+
if (rolled) return rolled;
|
|
1052
|
+
const stmt = database.query(`
|
|
989
1053
|
SELECT
|
|
990
1054
|
date(timestamp / 1000, 'unixepoch', 'localtime') as day,
|
|
991
1055
|
SUM(cost_total) as cost,
|
|
@@ -1049,11 +1113,17 @@ export async function getDailyActivity(days = 371): Promise<DailyActivityPoint[]
|
|
|
1049
1113
|
* - v9: stored judge prose (`prose` / `prose_hash`) for the frustration
|
|
1050
1114
|
* judge, so every session re-parses once to populate it. Verdicts live in
|
|
1051
1115
|
* `frustration_verdicts`, keyed by prose hash, which this reset never touches.
|
|
1116
|
+
* - v10: the Chinese and voice-input lexicon layer landed (boundary-free
|
|
1117
|
+
* patterns, because speech transcription emits one unpunctuated line), plus
|
|
1118
|
+
* two columns: `emphasis` (the shouting proxy capitalisation cannot see -
|
|
1119
|
+
* full-width bursts, repeated Han runs, repeated denials) and
|
|
1120
|
+
* `unsupported_script` (prose in a script no lexicon covers, which the
|
|
1121
|
+
* dashboard reports as unscored instead of counting as not annoyed).
|
|
1052
1122
|
*
|
|
1053
1123
|
* Existing `messages` rows are unaffected - `INSERT OR IGNORE` keeps them.
|
|
1054
1124
|
*/
|
|
1055
1125
|
function backfillUserMessages(database: Database): void {
|
|
1056
|
-
const row = database.
|
|
1126
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGES_BACKFILL_KEY) as
|
|
1057
1127
|
| { value: string }
|
|
1058
1128
|
| undefined;
|
|
1059
1129
|
if (!shouldResetBackfill(row?.value)) return;
|
|
@@ -1061,7 +1131,7 @@ function backfillUserMessages(database: Database): void {
|
|
|
1061
1131
|
database.run("DELETE FROM user_messages");
|
|
1062
1132
|
database.run("DELETE FROM file_offsets");
|
|
1063
1133
|
database
|
|
1064
|
-
.
|
|
1134
|
+
.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
1065
1135
|
.run(USER_MESSAGES_BACKFILL_KEY, BACKFILL_PENDING);
|
|
1066
1136
|
}
|
|
1067
1137
|
|
|
@@ -1074,7 +1144,7 @@ function backfillUserMessages(database: Database): void {
|
|
|
1074
1144
|
* written here prevents re-wiping on subsequent inits.
|
|
1075
1145
|
*/
|
|
1076
1146
|
function backfillToolCalls(database: Database): void {
|
|
1077
|
-
const row = database.
|
|
1147
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(TOOL_CALLS_BACKFILL_KEY) as
|
|
1078
1148
|
| { value: string }
|
|
1079
1149
|
| undefined;
|
|
1080
1150
|
if (!shouldResetBackfill(row?.value)) return;
|
|
@@ -1082,7 +1152,7 @@ function backfillToolCalls(database: Database): void {
|
|
|
1082
1152
|
database.run("DELETE FROM tool_calls");
|
|
1083
1153
|
database.run("DELETE FROM file_offsets");
|
|
1084
1154
|
database
|
|
1085
|
-
.
|
|
1155
|
+
.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
1086
1156
|
.run(TOOL_CALLS_BACKFILL_KEY, BACKFILL_PENDING);
|
|
1087
1157
|
}
|
|
1088
1158
|
|
|
@@ -1098,14 +1168,14 @@ function backfillToolCalls(database: Database): void {
|
|
|
1098
1168
|
* offset reset is safe. Same sentinel protocol as {@link backfillToolCalls}.
|
|
1099
1169
|
*/
|
|
1100
1170
|
function backfillReingestCosts(database: Database): void {
|
|
1101
|
-
const row = database.
|
|
1171
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(COST_REINGEST_BACKFILL_KEY) as
|
|
1102
1172
|
| { value: string }
|
|
1103
1173
|
| undefined;
|
|
1104
1174
|
if (!shouldResetBackfill(row?.value)) return;
|
|
1105
1175
|
|
|
1106
1176
|
database.run("DELETE FROM file_offsets");
|
|
1107
1177
|
database
|
|
1108
|
-
.
|
|
1178
|
+
.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
1109
1179
|
.run(COST_REINGEST_BACKFILL_KEY, BACKFILL_PENDING);
|
|
1110
1180
|
}
|
|
1111
1181
|
|
|
@@ -1119,14 +1189,14 @@ function backfillReingestCosts(database: Database): void {
|
|
|
1119
1189
|
* absence as absence. Same sentinel protocol as {@link backfillReingestCosts}.
|
|
1120
1190
|
*/
|
|
1121
1191
|
function backfillUnpricedCosts(database: Database): void {
|
|
1122
|
-
const row = database.
|
|
1192
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(COST_UNPRICED_BACKFILL_KEY) as
|
|
1123
1193
|
| { value: string }
|
|
1124
1194
|
| undefined;
|
|
1125
1195
|
if (!shouldResetBackfill(row?.value)) return;
|
|
1126
1196
|
|
|
1127
1197
|
database.run("DELETE FROM file_offsets");
|
|
1128
1198
|
database
|
|
1129
|
-
.
|
|
1199
|
+
.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
1130
1200
|
.run(COST_UNPRICED_BACKFILL_KEY, BACKFILL_PENDING);
|
|
1131
1201
|
}
|
|
1132
1202
|
|
|
@@ -1140,16 +1210,16 @@ function backfillUnpricedCosts(database: Database): void {
|
|
|
1140
1210
|
* interrupted run rolls back and retries on the next init.
|
|
1141
1211
|
*/
|
|
1142
1212
|
function backfillAgentType(database: Database): void {
|
|
1143
|
-
const row = database.
|
|
1213
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(AGENT_TYPE_BACKFILL_KEY) as
|
|
1144
1214
|
| { value: string }
|
|
1145
1215
|
| undefined;
|
|
1146
1216
|
if (row?.value !== BACKFILL_PENDING) return;
|
|
1147
1217
|
|
|
1148
|
-
const sessionFiles = database.
|
|
1218
|
+
const sessionFiles = database.query("SELECT DISTINCT session_file FROM messages").all() as {
|
|
1149
1219
|
session_file: string;
|
|
1150
1220
|
}[];
|
|
1151
|
-
const update = database.
|
|
1152
|
-
const markComplete = database.
|
|
1221
|
+
const update = database.query("UPDATE messages SET agent_type = ? WHERE session_file = ?");
|
|
1222
|
+
const markComplete = database.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
|
|
1153
1223
|
const apply = database.transaction(() => {
|
|
1154
1224
|
for (const { session_file } of sessionFiles) {
|
|
1155
1225
|
const agentType = classifyAgentType(session_file);
|
|
@@ -1177,12 +1247,12 @@ function backfillAgentType(database: Database): void {
|
|
|
1177
1247
|
* retries on the next init.
|
|
1178
1248
|
*/
|
|
1179
1249
|
function backfillForkDuplicates(database: Database): void {
|
|
1180
|
-
const row = database.
|
|
1250
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(FORK_DEDUPE_KEY) as
|
|
1181
1251
|
| { value: string }
|
|
1182
1252
|
| undefined;
|
|
1183
1253
|
if (row?.value === BACKFILL_COMPLETE) return;
|
|
1184
1254
|
|
|
1185
|
-
const markComplete = database.
|
|
1255
|
+
const markComplete = database.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
|
|
1186
1256
|
const apply = database.transaction(() => {
|
|
1187
1257
|
database.run(`
|
|
1188
1258
|
DELETE FROM messages
|
|
@@ -1210,14 +1280,14 @@ function backfillForkDuplicates(database: Database): void {
|
|
|
1210
1280
|
* sentinel row in `meta`.
|
|
1211
1281
|
*/
|
|
1212
1282
|
function repairUserMessageLinks(database: Database): void {
|
|
1213
|
-
const row = database.
|
|
1283
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGE_LINKS_REPAIR_KEY) as
|
|
1214
1284
|
| { value: string }
|
|
1215
1285
|
| undefined;
|
|
1216
1286
|
if (!shouldResetBackfill(row?.value)) return;
|
|
1217
1287
|
|
|
1218
1288
|
database.run("DELETE FROM file_offsets");
|
|
1219
1289
|
database
|
|
1220
|
-
.
|
|
1290
|
+
.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
1221
1291
|
.run(USER_MESSAGE_LINKS_REPAIR_KEY, BACKFILL_PENDING);
|
|
1222
1292
|
}
|
|
1223
1293
|
|
|
@@ -1232,14 +1302,14 @@ function repairUserMessageLinks(database: Database): void {
|
|
|
1232
1302
|
* column. Idempotent: gated by a sentinel row in `meta`.
|
|
1233
1303
|
*/
|
|
1234
1304
|
function backfillPriorityPremiumRequests(database: Database): void {
|
|
1235
|
-
const row = database.
|
|
1305
|
+
const row = database.query("SELECT value FROM meta WHERE key = ?").get(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY) as
|
|
1236
1306
|
| { value: string }
|
|
1237
1307
|
| undefined;
|
|
1238
1308
|
if (!shouldResetBackfill(row?.value)) return;
|
|
1239
1309
|
|
|
1240
1310
|
database.run("DELETE FROM file_offsets");
|
|
1241
1311
|
database
|
|
1242
|
-
.
|
|
1312
|
+
.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
1243
1313
|
.run(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY, BACKFILL_PENDING);
|
|
1244
1314
|
}
|
|
1245
1315
|
|
|
@@ -1248,7 +1318,7 @@ function backfillPriorityPremiumRequests(database: Database): void {
|
|
|
1248
1318
|
*/
|
|
1249
1319
|
export function markSessionBackfillsComplete(): void {
|
|
1250
1320
|
if (!db) return;
|
|
1251
|
-
const markComplete = db.
|
|
1321
|
+
const markComplete = db.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
|
|
1252
1322
|
const apply = db.transaction(() => {
|
|
1253
1323
|
for (const key of [
|
|
1254
1324
|
USER_MESSAGES_BACKFILL_KEY,
|
|
@@ -1264,67 +1334,6 @@ export function markSessionBackfillsComplete(): void {
|
|
|
1264
1334
|
apply();
|
|
1265
1335
|
}
|
|
1266
1336
|
|
|
1267
|
-
/** Tables keyed by transcript path; orphans are purged from all of them. */
|
|
1268
|
-
const SESSION_FILE_TABLES = ["messages", "user_messages", "tool_calls", "file_offsets"] as const;
|
|
1269
|
-
|
|
1270
|
-
export interface PruneOrphanResult {
|
|
1271
|
-
/** Distinct session files whose rows were removed. */
|
|
1272
|
-
prunedFiles: number;
|
|
1273
|
-
/** Total rows removed across all session-keyed tables. */
|
|
1274
|
-
deletedRows: number;
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
/**
|
|
1278
|
-
* Delete every row whose `session_file` no longer exists on disk. Deleted
|
|
1279
|
-
* projects (tmp test suites, wiped transcripts) otherwise keep surfacing in
|
|
1280
|
-
* the Operational Feed and models list forever. Runs in one transaction; the
|
|
1281
|
-
* existing-file set is collected by the caller before any DELETE so the
|
|
1282
|
-
* comparison is a single in-memory set probe per stored path.
|
|
1283
|
-
*/
|
|
1284
|
-
export function pruneOrphanSessionsInDb(database: Database, existingFiles: readonly string[]): PruneOrphanResult {
|
|
1285
|
-
const existing = new Set(existingFiles.map(normalizeSessionFilePath));
|
|
1286
|
-
const orphans = new Set<string>();
|
|
1287
|
-
for (const table of SESSION_FILE_TABLES) {
|
|
1288
|
-
const rows = database.prepare(`SELECT DISTINCT session_file FROM ${table}`).all() as {
|
|
1289
|
-
session_file: string;
|
|
1290
|
-
}[];
|
|
1291
|
-
for (const row of rows) {
|
|
1292
|
-
if (!existing.has(normalizeSessionFilePath(row.session_file))) orphans.add(row.session_file);
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
if (orphans.size === 0) return { prunedFiles: 0, deletedRows: 0 };
|
|
1296
|
-
|
|
1297
|
-
let deletedRows = 0;
|
|
1298
|
-
const apply = database.transaction(() => {
|
|
1299
|
-
for (const sessionFile of orphans) {
|
|
1300
|
-
for (const table of SESSION_FILE_TABLES) {
|
|
1301
|
-
const result = database.prepare(`DELETE FROM ${table} WHERE session_file = ?`).run(sessionFile);
|
|
1302
|
-
deletedRows += Number(result.changes);
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
});
|
|
1306
|
-
apply();
|
|
1307
|
-
return { prunedFiles: orphans.size, deletedRows };
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
/**
|
|
1311
|
-
* {@link pruneOrphanSessionsInDb} against the module DB handle. No-op when
|
|
1312
|
-
* {@link initDb} has not run.
|
|
1313
|
-
*/
|
|
1314
|
-
export function pruneOrphanSessions(existingFiles: readonly string[]): PruneOrphanResult {
|
|
1315
|
-
if (!db) return { prunedFiles: 0, deletedRows: 0 };
|
|
1316
|
-
return pruneOrphanSessionsInDb(db, existingFiles);
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
/**
|
|
1320
|
-
* Comparison key for stored transcript paths: separators unified and case
|
|
1321
|
-
* folded on Windows, where lookups are case-insensitive.
|
|
1322
|
-
*/
|
|
1323
|
-
function normalizeSessionFilePath(sessionFile: string): string {
|
|
1324
|
-
const unified = sessionFile.replaceAll("\\", "/");
|
|
1325
|
-
return process.platform === "win32" ? unified.toLowerCase() : unified;
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
1337
|
/**
|
|
1329
1338
|
* Insert user-message stats. Idempotent via UNIQUE(session_file, entry_id).
|
|
1330
1339
|
* The `WHERE NOT EXISTS` clause matches {@link insertMessageStats}: forks
|
|
@@ -1338,9 +1347,10 @@ export function insertUserMessageStats(stats: Iterable<UserMessageStats>): numbe
|
|
|
1338
1347
|
INSERT OR IGNORE INTO user_messages (
|
|
1339
1348
|
session_file, entry_id, folder, timestamp, model, provider,
|
|
1340
1349
|
chars, words, yelling, profanity, anguish,
|
|
1341
|
-
negation, repetition, blame,
|
|
1350
|
+
negation, repetition, blame, emphasis, unsupported_script,
|
|
1351
|
+
prose, prose_hash
|
|
1342
1352
|
)
|
|
1343
|
-
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
1353
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
1344
1354
|
WHERE NOT EXISTS (
|
|
1345
1355
|
SELECT 1 FROM user_messages
|
|
1346
1356
|
WHERE entry_id = ? AND timestamp = ? AND session_file <> ?
|
|
@@ -1365,6 +1375,8 @@ export function insertUserMessageStats(stats: Iterable<UserMessageStats>): numbe
|
|
|
1365
1375
|
s.negation,
|
|
1366
1376
|
s.repetition,
|
|
1367
1377
|
s.blame,
|
|
1378
|
+
s.emphasis,
|
|
1379
|
+
s.unsupportedScript,
|
|
1368
1380
|
s.prose,
|
|
1369
1381
|
s.proseHash,
|
|
1370
1382
|
// `WHERE NOT EXISTS` binds: skip when a different session_file
|
|
@@ -1439,6 +1451,7 @@ export interface FrustrationVerdict {
|
|
|
1439
1451
|
interface FrustrationCountsRow {
|
|
1440
1452
|
messages: number;
|
|
1441
1453
|
judged: number;
|
|
1454
|
+
unscored: number;
|
|
1442
1455
|
annoyed: number;
|
|
1443
1456
|
at_assistant: number;
|
|
1444
1457
|
angry: number;
|
|
@@ -1460,12 +1473,13 @@ const REGEX_AT_ASSISTANT_SQL = "u.negation + u.repetition + u.blame > 0";
|
|
|
1460
1473
|
const FRUSTRATION_COUNTS_SQL = `
|
|
1461
1474
|
COUNT(*) AS messages,
|
|
1462
1475
|
COALESCE(SUM(${JUDGED_SQL}), 0) AS judged,
|
|
1476
|
+
COALESCE(SUM(CASE WHEN NOT ${JUDGED_SQL} AND u.unsupported_script = 1 THEN 1 ELSE 0 END), 0) AS unscored,
|
|
1463
1477
|
COALESCE(SUM(CASE WHEN ${JUDGED_SQL} THEN ${JUDGED_ANNOYED_SQL}
|
|
1464
|
-
ELSE u.yelling + u.profanity + u.anguish + u.negation + u.repetition + u.blame > 0 END), 0) AS annoyed,
|
|
1478
|
+
ELSE u.yelling + u.profanity + u.anguish + u.negation + u.repetition + u.blame + u.emphasis > 0 END), 0) AS annoyed,
|
|
1465
1479
|
COALESCE(SUM(CASE WHEN ${JUDGED_SQL} THEN ${JUDGED_AT_ASSISTANT_SQL}
|
|
1466
1480
|
ELSE ${REGEX_AT_ASSISTANT_SQL} END), 0) AS at_assistant,
|
|
1467
1481
|
COALESCE(SUM(CASE WHEN ${JUDGED_SQL} THEN ${JUDGED_AT_ASSISTANT_SQL} AND v.p_angry >= 0.5
|
|
1468
|
-
ELSE ${REGEX_AT_ASSISTANT_SQL} AND (u.profanity > 0 OR u.yelling > 0) END), 0) AS angry
|
|
1482
|
+
ELSE ${REGEX_AT_ASSISTANT_SQL} AND (u.profanity > 0 OR u.yelling > 0 OR u.emphasis > 0) END), 0) AS angry
|
|
1469
1483
|
`;
|
|
1470
1484
|
|
|
1471
1485
|
function hasRangeCutoff(cutoff: number | null | undefined): cutoff is number {
|
|
@@ -1476,6 +1490,7 @@ function toFrustrationCounts(row: FrustrationCountsRow | undefined): Frustration
|
|
|
1476
1490
|
return {
|
|
1477
1491
|
messages: row?.messages ?? 0,
|
|
1478
1492
|
judged: row?.judged ?? 0,
|
|
1493
|
+
unscored: row?.unscored ?? 0,
|
|
1479
1494
|
annoyed: row?.annoyed ?? 0,
|
|
1480
1495
|
atAssistant: row?.at_assistant ?? 0,
|
|
1481
1496
|
angry: row?.angry ?? 0,
|
|
@@ -1486,7 +1501,7 @@ function toFrustrationCounts(row: FrustrationCountsRow | undefined): Frustration
|
|
|
1486
1501
|
export function getFrustrationOverall(cutoff?: number | null): FrustrationCounts {
|
|
1487
1502
|
if (!db) return toFrustrationCounts(undefined);
|
|
1488
1503
|
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1489
|
-
const stmt = db.
|
|
1504
|
+
const stmt = db.query(`
|
|
1490
1505
|
SELECT ${FRUSTRATION_COUNTS_SQL}
|
|
1491
1506
|
FROM user_messages u
|
|
1492
1507
|
LEFT JOIN frustration_verdicts v ON v.prose_hash = u.prose_hash
|
|
@@ -1503,7 +1518,7 @@ export function getFrustrationOverall(cutoff?: number | null): FrustrationCounts
|
|
|
1503
1518
|
export function getFrustrationByModel(cutoff?: number | null): FrustrationModelRow[] {
|
|
1504
1519
|
if (!db) return [];
|
|
1505
1520
|
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1506
|
-
const stmt = db.
|
|
1521
|
+
const stmt = db.query(`
|
|
1507
1522
|
SELECT u.model AS model, u.provider AS provider, MIN(u.timestamp) AS first_seen, ${FRUSTRATION_COUNTS_SQL}
|
|
1508
1523
|
FROM user_messages u
|
|
1509
1524
|
LEFT JOIN frustration_verdicts v ON v.prose_hash = u.prose_hash
|
|
@@ -1534,7 +1549,7 @@ function pendingProseSql(hasCutoff: boolean): string {
|
|
|
1534
1549
|
export function getPendingFrustrationProse(cutoff?: number | null): PendingProse[] {
|
|
1535
1550
|
if (!db) return [];
|
|
1536
1551
|
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1537
|
-
const stmt = db.
|
|
1552
|
+
const stmt = db.query(pendingProseSql(hasCutoff));
|
|
1538
1553
|
return (hasCutoff ? stmt.all(cutoff) : stmt.all()) as PendingProse[];
|
|
1539
1554
|
}
|
|
1540
1555
|
|
|
@@ -1542,7 +1557,7 @@ export function getPendingFrustrationProse(cutoff?: number | null): PendingProse
|
|
|
1542
1557
|
export function getPendingFrustrationTotals(cutoff?: number | null): { messages: number; chars: number } {
|
|
1543
1558
|
if (!db) return { messages: 0, chars: 0 };
|
|
1544
1559
|
const hasCutoff = hasRangeCutoff(cutoff);
|
|
1545
|
-
const stmt = db.
|
|
1560
|
+
const stmt = db.query(
|
|
1546
1561
|
`SELECT COUNT(*) AS messages, COALESCE(SUM(LENGTH(prose)), 0) AS chars FROM (${pendingProseSql(hasCutoff)})`,
|
|
1547
1562
|
);
|
|
1548
1563
|
const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as { messages: number; chars: number } | undefined;
|