@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
@@ -8,6 +8,14 @@
8
8
  * keeps building the dashboard from source.
9
9
  */
10
10
 
11
+ /**
12
+ * Cheap check whether the generated text carries a gzip archive: sniffs the
13
+ * base64 of the gzip magic (`1f 8b 08`) without decoding megabytes.
14
+ */
15
+ export function hasEmbeddedClientArchive(txt: string): boolean {
16
+ return /^\s*H4s/.test(txt);
17
+ }
18
+
11
19
  /**
12
20
  * Decode the generated archive text.
13
21
  *
@@ -17,6 +25,7 @@
17
25
  * rather than decoded into garbage bytes.
18
26
  */
19
27
  export function decodeEmbeddedClientArchive(txt: string): Buffer | null {
28
+ if (!hasEmbeddedClientArchive(txt)) return null;
20
29
  const normalized = txt.replaceAll(/\s+/g, "");
21
30
  if (!normalized) return null;
22
31
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) return null;
@@ -16,7 +16,7 @@
16
16
  * using the judge the omp host registered through `startServer`. Standalone
17
17
  * `omp-stats` has none and only shows the regex fallback.
18
18
  */
19
- import type { ChoiceQuestion, Judge, Model, ScoreQuestion } from "@linxiraos/pi-ai";
19
+ import { type Judge, judgeQuestions, type Model } from "@linxiraos/pi-ai";
20
20
  import { compareRevision, parseRevision } from "@linxiraos/pi-catalog/compat/revision";
21
21
  import { classifyModel } from "@linxiraos/pi-catalog/compat/taxonomy";
22
22
  import { logger } from "@linxiraos/pi-utils";
@@ -32,6 +32,7 @@ import {
32
32
  type PendingProse,
33
33
  upsertFrustrationVerdicts,
34
34
  } from "./db";
35
+ import questionFile from "./prompts/judge/frustration.json" with { type: "json" };
35
36
  import type {
36
37
  FrustrationDashboardStats,
37
38
  FrustrationEstimate,
@@ -44,40 +45,16 @@ export interface StatsJudge extends Judge {
44
45
  primaryModel(): Model | undefined;
45
46
  }
46
47
 
47
- /** Lazily resolves the host judge on first estimate/run so `omp stats` startup stays fast. */
48
+ /** Lazily resolves the host judge on first estimate/run so `zetacode stats` startup stays fast. */
48
49
  export type StatsJudgeProvider = () => Promise<StatsJudge>;
49
50
 
50
- /** Where the user's annoyance is aimed. */
51
- export type FrustrationTarget = "assistant" | "other" | "none";
52
-
53
51
  /**
54
- * The two questions asked about every prose text, in ONE judge request.
52
+ * The two questions asked about every prose text, in ONE judge request:
53
+ * `annoyed` (four levels) and `target` (`assistant`, `other`, or `none`).
55
54
  * The cost constants below were measured with exactly these strings; changing
56
55
  * them invalidates both the estimate and every cached verdict's meaning.
57
56
  */
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
- };
57
+ export const FRUSTRATION_QUESTIONS = judgeQuestions(questionFile);
81
58
 
82
59
  /** Per-request input-token overhead of {@link FRUSTRATION_QUESTIONS}, measured on jev-1.13. */
83
60
  const REQUEST_OVERHEAD_TOKENS = 561;
@@ -101,7 +78,7 @@ const ATTEMPTS_PER_TEXT = 3;
101
78
  const CIRCUIT_BREAKER_FAILURES = 25;
102
79
 
103
80
  const NO_PROVIDER_REASON =
104
- "This dashboard was started without a judge (standalone zeta stats). Run `zeta-c stats` to classify.";
81
+ "This dashboard was started without a judge (standalone zetacode stats). Run `zeta-c stats` to classify.";
105
82
  const NO_MODEL_REASON = "No judge model is available. Configure the `judge` model role.";
106
83
 
107
84
  let judgeProvider: StatsJudgeProvider | undefined;
@@ -166,6 +143,7 @@ export function mergeFrustrationRows(rows: readonly FrustrationModelRow[]): Frus
166
143
  if (existing) {
167
144
  existing.messages += row.messages;
168
145
  existing.judged += row.judged;
146
+ existing.unscored += row.unscored;
169
147
  existing.annoyed += row.annoyed;
170
148
  existing.atAssistant += row.atAssistant;
171
149
  existing.angry += row.angry;
@@ -186,6 +164,7 @@ export function mergeFrustrationRows(rows: readonly FrustrationModelRow[]): Frus
186
164
  firstSeen: row.firstSeen,
187
165
  messages: row.messages,
188
166
  judged: row.judged,
167
+ unscored: row.unscored,
189
168
  annoyed: row.annoyed,
190
169
  atAssistant: row.atAssistant,
191
170
  angry: row.angry,
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { parseArgs } from "node:util";
4
4
  import { formatDuration, formatNumber, formatPercent, normalizePremiumRequests } from "@linxiraos/pi-utils";
5
5
  import chalk from "@linxiraos/pi-utils/chalk";
6
+ import { formatErrorRate } from "./client/data/formatters";
6
7
  import { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator";
7
8
  import { closeDb } from "./db";
8
9
  import { refreshRollups } from "./rollup";
@@ -58,7 +59,7 @@ export async function printStatsSummary(): Promise<void> {
58
59
 
59
60
  console.log(chalk.bold("Overall:"));
60
61
  console.log(` Requests: ${formatNumber(overall.totalRequests)} (${formatNumber(overall.failedRequests)} errors)`);
61
- console.log(` Error Rate: ${formatPercent(overall.errorRate)}`);
62
+ console.log(` Error Rate: ${formatErrorRate(overall.errorRate)}`);
62
63
  console.log(` Total Tokens: ${formatNumber(overall.totalInputTokens + overall.totalOutputTokens)}`);
63
64
  console.log(` Input Tokens: ${formatNumber(overall.totalInputTokens)}`);
64
65
  console.log(` Output Tokens: ${formatNumber(overall.totalOutputTokens)}`);
package/src/live.ts CHANGED
@@ -5,15 +5,16 @@
5
5
  * The dashboard never waits for ingest. {@link StatsLive.start} kicks a full
6
6
  * sync in the background and watches the sessions directory; changed
7
7
  * transcripts are re-synced (only those files) shortly after they are written.
8
- * Every committed batch bumps {@link LiveStatus.version} (throttled), so open
9
- * pages refetch and fill in while parsing is still running.
8
+ * Every batch that changes stored rows bumps {@link LiveStatus.version}
9
+ * (throttled), so open pages refetch and fill in while parsing is still
10
+ * running; syncs that change nothing leave the version alone.
10
11
  */
11
12
 
12
13
  import * as fs from "node:fs";
13
14
  import * as path from "node:path";
14
15
  import { getSessionsDir, logger } from "@linxiraos/pi-utils";
15
16
  import { syncAllSessions } from "./aggregator";
16
- import { initDb } from "./db";
17
+ import { getDataVersion, initDb } from "./db";
17
18
  import { getRollupStatus, refreshRollups } from "./rollup";
18
19
  import type { LiveStatus, LiveSyncStatus } from "./shared-types";
19
20
 
@@ -28,7 +29,8 @@ const SYNC_RETRY_MS = 10_000;
28
29
  /** Minimum spacing of progress-only status events. */
29
30
  const PROGRESS_THROTTLE_MS = 150;
30
31
 
31
- type Listener = (status: LiveStatus) => void;
32
+ /** Receives each distinct status and its JSON serialization (shared by every listener). */
33
+ type Listener = (status: LiveStatus, frame: string) => void;
32
34
 
33
35
  /** One per process; owned by the dashboard server (see `startServer`). */
34
36
  export class StatsLive {
@@ -36,6 +38,10 @@ export class StatsLive {
36
38
  #sync: LiveSyncStatus = { phase: "idle", current: 0, total: 0, processed: 0, lastSyncedAt: null, error: null };
37
39
  #indexingHours = 0;
38
40
  #listeners = new Set<Listener>();
41
+ /** Last frame sent to listeners; identical statuses are not re-sent. */
42
+ #lastFrame: string | null = null;
43
+ /** `PRAGMA data_version` at the last sync; null before the first. */
44
+ #dataVersion: number | null = null;
39
45
 
40
46
  #started = false;
41
47
  #watcher: fs.FSWatcher | null = null;
@@ -133,8 +139,9 @@ export class StatsLive {
133
139
  };
134
140
  this.#emitProgress();
135
141
  }
136
- if (event.processed > committed) {
137
- committed = event.processed;
142
+ // Only committed row changes make pages refetch; unchanged transcripts cost nothing.
143
+ if (event.changes > committed) {
144
+ committed = event.changes;
138
145
  this.#changed();
139
146
  }
140
147
  },
@@ -159,14 +166,28 @@ export class StatsLive {
159
166
  }, SYNC_RETRY_MS);
160
167
  }
161
168
  }
162
- // Another process may have ingested too; always let clients revalidate.
163
- this.#changed();
169
+ // Another process may have ingested too; its commits move SQLite's data_version.
170
+ if (this.#externalWrites()) this.#changed();
164
171
  this.#emit();
165
172
  }
166
173
 
174
+ /** Whether another connection committed since the last check (always true on the first). */
175
+ #externalWrites(): boolean {
176
+ const current = getDataVersion();
177
+ if (current === null) return false;
178
+ const changed = current !== this.#dataVersion;
179
+ this.#dataVersion = current;
180
+ return changed;
181
+ }
182
+
167
183
  /** Stored data changed: refresh rollups and (throttled) bump the version. */
168
184
  #changed(): void {
169
185
  void this.#refresh();
186
+ this.#bumpVersion();
187
+ }
188
+
189
+ /** Bump the data version (throttled), making open pages refetch. */
190
+ #bumpVersion(): void {
170
191
  if (this.#versionTimer) return;
171
192
  const wait = Math.max(0, this.#lastVersionAt + VERSION_THROTTLE_MS - Date.now());
172
193
  this.#versionTimer = setTimeout(() => {
@@ -190,8 +211,9 @@ export class StatsLive {
190
211
  await refreshRollups({
191
212
  onProgress: remaining => {
192
213
  this.#indexingHours = remaining;
193
- // Each rolled batch fills in more history for open pages.
194
- if (remaining > 0) this.#changed();
214
+ // Each rolled batch fills in more history for open pages; the rows
215
+ // themselves are already being rolled, so only the version moves.
216
+ if (remaining > 0) this.#bumpVersion();
195
217
  },
196
218
  });
197
219
  } while (this.#refreshAgain);
@@ -240,9 +262,12 @@ export class StatsLive {
240
262
 
241
263
  #emit(): void {
242
264
  const status = this.status();
265
+ const frame = JSON.stringify(status);
266
+ if (frame === this.#lastFrame) return;
267
+ this.#lastFrame = frame;
243
268
  for (const listener of this.#listeners) {
244
269
  try {
245
- listener(status);
270
+ listener(status, frame);
246
271
  } catch (error) {
247
272
  logger.warn("Stats live listener failed", { error: String(error) });
248
273
  }
package/src/locale.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Locale resolution shared by the stats server (which stamps the host's
3
+ * language into the served HTML) and the client bundle (which renders it).
4
+ * Lives outside `client/` so the server never imports browser code.
5
+ */
6
+
7
+ /** Locales the dashboard ships: English and Simplified Chinese. */
8
+ export type StatsLocale = "en" | "zh-CN";
9
+
10
+ /**
11
+ * Map a language tag to a shipped locale. Any `zh` variant is Simplified
12
+ * Chinese (the dashboard ships no Traditional pack); everything else —
13
+ * including browser tags such as `tr-TR` — is English, matching the
14
+ * dashboard's single number/date convention rather than the browser's.
15
+ */
16
+ export function resolveLocaleTag(tag: string | undefined | null): StatsLocale {
17
+ const normalized = tag?.trim().toLowerCase() ?? "";
18
+ if (normalized === "zh" || normalized.startsWith("zh-") || normalized.startsWith("zh_")) return "zh-CN";
19
+ return "en";
20
+ }
21
+
22
+ /** Pick the first shipped locale from a `navigator.languages` list; English when none match. */
23
+ export function resolveBrowserLocale(languages: readonly string[] | undefined): StatsLocale {
24
+ for (const language of languages ?? []) {
25
+ const resolved = resolveLocaleTag(language);
26
+ if (resolved !== "en") return resolved;
27
+ }
28
+ return "en";
29
+ }
package/src/parser.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import type * as nodeFs from "node:fs";
2
2
  import * as fs from "node:fs/promises";
3
- import * as os from "node:os";
4
3
  import * as path from "node:path";
5
4
  import {
6
5
  type AssistantMessage,
7
6
  coerceServiceTierByFamily,
8
- getPriorityPremiumRequests,
7
+ getPremiumServiceTierRequests,
8
+ parseServiceTier,
9
9
  resolveModelServiceTier,
10
10
  type ServiceTierByFamily,
11
11
  type ToolCall,
@@ -67,53 +67,6 @@ export function extractFolderFromPath(sessionPath: string): string {
67
67
  return projectDir.replace(/^--/, "/").replace(/--/g, "/");
68
68
  }
69
69
 
70
- /**
71
- * Decode a session project folder name back to its source cwd, mirroring the
72
- * encoder in the coding agent's session-paths module: home-relative cwds
73
- * encode as `-<segments>`, temp-relative ones as `-tmp<segments>`, and legacy
74
- * absolute ones as `--<absolute>--`, with `/`, `\`, and `:` collapsed to `-`.
75
- * Returns null when the name carries no decodable cwd.
76
- */
77
- export function decodeProjectFolderCwd(projectDir: string): string | null {
78
- if (projectDir === "-tmp" || projectDir.startsWith("-tmp-")) return os.tmpdir();
79
- if (projectDir.startsWith("--")) {
80
- const inner = projectDir.replace(/^--/, "").replace(/--$/, "");
81
- if (!inner) return path.resolve("/");
82
- // POSIX legacy names had their leading `/` stripped, so restore it;
83
- // Windows names collapsed a drive letter's `:` into a dash too
84
- // (`C--Users` decodes to `C:\Users`), so re-attach the colon.
85
- if (path.sep === "/") return path.resolve(`/${inner.replaceAll("-", "/")}`);
86
- return path.resolve(inner.replaceAll("-", "\\").replace(/^([A-Za-z])\\/, "$1:\\"));
87
- }
88
- if (projectDir.startsWith("-")) {
89
- const inner = projectDir.replace(/^-/, "");
90
- return path.resolve(os.homedir(), inner.replaceAll("-", path.sep));
91
- }
92
- // Folder names without the leading marker predate the encoder; no cwd is
93
- // recoverable, so they are never treated as temp.
94
- return null;
95
- }
96
-
97
- /**
98
- * Whether a session project folder decodes to a cwd inside the OS temp dir.
99
- * Test suites spawn agents under `os.tmpdir()` and re-create their transcripts
100
- * on every run; those sessions are throwaway usage that must not reach the
101
- * stats DB. The `-` collapse in the encoder is lossy (a literal `-` inside a
102
- * path segment is indistinguishable from a separator), which can only produce
103
- * false positives here — a skipped ingestion, never a misattributed row.
104
- */
105
- export function isTempProjectFolder(projectDir: string): boolean {
106
- const decoded = decodeProjectFolderCwd(projectDir);
107
- if (!decoded) return false;
108
- const tmp = path.resolve(os.tmpdir());
109
- const candidate = path.resolve(decoded);
110
- // NTFS comparisons are case-insensitive; resolve() normalizes separators.
111
- const caseFold = process.platform === "win32";
112
- const normalizedTmp = caseFold ? tmp.toLowerCase() : tmp;
113
- const normalizedCandidate = caseFold ? candidate.toLowerCase() : candidate;
114
- return normalizedCandidate === normalizedTmp || normalizedCandidate.startsWith(normalizedTmp + path.sep);
115
- }
116
-
117
70
  /**
118
71
  * Check if an entry is an assistant message.
119
72
  */
@@ -177,12 +130,17 @@ function extractUserText(content: unknown): string {
177
130
  /**
178
131
  * Build user-message stats from an entry. Returns null for empty/synthetic content.
179
132
  */
180
- function extractUserStats(sessionFile: string, folder: string, entry: SessionMessageEntry): UserMessageStats | null {
133
+ function extractUserStats(
134
+ sessionFile: string,
135
+ folder: string,
136
+ entry: SessionMessageEntry,
137
+ prevText: string,
138
+ ): UserMessageStats | null {
181
139
  const msg = entry.message as { role: "user"; content?: unknown; synthetic?: boolean };
182
140
  if (msg.role !== "user" || msg.synthetic) return null;
183
141
  const text = extractUserText(msg.content);
184
142
  if (!text.trim()) return null;
185
- const metrics = computeUserMessageMetrics(text);
143
+ const metrics = computeUserMessageMetrics(text, { prevText });
186
144
  const prose = judgeProse(text);
187
145
  const ts = Date.parse(entry.timestamp);
188
146
  return {
@@ -200,6 +158,8 @@ function extractUserStats(sessionFile: string, folder: string, entry: SessionMes
200
158
  negation: metrics.negation,
201
159
  repetition: metrics.repetition,
202
160
  blame: metrics.blame,
161
+ emphasis: metrics.emphasis,
162
+ unsupportedScript: metrics.unsupportedScript,
203
163
  prose,
204
164
  proseHash: prose ? Bun.hash(prose).toString(16) : "",
205
165
  };
@@ -284,20 +244,26 @@ function extractStats(
284
244
  const rawUsage = msg.usage as Partial<Usage> | undefined;
285
245
  if (!rawUsage || typeof rawUsage !== "object") return null;
286
246
 
287
- // Backfill: when the session recorded `priority` as the active service tier
288
- // at this point but the AI usage payload was captured before priority
289
- // requests were folded into `premiumRequests`, derive the count here so the
290
- // "Premium Reqs" stat aggregates priority traffic on re-sync. Trust any
291
- // non-zero value already in `usage.premiumRequests` (Copilot multipliers or
292
- // the new AI code path) and only synthesise when the field is missing/zero.
247
+ // Backfill: when the session recorded a premium tier (`priority`/`ultrafast`)
248
+ // as the active service tier at this point but the AI usage payload was
249
+ // captured before premium requests were folded into `premiumRequests`, derive
250
+ // the count here so the "Premium Reqs" stat aggregates premium traffic on
251
+ // re-sync. The tier the provider reported serving the turn (`msg.serviceTier`)
252
+ // is authoritative — it is proof the tier reached the wire, so the
253
+ // discovery-metadata gate is skipped; the session's live setting is only a
254
+ // fallback for turns recorded before that field existed. Trust any non-zero
255
+ // value already in `usage.premiumRequests` (Copilot multipliers or the new AI
256
+ // code path) and only synthesise when the field is missing/zero.
293
257
  const recorded = rawUsage.premiumRequests ?? 0;
294
258
  const model = {
295
259
  provider: msg.provider,
296
260
  api: msg.api,
297
261
  identity: classifyModel(msg.provider, msg.model, { lenient: true }),
298
262
  };
299
- const tier = resolveModelServiceTier(currentServiceTier, model);
300
- const derived = recorded > 0 ? recorded : getPriorityPremiumRequests(tier, model);
263
+ const servedTier = parseServiceTier(msg.serviceTier);
264
+ const tier = servedTier ?? resolveModelServiceTier(currentServiceTier, model);
265
+ const derived =
266
+ recorded > 0 ? recorded : getPremiumServiceTierRequests(tier, model, { served: servedTier !== undefined });
301
267
  const wellFormed =
302
268
  isFiniteCount(rawUsage.input) &&
303
269
  isFiniteCount(rawUsage.output) &&
@@ -339,6 +305,7 @@ function extractStats(
339
305
  errorMessage: msg.errorMessage ?? null,
340
306
  usage,
341
307
  agentType,
308
+ serviceTier: servedTier ?? null,
342
309
  };
343
310
  }
344
311
 
@@ -594,6 +561,11 @@ export async function parseSessionFile(
594
561
  const toolCalls: ToolCallStats[] = [];
595
562
  const toolResults: ToolResultLink[] = [];
596
563
 
564
+ // The previous user message, for the whole-message repetition signal. An
565
+ // incremental parse starts mid-session, so the first message of a resumed
566
+ // pass simply carries no predecessor.
567
+ let prevUserText = "";
568
+
597
569
  // Reduce each entry immediately so full replays do not retain tool-output/message bodies.
598
570
  const visit = (entry: SessionEntry): void => {
599
571
  if (isServiceTierChange(entry)) {
@@ -601,8 +573,11 @@ export async function parseSessionFile(
601
573
  return;
602
574
  }
603
575
  if (isUserMessage(entry)) {
604
- const userMsg = extractUserStats(sessionPath, folder, entry);
605
- if (userMsg) userStats.push(userMsg);
576
+ const userMsg = extractUserStats(sessionPath, folder, entry, prevUserText);
577
+ if (userMsg) {
578
+ userStats.push(userMsg);
579
+ prevUserText = userMsg.prose || prevUserText;
580
+ }
606
581
  return;
607
582
  }
608
583
  if (isToolResultMessage(entry)) {
@@ -728,17 +703,7 @@ export async function listSessionFiles(folderPath: string): Promise<string[]> {
728
703
  */
729
704
  export async function listAllSessionFiles(): Promise<string[]> {
730
705
  const folders = await listSessionFolders();
731
- const allFiles: string[] = [];
732
-
733
- for (const folder of folders) {
734
- // Skip at enumeration: temp-cwd project folders re-create their
735
- // transcripts on every test run and must never reach the parser or DB.
736
- if (isTempProjectFolder(path.basename(folder))) continue;
737
- const files = await listSessionFiles(folder);
738
- allFiles.push(...files);
739
- }
740
-
741
- return allFiles;
706
+ return (await Promise.all(folders.map(listSessionFiles))).flat();
742
707
  }
743
708
 
744
709
  /**
@@ -265,7 +265,7 @@ async function reclaimStatsPort(port: number, hasDashboardIdentity = false): Pro
265
265
  /(?:^|\/)(?:zeta-c|zeta-cli|zetacode|omp)(?:\.exe)?["'\s]+stats(?:["'\s]|$)/.test(normalizedCommand);
266
266
  if (!STATS_RUNTIME_IMAGES[normalizedImage] || (!hasStatsIdentity && !hasDashboardIdentity)) {
267
267
  throw new Error(
268
- `Port ${port} is in use by ${holder.image} (PID ${holder.pid}), which is not identifiable as a zeta stats dashboard; refusing to stop it.`,
268
+ `Port ${port} is in use by ${holder.image} (PID ${holder.pid}), which is not identifiable as a zetacode stats dashboard; refusing to stop it.`,
269
269
  );
270
270
  }
271
271
 
@@ -0,0 +1,19 @@
1
+ {
2
+ "annoyed": {
3
+ "instructions": "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.",
4
+ "criteria": [
5
+ "neutral / no frustration",
6
+ "mild irritation or impatience",
7
+ "clearly annoyed or exasperated",
8
+ "angry, hostile, or swearing"
9
+ ]
10
+ },
11
+ "target": {
12
+ "instructions": "If the user sounds annoyed, what is the annoyance aimed at?",
13
+ "criteria": {
14
+ "assistant": "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')",
15
+ "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",
16
+ "none": "not annoyed: a plain question, instruction, design musing, or casual chat"
17
+ }
18
+ }
19
+ }
package/src/rollup.ts CHANGED
@@ -32,6 +32,7 @@ import type {
32
32
  AgentTypeStats,
33
33
  AggregatedStats,
34
34
  CostTimeSeriesPoint,
35
+ DailyActivityPoint,
35
36
  FolderStats,
36
37
  ModelPerformancePoint,
37
38
  ModelStats,
@@ -179,7 +180,7 @@ const EXACT_DIRTY_SESSIONS = 512;
179
180
  */
180
181
  export function ensureRollupSchema(database: Database): void {
181
182
  const readMeta = (key: string) =>
182
- (database.prepare("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | undefined)?.value;
183
+ (database.query("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | undefined)?.value;
183
184
  const fresh = readMeta(ROLLUP_VERSION_KEY) !== ROLLUP_VERSION;
184
185
  // Steady state touches no schema: DDL on every open would contend with
185
186
  // other omp processes writing the same database.
@@ -273,7 +274,7 @@ export function ensureRollupSchema(database: Database): void {
273
274
  }
274
275
  }
275
276
  database
276
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
277
+ .query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
277
278
  .run(TRIGGER_VERSION_KEY, TRIGGER_VERSION);
278
279
  if (fresh) {
279
280
  database.run(`
@@ -286,7 +287,7 @@ export function ensureRollupSchema(database: Database): void {
286
287
  SELECT DISTINCT session_file FROM messages UNION SELECT DISTINCT session_file FROM tool_calls
287
288
  `);
288
289
  database
289
- .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
290
+ .query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
290
291
  .run(ROLLUP_VERSION_KEY, ROLLUP_VERSION);
291
292
  }
292
293
  })
@@ -307,7 +308,7 @@ export function getRollupStatus(): RollupStatus {
307
308
  const database = currentDb();
308
309
  if (!database) return { dirtyHours: 0, dirtySessions: 0 };
309
310
  return database
310
- .prepare(
311
+ .query(
311
312
  "SELECT (SELECT COUNT(*) FROM rollup_dirty) AS dirtyHours, (SELECT COUNT(*) FROM session_dirty) AS dirtySessions",
312
313
  )
313
314
  .get() as RollupStatus;
@@ -322,25 +323,25 @@ export function refreshRollupBatch(limit: number): number {
322
323
  const database = currentDb();
323
324
  if (!database) return 0;
324
325
  const run = database.transaction(() => {
325
- const buckets = database.prepare("SELECT bucket FROM rollup_dirty ORDER BY bucket DESC LIMIT ?").all(limit) as {
326
+ const buckets = database.query("SELECT bucket FROM rollup_dirty ORDER BY bucket DESC LIMIT ?").all(limit) as {
326
327
  bucket: number;
327
328
  }[];
328
- const clearMessages = database.prepare("DELETE FROM message_rollup WHERE bucket = ?");
329
- const clearTools = database.prepare("DELETE FROM tool_rollup WHERE bucket = ?");
330
- const fillMessages = database.prepare(`
329
+ const clearMessages = database.query("DELETE FROM message_rollup WHERE bucket = ?");
330
+ const clearTools = database.query("DELETE FROM tool_rollup WHERE bucket = ?");
331
+ const fillMessages = database.query(`
331
332
  INSERT INTO message_rollup (${MESSAGE_ROLLUP_COLUMNS})
332
333
  SELECT ${messageFacts("?1", "")}
333
334
  FROM messages WHERE timestamp >= ?1 AND timestamp < ?1 + ${HOUR_MS}
334
335
  GROUP BY ${MESSAGE_DIMENSIONS}
335
336
  `);
336
- const fillTools = database.prepare(`
337
+ const fillTools = database.query(`
337
338
  INSERT INTO tool_rollup (${TOOL_ROLLUP_COLUMNS})
338
339
  SELECT ${toolFacts("?1")}
339
340
  FROM tool_calls t ${TOOL_JOIN}
340
341
  WHERE t.timestamp >= ?1 AND t.timestamp < ?1 + ${HOUR_MS}
341
342
  GROUP BY t.tool_name, t.model, t.provider
342
343
  `);
343
- const clean = database.prepare("DELETE FROM rollup_dirty WHERE bucket = ?");
344
+ const clean = database.query("DELETE FROM rollup_dirty WHERE bucket = ?");
344
345
  for (const { bucket } of buckets) {
345
346
  clearMessages.run(bucket);
346
347
  clearTools.run(bucket);
@@ -351,16 +352,16 @@ export function refreshRollupBatch(limit: number): number {
351
352
 
352
353
  // Transcripts are cheap (indexed by session_file); roll many per hour-batch.
353
354
  const sessions = database
354
- .prepare("SELECT session_file FROM session_dirty LIMIT ?")
355
+ .query("SELECT session_file FROM session_dirty LIMIT ?")
355
356
  .all(limit * SESSIONS_PER_HOUR) as { session_file: string }[];
356
- const clearSession = database.prepare("DELETE FROM session_rollup WHERE session_file = ?");
357
- const fillSession = database.prepare(`
357
+ const clearSession = database.query("DELETE FROM session_rollup WHERE session_file = ?");
358
+ const fillSession = database.query(`
358
359
  INSERT INTO session_rollup (${SESSION_ROLLUP_COLUMNS})
359
360
  SELECT ${sessionFacts("m.")}
360
361
  FROM messages m WHERE m.session_file = ?1
361
362
  GROUP BY m.session_file
362
363
  `);
363
- const cleanSession = database.prepare("DELETE FROM session_dirty WHERE session_file = ?");
364
+ const cleanSession = database.query("DELETE FROM session_dirty WHERE session_file = ?");
364
365
  for (const { session_file } of sessions) {
365
366
  clearSession.run(session_file);
366
367
  fillSession.run(session_file);
@@ -393,7 +394,7 @@ export async function refreshRollups(opts?: RefreshOptions): Promise<void> {
393
394
  const elapsed = performance.now() - started;
394
395
  if (elapsed < REFRESH_TARGET_MS / 2) batch = Math.min(REFRESH_MAX_BATCH, batch * 2);
395
396
  else if (elapsed > REFRESH_TARGET_MS * 2) batch = Math.max(1, Math.floor(batch / 2));
396
- opts?.onProgress?.(getRollupStatus().dirtyHours);
397
+ if (opts?.onProgress) opts.onProgress(getRollupStatus().dirtyHours);
397
398
  await Bun.sleep(0);
398
399
  }
399
400
  }
@@ -416,7 +417,7 @@ interface Source {
416
417
 
417
418
  function dirtyIsSmall(database: Database): boolean {
418
419
  const row = database
419
- .prepare(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM rollup_dirty LIMIT ${EXACT_DIRTY_LIMIT + 1})`)
420
+ .query(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM rollup_dirty LIMIT ${EXACT_DIRTY_LIMIT + 1})`)
420
421
  .get() as {
421
422
  n: number;
422
423
  };
@@ -508,7 +509,8 @@ function queryMessages<T>(window: Pick<RangeWindow, "cutoff"> & { bucketMs?: num
508
509
  if (!database) return [];
509
510
  const fine = window.bucketMs !== undefined && window.bucketMs < HOUR_MS;
510
511
  const source = messageSource(database, normalizeCutoff(window.cutoff), fine);
511
- return database.prepare(`SELECT ${select} FROM (${source.sql}) f ${tail}`).all(...source.params) as T[];
512
+ // `query` caches the compiled statement: the SQL varies only over a small set of shapes.
513
+ return database.query(`SELECT ${select} FROM (${source.sql}) f ${tail}`).all(...source.params) as T[];
512
514
  }
513
515
 
514
516
  function queryTools<T>(window: Pick<RangeWindow, "cutoff"> & { bucketMs?: number }, select: string, tail = ""): T[] {
@@ -516,7 +518,7 @@ function queryTools<T>(window: Pick<RangeWindow, "cutoff"> & { bucketMs?: number
516
518
  if (!database) return [];
517
519
  const fine = window.bucketMs !== undefined && window.bucketMs < HOUR_MS;
518
520
  const source = toolSource(database, normalizeCutoff(window.cutoff), fine);
519
- return database.prepare(`SELECT ${select} FROM (${source.sql}) f ${tail}`).all(...source.params) as T[];
521
+ return database.query(`SELECT ${select} FROM (${source.sql}) f ${tail}`).all(...source.params) as T[];
520
522
  }
521
523
 
522
524
  function normalizeCutoff(cutoff: number | null | undefined): number | null {
@@ -804,6 +806,25 @@ export function getProviderHourlyBurn(cutoff: number | null = null): ProviderHou
804
806
  }));
805
807
  }
806
808
 
809
+ /**
810
+ * Per-local-day request/token/cost totals since `cutoff`, oldest first, read
811
+ * from the hourly rollup. Returns null when the local UTC offset is not a whole
812
+ * hour (now or at `cutoff`), since hourly buckets then straddle local midnight,
813
+ * or while the dirty backlog is too large for exact reads; callers then
814
+ * aggregate the raw rows instead.
815
+ */
816
+ export function getDailyActivityFromRollup(cutoff: number): DailyActivityPoint[] | null {
817
+ if (new Date().getTimezoneOffset() % 60 !== 0 || new Date(cutoff).getTimezoneOffset() % 60 !== 0) return null;
818
+ const database = currentDb();
819
+ if (!database || !dirtyIsSmall(database)) return null;
820
+ return queryMessages<{ day: string; cost: number; requests: number; total_tokens: number | null }>(
821
+ { cutoff },
822
+ `date(f.bucket / 1000, 'unixepoch', 'localtime') AS day, TOTAL(f.cost_total) AS cost,
823
+ SUM(f.requests) AS requests, SUM(f.total_tokens) AS total_tokens`,
824
+ "GROUP BY day ORDER BY day",
825
+ ).map(row => ({ day: row.day, cost: row.cost, requests: row.requests, totalTokens: row.total_tokens ?? 0 }));
826
+ }
827
+
807
828
  /** Tokens/cost per bucket per provider. */
808
829
  export function getProviderTimeSeries({ cutoff, bucketMs }: RangeWindow): ProviderTimeSeriesPoint[] {
809
830
  return queryMessages<{
@@ -855,7 +876,7 @@ export function getSessionRollups(): SessionRollupRow[] {
855
876
  const database = currentDb();
856
877
  if (!database) return [];
857
878
  const { n } = database
858
- .prepare(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM session_dirty LIMIT ${EXACT_DIRTY_SESSIONS + 1})`)
879
+ .query(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM session_dirty LIMIT ${EXACT_DIRTY_SESSIONS + 1})`)
859
880
  .get() as { n: number };
860
881
  const exact = n <= EXACT_DIRTY_SESSIONS;
861
882
  const parts = [
@@ -869,7 +890,7 @@ export function getSessionRollups(): SessionRollupRow[] {
869
890
  );
870
891
  }
871
892
  return database
872
- .prepare(
893
+ .query(
873
894
  `SELECT session_file AS sessionFile, requests, started_at AS startedAt, ended_at AS endedAt,
874
895
  total_tokens AS totalTokens, cost_total AS costTotal, unpriced AS unpricedRequests, models,
875
896
  tool_calls AS toolCalls