@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.
Files changed (125) hide show
  1. package/CHANGELOG.md +12 -5
  2. package/build.ts +4 -0
  3. package/dist/client/favicon.ico +0 -0
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +1 -0
  6. package/dist/client/index.js +96 -96
  7. package/dist/types/aggregator.d.ts +4 -1
  8. package/dist/types/client/api.d.ts +5 -1
  9. package/dist/types/client/app/nav.d.ts +5 -2
  10. package/dist/types/client/data/formatters.d.ts +7 -2
  11. package/dist/types/client/data/live.d.ts +16 -11
  12. package/dist/types/client/data/query-store.d.ts +39 -0
  13. package/dist/types/client/data/query.d.ts +10 -5
  14. package/dist/types/client/data/range.d.ts +4 -3
  15. package/dist/types/client/i18n/format.d.ts +9 -0
  16. package/dist/types/client/i18n/index.d.ts +18 -0
  17. package/dist/types/client/i18n/messages/charts.d.ts +8 -0
  18. package/dist/types/client/i18n/messages/common.d.ts +153 -0
  19. package/dist/types/client/i18n/messages/costs.d.ts +92 -0
  20. package/dist/types/client/i18n/messages/en.d.ts +1255 -0
  21. package/dist/types/client/i18n/messages/errors.d.ts +78 -0
  22. package/dist/types/client/i18n/messages/frustration.d.ts +191 -0
  23. package/dist/types/client/i18n/messages/gain.d.ts +54 -0
  24. package/dist/types/client/i18n/messages/models.d.ts +94 -0
  25. package/dist/types/client/i18n/messages/nav.d.ts +46 -0
  26. package/dist/types/client/i18n/messages/overview.d.ts +80 -0
  27. package/dist/types/client/i18n/messages/projects.d.ts +84 -0
  28. package/dist/types/client/i18n/messages/providers.d.ts +142 -0
  29. package/dist/types/client/i18n/messages/requests.d.ts +72 -0
  30. package/dist/types/client/i18n/messages/tools.d.ts +119 -0
  31. package/dist/types/client/i18n/messages/traces.d.ts +144 -0
  32. package/dist/types/client/i18n/messages/ui.d.ts +44 -0
  33. package/dist/types/client/i18n/messages/zh-CN.d.ts +13 -0
  34. package/dist/types/client/i18n/types.d.ts +15 -0
  35. package/dist/types/client/traces/Minimap.d.ts +2 -0
  36. package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
  37. package/dist/types/client/traces/TranscriptList.d.ts +3 -1
  38. package/dist/types/db.d.ts +14 -21
  39. package/dist/types/embedded-client.d.ts +5 -0
  40. package/dist/types/frustration.d.ts +18 -9
  41. package/dist/types/live.d.ts +5 -3
  42. package/dist/types/locale.d.ts +16 -0
  43. package/dist/types/parser.d.ts +0 -17
  44. package/dist/types/rollup.d.ts +9 -1
  45. package/dist/types/server.d.ts +6 -0
  46. package/dist/types/shared-types.d.ts +7 -0
  47. package/dist/types/trace.d.ts +18 -6
  48. package/dist/types/types.d.ts +13 -1
  49. package/dist/types/user-metrics.d.ts +23 -1
  50. package/package.json +5 -5
  51. package/src/aggregator.ts +31 -11
  52. package/src/client/api.ts +40 -5
  53. package/src/client/app/LiveChip.tsx +14 -13
  54. package/src/client/app/Shell.tsx +19 -13
  55. package/src/client/app/ThemeToggle.tsx +4 -3
  56. package/src/client/app/nav.ts +19 -16
  57. package/src/client/data/formatters.ts +80 -13
  58. package/src/client/data/live.tsx +85 -45
  59. package/src/client/data/query-store.ts +108 -0
  60. package/src/client/data/query.ts +47 -56
  61. package/src/client/data/range.ts +34 -14
  62. package/src/client/data/series.ts +7 -1
  63. package/src/client/favicon.ico +0 -0
  64. package/src/client/i18n/format.ts +28 -0
  65. package/src/client/i18n/index.ts +53 -0
  66. package/src/client/i18n/messages/charts.ts +8 -0
  67. package/src/client/i18n/messages/common.ts +157 -0
  68. package/src/client/i18n/messages/costs.ts +104 -0
  69. package/src/client/i18n/messages/en.ts +40 -0
  70. package/src/client/i18n/messages/errors.ts +80 -0
  71. package/src/client/i18n/messages/frustration.ts +198 -0
  72. package/src/client/i18n/messages/gain.ts +54 -0
  73. package/src/client/i18n/messages/models.ts +94 -0
  74. package/src/client/i18n/messages/nav.ts +46 -0
  75. package/src/client/i18n/messages/overview.ts +90 -0
  76. package/src/client/i18n/messages/projects.ts +84 -0
  77. package/src/client/i18n/messages/providers.ts +165 -0
  78. package/src/client/i18n/messages/requests.ts +73 -0
  79. package/src/client/i18n/messages/tools.ts +120 -0
  80. package/src/client/i18n/messages/traces.ts +160 -0
  81. package/src/client/i18n/messages/ui.ts +46 -0
  82. package/src/client/i18n/messages/zh-CN.ts +43 -0
  83. package/src/client/i18n/types.ts +19 -0
  84. package/src/client/index.tsx +4 -0
  85. package/src/client/routes/CostsRoute.tsx +61 -48
  86. package/src/client/routes/ErrorsRoute.tsx +71 -47
  87. package/src/client/routes/FrustrationRoute.tsx +131 -116
  88. package/src/client/routes/GainRoute.tsx +42 -30
  89. package/src/client/routes/ModelsRoute.tsx +90 -73
  90. package/src/client/routes/OverviewRoute.tsx +109 -63
  91. package/src/client/routes/ProjectsRoute.tsx +73 -46
  92. package/src/client/routes/ProvidersRoute.tsx +151 -114
  93. package/src/client/routes/RequestsRoute.tsx +69 -46
  94. package/src/client/routes/ToolsRoute.tsx +108 -94
  95. package/src/client/routes/TracesRoute.tsx +25 -23
  96. package/src/client/styles.css +5 -2
  97. package/src/client/traces/AggregatesPanel.tsx +19 -8
  98. package/src/client/traces/Minimap.tsx +65 -27
  99. package/src/client/traces/SpanDrawer.tsx +46 -21
  100. package/src/client/traces/SummaryStrip.tsx +27 -11
  101. package/src/client/traces/TimelineCanvas.tsx +245 -116
  102. package/src/client/traces/TraceView.tsx +51 -36
  103. package/src/client/traces/TranscriptList.tsx +20 -11
  104. package/src/client/traces/traces.css +26 -0
  105. package/src/client/ui/JsonBlock.tsx +3 -2
  106. package/src/client/ui/Modal.tsx +3 -2
  107. package/src/client/ui/RequestDrawer.tsx +33 -30
  108. package/src/client/ui/SearchInput.tsx +2 -1
  109. package/src/client/ui/States.tsx +3 -2
  110. package/src/client/ui/Table.tsx +7 -3
  111. package/src/db.ts +150 -135
  112. package/src/embedded-client.ts +9 -0
  113. package/src/frustration.ts +9 -30
  114. package/src/index.ts +2 -1
  115. package/src/live.ts +36 -11
  116. package/src/locale.ts +29 -0
  117. package/src/parser.ts +37 -72
  118. package/src/port-conflict.ts +1 -1
  119. package/src/prompts/judge/frustration.json +19 -0
  120. package/src/rollup.ts +41 -20
  121. package/src/server.ts +67 -27
  122. package/src/shared-types.ts +7 -0
  123. package/src/trace.ts +54 -40
  124. package/src/types.ts +13 -1
  125. 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
- const PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY = "premium_requests_priority_v1";
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.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages'").get() !== undefined;
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.prepare("PRAGMA table_info(file_offsets)").all() as { name: string }[];
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.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
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.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)").run(
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.prepare("PRAGMA table_info(user_messages)").all() as {
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
- .prepare(`
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
- if (rows.length === 0) return;
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
- .prepare(`
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.prepare("UPDATE messages SET cost_no_cache_input = ? WHERE id = ?");
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
- /** Commit distinct transcripts' rows, reconciliation state, and cursors together; failure rolls back the batch. */
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) 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);
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
- 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));
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?.prepare("SELECT 1 FROM meta WHERE key = 'session_reconciliation'").get());
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?.prepare("DELETE FROM meta WHERE key = 'session_reconciliation'").run();
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.prepare("SELECT COUNT(*) as count FROM messages");
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.prepare(`
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.prepare(`
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.prepare("SELECT * FROM messages WHERE id = ?");
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 stmt = database.prepare(`
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.prepare("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGES_BACKFILL_KEY) as
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
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
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.prepare("SELECT value FROM meta WHERE key = ?").get(TOOL_CALLS_BACKFILL_KEY) as
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
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
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.prepare("SELECT value FROM meta WHERE key = ?").get(COST_REINGEST_BACKFILL_KEY) as
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
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
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.prepare("SELECT value FROM meta WHERE key = ?").get(COST_UNPRICED_BACKFILL_KEY) as
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
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
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.prepare("SELECT value FROM meta WHERE key = ?").get(AGENT_TYPE_BACKFILL_KEY) as
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.prepare("SELECT DISTINCT session_file FROM messages").all() as {
1218
+ const sessionFiles = database.query("SELECT DISTINCT session_file FROM messages").all() as {
1149
1219
  session_file: string;
1150
1220
  }[];
1151
- const update = database.prepare("UPDATE messages SET agent_type = ? WHERE session_file = ?");
1152
- const markComplete = database.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
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.prepare("SELECT value FROM meta WHERE key = ?").get(FORK_DEDUPE_KEY) as
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.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
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.prepare("SELECT value FROM meta WHERE key = ?").get(USER_MESSAGE_LINKS_REPAIR_KEY) as
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
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
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.prepare("SELECT value FROM meta WHERE key = ?").get(PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY) as
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
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
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.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
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, prose, prose_hash
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.prepare(`
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.prepare(`
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.prepare(pendingProseSql(hasCutoff));
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.prepare(
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;