@oh-my-pi/omp-stats 18.3.5 → 18.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +13 -11
  3. package/build.ts +5 -49
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +2 -2
  6. package/dist/client/index.js +105 -126
  7. package/dist/types/aggregator.d.ts +25 -22
  8. package/dist/types/client/api.d.ts +11 -7
  9. package/dist/types/client/app/LiveChip.d.ts +2 -0
  10. package/dist/types/client/app/Shell.d.ts +16 -0
  11. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  12. package/dist/types/client/app/nav.d.ts +18 -0
  13. package/dist/types/client/charts/BarList.d.ts +17 -0
  14. package/dist/types/client/charts/Chart.d.ts +49 -0
  15. package/dist/types/client/charts/Legend.d.ts +16 -0
  16. package/dist/types/client/charts/ShareBar.d.ts +11 -0
  17. package/dist/types/client/charts/Sparkline.d.ts +11 -0
  18. package/dist/types/client/charts/TimeChart.d.ts +9 -0
  19. package/dist/types/client/charts/index.d.ts +7 -0
  20. package/dist/types/client/charts/types.d.ts +25 -0
  21. package/dist/types/client/charts/useWidth.d.ts +3 -0
  22. package/dist/types/client/data/colors.d.ts +25 -0
  23. package/dist/types/client/data/formatters.d.ts +9 -1
  24. package/dist/types/client/data/live.d.ts +29 -0
  25. package/dist/types/client/data/query.d.ts +35 -0
  26. package/dist/types/client/data/range.d.ts +31 -0
  27. package/dist/types/client/data/series.d.ts +28 -0
  28. package/dist/types/client/data/useHashRoute.d.ts +18 -4
  29. package/dist/types/client/data/view-models.d.ts +111 -28
  30. package/dist/types/client/index.d.ts +0 -1
  31. package/dist/types/client/routes/CostsRoute.d.ts +2 -2
  32. package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
  33. package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
  34. package/dist/types/client/routes/GainRoute.d.ts +1 -2
  35. package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
  36. package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
  37. package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
  38. package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
  39. package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
  40. package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
  41. package/dist/types/client/routes/TracesRoute.d.ts +2 -2
  42. package/dist/types/client/routes/index.d.ts +1 -1
  43. package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
  44. package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
  45. package/dist/types/client/traces/trace-colors.d.ts +18 -12
  46. package/dist/types/client/ui/Badge.d.ts +19 -0
  47. package/dist/types/client/ui/Card.d.ts +25 -0
  48. package/dist/types/client/ui/Drawer.d.ts +25 -0
  49. package/dist/types/client/ui/JsonBlock.d.ts +2 -2
  50. package/dist/types/client/ui/Modal.d.ts +23 -0
  51. package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
  52. package/dist/types/client/ui/SearchInput.d.ts +8 -0
  53. package/dist/types/client/ui/Segmented.d.ts +15 -0
  54. package/dist/types/client/ui/Stat.d.ts +36 -0
  55. package/dist/types/client/ui/States.d.ts +41 -0
  56. package/dist/types/client/ui/Table.d.ts +48 -0
  57. package/dist/types/client/ui/index.d.ts +9 -10
  58. package/dist/types/db.d.ts +90 -106
  59. package/dist/types/frustration.d.ts +70 -0
  60. package/dist/types/index.d.ts +3 -1
  61. package/dist/types/live.d.ts +30 -0
  62. package/dist/types/rollup.d.ts +115 -0
  63. package/dist/types/server.d.ts +6 -1
  64. package/dist/types/shared-types.d.ts +116 -56
  65. package/dist/types/types.d.ts +4 -0
  66. package/dist/types/user-metrics.d.ts +8 -0
  67. package/package.json +7 -13
  68. package/src/aggregator.ts +276 -211
  69. package/src/client/App.tsx +38 -80
  70. package/src/client/api.ts +65 -7
  71. package/src/client/app/LiveChip.tsx +97 -0
  72. package/src/client/app/Shell.tsx +181 -0
  73. package/src/client/app/ThemeToggle.tsx +5 -2
  74. package/src/client/app/nav.ts +78 -0
  75. package/src/client/charts/BarList.tsx +48 -0
  76. package/src/client/charts/Chart.tsx +388 -0
  77. package/src/client/charts/Legend.tsx +61 -0
  78. package/src/client/charts/ShareBar.tsx +26 -0
  79. package/src/client/charts/Sparkline.tsx +38 -0
  80. package/src/client/charts/TimeChart.tsx +22 -0
  81. package/src/client/charts/index.ts +7 -0
  82. package/src/client/charts/types.ts +27 -0
  83. package/src/client/charts/useWidth.ts +19 -0
  84. package/src/client/data/colors.ts +45 -0
  85. package/src/client/data/formatters.ts +30 -9
  86. package/src/client/data/live.tsx +85 -0
  87. package/src/client/data/query.ts +166 -0
  88. package/src/client/data/range.ts +82 -0
  89. package/src/client/data/series.ts +85 -0
  90. package/src/client/data/useHashRoute.ts +41 -83
  91. package/src/client/data/view-models.ts +347 -140
  92. package/src/client/index.tsx +3 -6
  93. package/src/client/routes/CostsRoute.tsx +394 -222
  94. package/src/client/routes/ErrorsRoute.tsx +401 -98
  95. package/src/client/routes/FrustrationRoute.tsx +924 -0
  96. package/src/client/routes/GainRoute.tsx +209 -206
  97. package/src/client/routes/ModelsRoute.tsx +540 -431
  98. package/src/client/routes/OverviewRoute.tsx +283 -311
  99. package/src/client/routes/ProjectsRoute.tsx +298 -156
  100. package/src/client/routes/ProvidersRoute.tsx +948 -416
  101. package/src/client/routes/RequestsRoute.tsx +295 -107
  102. package/src/client/routes/ToolsRoute.tsx +495 -398
  103. package/src/client/routes/TracesRoute.tsx +136 -117
  104. package/src/client/routes/costs.css +24 -0
  105. package/src/client/routes/errors.css +60 -0
  106. package/src/client/routes/frustration.css +123 -0
  107. package/src/client/routes/index.ts +1 -1
  108. package/src/client/routes/models.css +43 -0
  109. package/src/client/routes/projects.css +6 -0
  110. package/src/client/routes/providers.css +28 -0
  111. package/src/client/routes/tools.css +14 -0
  112. package/src/client/styles.css +1396 -1299
  113. package/src/client/traces/AggregatesPanel.tsx +55 -74
  114. package/src/client/traces/Minimap.tsx +6 -15
  115. package/src/client/traces/SpanDrawer.tsx +128 -194
  116. package/src/client/traces/SummaryStrip.tsx +21 -24
  117. package/src/client/traces/TimelineCanvas.tsx +27 -44
  118. package/src/client/traces/TraceView.tsx +175 -130
  119. package/src/client/traces/TranscriptList.tsx +13 -41
  120. package/src/client/traces/trace-colors.ts +60 -53
  121. package/src/client/traces/traces.css +279 -0
  122. package/src/client/ui/Badge.tsx +29 -0
  123. package/src/client/ui/Card.tsx +61 -0
  124. package/src/client/ui/Drawer.tsx +93 -0
  125. package/src/client/ui/JsonBlock.tsx +26 -44
  126. package/src/client/ui/Modal.tsx +106 -0
  127. package/src/client/ui/RequestDrawer.tsx +163 -188
  128. package/src/client/ui/SearchInput.tsx +25 -0
  129. package/src/client/ui/Segmented.tsx +61 -0
  130. package/src/client/ui/Stat.tsx +104 -0
  131. package/src/client/ui/States.tsx +81 -0
  132. package/src/client/ui/Table.tsx +229 -0
  133. package/src/client/ui/index.ts +9 -10
  134. package/src/client/ui/modal.css +54 -0
  135. package/src/client/ui/request-drawer.css +40 -0
  136. package/src/db.ts +379 -983
  137. package/src/frustration.ts +473 -0
  138. package/src/index.ts +22 -22
  139. package/src/live.ts +259 -0
  140. package/src/parser.ts +55 -66
  141. package/src/rollup.ts +951 -0
  142. package/src/server.ts +123 -40
  143. package/src/shared-types.ts +120 -56
  144. package/src/trace.ts +14 -7
  145. package/src/types.ts +4 -0
  146. package/src/user-metrics.ts +13 -0
  147. package/dist/client/styles.css +0 -1976
  148. package/dist/types/client/app/AppLayout.d.ts +0 -16
  149. package/dist/types/client/app/NavRail.d.ts +0 -7
  150. package/dist/types/client/app/RangeControl.d.ts +0 -7
  151. package/dist/types/client/app/SyncButton.d.ts +0 -14
  152. package/dist/types/client/app/TopBar.d.ts +0 -15
  153. package/dist/types/client/app/routes.d.ts +0 -12
  154. package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
  155. package/dist/types/client/components/chart-shared.d.ts +0 -178
  156. package/dist/types/client/components/models-table-shared.d.ts +0 -175
  157. package/dist/types/client/components/range-meta.d.ts +0 -21
  158. package/dist/types/client/data/charts.d.ts +0 -1
  159. package/dist/types/client/data/useResource.d.ts +0 -13
  160. package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
  161. package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
  162. package/dist/types/client/ui/DataTable.d.ts +0 -17
  163. package/dist/types/client/ui/EmptyState.d.ts +0 -7
  164. package/dist/types/client/ui/ErrorState.d.ts +0 -6
  165. package/dist/types/client/ui/MetricCluster.d.ts +0 -5
  166. package/dist/types/client/ui/Panel.d.ts +0 -7
  167. package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
  168. package/dist/types/client/ui/Skeleton.d.ts +0 -8
  169. package/dist/types/client/ui/StatusPill.d.ts +0 -7
  170. package/src/client/app/AppLayout.tsx +0 -93
  171. package/src/client/app/NavRail.tsx +0 -44
  172. package/src/client/app/RangeControl.tsx +0 -39
  173. package/src/client/app/SyncButton.tsx +0 -75
  174. package/src/client/app/TopBar.tsx +0 -73
  175. package/src/client/app/routes.ts +0 -93
  176. package/src/client/components/AgentTokenShare.tsx +0 -68
  177. package/src/client/components/chart-shared.tsx +0 -273
  178. package/src/client/components/models-table-shared.tsx +0 -255
  179. package/src/client/components/range-meta.ts +0 -73
  180. package/src/client/data/charts.ts +0 -14
  181. package/src/client/data/useResource.ts +0 -154
  182. package/src/client/routes/BehaviorRoute.tsx +0 -623
  183. package/src/client/ui/AsyncBoundary.tsx +0 -54
  184. package/src/client/ui/DataTable.tsx +0 -122
  185. package/src/client/ui/EmptyState.tsx +0 -16
  186. package/src/client/ui/ErrorState.tsx +0 -25
  187. package/src/client/ui/MetricCluster.tsx +0 -92
  188. package/src/client/ui/Panel.tsx +0 -24
  189. package/src/client/ui/SegmentedControl.tsx +0 -36
  190. package/src/client/ui/Skeleton.tsx +0 -17
  191. package/src/client/ui/StatusPill.tsx +0 -15
  192. package/tailwind.config.js +0 -40
@@ -0,0 +1,473 @@
1
+ /**
2
+ * Frustration dashboard: per-model share of user messages that sound annoyed,
3
+ * annoyed at the assistant, and angry at the assistant.
4
+ *
5
+ * Each message is classified by its cached judge verdict when one exists:
6
+ * annoyed = P(level 2) + P(level 3) ≥ 0.5 on {@link FRUSTRATION_QUESTIONS}.annoyed
7
+ * atAssistant = annoyed && target = `assistant`
8
+ * angry = atAssistant && P(level 3) ≥ 0.5
9
+ * and otherwise by the regex signals stored at ingest (`user-metrics.ts`):
10
+ * annoyed = yelling + profanity + anguish + negation + repetition + blame > 0
11
+ * atAssistant = negation + repetition + blame > 0
12
+ * angry = atAssistant && (profanity > 0 || yelling > 0)
13
+ * The SQL implementing both lives in `db.ts` (`FRUSTRATION_COUNTS_SQL`).
14
+ *
15
+ * Verdicts come from a single process-wide judge run over the unjudged prose,
16
+ * using the judge the omp host registered through `startServer`. Standalone
17
+ * `omp-stats` has none and only shows the regex fallback.
18
+ */
19
+ import type { ChoiceQuestion, Judge, Model, ScoreQuestion } from "@oh-my-pi/pi-ai";
20
+ import { compareRevision, parseRevision } from "@oh-my-pi/pi-catalog/compat/revision";
21
+ import { classifyModel } from "@oh-my-pi/pi-catalog/compat/taxonomy";
22
+ import { logger } from "@oh-my-pi/pi-utils";
23
+ import { getTimeRangeConfig } from "./aggregator";
24
+ import {
25
+ type FrustrationModelRow,
26
+ type FrustrationVerdict,
27
+ getFrustrationByModel,
28
+ getFrustrationOverall,
29
+ getPendingFrustrationProse,
30
+ getPendingFrustrationTotals,
31
+ initDb,
32
+ type PendingProse,
33
+ upsertFrustrationVerdicts,
34
+ } from "./db";
35
+ import type {
36
+ FrustrationDashboardStats,
37
+ FrustrationEstimate,
38
+ FrustrationJobStatus,
39
+ FrustrationModelStats,
40
+ } from "./shared-types";
41
+
42
+ /** Judge supplied by the omp host process; `primaryModel` prices the pre-run estimate. */
43
+ export interface StatsJudge extends Judge {
44
+ primaryModel(): Model | undefined;
45
+ }
46
+
47
+ /** Lazily resolves the host judge on first estimate/run so `omp stats` startup stays fast. */
48
+ export type StatsJudgeProvider = () => Promise<StatsJudge>;
49
+
50
+ /** Where the user's annoyance is aimed. */
51
+ export type FrustrationTarget = "assistant" | "other" | "none";
52
+
53
+ /**
54
+ * The two questions asked about every prose text, in ONE judge request.
55
+ * The cost constants below were measured with exactly these strings; changing
56
+ * them invalidates both the estimate and every cached verdict's meaning.
57
+ */
58
+ export const FRUSTRATION_QUESTIONS: { annoyed: ScoreQuestion; target: ChoiceQuestion<FrustrationTarget> } = {
59
+ annoyed: {
60
+ type: "score",
61
+ instructions:
62
+ "A user typed this message to an AI coding assistant (code blocks and markup were removed). Rate how frustrated or annoyed the user sounds. Judge tone and wording only (caps, swearing, 'again', 'why did you', 'stop', 'wtf', exasperation), not task difficulty. Plain instructions or questions are neutral.",
63
+ criteria: [
64
+ "neutral / no frustration",
65
+ "mild irritation or impatience",
66
+ "clearly annoyed or exasperated",
67
+ "angry, hostile, or swearing",
68
+ ],
69
+ },
70
+ target: {
71
+ type: "choice",
72
+ instructions: "If the user sounds annoyed, what is the annoyance aimed at?",
73
+ criteria: {
74
+ assistant:
75
+ "the AI's own behavior in this session: what it did, wrote, ignored, repeated, took too long on, or misunderstood (incl. reacting to its output with 'wtf', 'no', 'stop', 'again', 'why did you')",
76
+ other: "build tools, third-party libraries/services, pre-existing code or tests, the user's own past design, other people, or general product/UX feedback about the thing being built",
77
+ none: "not annoyed: a plain question, instruction, design musing, or casual chat",
78
+ },
79
+ },
80
+ };
81
+
82
+ /** Per-request input-token overhead of {@link FRUSTRATION_QUESTIONS}, measured on jev-1.13. */
83
+ const REQUEST_OVERHEAD_TOKENS = 561;
84
+ /** Prose characters per input token, measured on jev-1.13. */
85
+ const CHARS_PER_TOKEN = 5.9;
86
+ /** Output tokens a prompted chat judge bills per request; native judges bill none (zero output price). */
87
+ const OUTPUT_TOKENS_PER_REQUEST = 8;
88
+ /** Judge requests in flight at the start of a run; {@link AdaptiveLimit} tunes it from there. */
89
+ const INITIAL_CONCURRENCY = 32;
90
+ const MIN_CONCURRENCY = 4;
91
+ const MAX_CONCURRENCY = 256;
92
+ /** Wait before the second attempt at a text; doubles for the third. */
93
+ const RETRY_DELAY_MS = 250;
94
+ /** Failures within this window after a backoff don't halve the limit again. */
95
+ const BACKOFF_COOLDOWN_MS = 2_000;
96
+ /** Verdicts buffered before a write; a partial batch is written after {@link VERDICT_FLUSH_MS}. */
97
+ const VERDICT_BATCH = 64;
98
+ const VERDICT_FLUSH_MS = 500;
99
+ const ATTEMPTS_PER_TEXT = 3;
100
+ /** Stop the run when this many texts failed before any succeeded: the judge is not working. */
101
+ const CIRCUIT_BREAKER_FAILURES = 25;
102
+
103
+ const NO_PROVIDER_REASON =
104
+ "This dashboard was started without a judge (standalone omp-stats). Run `omp stats` to classify.";
105
+ const NO_MODEL_REASON = "No judge model is available. Configure the `judge` model role.";
106
+
107
+ let judgeProvider: StatsJudgeProvider | undefined;
108
+ let judgePromise: Promise<StatsJudge> | undefined;
109
+
110
+ /** Live status of the latest run; each run owns its own object so a cancelled run's stragglers can't touch a newer one. */
111
+ let currentJob: FrustrationJobStatus = {
112
+ state: "idle",
113
+ total: 0,
114
+ done: 0,
115
+ failed: 0,
116
+ cost: 0,
117
+ judge: null,
118
+ error: null,
119
+ startedAt: null,
120
+ finishedAt: null,
121
+ concurrency: 0,
122
+ };
123
+ let currentController: AbortController | undefined;
124
+ /** Set while a start request is resolving the judge / loading the queue, so a concurrent start gets 409. */
125
+ let starting = false;
126
+
127
+ /** Register (or replace) the host judge. Called by `startServer`. */
128
+ export function setStatsJudgeProvider(provider: StatsJudgeProvider | undefined): void {
129
+ judgeProvider = provider;
130
+ judgePromise = undefined;
131
+ }
132
+
133
+ /** Resolve the registered judge once; a rejected resolution is forgotten so the next call retries. */
134
+ function loadJudge(provider: StatsJudgeProvider): Promise<StatsJudge> {
135
+ if (judgePromise) return judgePromise;
136
+ const promise = provider();
137
+ judgePromise = promise;
138
+ promise.catch(() => {
139
+ if (judgePromise === promise) judgePromise = undefined;
140
+ });
141
+ return promise;
142
+ }
143
+
144
+ function errorMessage(error: unknown): string {
145
+ return error instanceof Error ? error.message : String(error);
146
+ }
147
+
148
+ /** Snapshot of the current run's progress. */
149
+ export function getFrustrationJobStatus(): FrustrationJobStatus {
150
+ return { ...currentJob };
151
+ }
152
+
153
+ /**
154
+ * Merge per-(model, provider) rows into catalog identities (`class/family/revision`)
155
+ * so provider and spelling variants share a row. Classified rows sort by class,
156
+ * then revision ascending, then family; unclassified rows follow by first use.
157
+ */
158
+ export function mergeFrustrationRows(rows: readonly FrustrationModelRow[]): FrustrationModelStats[] {
159
+ const merged = new Map<string, FrustrationModelStats>();
160
+ for (const row of rows) {
161
+ const identity = classifyModel(row.provider ?? "", row.model, { lenient: true });
162
+ const classified = identity.class !== "unknown" && identity.revision !== undefined;
163
+ const family = identity.family ?? null;
164
+ const key = classified ? `${identity.class}/${family ?? ""}/${identity.revision}` : row.model;
165
+ const existing = merged.get(key);
166
+ if (existing) {
167
+ existing.messages += row.messages;
168
+ existing.judged += row.judged;
169
+ existing.annoyed += row.annoyed;
170
+ existing.atAssistant += row.atAssistant;
171
+ existing.angry += row.angry;
172
+ existing.firstSeen = Math.min(existing.firstSeen, row.firstSeen);
173
+ if (!existing.models.includes(row.model)) existing.models.push(row.model);
174
+ continue;
175
+ }
176
+ const revision = classified ? (identity.revision ?? null) : null;
177
+ // `4.5.0` → `4.5`, `5.0.0` → `5`.
178
+ const shortRevision = revision?.replace(/(?:\.0)+$/, "");
179
+ merged.set(key, {
180
+ key,
181
+ label: shortRevision ? [family, shortRevision].filter(Boolean).join(" ") : row.model,
182
+ modelClass: identity.class,
183
+ family,
184
+ revision,
185
+ models: [row.model],
186
+ firstSeen: row.firstSeen,
187
+ messages: row.messages,
188
+ judged: row.judged,
189
+ annoyed: row.annoyed,
190
+ atAssistant: row.atAssistant,
191
+ angry: row.angry,
192
+ });
193
+ }
194
+ return [...merged.values()].sort(compareModelStats);
195
+ }
196
+
197
+ function compareModelStats(a: FrustrationModelStats, b: FrustrationModelStats): number {
198
+ const aRevision = a.revision ? parseRevision(a.revision) : undefined;
199
+ const bRevision = b.revision ? parseRevision(b.revision) : undefined;
200
+ if (!aRevision || !bRevision) {
201
+ if (aRevision) return -1;
202
+ if (bRevision) return 1;
203
+ return a.firstSeen - b.firstSeen;
204
+ }
205
+ if (a.modelClass !== b.modelClass) return a.modelClass < b.modelClass ? -1 : 1;
206
+ const byRevision = compareRevision(aRevision, bRevision);
207
+ if (byRevision !== 0) return byRevision;
208
+ const aFamily = a.family ?? "";
209
+ const bFamily = b.family ?? "";
210
+ return aFamily < bFamily ? -1 : aFamily > bFamily ? 1 : 0;
211
+ }
212
+
213
+ /** Payload of `GET /api/stats/frustration`. */
214
+ export async function getFrustrationDashboardStats(range?: string | null): Promise<FrustrationDashboardStats> {
215
+ await initDb();
216
+ const { cutoff } = getTimeRangeConfig(range);
217
+ return {
218
+ overall: getFrustrationOverall(cutoff),
219
+ byModel: mergeFrustrationRows(getFrustrationByModel(cutoff)),
220
+ judgeAvailable: judgeProvider !== undefined,
221
+ job: getFrustrationJobStatus(),
222
+ };
223
+ }
224
+
225
+ /** Pre-run cost quote for judging every unjudged prose text in range. */
226
+ export async function estimateFrustrationRun(range?: string | null): Promise<FrustrationEstimate> {
227
+ if (!judgeProvider) return { available: false, reason: NO_PROVIDER_REASON };
228
+ let judge: StatsJudge;
229
+ try {
230
+ judge = await loadJudge(judgeProvider);
231
+ } catch (error) {
232
+ return { available: false, reason: `The judge could not be loaded: ${errorMessage(error)}` };
233
+ }
234
+ const model = judge.primaryModel();
235
+ if (!model) return { available: false, reason: NO_MODEL_REASON };
236
+ await initDb();
237
+ const { cutoff } = getTimeRangeConfig(range);
238
+ const { messages, chars } = getPendingFrustrationTotals(cutoff);
239
+ const inputTokens = messages * REQUEST_OVERHEAD_TOKENS + Math.ceil(chars / CHARS_PER_TOKEN);
240
+ const cost =
241
+ (inputTokens * model.cost.input) / 1e6 + (messages * OUTPUT_TOKENS_PER_REQUEST * model.cost.output) / 1e6;
242
+ return { available: true, messages, chars, inputTokens, cost, judge: `${model.provider}/${model.id}` };
243
+ }
244
+
245
+ /** Outcome of {@link startFrustrationRun}: the new job, or the HTTP status and reason it was refused. */
246
+ export type StartFrustrationRunResult =
247
+ | {
248
+ started: true;
249
+ job: FrustrationJobStatus;
250
+ /** Settles once every worker has stopped (done, cancelled, or failed); never rejects. */
251
+ finished: Promise<void>;
252
+ }
253
+ | { started: false; status: 409 | 503; error: string };
254
+
255
+ /**
256
+ * Start the process-wide judge run over the unique unjudged prose in range.
257
+ * Returns immediately after queueing; progress is read via {@link getFrustrationJobStatus}.
258
+ */
259
+ export async function startFrustrationRun(range?: string | null): Promise<StartFrustrationRunResult> {
260
+ if (starting || currentJob.state === "running") {
261
+ return { started: false, status: 409, error: "A frustration judge run is already in progress." };
262
+ }
263
+ const provider = judgeProvider;
264
+ if (!provider) return { started: false, status: 503, error: NO_PROVIDER_REASON };
265
+ starting = true;
266
+ try {
267
+ let judge: StatsJudge;
268
+ try {
269
+ judge = await loadJudge(provider);
270
+ } catch (error) {
271
+ return { started: false, status: 503, error: `The judge could not be loaded: ${errorMessage(error)}` };
272
+ }
273
+ const model = judge.primaryModel();
274
+ if (!model) return { started: false, status: 503, error: NO_MODEL_REASON };
275
+ await initDb();
276
+ const { cutoff } = getTimeRangeConfig(range);
277
+ const pending = shuffle(getPendingFrustrationProse(cutoff));
278
+ const now = Date.now();
279
+ const job: FrustrationJobStatus = {
280
+ state: pending.length > 0 ? "running" : "done",
281
+ total: pending.length,
282
+ done: 0,
283
+ failed: 0,
284
+ cost: 0,
285
+ judge: `${model.provider}/${model.id}`,
286
+ error: null,
287
+ startedAt: now,
288
+ finishedAt: pending.length > 0 ? null : now,
289
+ concurrency: 0,
290
+ };
291
+ currentJob = job;
292
+ if (pending.length === 0) return { started: true, job: { ...job }, finished: Promise.resolve() };
293
+ const controller = new AbortController();
294
+ currentController = controller;
295
+ const finished = runJob(job, judge, pending, controller);
296
+ return { started: true, job: { ...job }, finished };
297
+ } finally {
298
+ starting = false;
299
+ }
300
+ }
301
+
302
+ /** Abort the active run; in-flight judge requests are cancelled and no new text is taken. */
303
+ export function cancelFrustrationRun(): FrustrationJobStatus {
304
+ if (currentJob.state === "running") {
305
+ currentController?.abort(new Error("frustration run cancelled"));
306
+ currentJob.state = "cancelled";
307
+ currentJob.finishedAt = Date.now();
308
+ }
309
+ return getFrustrationJobStatus();
310
+ }
311
+
312
+ /** Fisher–Yates: a random order makes every model's rates converge evenly while verdicts stream in. */
313
+ function shuffle<T>(items: T[]): T[] {
314
+ for (let i = items.length - 1; i > 0; i--) {
315
+ const j = Math.floor(Math.random() * (i + 1));
316
+ [items[i], items[j]] = [items[j], items[i]];
317
+ }
318
+ return items;
319
+ }
320
+
321
+ /**
322
+ * In-flight limit for judge requests, adapted like TCP congestion control:
323
+ * it grows by one per success until the first failure (slow start), then by
324
+ * about four per window of successes, and halves on failure — at most once
325
+ * per {@link BACKOFF_COOLDOWN_MS}, so one burst of rate-limit errors from the
326
+ * same window counts once. Judges are latency-bound (a call takes ~1s whatever
327
+ * the model speed), so throughput scales with how many calls are in flight.
328
+ */
329
+ class AdaptiveLimit {
330
+ #limit = INITIAL_CONCURRENCY;
331
+ #slowStart = true;
332
+ #lastBackoff = 0;
333
+
334
+ get value(): number {
335
+ return Math.floor(this.#limit);
336
+ }
337
+
338
+ succeeded(): void {
339
+ this.#limit = Math.min(MAX_CONCURRENCY, this.#limit + (this.#slowStart ? 1 : 4 / this.#limit));
340
+ }
341
+
342
+ failed(): void {
343
+ this.#slowStart = false;
344
+ const now = Date.now();
345
+ if (now - this.#lastBackoff < BACKOFF_COOLDOWN_MS) return;
346
+ this.#lastBackoff = now;
347
+ this.#limit = Math.max(MIN_CONCURRENCY, this.#limit / 2);
348
+ }
349
+ }
350
+
351
+ async function runJob(
352
+ job: FrustrationJobStatus,
353
+ judge: StatsJudge,
354
+ queue: readonly PendingProse[],
355
+ controller: AbortController,
356
+ ): Promise<void> {
357
+ const { signal } = controller;
358
+ const limit = new AdaptiveLimit();
359
+ let next = 0;
360
+ let inFlight = 0;
361
+ let labelFromResult = false;
362
+ let lastError: string | null = null;
363
+ let tripped = false;
364
+
365
+ // Verdicts are written in batches: one transaction per flush instead of one per text.
366
+ let buffered: FrustrationVerdict[] = [];
367
+ let flushTimer: NodeJS.Timeout | undefined;
368
+ const flush = () => {
369
+ clearTimeout(flushTimer);
370
+ flushTimer = undefined;
371
+ const batch = buffered;
372
+ buffered = [];
373
+ upsertFrustrationVerdicts(batch);
374
+ };
375
+ const crash = (error: unknown) => {
376
+ if (tripped) return;
377
+ // Only non-judge failures (e.g. a SQLite write) reach here.
378
+ tripped = true;
379
+ lastError = errorMessage(error);
380
+ logger.error("frustration judge run crashed", { error: lastError });
381
+ controller.abort(error);
382
+ };
383
+ const record = (verdict: FrustrationVerdict) => {
384
+ buffered.push(verdict);
385
+ if (buffered.length >= VERDICT_BATCH) {
386
+ flush();
387
+ } else {
388
+ flushTimer ??= setTimeout(() => {
389
+ try {
390
+ flush();
391
+ } catch (error) {
392
+ crash(error);
393
+ }
394
+ }, VERDICT_FLUSH_MS);
395
+ }
396
+ };
397
+
398
+ const judgeText = async (item: PendingProse): Promise<void> => {
399
+ for (let attempt = 1; attempt <= ATTEMPTS_PER_TEXT; attempt++) {
400
+ if (signal.aborted) return;
401
+ // Failures are mostly rate limits: give the judge a moment before retrying.
402
+ if (attempt > 1) await Bun.sleep(RETRY_DELAY_MS * (attempt - 1));
403
+ if (signal.aborted) return;
404
+ try {
405
+ const result = await judge.judge({ state: item.prose, questions: FRUSTRATION_QUESTIONS }, { signal });
406
+ limit.succeeded();
407
+ const levels = result.answers.annoyed.probabilities;
408
+ const pAngry = levels["3"] ?? 0;
409
+ const label = `${result.provider}/${result.model}`;
410
+ record({
411
+ proseHash: item.hash,
412
+ pAnnoyed: (levels["2"] ?? 0) + pAngry,
413
+ pAngry,
414
+ target: result.answers.target.choice,
415
+ judge: label,
416
+ judgedAt: Date.now(),
417
+ });
418
+ job.done++;
419
+ job.cost += result.usage.cost.total;
420
+ if (!labelFromResult) {
421
+ labelFromResult = true;
422
+ job.judge = label;
423
+ }
424
+ return;
425
+ } catch (error) {
426
+ if (signal.aborted) return;
427
+ limit.failed();
428
+ lastError = errorMessage(error);
429
+ logger.warn("frustration judge attempt failed", { hash: item.hash, attempt, error: lastError });
430
+ }
431
+ }
432
+ job.failed++;
433
+ if (job.failed >= CIRCUIT_BREAKER_FAILURES && job.done === 0 && !tripped) {
434
+ tripped = true;
435
+ logger.error("frustration judge run stopped: no text succeeded", { failed: job.failed, error: lastError });
436
+ controller.abort(new Error("frustration judge circuit breaker tripped"));
437
+ }
438
+ };
439
+
440
+ // Keep `limit.value` requests in flight; every completion refills the pool.
441
+ const { promise: drained, resolve } = Promise.withResolvers<void>();
442
+ const pump = () => {
443
+ while (!signal.aborted && inFlight < limit.value && next < queue.length) {
444
+ const item = queue[next++];
445
+ inFlight++;
446
+ judgeText(item)
447
+ .catch(crash)
448
+ .finally(() => {
449
+ inFlight--;
450
+ pump();
451
+ });
452
+ }
453
+ job.concurrency = limit.value;
454
+ if (inFlight === 0 && (signal.aborted || next >= queue.length)) resolve();
455
+ };
456
+ pump();
457
+ await drained;
458
+
459
+ // Verdicts already paid for are kept, even when the run was cancelled.
460
+ try {
461
+ flush();
462
+ } catch (error) {
463
+ crash(error);
464
+ }
465
+ if (tripped) {
466
+ job.state = "failed";
467
+ job.error = lastError;
468
+ } else if (job.state === "running") {
469
+ job.state = "done";
470
+ }
471
+ job.finishedAt ??= Date.now();
472
+ if (currentController === controller) currentController = undefined;
473
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import { parseArgs } from "node:util";
4
4
  import { formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils";
5
5
  import { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator";
6
6
  import { closeDb } from "./db";
7
+ import { refreshRollups } from "./rollup";
7
8
  import { formatStatsDashboardUrl, startServer } from "./server";
8
9
 
9
10
  export {
@@ -16,8 +17,10 @@ export {
16
17
  syncAllSessions,
17
18
  } from "./aggregator";
18
19
  export { closeDb } from "./db";
20
+ export { refreshRollups } from "./rollup";
21
+ export type { StatsJudge, StatsJudgeProvider } from "./frustration";
19
22
  export { getGainDashboardStats } from "./gain-aggregator";
20
- export { formatStatsDashboardUrl, startServer } from "./server";
23
+ export { formatStatsDashboardUrl, type StartServerOptions, startServer } from "./server";
21
24
  export type { GainDashboardStats, GainSource, GainSourceTotals, GainTimeSeriesPoint } from "./shared-types";
22
25
  export type {
23
26
  AggregatedStats,
@@ -153,7 +156,20 @@ Examples:
153
156
  }
154
157
 
155
158
  try {
156
- // Sync first
159
+ if (!values.json && !values.sync) {
160
+ // The dashboard ingests sessions in the background and streams progress to the page.
161
+ const { port: actualPort } = await startServer(values.port, values.host);
162
+ console.log(`Dashboard available at: ${formatStatsDashboardUrl(values.host, actualPort)}`);
163
+ console.log("Press Ctrl+C to stop\n");
164
+ process.on("SIGINT", () => {
165
+ console.log("\nShutting down...");
166
+ closeDb();
167
+ process.exit(0);
168
+ });
169
+ return;
170
+ }
171
+
172
+ // One-shot reports need fully ingested, fully rolled-up data before printing.
157
173
  const tty = process.stderr.isTTY === true;
158
174
  process.stderr.write("Syncing session files...\n");
159
175
  let lastWidth = 0;
@@ -176,31 +192,15 @@ Examples:
176
192
  },
177
193
  });
178
194
  if (tty && lastWidth > 0) process.stderr.write(`\r${" ".repeat(lastWidth)}\r`);
195
+ await refreshRollups();
179
196
  const total = await getTotalMessageCount();
180
- console.log(`Synced ${processed} new entries from ${files} files (${total} total)\n`);
197
+ process.stderr.write(`Synced ${processed} new entries from ${files} files (${total} total)\n\n`);
181
198
 
182
199
  if (values.json) {
183
- const stats = await getDashboardStats();
184
- console.log(JSON.stringify(stats, null, 2));
185
- return;
186
- }
187
-
188
- if (values.sync) {
200
+ console.log(JSON.stringify(await getDashboardStats(), null, 2));
201
+ } else {
189
202
  await printStats();
190
- return;
191
203
  }
192
-
193
- // Start server
194
- const { port: actualPort } = await startServer(values.port, values.host);
195
- console.log(`Dashboard available at: ${formatStatsDashboardUrl(values.host, actualPort)}`);
196
- console.log("Press Ctrl+C to stop\n");
197
-
198
- // Keep process running
199
- process.on("SIGINT", () => {
200
- console.log("\nShutting down...");
201
- closeDb();
202
- process.exit(0);
203
- });
204
204
  } catch (error) {
205
205
  console.error("Error:", error);
206
206
  closeDb();