@linxiraos/pi-stats 1.1.27 → 1.2.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.
- package/CHANGELOG.md +10 -7
- package/README.md +4 -4
- package/build.ts +4 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +1 -0
- package/dist/client/index.js +96 -96
- package/dist/types/aggregator.d.ts +4 -1
- package/dist/types/client/api.d.ts +5 -1
- package/dist/types/client/app/nav.d.ts +5 -2
- package/dist/types/client/data/formatters.d.ts +5 -2
- package/dist/types/client/data/live.d.ts +16 -11
- package/dist/types/client/data/query-store.d.ts +39 -0
- package/dist/types/client/data/query.d.ts +10 -5
- package/dist/types/client/data/range.d.ts +4 -3
- package/dist/types/client/i18n/format.d.ts +9 -0
- package/dist/types/client/i18n/index.d.ts +18 -0
- package/dist/types/client/i18n/messages/charts.d.ts +8 -0
- package/dist/types/client/i18n/messages/common.d.ts +153 -0
- package/dist/types/client/i18n/messages/costs.d.ts +92 -0
- package/dist/types/client/i18n/messages/en.d.ts +1255 -0
- package/dist/types/client/i18n/messages/errors.d.ts +78 -0
- package/dist/types/client/i18n/messages/frustration.d.ts +191 -0
- package/dist/types/client/i18n/messages/gain.d.ts +54 -0
- package/dist/types/client/i18n/messages/models.d.ts +94 -0
- package/dist/types/client/i18n/messages/nav.d.ts +46 -0
- package/dist/types/client/i18n/messages/overview.d.ts +80 -0
- package/dist/types/client/i18n/messages/projects.d.ts +84 -0
- package/dist/types/client/i18n/messages/providers.d.ts +142 -0
- package/dist/types/client/i18n/messages/requests.d.ts +72 -0
- package/dist/types/client/i18n/messages/tools.d.ts +119 -0
- package/dist/types/client/i18n/messages/traces.d.ts +144 -0
- package/dist/types/client/i18n/messages/ui.d.ts +44 -0
- package/dist/types/client/i18n/messages/zh-CN.d.ts +13 -0
- package/dist/types/client/i18n/types.d.ts +15 -0
- package/dist/types/client/traces/Minimap.d.ts +2 -0
- package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
- package/dist/types/client/traces/TranscriptList.d.ts +3 -1
- package/dist/types/db.d.ts +14 -21
- package/dist/types/embedded-client.d.ts +5 -0
- package/dist/types/frustration.d.ts +18 -9
- package/dist/types/live.d.ts +5 -3
- package/dist/types/locale.d.ts +16 -0
- package/dist/types/parser.d.ts +0 -17
- package/dist/types/rollup.d.ts +9 -1
- package/dist/types/server.d.ts +6 -0
- package/dist/types/shared-types.d.ts +7 -0
- package/dist/types/trace.d.ts +18 -6
- package/dist/types/types.d.ts +12 -0
- package/dist/types/user-metrics.d.ts +23 -1
- package/package.json +7 -7
- package/src/aggregator.ts +28 -11
- package/src/client/api.ts +40 -5
- package/src/client/app/LiveChip.tsx +14 -13
- package/src/client/app/Shell.tsx +19 -13
- package/src/client/app/ThemeToggle.tsx +4 -3
- package/src/client/app/nav.ts +19 -16
- package/src/client/data/formatters.ts +73 -13
- package/src/client/data/live.tsx +85 -45
- package/src/client/data/query-store.ts +108 -0
- package/src/client/data/query.ts +47 -56
- package/src/client/data/range.ts +34 -14
- package/src/client/data/series.ts +7 -1
- package/src/client/favicon.ico +0 -0
- package/src/client/i18n/format.ts +28 -0
- package/src/client/i18n/index.ts +53 -0
- package/src/client/i18n/messages/charts.ts +8 -0
- package/src/client/i18n/messages/common.ts +157 -0
- package/src/client/i18n/messages/costs.ts +104 -0
- package/src/client/i18n/messages/en.ts +40 -0
- package/src/client/i18n/messages/errors.ts +80 -0
- package/src/client/i18n/messages/frustration.ts +198 -0
- package/src/client/i18n/messages/gain.ts +54 -0
- package/src/client/i18n/messages/models.ts +94 -0
- package/src/client/i18n/messages/nav.ts +46 -0
- package/src/client/i18n/messages/overview.ts +90 -0
- package/src/client/i18n/messages/projects.ts +84 -0
- package/src/client/i18n/messages/providers.ts +165 -0
- package/src/client/i18n/messages/requests.ts +73 -0
- package/src/client/i18n/messages/tools.ts +120 -0
- package/src/client/i18n/messages/traces.ts +160 -0
- package/src/client/i18n/messages/ui.ts +46 -0
- package/src/client/i18n/messages/zh-CN.ts +43 -0
- package/src/client/i18n/types.ts +19 -0
- package/src/client/index.tsx +4 -0
- package/src/client/routes/CostsRoute.tsx +61 -48
- package/src/client/routes/ErrorsRoute.tsx +71 -47
- package/src/client/routes/FrustrationRoute.tsx +132 -117
- package/src/client/routes/GainRoute.tsx +42 -30
- package/src/client/routes/ModelsRoute.tsx +87 -71
- package/src/client/routes/OverviewRoute.tsx +107 -62
- package/src/client/routes/ProjectsRoute.tsx +71 -45
- package/src/client/routes/ProvidersRoute.tsx +149 -113
- package/src/client/routes/RequestsRoute.tsx +68 -45
- package/src/client/routes/ToolsRoute.tsx +105 -92
- package/src/client/routes/TracesRoute.tsx +25 -23
- package/src/client/styles.css +4 -2
- package/src/client/traces/AggregatesPanel.tsx +19 -8
- package/src/client/traces/Minimap.tsx +65 -27
- package/src/client/traces/SpanDrawer.tsx +46 -21
- package/src/client/traces/SummaryStrip.tsx +27 -11
- package/src/client/traces/TimelineCanvas.tsx +245 -116
- package/src/client/traces/TraceView.tsx +51 -36
- package/src/client/traces/TranscriptList.tsx +20 -11
- package/src/client/traces/traces.css +26 -0
- package/src/client/ui/JsonBlock.tsx +3 -2
- package/src/client/ui/Modal.tsx +3 -2
- package/src/client/ui/RequestDrawer.tsx +33 -30
- package/src/client/ui/SearchInput.tsx +2 -1
- package/src/client/ui/States.tsx +3 -2
- package/src/client/ui/Table.tsx +7 -3
- package/src/db.ts +150 -135
- package/src/embedded-client.ts +9 -0
- package/src/frustration.ts +9 -30
- package/src/live.ts +36 -11
- package/src/locale.ts +29 -0
- package/src/parser.ts +37 -72
- package/src/port-conflict.ts +1 -1
- package/src/prompts/judge/frustration.json +19 -0
- package/src/rollup.ts +41 -20
- package/src/server.ts +67 -27
- package/src/shared-types.ts +7 -0
- package/src/trace.ts +54 -40
- package/src/types.ts +12 -0
- package/src/user-metrics.ts +220 -6
package/src/embedded-client.ts
CHANGED
|
@@ -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;
|
package/src/frustration.ts
CHANGED
|
@@ -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
|
|
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 `
|
|
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
|
|
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
|
|
81
|
+
"This dashboard was started without a judge (standalone zetacode stats). Run `zetacode 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/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
|
|
9
|
-
* pages refetch and fill in while parsing is still
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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`
|
|
288
|
-
// at this point but the AI usage payload was
|
|
289
|
-
// requests were folded into `premiumRequests`, derive
|
|
290
|
-
// "Premium Reqs" stat aggregates
|
|
291
|
-
//
|
|
292
|
-
//
|
|
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
|
|
300
|
-
const
|
|
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)
|
|
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
|
-
|
|
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
|
/**
|
package/src/port-conflict.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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.
|
|
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.
|
|
329
|
-
const clearTools = database.
|
|
330
|
-
const fillMessages = database.
|
|
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.
|
|
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.
|
|
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
|
-
.
|
|
355
|
+
.query("SELECT session_file FROM session_dirty LIMIT ?")
|
|
355
356
|
.all(limit * SESSIONS_PER_HOUR) as { session_file: string }[];
|
|
356
|
-
const clearSession = database.
|
|
357
|
-
const fillSession = database.
|
|
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.
|
|
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
|
|
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
|
-
.
|
|
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
|
-
|
|
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.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|