@linxiraos/pi-stats 1.3.2 → 1.4.1

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 (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -0
  3. package/dist/client/index.css +1 -1
  4. package/dist/client/index.js +91 -91
  5. package/dist/types/client/app/LanguageToggle.d.ts +7 -0
  6. package/dist/types/client/i18n/index.d.ts +11 -3
  7. package/dist/types/client/i18n/messages/common.d.ts +4 -0
  8. package/dist/types/client/i18n/messages/en.d.ts +76 -0
  9. package/dist/types/client/i18n/messages/frustration.d.ts +72 -0
  10. package/dist/types/client/i18n/messages/zh-CN.d.ts +1 -1
  11. package/dist/types/client/i18n/useLocale.d.ts +7 -0
  12. package/dist/types/db.d.ts +34 -1
  13. package/dist/types/model-identity.d.ts +29 -0
  14. package/dist/types/model-metrics.d.ts +53 -0
  15. package/dist/types/model-side.d.ts +6 -0
  16. package/dist/types/parser.d.ts +2 -1
  17. package/dist/types/shared-types.d.ts +88 -0
  18. package/dist/types/types.d.ts +47 -1
  19. package/dist/types/user-metrics.d.ts +22 -2
  20. package/package.json +4 -4
  21. package/src/aggregator.ts +1 -0
  22. package/src/client/App.tsx +4 -0
  23. package/src/client/app/LanguageToggle.tsx +30 -0
  24. package/src/client/app/Shell.tsx +12 -7
  25. package/src/client/app/ThemeToggle.tsx +11 -8
  26. package/src/client/i18n/index.ts +49 -6
  27. package/src/client/i18n/messages/common.ts +4 -0
  28. package/src/client/i18n/messages/frustration.ts +77 -0
  29. package/src/client/i18n/useLocale.ts +11 -0
  30. package/src/client/routes/FrustrationRoute.tsx +439 -4
  31. package/src/client/routes/ProvidersRoute.tsx +2 -4
  32. package/src/client/routes/ToolsRoute.tsx +7 -9
  33. package/src/client/routes/frustration.css +13 -0
  34. package/src/client/styles.css +13 -0
  35. package/src/db.ts +321 -2
  36. package/src/frustration.ts +8 -33
  37. package/src/model-identity.ts +61 -0
  38. package/src/model-metrics.ts +153 -0
  39. package/src/model-side.ts +77 -0
  40. package/src/parser.ts +104 -11
  41. package/src/shared-types.ts +93 -0
  42. package/src/types.ts +48 -1
  43. package/src/user-metrics.ts +9 -7
package/src/db.ts CHANGED
@@ -15,10 +15,13 @@ import { classifyAgentType, type ParseSessionResult, type SessionParserState } f
15
15
  import { ensureRollupSchema, getDailyActivityFromRollup } from "./rollup";
16
16
  import type {
17
17
  AgentType,
18
+ AssistantTextStats,
18
19
  DailyActivityPoint,
19
20
  FrustrationCounts,
20
21
  MessageStats,
21
22
  MessageStatsInput,
23
+ ModelSideCounts,
24
+ ModelSideTimeSeriesPoint,
22
25
  ToolCallStats,
23
26
  ToolResultLink,
24
27
  UserMessageLink,
@@ -89,6 +92,11 @@ export function currentDb(): Database | null {
89
92
  const BACKFILL_COMPLETE = "complete";
90
93
  const BACKFILL_PENDING = "pending";
91
94
  const USER_MESSAGES_BACKFILL_KEY = "user_messages_v9";
95
+ // Model-side analysis (`assistant_text`) was introduced after every existing
96
+ // database had already burned its session backfills, so it needs its own
97
+ // sentinel: a completed user-message backfill must never leave old sessions
98
+ // without assistant prose forever.
99
+ const ASSISTANT_TEXT_BACKFILL_KEY = "assistant_text_v1";
92
100
  const USER_MESSAGE_LINKS_REPAIR_KEY = "user_message_links_v1";
93
101
  // v2: the parser also records the served service tier per message, so a full
94
102
  // re-parse fills `service_tier` and re-derives ultrafast premium counts that the
@@ -217,6 +225,38 @@ export async function initDb(): Promise<Database> {
217
225
  CREATE INDEX IF NOT EXISTS idx_user_messages_entry_timestamp ON user_messages(entry_id, timestamp);
218
226
  CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
219
227
 
228
+ -- Model-side analysis: one row per assistant turn that produced prose.
229
+ -- Companion to messages (usage, no text) and keyed like user_messages
230
+ -- so a fork's copied entry is deduplicated the same way. prose is the
231
+ -- structured-content-stripped body capped at PROSE_MAX_CHARS; thinking
232
+ -- blocks never reach this table.
233
+ CREATE TABLE IF NOT EXISTS assistant_text (
234
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
235
+ session_file TEXT NOT NULL,
236
+ entry_id TEXT NOT NULL,
237
+ folder TEXT NOT NULL,
238
+ timestamp INTEGER NOT NULL,
239
+ model TEXT NOT NULL,
240
+ provider TEXT NOT NULL,
241
+ agent_type TEXT NOT NULL DEFAULT 'main',
242
+ chars INTEGER NOT NULL,
243
+ words INTEGER NOT NULL,
244
+ self_blame INTEGER NOT NULL DEFAULT 0,
245
+ restatement INTEGER NOT NULL DEFAULT 0,
246
+ hedge INTEGER NOT NULL DEFAULT 0,
247
+ verbose INTEGER NOT NULL DEFAULT 0,
248
+ tool_errors INTEGER NOT NULL DEFAULT 0,
249
+ tool_spiral INTEGER NOT NULL DEFAULT 0,
250
+ collapse INTEGER NOT NULL DEFAULT 0,
251
+ unsupported_script INTEGER NOT NULL DEFAULT 0,
252
+ prose TEXT NOT NULL,
253
+ UNIQUE(session_file, entry_id)
254
+ );
255
+
256
+ CREATE INDEX IF NOT EXISTS idx_assistant_text_timestamp ON assistant_text(timestamp);
257
+ CREATE INDEX IF NOT EXISTS idx_assistant_text_timestamp_model ON assistant_text(timestamp, model, provider);
258
+ CREATE INDEX IF NOT EXISTS idx_assistant_text_session ON assistant_text(session_file, timestamp);
259
+
220
260
  -- Judge verdicts keyed by prose hash: identical messages share one verdict,
221
261
  -- and verdicts outlive user_messages re-parses. No backfill wipes this table.
222
262
  CREATE TABLE IF NOT EXISTS frustration_verdicts (
@@ -369,6 +409,7 @@ export async function initDb(): Promise<Database> {
369
409
  }
370
410
  db.run("CREATE INDEX IF NOT EXISTS idx_user_messages_prose_hash ON user_messages(prose_hash)");
371
411
  backfillUserMessages(db);
412
+ backfillAssistantText(db);
372
413
  backfillToolCalls(db);
373
414
  backfillReingestCosts(db);
374
415
  backfillUnpricedCosts(db);
@@ -734,6 +775,11 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
734
775
  "SELECT entry_id, timestamp, model FROM user_messages WHERE session_file = ?",
735
776
  )
736
777
  .all(sessionFile);
778
+ const texts = database
779
+ .query<{ entry_id: string; timestamp: number }, [string]>(
780
+ "SELECT entry_id, timestamp FROM assistant_text WHERE session_file = ?",
781
+ )
782
+ .all(sessionFile);
737
783
  const tools = database
738
784
  .query<
739
785
  { entry_id: string; timestamp: number; tool_call_id: string; result_chars: number | null },
@@ -743,19 +789,23 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
743
789
  const replace = result.reset || rebuild;
744
790
  if (replace) {
745
791
  // Only removed owners require another full replay, not an identity-only file replacement.
746
- if (!reconcile && (messages.length > 0 || users.length > 0 || tools.length > 0)) {
792
+ if (!reconcile && (messages.length > 0 || users.length > 0 || texts.length > 0 || tools.length > 0)) {
747
793
  const retainedMessages = new Set(
748
794
  result.stats.map(row => JSON.stringify([row.entryId, row.timestamp])),
749
795
  );
750
796
  const retainedUsers = new Set(
751
797
  result.userStats.map(row => JSON.stringify([row.entryId, row.timestamp])),
752
798
  );
799
+ const retainedTexts = new Set(
800
+ result.assistantTexts.map(row => JSON.stringify([row.entryId, row.timestamp])),
801
+ );
753
802
  const retainedTools = new Set(
754
803
  result.toolCalls.map(row => JSON.stringify([row.entryId, row.timestamp, row.toolCallId])),
755
804
  );
756
805
  reconcile =
757
806
  messages.some(row => !retainedMessages.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
758
807
  users.some(row => !retainedUsers.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
808
+ texts.some(row => !retainedTexts.has(JSON.stringify([row.entry_id, row.timestamp]))) ||
759
809
  tools.some(
760
810
  row => !retainedTools.has(JSON.stringify([row.entry_id, row.timestamp, row.tool_call_id])),
761
811
  );
@@ -765,9 +815,11 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
765
815
  // IDs and avoid rewriting every table/index for transcripts whose contents did not change.
766
816
  const messageById = new Map(messages.map(row => [row.entry_id, row.timestamp]));
767
817
  const userById = new Map(users.map(row => [row.entry_id, row]));
818
+ const textById = new Map(texts.map(row => [row.entry_id, row]));
768
819
  const toolById = new Map(tools.map(row => [row.tool_call_id, row]));
769
820
  const absentMessages = new Set(messageById.keys());
770
821
  const absentUsers = new Set(userById.keys());
822
+ const absentTexts = new Set(textById.keys());
771
823
  const absentTools = new Set(toolById.keys());
772
824
  const stats = result.stats.filter(row => {
773
825
  if (messageById.get(row.entryId) !== row.timestamp) return true;
@@ -779,6 +831,11 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
779
831
  absentUsers.delete(row.entryId);
780
832
  return false;
781
833
  });
834
+ const assistantTexts = result.assistantTexts.filter(row => {
835
+ if (textById.get(row.entryId)?.timestamp !== row.timestamp) return true;
836
+ absentTexts.delete(row.entryId);
837
+ return false;
838
+ });
782
839
  const toolCalls = result.toolCalls.filter(row => {
783
840
  const stored = toolById.get(row.toolCallId);
784
841
  if (stored?.entry_id !== row.entryId || stored.timestamp !== row.timestamp) return true;
@@ -789,6 +846,7 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
789
846
  ...result,
790
847
  stats,
791
848
  userStats,
849
+ assistantTexts,
792
850
  toolCalls,
793
851
  // A reused ID needs fresh linkage after its old identity is removed.
794
852
  userLinks: result.userLinks.filter(
@@ -799,7 +857,7 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
799
857
  ),
800
858
  };
801
859
  // Repair stale owners without invalidating IDs of records still present in the transcript.
802
- if (absentMessages.size > 0 || absentUsers.size > 0 || absentTools.size > 0) {
860
+ if (absentMessages.size > 0 || absentUsers.size > 0 || absentTexts.size > 0 || absentTools.size > 0) {
803
861
  reconcile = true;
804
862
  for (const id of absentMessages) {
805
863
  changes += database
@@ -811,6 +869,11 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
811
869
  .query("DELETE FROM user_messages WHERE session_file = ? AND entry_id = ?")
812
870
  .run(sessionFile, id).changes;
813
871
  }
872
+ for (const id of absentTexts) {
873
+ changes += database
874
+ .query("DELETE FROM assistant_text WHERE session_file = ? AND entry_id = ?")
875
+ .run(sessionFile, id).changes;
876
+ }
814
877
  for (const id of absentTools) {
815
878
  changes += database
816
879
  .query("DELETE FROM tool_calls WHERE session_file = ? AND tool_call_id = ?")
@@ -827,12 +890,19 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
827
890
  .query("DELETE FROM user_messages WHERE session_file = ?")
828
891
  .run(sessionFile).changes;
829
892
  }
893
+ if (texts.length > 0) {
894
+ changes += database
895
+ .query("DELETE FROM assistant_text WHERE session_file = ?")
896
+ .run(sessionFile).changes;
897
+ }
830
898
  if (tools.length > 0) {
831
899
  changes += database.query("DELETE FROM tool_calls WHERE session_file = ?").run(sessionFile).changes;
832
900
  }
833
901
  }
834
902
  }
835
903
  writes.push(rows);
904
+ // `processed` keeps its historical meaning (usage + user rows); the
905
+ // model-side rows are visible through `changes` and batch sizing.
836
906
  const count = rows.stats.length + rows.userStats.length;
837
907
  processed += count;
838
908
  if (count > 0) files++;
@@ -841,6 +911,7 @@ export function applySessionParseResults(sessions: ParsedSession[]): {
841
911
  changes +=
842
912
  insertMessageStats(parsedRows(writes, result => result.stats)) +
843
913
  insertUserMessageStats(parsedRows(writes, result => result.userStats)) +
914
+ insertAssistantText(parsedRows(writes, result => result.assistantTexts)) +
844
915
  updateUserMessageLinks(parsedRows(writes, result => result.userLinks)) +
845
916
  insertToolCalls(parsedRows(writes, result => result.toolCalls)) +
846
917
  updateToolResults(parsedRows(writes, result => result.toolResults));
@@ -1148,6 +1219,31 @@ function backfillUserMessages(database: Database): void {
1148
1219
  .run(USER_MESSAGES_BACKFILL_KEY, BACKFILL_PENDING);
1149
1220
  }
1150
1221
 
1222
+ /**
1223
+ * One-shot wipe of `assistant_text` + `file_offsets` when the model-side
1224
+ * analysis table is introduced (or its signal definitions bump), so the next
1225
+ * sync re-parses every session and ingests historical assistant text.
1226
+ *
1227
+ * Bounded by construction: the wipe runs exactly once per schema version
1228
+ * (`INSERT OR REPLACE` writes PENDING before the sync starts, and
1229
+ * {@link markSessionBackfillsComplete} writes COMPLETE after it), and the
1230
+ * re-parse reads each transcript once, in the existing worker pool, at the
1231
+ * same cost as any other full ingest. The only lasting cost is the stored
1232
+ * prose itself - one capped row per prose-bearing assistant turn.
1233
+ */
1234
+ function backfillAssistantText(database: Database): void {
1235
+ const row = database
1236
+ .query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?")
1237
+ .get(ASSISTANT_TEXT_BACKFILL_KEY);
1238
+ if (!shouldResetBackfill(row?.value)) return;
1239
+
1240
+ database.run("DELETE FROM assistant_text");
1241
+ database.run("DELETE FROM file_offsets");
1242
+ database
1243
+ .query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
1244
+ .run(ASSISTANT_TEXT_BACKFILL_KEY, BACKFILL_PENDING);
1245
+ }
1246
+
1151
1247
  /**
1152
1248
  * One-shot wipe of `tool_calls` + `file_offsets` when the `tool_calls` table
1153
1249
  * is introduced (or its schema version bumps), so the next sync re-parses
@@ -1335,6 +1431,7 @@ export function markSessionBackfillsComplete(): void {
1335
1431
  const apply = db.transaction(() => {
1336
1432
  for (const key of [
1337
1433
  USER_MESSAGES_BACKFILL_KEY,
1434
+ ASSISTANT_TEXT_BACKFILL_KEY,
1338
1435
  TOOL_CALLS_BACKFILL_KEY,
1339
1436
  USER_MESSAGE_LINKS_REPAIR_KEY,
1340
1437
  PRIORITY_PREMIUM_REQUESTS_BACKFILL_KEY,
@@ -1405,6 +1502,63 @@ export function insertUserMessageStats(stats: Iterable<UserMessageStats>): numbe
1405
1502
  return inserted;
1406
1503
  }
1407
1504
 
1505
+ /**
1506
+ * Insert model-side assistant turns. Idempotent via UNIQUE(session_file,
1507
+ * entry_id); the `WHERE NOT EXISTS` guard mirrors {@link insertUserMessageStats}
1508
+ * because a fork deep-copies assistant entries into the child transcript under
1509
+ * the same entry id and timestamp.
1510
+ */
1511
+ export function insertAssistantText(stats: Iterable<AssistantTextStats>): number {
1512
+ if (!db) return 0;
1513
+
1514
+ const stmt = db.query(`
1515
+ INSERT OR IGNORE INTO assistant_text (
1516
+ session_file, entry_id, folder, timestamp, model, provider, agent_type,
1517
+ chars, words, self_blame, restatement, hedge, verbose,
1518
+ tool_errors, tool_spiral, collapse, unsupported_script, prose
1519
+ )
1520
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
1521
+ WHERE NOT EXISTS (
1522
+ SELECT 1 FROM assistant_text
1523
+ WHERE entry_id = ? AND timestamp = ? AND session_file <> ?
1524
+ )
1525
+ `);
1526
+
1527
+ let inserted = 0;
1528
+ const insert = db.transaction(() => {
1529
+ for (const s of stats) {
1530
+ const result = stmt.run(
1531
+ s.sessionFile,
1532
+ s.entryId,
1533
+ s.folder,
1534
+ s.timestamp,
1535
+ s.model,
1536
+ s.provider,
1537
+ s.agentType,
1538
+ s.chars,
1539
+ s.words,
1540
+ s.selfBlame,
1541
+ s.restatement,
1542
+ s.hedge,
1543
+ s.verbose,
1544
+ s.toolErrors,
1545
+ s.toolSpiral,
1546
+ s.collapse,
1547
+ s.unsupportedScript,
1548
+ s.prose,
1549
+ // `WHERE NOT EXISTS` binds: skip when a different session_file
1550
+ // already holds this (entry_id, timestamp).
1551
+ s.entryId,
1552
+ s.timestamp,
1553
+ s.sessionFile,
1554
+ );
1555
+ if (result.changes > 0) inserted++;
1556
+ }
1557
+ });
1558
+ insert();
1559
+ return inserted;
1560
+ }
1561
+
1408
1562
  /**
1409
1563
  * Backfill the responding `model`/`provider` on user-message rows that were
1410
1564
  * persisted before their assistant reply was parsed by an incremental tail
@@ -1547,6 +1701,171 @@ export function getFrustrationByModel(cutoff?: number | null): FrustrationModelR
1547
1701
  }));
1548
1702
  }
1549
1703
 
1704
+ // ---------------------------------------------------------------------------
1705
+ // Model side (AI breakdown)
1706
+ // ---------------------------------------------------------------------------
1707
+
1708
+ interface ModelSideCountsRow {
1709
+ messages: number;
1710
+ blamed: number;
1711
+ restated: number;
1712
+ hedged: number;
1713
+ verbose: number;
1714
+ spiraled: number;
1715
+ breakdown: number;
1716
+ collapse: number;
1717
+ words: number;
1718
+ tool_errors: number;
1719
+ unscored: number;
1720
+ }
1721
+
1722
+ /** Model-side tallies of one responding (model, provider) pair, before identity merging. */
1723
+ export interface ModelSideModelRow extends ModelSideCounts {
1724
+ model: string;
1725
+ provider: string;
1726
+ /** Earliest analyzed turn timestamp (ms) in range. */
1727
+ firstSeen: number;
1728
+ }
1729
+
1730
+ /** Model-side tallies of one session, before the dashboard's top-N cut. */
1731
+ export interface ModelSideSessionRow extends ModelSideCounts {
1732
+ sessionFile: string;
1733
+ folder: string;
1734
+ models: string[];
1735
+ firstSeen: number;
1736
+ lastSeen: number;
1737
+ }
1738
+
1739
+ // Shares are per analyzed turn. `collapse`, `words` and `tool_errors` are
1740
+ // additive sums; the UI divides them by `messages` for means.
1741
+ const MODEL_SIDE_COUNTS_SQL = `
1742
+ COUNT(*) AS messages,
1743
+ COALESCE(SUM(CASE WHEN a.self_blame > 0 THEN 1 ELSE 0 END), 0) AS blamed,
1744
+ COALESCE(SUM(CASE WHEN a.restatement > 0 THEN 1 ELSE 0 END), 0) AS restated,
1745
+ COALESCE(SUM(CASE WHEN a.hedge > 0 THEN 1 ELSE 0 END), 0) AS hedged,
1746
+ COALESCE(SUM(CASE WHEN a.verbose > 0 THEN 1 ELSE 0 END), 0) AS verbose,
1747
+ COALESCE(SUM(CASE WHEN a.tool_spiral > 0 THEN 1 ELSE 0 END), 0) AS spiraled,
1748
+ COALESCE(SUM(CASE WHEN a.collapse > 0 THEN 1 ELSE 0 END), 0) AS breakdown,
1749
+ COALESCE(SUM(a.collapse), 0) AS collapse,
1750
+ COALESCE(SUM(a.words), 0) AS words,
1751
+ COALESCE(SUM(a.tool_errors), 0) AS tool_errors,
1752
+ COALESCE(SUM(CASE WHEN a.unsupported_script = 1 THEN 1 ELSE 0 END), 0) AS unscored
1753
+ `;
1754
+
1755
+ function toModelSideCounts(row: ModelSideCountsRow | undefined): ModelSideCounts {
1756
+ return {
1757
+ messages: row?.messages ?? 0,
1758
+ blamed: row?.blamed ?? 0,
1759
+ restated: row?.restated ?? 0,
1760
+ hedged: row?.hedged ?? 0,
1761
+ verbose: row?.verbose ?? 0,
1762
+ spiraled: row?.spiraled ?? 0,
1763
+ breakdown: row?.breakdown ?? 0,
1764
+ collapse: row?.collapse ?? 0,
1765
+ words: row?.words ?? 0,
1766
+ toolErrors: row?.tool_errors ?? 0,
1767
+ unscored: row?.unscored ?? 0,
1768
+ };
1769
+ }
1770
+
1771
+ /** Model-side tallies over every analyzed assistant turn in range. */
1772
+ export function getModelSideOverall(cutoff?: number | null): ModelSideCounts {
1773
+ if (!db) return toModelSideCounts(undefined);
1774
+ const hasCutoff = hasRangeCutoff(cutoff);
1775
+ const stmt = db.query(
1776
+ `SELECT ${MODEL_SIDE_COUNTS_SQL} FROM assistant_text a${hasCutoff ? " WHERE a.timestamp >= ?" : ""}`,
1777
+ );
1778
+ const row = (hasCutoff ? stmt.get(cutoff) : stmt.get()) as ModelSideCountsRow | undefined;
1779
+ return toModelSideCounts(row);
1780
+ }
1781
+
1782
+ /** Model-side tallies per responding (model, provider) over the range. */
1783
+ export function getModelSideByModel(cutoff?: number | null): ModelSideModelRow[] {
1784
+ if (!db) return [];
1785
+ const hasCutoff = hasRangeCutoff(cutoff);
1786
+ const stmt = db.query(`
1787
+ SELECT a.model AS model, a.provider AS provider, MIN(a.timestamp) AS first_seen, ${MODEL_SIDE_COUNTS_SQL}
1788
+ FROM assistant_text a
1789
+ ${hasCutoff ? "WHERE a.timestamp >= ?" : ""}
1790
+ GROUP BY a.model, a.provider
1791
+ `);
1792
+ const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as (ModelSideCountsRow & {
1793
+ model: string;
1794
+ provider: string;
1795
+ first_seen: number;
1796
+ })[];
1797
+ return rows.map(row => ({
1798
+ model: row.model,
1799
+ provider: row.provider,
1800
+ firstSeen: row.first_seen,
1801
+ ...toModelSideCounts(row),
1802
+ }));
1803
+ }
1804
+
1805
+ /** Model-side tallies per session, busiest first. */
1806
+ export function getModelSideBySession(cutoff?: number | null, limit = 40): ModelSideSessionRow[] {
1807
+ if (!db) return [];
1808
+ const hasCutoff = hasRangeCutoff(cutoff);
1809
+ const stmt = db.query(`
1810
+ SELECT a.session_file AS session_file, a.folder AS folder, GROUP_CONCAT(DISTINCT a.model) AS models,
1811
+ MIN(a.timestamp) AS first_seen, MAX(a.timestamp) AS last_seen, ${MODEL_SIDE_COUNTS_SQL}
1812
+ FROM assistant_text a
1813
+ ${hasCutoff ? "WHERE a.timestamp >= ?" : ""}
1814
+ GROUP BY a.session_file, a.folder
1815
+ ORDER BY messages DESC, last_seen DESC
1816
+ LIMIT ?
1817
+ `);
1818
+ const rows = (hasCutoff ? stmt.all(cutoff, limit) : stmt.all(limit)) as (ModelSideCountsRow & {
1819
+ session_file: string;
1820
+ folder: string;
1821
+ models: string | null;
1822
+ first_seen: number;
1823
+ last_seen: number;
1824
+ })[];
1825
+ return rows.map(row => ({
1826
+ sessionFile: row.session_file,
1827
+ folder: row.folder,
1828
+ models: (row.models ?? "").split(",").filter(Boolean),
1829
+ firstSeen: row.first_seen,
1830
+ lastSeen: row.last_seen,
1831
+ ...toModelSideCounts(row),
1832
+ }));
1833
+ }
1834
+
1835
+ /** Model-side tallies per time bucket, ascending. */
1836
+ export function getModelSideTimeSeries({
1837
+ cutoff,
1838
+ bucketMs,
1839
+ }: {
1840
+ cutoff?: number | null;
1841
+ bucketMs: number;
1842
+ }): ModelSideTimeSeriesPoint[] {
1843
+ if (!db) return [];
1844
+ const hasCutoff = hasRangeCutoff(cutoff);
1845
+ const stmt = db.query(`
1846
+ SELECT (a.timestamp / ?) * ? AS bucket, ${MODEL_SIDE_COUNTS_SQL}
1847
+ FROM assistant_text a
1848
+ ${hasCutoff ? "WHERE a.timestamp >= ?" : ""}
1849
+ GROUP BY 1
1850
+ ORDER BY 1
1851
+ `);
1852
+ const rows = (
1853
+ hasCutoff ? stmt.all(bucketMs, bucketMs, cutoff) : stmt.all(bucketMs, bucketMs)
1854
+ ) as (ModelSideCountsRow & {
1855
+ bucket: number;
1856
+ })[];
1857
+ return rows.map(row => ({
1858
+ timestamp: row.bucket,
1859
+ messages: row.messages,
1860
+ blamed: row.blamed,
1861
+ restated: row.restated,
1862
+ hedged: row.hedged,
1863
+ spiraled: row.spiraled,
1864
+ breakdown: row.breakdown,
1865
+ collapse: row.collapse,
1866
+ }));
1867
+ }
1868
+
1550
1869
  /** Unique unjudged prose in range, one row per prose hash. */
1551
1870
  function pendingProseSql(hasCutoff: boolean): string {
1552
1871
  return `
@@ -17,8 +17,6 @@
17
17
  * `omp-stats` has none and only shows the regex fallback.
18
18
  */
19
19
  import { type Judge, judgeQuestions, type Model } from "@linxiraos/pi-ai";
20
- import { compareRevision, parseRevision } from "@linxiraos/pi-catalog/compat/revision";
21
- import { classifyModel } from "@linxiraos/pi-catalog/compat/taxonomy";
22
20
  import { getEffectiveTokenCost } from "@linxiraos/pi-catalog/models";
23
21
  import { logger } from "@linxiraos/pi-utils";
24
22
  import { getTimeRangeConfig } from "./aggregator";
@@ -33,6 +31,8 @@ import {
33
31
  type PendingProse,
34
32
  upsertFrustrationVerdicts,
35
33
  } from "./db";
34
+ import { compareModelIdentity, modelIdentityOf } from "./model-identity";
35
+ import { getModelSideDashboardStats } from "./model-side";
36
36
  import questionFile from "./prompts/judge/frustration.json" with { type: "json" };
37
37
  import type {
38
38
  FrustrationDashboardStats,
@@ -136,11 +136,8 @@ export function getFrustrationJobStatus(): FrustrationJobStatus {
136
136
  export function mergeFrustrationRows(rows: readonly FrustrationModelRow[]): FrustrationModelStats[] {
137
137
  const merged = new Map<string, FrustrationModelStats>();
138
138
  for (const row of rows) {
139
- const identity = classifyModel(row.provider ?? "", row.model, { lenient: true });
140
- const classified = identity.class !== "unknown" && identity.revision !== undefined;
141
- const family = identity.family ?? null;
142
- const key = classified ? `${identity.class}/${family ?? ""}/${identity.revision}` : row.model;
143
- const existing = merged.get(key);
139
+ const identity = modelIdentityOf(row.provider, row.model);
140
+ const existing = merged.get(identity.key);
144
141
  if (existing) {
145
142
  existing.messages += row.messages;
146
143
  existing.judged += row.judged;
@@ -152,15 +149,8 @@ export function mergeFrustrationRows(rows: readonly FrustrationModelRow[]): Frus
152
149
  if (!existing.models.includes(row.model)) existing.models.push(row.model);
153
150
  continue;
154
151
  }
155
- const revision = classified ? (identity.revision ?? null) : null;
156
- // `4.5.0` → `4.5`, `5.0.0` → `5`.
157
- const shortRevision = revision?.replace(/(?:\.0)+$/, "");
158
- merged.set(key, {
159
- key,
160
- label: shortRevision ? [family, shortRevision].filter(Boolean).join(" ") : row.model,
161
- modelClass: identity.class,
162
- family,
163
- revision,
152
+ merged.set(identity.key, {
153
+ ...identity,
164
154
  models: [row.model],
165
155
  firstSeen: row.firstSeen,
166
156
  messages: row.messages,
@@ -171,23 +161,7 @@ export function mergeFrustrationRows(rows: readonly FrustrationModelRow[]): Frus
171
161
  angry: row.angry,
172
162
  });
173
163
  }
174
- return [...merged.values()].sort(compareModelStats);
175
- }
176
-
177
- function compareModelStats(a: FrustrationModelStats, b: FrustrationModelStats): number {
178
- const aRevision = a.revision ? parseRevision(a.revision) : undefined;
179
- const bRevision = b.revision ? parseRevision(b.revision) : undefined;
180
- if (!aRevision || !bRevision) {
181
- if (aRevision) return -1;
182
- if (bRevision) return 1;
183
- return a.firstSeen - b.firstSeen;
184
- }
185
- if (a.modelClass !== b.modelClass) return a.modelClass < b.modelClass ? -1 : 1;
186
- const byRevision = compareRevision(aRevision, bRevision);
187
- if (byRevision !== 0) return byRevision;
188
- const aFamily = a.family ?? "";
189
- const bFamily = b.family ?? "";
190
- return aFamily < bFamily ? -1 : aFamily > bFamily ? 1 : 0;
164
+ return [...merged.values()].sort(compareModelIdentity);
191
165
  }
192
166
 
193
167
  /** Payload of `GET /api/stats/frustration`. */
@@ -197,6 +171,7 @@ export async function getFrustrationDashboardStats(range?: string | null): Promi
197
171
  return {
198
172
  overall: getFrustrationOverall(cutoff),
199
173
  byModel: mergeFrustrationRows(getFrustrationByModel(cutoff)),
174
+ modelSide: await getModelSideDashboardStats(range),
200
175
  judgeAvailable: judgeProvider !== undefined,
201
176
  job: getFrustrationJobStatus(),
202
177
  };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Catalog identity for a dashboard model row: merges provider and spelling
3
+ * variants (`claude-opus-4.6`, `anthropic/claude-opus-4-6`) into one display
4
+ * row, shared by the user-side frustration table and the model-side breakdown
5
+ * table so both group and order identically.
6
+ */
7
+ import { compareRevision, parseRevision } from "@linxiraos/pi-catalog/compat/revision";
8
+ import { classifyModel } from "@linxiraos/pi-catalog/compat/taxonomy";
9
+
10
+ export interface ModelIdentity {
11
+ /** Stable key: `class/family/revision` when classified, else the raw model id. */
12
+ key: string;
13
+ /** Display label, e.g. `opus 4.5`; the raw model id when unclassified. */
14
+ label: string;
15
+ /** Catalog identity class, e.g. `anthropic`; `unknown` when unclassified. */
16
+ modelClass: string;
17
+ /** Catalog family within the class, e.g. `opus`. */
18
+ family: string | null;
19
+ /** Canonical `major.minor.patch`, e.g. `4.5.0`; null when unclassified. */
20
+ revision: string | null;
21
+ }
22
+
23
+ /** Resolve the display identity of one persisted (provider, model) pair. */
24
+ export function modelIdentityOf(provider: string | null, model: string): ModelIdentity {
25
+ const identity = classifyModel(provider ?? "", model, { lenient: true });
26
+ const classified = identity.class !== "unknown" && identity.revision !== undefined;
27
+ const family = identity.family ?? null;
28
+ const revision = classified ? (identity.revision ?? null) : null;
29
+ // `4.5.0` → `4.5`, `5.0.0` → `5`.
30
+ const shortRevision = revision?.replace(/(?:\.0)+$/, "");
31
+ return {
32
+ key: classified ? `${identity.class}/${family ?? ""}/${identity.revision}` : model,
33
+ label: shortRevision ? [family, shortRevision].filter(Boolean).join(" ") : model,
34
+ modelClass: identity.class,
35
+ family,
36
+ revision,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Sort order shared by every model table: classified rows by class, then
42
+ * revision ascending, then family; unclassified rows follow by first use.
43
+ */
44
+ export function compareModelIdentity(
45
+ a: { modelClass: string; family: string | null; revision: string | null; firstSeen: number },
46
+ b: { modelClass: string; family: string | null; revision: string | null; firstSeen: number },
47
+ ): number {
48
+ const aRevision = a.revision ? parseRevision(a.revision) : undefined;
49
+ const bRevision = b.revision ? parseRevision(b.revision) : undefined;
50
+ if (!aRevision || !bRevision) {
51
+ if (aRevision) return -1;
52
+ if (bRevision) return 1;
53
+ return a.firstSeen - b.firstSeen;
54
+ }
55
+ if (a.modelClass !== b.modelClass) return a.modelClass < b.modelClass ? -1 : 1;
56
+ const byRevision = compareRevision(aRevision, bRevision);
57
+ if (byRevision !== 0) return byRevision;
58
+ const aFamily = a.family ?? "";
59
+ const bFamily = b.family ?? "";
60
+ return aFamily < bFamily ? -1 : aFamily > bFamily ? 1 : 0;
61
+ }