atom-agent 0.3.0 → 1.1.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.
- package/CHANGELOG.md +82 -0
- package/README.md +83 -32
- package/dist/App.js +2178 -318
- package/dist/adapters.js +146 -15
- package/dist/agent/gates.js +153 -0
- package/dist/agent/loop-guard.js +184 -0
- package/dist/agent/loop.js +908 -0
- package/dist/agent/normalize.js +144 -0
- package/dist/agent/types.js +1 -0
- package/dist/auth.js +2 -1
- package/dist/cli.js +68 -6
- package/dist/compact.js +6 -48
- package/dist/config.js +171 -0
- package/dist/context-manager.js +564 -0
- package/dist/kilo.js +343 -0
- package/dist/local-discovery.js +308 -0
- package/dist/policy.js +286 -0
- package/dist/prompt-cache.js +99 -0
- package/dist/providers.js +183 -2
- package/dist/rollback.js +21 -0
- package/dist/scheduler.js +247 -0
- package/dist/session.js +35 -3
- package/dist/skills.js +214 -43
- package/dist/snapshots.js +57 -2
- package/dist/system.js +8 -1
- package/dist/telemetry-dashboard.js +589 -0
- package/dist/telemetry-server.js +301 -0
- package/dist/telemetry.js +1056 -0
- package/dist/tools/dir-cache.js +207 -0
- package/dist/tools/filesystem.js +149 -0
- package/dist/tools/fingerprints.js +33 -0
- package/dist/tools/overflow.js +76 -0
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +802 -0
- package/dist/tools/search.js +242 -0
- package/dist/tools/shared.js +31 -0
- package/dist/tools/shell.js +273 -0
- package/dist/tools/todo.js +191 -0
- package/dist/tools/web.js +454 -0
- package/dist/tools.js +17 -1863
- package/dist/ui/activity.js +51 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +112 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/errors.js +129 -0
- package/dist/ui/highlight.js +120 -0
- package/dist/ui/input-model.js +115 -0
- package/dist/ui/input.js +40 -0
- package/dist/ui/live-tail.js +15 -0
- package/dist/ui/markdown.js +525 -0
- package/dist/ui/modals.js +47 -0
- package/dist/ui/palette.js +70 -0
- package/dist/ui/pickers.js +32 -0
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +75 -0
- package/dist/ui/theme.js +128 -0
- package/dist/ui/todo-panel.js +30 -0
- package/dist/ui/tool-inspector.js +59 -0
- package/dist/ui/transcript.js +128 -0
- package/dist/zen.js +145 -666
- package/package.json +1 -1
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
// Local observability telemetry for ATOM (production-quality, not a debug page).
|
|
2
|
+
//
|
|
3
|
+
// What this module is: a lightweight, privacy-conscious, persistent trace of
|
|
4
|
+
// what the agent actually did — sessions, turns, loop iterations, model calls
|
|
5
|
+
// (with API-reported token usage only), tool calls (with measured durations
|
|
6
|
+
// and success/error classification), retries, and turn outcomes. The local
|
|
7
|
+
// HTML dashboard (src/telemetry-dashboard.ts) renders these traces.
|
|
8
|
+
//
|
|
9
|
+
// What this module is NOT:
|
|
10
|
+
// - No estimates, ever. Every numeric field is either measured (timestamps,
|
|
11
|
+
// durations, counts, API-reported token usage) or absent. Absent means
|
|
12
|
+
// "not reported" — the dashboard renders it as n/a, never zero.
|
|
13
|
+
// - No cost synthesis. No provider API reports cost, so cost fields stay null
|
|
14
|
+
// (with an explicit note) unless a future provider reports them. There is
|
|
15
|
+
// deliberately no pricing table to multiply tokens by.
|
|
16
|
+
// - No tool-level token attribution. Tools don't consume model tokens; usage
|
|
17
|
+
// is recorded per model call (the POST that reported it) and aggregated per
|
|
18
|
+
// turn/session. Tool rows show tokens as n/a with that explanation.
|
|
19
|
+
// - No network, no exfiltration. Traces live under ~/.atom/telemetry/
|
|
20
|
+
// (ATOM_HOME override honored, 0600 POSIX perms like auth.json/session.json,
|
|
21
|
+
// best-effort Windows), written atomically (temp file + rename) on turn
|
|
22
|
+
// boundaries — never per token. Everything here is best-effort and NEVER
|
|
23
|
+
// throws: a telemetry failure must never break a turn.
|
|
24
|
+
//
|
|
25
|
+
// Privacy: stored previews are truncated (see caps below) and scrubbed of
|
|
26
|
+
// known provider secrets (env-provided values via the injected secrets
|
|
27
|
+
// provider). API keys are never stored. Full file contents, full tool results,
|
|
28
|
+
// and full prompts are never persisted — only short previews plus byte sizes,
|
|
29
|
+
// so a trace stays small and reviewable. Opt out entirely with
|
|
30
|
+
// ATOM_TELEMETRY=0 or `"telemetry": {"enabled": false}` in atom.json.
|
|
31
|
+
//
|
|
32
|
+
// Performance: recording is in-memory pushes plus Date.now() reads (sub-
|
|
33
|
+
// microsecond each, no I/O in the hot path). The only disk write is one small
|
|
34
|
+
// atomic JSON write per completed/failed turn plus session end — typically a
|
|
35
|
+
// few KB. When disabled, every method is a no-op early return.
|
|
36
|
+
//
|
|
37
|
+
// Vocabulary mapping (ATOM concepts → observability terms):
|
|
38
|
+
// - session = one App mount (process lifetime). /clear and /new stay inside
|
|
39
|
+
// the same telemetry session as events; the trace never rewrites history.
|
|
40
|
+
// - turn = one user message plus the full agentic loop it triggered.
|
|
41
|
+
// - iteration = one tool-round step of the loop (the `step` index in
|
|
42
|
+
// runLoopWithChat): exactly one model call followed by zero or more tool
|
|
43
|
+
// calls. Displayed 1-based.
|
|
44
|
+
// - model call = one chatFn invocation (one chat POST, including its internal
|
|
45
|
+
// transport retries — retry phase events attach to the call they precede).
|
|
46
|
+
// - tool call = one runOneTool execution (serial or one member of a parallel
|
|
47
|
+
// batch; each member is timed individually).
|
|
48
|
+
// - subagent = a delegated worker (delegate tool). ATOM v1 runs a single
|
|
49
|
+
// agent loop (depth 1), so this list is normally empty — the dashboard says
|
|
50
|
+
// so explicitly instead of implying activity. The type and hook exist so a
|
|
51
|
+
// future delegate tool wires in with one call.
|
|
52
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
53
|
+
import * as path from "node:path";
|
|
54
|
+
import { randomUUID } from "node:crypto";
|
|
55
|
+
import { scrubSecrets } from "./policy.js";
|
|
56
|
+
import { atomDir } from "./auth.js";
|
|
57
|
+
export const TELEMETRY_VERSION = 1;
|
|
58
|
+
export const TELEMETRY_DIRNAME = "telemetry";
|
|
59
|
+
export const TELEMETRY_SESSIONS_DIRNAME = "sessions";
|
|
60
|
+
export const TELEMETRY_DASHBOARD_FILENAME = "dashboard.html";
|
|
61
|
+
// Preview caps: traces stay small and reviewable. The full text already lives
|
|
62
|
+
// in history/transcript for the live session; telemetry keeps a scrubbed head
|
|
63
|
+
// plus the full byte size so nothing is silently misrepresented.
|
|
64
|
+
export const TELEMETRY_INPUT_PREVIEW_CHARS = 500;
|
|
65
|
+
export const TELEMETRY_ARGS_PREVIEW_CHARS = 2000;
|
|
66
|
+
export const TELEMETRY_RESULT_PREVIEW_CHARS = 2000;
|
|
67
|
+
// Store retention (best-effort prune on flush): newest files win.
|
|
68
|
+
export const TELEMETRY_MAX_SESSION_FILES = 200;
|
|
69
|
+
export const TELEMETRY_MAX_SESSION_AGE_DAYS = 90;
|
|
70
|
+
// --- Pure helpers ---
|
|
71
|
+
function toIso(ms) {
|
|
72
|
+
try {
|
|
73
|
+
return new Date(ms).toISOString();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return new Date().toISOString();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function finiteCount(value) {
|
|
80
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
81
|
+
? Math.floor(value)
|
|
82
|
+
: undefined;
|
|
83
|
+
}
|
|
84
|
+
// Keep only API-reported usage fields (mirrors zen.ts parseUsage semantics:
|
|
85
|
+
// present-only, never synthesized). Returns undefined when nothing usable.
|
|
86
|
+
export function cleanUsage(value) {
|
|
87
|
+
if (typeof value !== "object" || value === null)
|
|
88
|
+
return undefined;
|
|
89
|
+
const o = value;
|
|
90
|
+
const out = {};
|
|
91
|
+
const prompt = finiteCount(o["prompt_tokens"]);
|
|
92
|
+
if (prompt !== undefined)
|
|
93
|
+
out.prompt_tokens = prompt;
|
|
94
|
+
const completion = finiteCount(o["completion_tokens"]);
|
|
95
|
+
if (completion !== undefined)
|
|
96
|
+
out.completion_tokens = completion;
|
|
97
|
+
const total = finiteCount(o["total_tokens"]);
|
|
98
|
+
if (total !== undefined)
|
|
99
|
+
out.total_tokens = total;
|
|
100
|
+
const read = finiteCount(o["cacheReadTokens"]);
|
|
101
|
+
if (read !== undefined)
|
|
102
|
+
out.cacheReadTokens = read;
|
|
103
|
+
const written = finiteCount(o["cacheWriteTokens"]);
|
|
104
|
+
if (written !== undefined)
|
|
105
|
+
out.cacheWriteTokens = written;
|
|
106
|
+
return out.prompt_tokens !== undefined ||
|
|
107
|
+
out.completion_tokens !== undefined ||
|
|
108
|
+
out.total_tokens !== undefined ||
|
|
109
|
+
out.cacheReadTokens !== undefined ||
|
|
110
|
+
out.cacheWriteTokens !== undefined
|
|
111
|
+
? out
|
|
112
|
+
: undefined;
|
|
113
|
+
}
|
|
114
|
+
export function addUsageInto(target, extra) {
|
|
115
|
+
if (!extra)
|
|
116
|
+
return false;
|
|
117
|
+
let touched = false;
|
|
118
|
+
["prompt_tokens", "completion_tokens", "total_tokens", "cacheReadTokens", "cacheWriteTokens"].forEach((k) => {
|
|
119
|
+
const v = extra[k];
|
|
120
|
+
if (v !== undefined) {
|
|
121
|
+
target[k] = (target[k] ?? 0) + v;
|
|
122
|
+
touched = true;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return touched;
|
|
126
|
+
}
|
|
127
|
+
// Classify a tool result string with the same success rule the loop uses
|
|
128
|
+
// (result starts with "Error" = failure). errorKind distinguishes model
|
|
129
|
+
// mistakes (never executed) from real execution failures and denials.
|
|
130
|
+
export function classifyToolResult(result, opts) {
|
|
131
|
+
if (opts?.cancelled)
|
|
132
|
+
return { success: false, errorKind: "cancelled" };
|
|
133
|
+
if (opts?.threw)
|
|
134
|
+
return { success: false, errorKind: "transport-error" };
|
|
135
|
+
if (typeof result !== "string" || !result.startsWith("Error")) {
|
|
136
|
+
return { success: true };
|
|
137
|
+
}
|
|
138
|
+
if (result.includes("unknown tool"))
|
|
139
|
+
return { success: false, errorKind: "unknown-tool" };
|
|
140
|
+
if (result.includes("invalid call") || result.includes("invalid JSON")) {
|
|
141
|
+
return { success: false, errorKind: "invalid-args" };
|
|
142
|
+
}
|
|
143
|
+
if (result.includes("denied by user"))
|
|
144
|
+
return { success: false, errorKind: "denied" };
|
|
145
|
+
return { success: false, errorKind: "tool-error" };
|
|
146
|
+
}
|
|
147
|
+
// Map a turn's ending to an outcome. Cancelled/failed come from the control
|
|
148
|
+
// flow; the blocked/unverified/budget labels come from the loop's own
|
|
149
|
+
// end-of-turn notices (same strings the transcript shows).
|
|
150
|
+
export function classifyTurnOutcome(reply, opts) {
|
|
151
|
+
if (opts?.cancelled)
|
|
152
|
+
return "cancelled";
|
|
153
|
+
if (opts?.error)
|
|
154
|
+
return "failed";
|
|
155
|
+
const text = typeof reply === "string" ? reply : "";
|
|
156
|
+
if (text.includes("(stopped: too many tool steps)"))
|
|
157
|
+
return "budget-exceeded";
|
|
158
|
+
if (text.includes("(blocked:"))
|
|
159
|
+
return "blocked";
|
|
160
|
+
if (text.includes("(unverified:"))
|
|
161
|
+
return "unverified";
|
|
162
|
+
return "completed";
|
|
163
|
+
}
|
|
164
|
+
// Parse the retry detail strings chatCompletion emits via onPhase("retry"):
|
|
165
|
+
// `attempt 1/2 after 1000ms (HTTP 429)` or `attempt 1/2 after 1000ms (<msg>)`.
|
|
166
|
+
// Best-effort: unparseable details still record with null fields + raw text.
|
|
167
|
+
export function parseRetryDetail(detail) {
|
|
168
|
+
const out = { attempt: null, delayMs: null, status: null };
|
|
169
|
+
if (typeof detail !== "string")
|
|
170
|
+
return out;
|
|
171
|
+
const attempt = /attempt\s+(\d+)\s*\//i.exec(detail);
|
|
172
|
+
if (attempt) {
|
|
173
|
+
const n = Number(attempt[1]);
|
|
174
|
+
if (Number.isFinite(n))
|
|
175
|
+
out.attempt = Math.floor(n);
|
|
176
|
+
}
|
|
177
|
+
const delay = /after\s+(\d+)\s*ms/i.exec(detail);
|
|
178
|
+
if (delay) {
|
|
179
|
+
const n = Number(delay[1]);
|
|
180
|
+
if (Number.isFinite(n))
|
|
181
|
+
out.delayMs = Math.floor(n);
|
|
182
|
+
}
|
|
183
|
+
const status = /HTTP\s+(\d{3})/i.exec(detail);
|
|
184
|
+
if (status) {
|
|
185
|
+
const n = Number(status[1]);
|
|
186
|
+
if (Number.isFinite(n))
|
|
187
|
+
out.status = Math.floor(n);
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
function truncatePreview(text, cap) {
|
|
192
|
+
const s = typeof text === "string" ? text : String(text ?? "");
|
|
193
|
+
if (s.length <= cap)
|
|
194
|
+
return { preview: s, truncated: false, chars: s.length };
|
|
195
|
+
return { preview: s.slice(0, cap), truncated: true, chars: s.length };
|
|
196
|
+
}
|
|
197
|
+
function newSessionId() {
|
|
198
|
+
try {
|
|
199
|
+
return randomUUID();
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return `ses-${Date.now().toString(36)}-${Math.floor(Math.random() * 0xffffffff).toString(36)}`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function projectBasename() {
|
|
206
|
+
try {
|
|
207
|
+
const base = path.basename(process.cwd());
|
|
208
|
+
return typeof base === "string" && base.length > 0 ? base : null;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// --- Enablement ---
|
|
215
|
+
// Env override: ATOM_TELEMETRY=0/false/no/off disables; =1/true/yes/on
|
|
216
|
+
// enables. Returns undefined when unset/unrecognized (caller falls through).
|
|
217
|
+
export function telemetryEnvOverride(env = process.env) {
|
|
218
|
+
const raw = env["ATOM_TELEMETRY"];
|
|
219
|
+
if (raw === undefined)
|
|
220
|
+
return undefined;
|
|
221
|
+
const v = String(raw).trim().toLowerCase();
|
|
222
|
+
if (v === "0" || v === "false" || v === "no" || v === "off")
|
|
223
|
+
return false;
|
|
224
|
+
if (v === "1" || v === "true" || v === "yes" || v === "on")
|
|
225
|
+
return true;
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
// Precedence: env override > atom.json telemetry.enabled > default (on).
|
|
229
|
+
export function resolveTelemetryEnabled(env = process.env, configValue) {
|
|
230
|
+
const override = telemetryEnvOverride(env);
|
|
231
|
+
if (override !== undefined)
|
|
232
|
+
return override;
|
|
233
|
+
if (typeof configValue === "boolean")
|
|
234
|
+
return configValue;
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
// --- File store (all best-effort, never throw) ---
|
|
238
|
+
export function telemetryDir(home) {
|
|
239
|
+
return path.join(atomDir(home), TELEMETRY_DIRNAME);
|
|
240
|
+
}
|
|
241
|
+
export function telemetrySessionsDir(home) {
|
|
242
|
+
return path.join(telemetryDir(home), TELEMETRY_SESSIONS_DIRNAME);
|
|
243
|
+
}
|
|
244
|
+
export function telemetrySessionFilePath(sessionId, home) {
|
|
245
|
+
const safe = typeof sessionId === "string" && sessionId.length > 0 ? sessionId : "unknown";
|
|
246
|
+
return path.join(telemetrySessionsDir(home), `${safe}.json`);
|
|
247
|
+
}
|
|
248
|
+
export function telemetryDashboardFilePath(home) {
|
|
249
|
+
return path.join(telemetryDir(home), TELEMETRY_DASHBOARD_FILENAME);
|
|
250
|
+
}
|
|
251
|
+
// Atomic save (temp file + rename, 0600 POSIX like session.json). Returns
|
|
252
|
+
// false (never throws) when disabled state, bad input, or disk errors.
|
|
253
|
+
export function saveTelemetrySession(session, home) {
|
|
254
|
+
try {
|
|
255
|
+
if (!session || typeof session.sessionId !== "string")
|
|
256
|
+
return false;
|
|
257
|
+
const dir = telemetrySessionsDir(home);
|
|
258
|
+
mkdirSync(dir, { recursive: true });
|
|
259
|
+
const finalPath = path.join(dir, `${session.sessionId}.json`);
|
|
260
|
+
const tmpPath = path.join(dir, `.${session.sessionId}.tmp.${process.pid}`);
|
|
261
|
+
writeFileSync(tmpPath, JSON.stringify(session, null, 2) + "\n", "utf8");
|
|
262
|
+
try {
|
|
263
|
+
chmodSync(tmpPath, 0o600);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// best-effort on Windows; ignore
|
|
267
|
+
}
|
|
268
|
+
renameSync(tmpPath, finalPath);
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Load every session file, newest-first by startedAt. Skips missing dirs,
|
|
276
|
+
// unreadable files, and corrupt entries (counts them, never throws).
|
|
277
|
+
export function loadTelemetrySessions(home) {
|
|
278
|
+
const sessions = [];
|
|
279
|
+
let corrupt = 0;
|
|
280
|
+
try {
|
|
281
|
+
const dir = telemetrySessionsDir(home);
|
|
282
|
+
if (!existsSync(dir))
|
|
283
|
+
return { sessions, corrupt };
|
|
284
|
+
let entries;
|
|
285
|
+
try {
|
|
286
|
+
entries = readdirSync(dir);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return { sessions, corrupt };
|
|
290
|
+
}
|
|
291
|
+
for (const name of entries) {
|
|
292
|
+
if (!name.endsWith(".json") || name.startsWith("."))
|
|
293
|
+
continue;
|
|
294
|
+
try {
|
|
295
|
+
const raw = readFileSync(path.join(dir, name), "utf8");
|
|
296
|
+
const data = JSON.parse(raw);
|
|
297
|
+
const session = validateTelemetrySession(data);
|
|
298
|
+
if (session)
|
|
299
|
+
sessions.push(session);
|
|
300
|
+
else
|
|
301
|
+
corrupt += 1;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
corrupt += 1;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// never throw across the telemetry boundary
|
|
310
|
+
}
|
|
311
|
+
sessions.sort((a, b) => (a.startedAt < b.startedAt ? 1 : a.startedAt > b.startedAt ? -1 : 0));
|
|
312
|
+
return { sessions, corrupt };
|
|
313
|
+
}
|
|
314
|
+
// Retention: drop files older than maxAgeDays, then oldest beyond maxFiles.
|
|
315
|
+
// Best-effort, never throws. Returns files removed.
|
|
316
|
+
export function pruneTelemetrySessions(home, maxFiles = TELEMETRY_MAX_SESSION_FILES, maxAgeDays = TELEMETRY_MAX_SESSION_AGE_DAYS) {
|
|
317
|
+
let removed = 0;
|
|
318
|
+
try {
|
|
319
|
+
const dir = telemetrySessionsDir(home);
|
|
320
|
+
if (!existsSync(dir))
|
|
321
|
+
return 0;
|
|
322
|
+
let entries;
|
|
323
|
+
try {
|
|
324
|
+
entries = readdirSync(dir);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
329
|
+
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
330
|
+
const files = [];
|
|
331
|
+
for (const name of entries) {
|
|
332
|
+
if (!name.endsWith(".json") || name.startsWith("."))
|
|
333
|
+
continue;
|
|
334
|
+
const full = path.join(dir, name);
|
|
335
|
+
let mtimeMs = 0;
|
|
336
|
+
try {
|
|
337
|
+
mtimeMs = statSync(full).mtimeMs;
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (mtimeMs < cutoff) {
|
|
343
|
+
try {
|
|
344
|
+
rmSync(full, { force: true });
|
|
345
|
+
removed += 1;
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
// ignore per-file failures
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
files.push({ name, mtimeMs });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (files.length > maxFiles) {
|
|
356
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
357
|
+
for (const f of files.slice(maxFiles)) {
|
|
358
|
+
try {
|
|
359
|
+
rmSync(path.join(dir, f.name), { force: true });
|
|
360
|
+
removed += 1;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// ignore per-file failures
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// never throw
|
|
370
|
+
}
|
|
371
|
+
return removed;
|
|
372
|
+
}
|
|
373
|
+
function isRecord(value) {
|
|
374
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
375
|
+
}
|
|
376
|
+
// Lenient validation for stored sessions: shape-check the envelope and turn
|
|
377
|
+
// essentials, pass through the rest (a newer writer's extra fields survive a
|
|
378
|
+
// round trip through an older reader only if we don't strip them — but for
|
|
379
|
+
// the dashboard we only need the documented shape, so unknown fields are
|
|
380
|
+
// dropped rather than risk rendering garbage).
|
|
381
|
+
function validateTelemetrySession(data) {
|
|
382
|
+
if (!isRecord(data))
|
|
383
|
+
return null;
|
|
384
|
+
if (data["version"] !== TELEMETRY_VERSION)
|
|
385
|
+
return null;
|
|
386
|
+
const sessionId = data["sessionId"];
|
|
387
|
+
const startedAt = data["startedAt"];
|
|
388
|
+
if (typeof sessionId !== "string" || sessionId.length === 0)
|
|
389
|
+
return null;
|
|
390
|
+
if (typeof startedAt !== "string" || Number.isNaN(Date.parse(startedAt)))
|
|
391
|
+
return null;
|
|
392
|
+
const turns = data["turns"];
|
|
393
|
+
if (!Array.isArray(turns))
|
|
394
|
+
return null;
|
|
395
|
+
const subagents = Array.isArray(data["subagents"]) ? data["subagents"] : [];
|
|
396
|
+
const events = Array.isArray(data["events"]) ? data["events"] : [];
|
|
397
|
+
return {
|
|
398
|
+
version: TELEMETRY_VERSION,
|
|
399
|
+
sessionId,
|
|
400
|
+
startedAt,
|
|
401
|
+
endedAt: typeof data["endedAt"] === "string" ? data["endedAt"] : null,
|
|
402
|
+
atomVersion: typeof data["atomVersion"] === "string" ? data["atomVersion"] : null,
|
|
403
|
+
project: typeof data["project"] === "string" ? data["project"] : null,
|
|
404
|
+
provider: typeof data["provider"] === "string" ? data["provider"] : "unknown",
|
|
405
|
+
model: typeof data["model"] === "string" ? data["model"] : "unknown",
|
|
406
|
+
turns: turns,
|
|
407
|
+
subagents,
|
|
408
|
+
events,
|
|
409
|
+
compactionUsage: isRecord(data["compactionUsage"]) ? data["compactionUsage"] : {},
|
|
410
|
+
compactionReported: data["compactionReported"] === true,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
export const TELEMETRY_COST_NOTE = "Cost is not reported by any provider API — shown as n/a. No estimates are synthesized.";
|
|
414
|
+
export function emptyOutcomes() {
|
|
415
|
+
return {
|
|
416
|
+
completed: 0,
|
|
417
|
+
blocked: 0,
|
|
418
|
+
unverified: 0,
|
|
419
|
+
"budget-exceeded": 0,
|
|
420
|
+
failed: 0,
|
|
421
|
+
cancelled: 0,
|
|
422
|
+
pending: 0,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
export function summarizeTelemetry(sessions) {
|
|
426
|
+
const agg = {
|
|
427
|
+
sessions: sessions.length,
|
|
428
|
+
turns: 0,
|
|
429
|
+
modelCalls: 0,
|
|
430
|
+
toolCalls: 0,
|
|
431
|
+
succeededToolCalls: 0,
|
|
432
|
+
failedToolCalls: 0,
|
|
433
|
+
toolSuccessRate: null,
|
|
434
|
+
usage: {},
|
|
435
|
+
usageReported: false,
|
|
436
|
+
compactionUsage: {},
|
|
437
|
+
compactionReported: false,
|
|
438
|
+
retries: 0,
|
|
439
|
+
cacheHits: 0,
|
|
440
|
+
repetitionHits: 0,
|
|
441
|
+
outcomes: emptyOutcomes(),
|
|
442
|
+
byTool: [],
|
|
443
|
+
avgModelLatencyMs: null,
|
|
444
|
+
totalModelLatencyMs: 0,
|
|
445
|
+
avgToolDurationMs: null,
|
|
446
|
+
totalToolDurationMs: 0,
|
|
447
|
+
costUsd: null,
|
|
448
|
+
costNote: TELEMETRY_COST_NOTE,
|
|
449
|
+
};
|
|
450
|
+
const byTool = new Map();
|
|
451
|
+
let modelLatencyCount = 0;
|
|
452
|
+
let toolDurationCount = 0;
|
|
453
|
+
try {
|
|
454
|
+
for (const s of sessions) {
|
|
455
|
+
if (!s || !Array.isArray(s.turns))
|
|
456
|
+
continue;
|
|
457
|
+
if (s.compactionReported) {
|
|
458
|
+
if (addUsageInto(agg.compactionUsage, s.compactionUsage))
|
|
459
|
+
agg.compactionReported = true;
|
|
460
|
+
}
|
|
461
|
+
for (const t of s.turns) {
|
|
462
|
+
agg.turns += 1;
|
|
463
|
+
if (t.outcome in agg.outcomes)
|
|
464
|
+
agg.outcomes[t.outcome] += 1;
|
|
465
|
+
if (t.usageReported) {
|
|
466
|
+
if (addUsageInto(agg.usage, t.usage))
|
|
467
|
+
agg.usageReported = true;
|
|
468
|
+
}
|
|
469
|
+
agg.retries += typeof t.retryCount === "number" ? t.retryCount : 0;
|
|
470
|
+
if (t.loop) {
|
|
471
|
+
if (typeof t.loop.cacheHits === "number" && t.loop.cacheHits > 0) {
|
|
472
|
+
agg.cacheHits += Math.floor(t.loop.cacheHits);
|
|
473
|
+
}
|
|
474
|
+
if (typeof t.loop.repetitionHits === "number" && t.loop.repetitionHits > 0) {
|
|
475
|
+
agg.repetitionHits += Math.floor(t.loop.repetitionHits);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (Array.isArray(t.modelCalls)) {
|
|
479
|
+
for (const m of t.modelCalls) {
|
|
480
|
+
agg.modelCalls += 1;
|
|
481
|
+
// Note: retries are totaled from turn.retryCount below (each
|
|
482
|
+
// retry increments it exactly once when observed). The per-call
|
|
483
|
+
// m.retries arrays are the same events attributed to their call —
|
|
484
|
+
// summing both would double-count.
|
|
485
|
+
if (typeof m.durationMs === "number" && Number.isFinite(m.durationMs) && m.durationMs >= 0) {
|
|
486
|
+
agg.totalModelLatencyMs += m.durationMs;
|
|
487
|
+
modelLatencyCount += 1;
|
|
488
|
+
}
|
|
489
|
+
if (typeof m.costUsd === "number" && Number.isFinite(m.costUsd) && m.costUsd >= 0) {
|
|
490
|
+
agg.costUsd = (agg.costUsd ?? 0) + m.costUsd;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (Array.isArray(t.toolCalls)) {
|
|
495
|
+
for (const c of t.toolCalls) {
|
|
496
|
+
agg.toolCalls += 1;
|
|
497
|
+
if (c.success)
|
|
498
|
+
agg.succeededToolCalls += 1;
|
|
499
|
+
else
|
|
500
|
+
agg.failedToolCalls += 1;
|
|
501
|
+
if (typeof c.durationMs === "number" && Number.isFinite(c.durationMs) && c.durationMs >= 0) {
|
|
502
|
+
agg.totalToolDurationMs += c.durationMs;
|
|
503
|
+
toolDurationCount += 1;
|
|
504
|
+
}
|
|
505
|
+
const name = typeof c.name === "string" && c.name.length > 0 ? c.name : "(unknown)";
|
|
506
|
+
let entry = byTool.get(name);
|
|
507
|
+
if (!entry) {
|
|
508
|
+
entry = { calls: 0, succeeded: 0, failed: 0, totalDurationMs: 0 };
|
|
509
|
+
byTool.set(name, entry);
|
|
510
|
+
}
|
|
511
|
+
entry.calls += 1;
|
|
512
|
+
if (c.success)
|
|
513
|
+
entry.succeeded += 1;
|
|
514
|
+
else
|
|
515
|
+
entry.failed += 1;
|
|
516
|
+
if (typeof c.durationMs === "number" && Number.isFinite(c.durationMs) && c.durationMs >= 0) {
|
|
517
|
+
entry.totalDurationMs += c.durationMs;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
// aggregates are best-effort; return what accumulated
|
|
526
|
+
}
|
|
527
|
+
if (agg.toolCalls > 0)
|
|
528
|
+
agg.toolSuccessRate = agg.succeededToolCalls / agg.toolCalls;
|
|
529
|
+
if (modelLatencyCount > 0)
|
|
530
|
+
agg.avgModelLatencyMs = agg.totalModelLatencyMs / modelLatencyCount;
|
|
531
|
+
if (toolDurationCount > 0)
|
|
532
|
+
agg.avgToolDurationMs = agg.totalToolDurationMs / toolDurationCount;
|
|
533
|
+
agg.byTool = [...byTool.entries()]
|
|
534
|
+
.map(([name, v]) => ({
|
|
535
|
+
name,
|
|
536
|
+
calls: v.calls,
|
|
537
|
+
succeeded: v.succeeded,
|
|
538
|
+
failed: v.failed,
|
|
539
|
+
totalDurationMs: v.totalDurationMs,
|
|
540
|
+
avgDurationMs: v.calls > 0 ? v.totalDurationMs / v.calls : null,
|
|
541
|
+
}))
|
|
542
|
+
.sort((a, b) => b.calls - a.calls || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
543
|
+
return agg;
|
|
544
|
+
}
|
|
545
|
+
// In-memory trace for one session plus atomic turn-boundary persistence.
|
|
546
|
+
// Every public method is safe to call with null/undefined turn ids and never
|
|
547
|
+
// throws; when disabled, all record methods are no-ops.
|
|
548
|
+
export class TelemetryRecorder {
|
|
549
|
+
sessionId;
|
|
550
|
+
home;
|
|
551
|
+
enabled;
|
|
552
|
+
secrets;
|
|
553
|
+
now;
|
|
554
|
+
session;
|
|
555
|
+
turnSeq = 0;
|
|
556
|
+
modelSeq = 0;
|
|
557
|
+
toolSeq = 0;
|
|
558
|
+
// Retries observed (via onPhase) before their model call completes.
|
|
559
|
+
pendingRetries = [];
|
|
560
|
+
openTurns = new Map();
|
|
561
|
+
constructor(opts = {}) {
|
|
562
|
+
this.home = opts.home;
|
|
563
|
+
this.enabled = opts.enabled ?? true;
|
|
564
|
+
this.secrets = opts.secrets ?? (() => []);
|
|
565
|
+
this.now = opts.now ?? Date.now;
|
|
566
|
+
this.sessionId =
|
|
567
|
+
typeof opts.sessionId === "string" && opts.sessionId.length > 0 ? opts.sessionId : newSessionId();
|
|
568
|
+
const startedMs = this.safeNow();
|
|
569
|
+
this.session = {
|
|
570
|
+
version: TELEMETRY_VERSION,
|
|
571
|
+
sessionId: this.sessionId,
|
|
572
|
+
startedAt: toIso(startedMs),
|
|
573
|
+
endedAt: null,
|
|
574
|
+
atomVersion: opts.atomVersion ?? null,
|
|
575
|
+
project: opts.project !== undefined ? opts.project : projectBasename(),
|
|
576
|
+
provider: opts.provider ?? "unknown",
|
|
577
|
+
model: opts.model ?? "unknown",
|
|
578
|
+
turns: [],
|
|
579
|
+
subagents: [],
|
|
580
|
+
events: [],
|
|
581
|
+
compactionUsage: {},
|
|
582
|
+
compactionReported: false,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
isEnabled() {
|
|
586
|
+
return this.enabled;
|
|
587
|
+
}
|
|
588
|
+
safeNow() {
|
|
589
|
+
try {
|
|
590
|
+
const n = this.now();
|
|
591
|
+
return typeof n === "number" && Number.isFinite(n) ? n : Date.now();
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
return Date.now();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
scrub(text) {
|
|
598
|
+
try {
|
|
599
|
+
const secrets = this.secrets();
|
|
600
|
+
if (!Array.isArray(secrets) || secrets.length === 0)
|
|
601
|
+
return text;
|
|
602
|
+
return scrubSecrets(text, secrets);
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
return text;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
setSessionMeta(meta) {
|
|
609
|
+
try {
|
|
610
|
+
if (!this.enabled)
|
|
611
|
+
return;
|
|
612
|
+
if (typeof meta.provider === "string" && meta.provider.length > 0) {
|
|
613
|
+
if (this.session.provider !== meta.provider) {
|
|
614
|
+
this.recordEvent("provider-switch", `${this.session.provider} → ${meta.provider}`);
|
|
615
|
+
}
|
|
616
|
+
this.session.provider = meta.provider;
|
|
617
|
+
}
|
|
618
|
+
if (typeof meta.model === "string" && meta.model.length > 0) {
|
|
619
|
+
if (this.session.model !== meta.model) {
|
|
620
|
+
this.recordEvent("model-switch", `${this.session.model} → ${meta.model}`);
|
|
621
|
+
}
|
|
622
|
+
this.session.model = meta.model;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
// never throw
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
startTurn(input, meta) {
|
|
630
|
+
try {
|
|
631
|
+
if (!this.enabled)
|
|
632
|
+
return null;
|
|
633
|
+
this.turnSeq += 1;
|
|
634
|
+
const id = `t${this.turnSeq}`;
|
|
635
|
+
const startedMs = this.safeNow();
|
|
636
|
+
const scrubbed = this.scrub(typeof input === "string" ? input : "");
|
|
637
|
+
const preview = truncatePreview(scrubbed, TELEMETRY_INPUT_PREVIEW_CHARS);
|
|
638
|
+
const turn = {
|
|
639
|
+
id,
|
|
640
|
+
seq: this.turnSeq,
|
|
641
|
+
startedAt: toIso(startedMs),
|
|
642
|
+
endedAt: null,
|
|
643
|
+
durationMs: null,
|
|
644
|
+
inputPreview: preview.preview,
|
|
645
|
+
inputChars: typeof input === "string" ? input.length : 0,
|
|
646
|
+
provider: meta.provider,
|
|
647
|
+
model: meta.model,
|
|
648
|
+
effort: meta.effort,
|
|
649
|
+
mode: meta.mode,
|
|
650
|
+
outcome: "pending",
|
|
651
|
+
replyPreview: null,
|
|
652
|
+
error: null,
|
|
653
|
+
iterations: [],
|
|
654
|
+
modelCalls: [],
|
|
655
|
+
toolCalls: [],
|
|
656
|
+
usage: {},
|
|
657
|
+
usageReported: false,
|
|
658
|
+
retryCount: 0,
|
|
659
|
+
};
|
|
660
|
+
this.session.turns.push(turn);
|
|
661
|
+
this.openTurns.set(id, turn);
|
|
662
|
+
this.pendingRetries = [];
|
|
663
|
+
return id;
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
recordUsage(turnId, usage) {
|
|
670
|
+
try {
|
|
671
|
+
if (!this.enabled || !turnId)
|
|
672
|
+
return;
|
|
673
|
+
const turn = this.openTurns.get(turnId);
|
|
674
|
+
if (!turn)
|
|
675
|
+
return;
|
|
676
|
+
const clean = cleanUsage(usage);
|
|
677
|
+
if (clean && addUsageInto(turn.usage, clean))
|
|
678
|
+
turn.usageReported = true;
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
// never throw
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
recordRetry(turnId, detail) {
|
|
685
|
+
try {
|
|
686
|
+
if (!this.enabled || !turnId)
|
|
687
|
+
return;
|
|
688
|
+
const turn = this.openTurns.get(turnId);
|
|
689
|
+
if (!turn)
|
|
690
|
+
return;
|
|
691
|
+
const parsed = parseRetryDetail(detail);
|
|
692
|
+
const retry = {
|
|
693
|
+
at: toIso(this.safeNow()),
|
|
694
|
+
attempt: parsed.attempt,
|
|
695
|
+
delayMs: parsed.delayMs,
|
|
696
|
+
status: parsed.status,
|
|
697
|
+
detail: typeof detail === "string" ? detail.slice(0, 500) : String(detail ?? "").slice(0, 500),
|
|
698
|
+
};
|
|
699
|
+
turn.retryCount += 1;
|
|
700
|
+
// Buffer until the owning model call completes: zen reports completions
|
|
701
|
+
// only, and the loop runs one in-flight POST at a time, so the next
|
|
702
|
+
// recordModelCall in this turn is exactly the call these retries belong
|
|
703
|
+
// to. recordModelCall drains the buffer; endTurn sweeps leftovers (a
|
|
704
|
+
// failed POST) onto the last call for visibility.
|
|
705
|
+
this.pendingRetries.push(retry);
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
// never throw
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
recordModelCall(turnId, info) {
|
|
712
|
+
try {
|
|
713
|
+
if (!this.enabled || !turnId || !info)
|
|
714
|
+
return;
|
|
715
|
+
const turn = this.openTurns.get(turnId);
|
|
716
|
+
if (!turn)
|
|
717
|
+
return;
|
|
718
|
+
this.modelSeq += 1;
|
|
719
|
+
const retries = this.pendingRetries;
|
|
720
|
+
this.pendingRetries = [];
|
|
721
|
+
const clean = cleanUsage(info.usage);
|
|
722
|
+
if (clean && addUsageInto(turn.usage, clean))
|
|
723
|
+
turn.usageReported = true;
|
|
724
|
+
const call = {
|
|
725
|
+
id: `m${this.modelSeq}`,
|
|
726
|
+
seq: this.modelSeq,
|
|
727
|
+
iteration: typeof info.step === "number" ? info.step : 0,
|
|
728
|
+
provider: turn.provider,
|
|
729
|
+
model: turn.model,
|
|
730
|
+
startedAt: typeof info.startedAt === "string" ? info.startedAt : toIso(this.safeNow()),
|
|
731
|
+
endedAt: typeof info.endedAt === "string" ? info.endedAt : toIso(this.safeNow()),
|
|
732
|
+
durationMs: typeof info.durationMs === "number" && Number.isFinite(info.durationMs) && info.durationMs >= 0
|
|
733
|
+
? Math.floor(info.durationMs)
|
|
734
|
+
: 0,
|
|
735
|
+
usage: clean,
|
|
736
|
+
usageReported: info.usageReported === true && clean !== undefined,
|
|
737
|
+
reasoningLabel: typeof info.reasoningLabel === "string" ? info.reasoningLabel : undefined,
|
|
738
|
+
toolCallCount: typeof info.toolCallCount === "number" ? info.toolCallCount : 0,
|
|
739
|
+
finishReason: info.finishReason === "tool_calls" || info.finishReason === "error" ? info.finishReason : "final",
|
|
740
|
+
error: typeof info.error === "string" ? info.error.slice(0, 500) : undefined,
|
|
741
|
+
retries,
|
|
742
|
+
costUsd: null,
|
|
743
|
+
};
|
|
744
|
+
turn.modelCalls.push(call);
|
|
745
|
+
this.upsertIteration(turn, call.iteration, call.id, null);
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
// never throw
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
recordToolCall(turnId, info) {
|
|
752
|
+
try {
|
|
753
|
+
if (!this.enabled || !turnId || !info)
|
|
754
|
+
return;
|
|
755
|
+
const turn = this.openTurns.get(turnId);
|
|
756
|
+
if (!turn)
|
|
757
|
+
return;
|
|
758
|
+
this.toolSeq += 1;
|
|
759
|
+
const name = typeof info.name === "string" && info.name.length > 0 ? info.name : "(unknown)";
|
|
760
|
+
const rawResult = typeof info.result === "string" ? info.result : "";
|
|
761
|
+
const classified = classifyToolResult(rawResult, { cancelled: info.cancelled, threw: info.threw });
|
|
762
|
+
const args = this.scrub(typeof info.argsJson === "string" ? info.argsJson : "{}");
|
|
763
|
+
const result = this.scrub(rawResult);
|
|
764
|
+
const argsT = truncatePreview(args, TELEMETRY_ARGS_PREVIEW_CHARS);
|
|
765
|
+
const resultT = truncatePreview(result, TELEMETRY_RESULT_PREVIEW_CHARS);
|
|
766
|
+
const call = {
|
|
767
|
+
id: `c${this.toolSeq}`,
|
|
768
|
+
seq: this.toolSeq,
|
|
769
|
+
iteration: typeof info.step === "number" ? info.step : 0,
|
|
770
|
+
providerToolCallId: typeof info.toolCallId === "string" ? info.toolCallId : "",
|
|
771
|
+
name,
|
|
772
|
+
startedAt: typeof info.startedAt === "string" ? info.startedAt : toIso(this.safeNow()),
|
|
773
|
+
endedAt: typeof info.endedAt === "string" ? info.endedAt : toIso(this.safeNow()),
|
|
774
|
+
durationMs: typeof info.durationMs === "number" && Number.isFinite(info.durationMs) && info.durationMs >= 0
|
|
775
|
+
? Math.floor(info.durationMs)
|
|
776
|
+
: 0,
|
|
777
|
+
success: classified.success,
|
|
778
|
+
errorKind: classified.success ? undefined : classified.errorKind,
|
|
779
|
+
argsPreview: argsT.preview,
|
|
780
|
+
argsTruncated: argsT.truncated,
|
|
781
|
+
argsChars: argsT.chars,
|
|
782
|
+
resultPreview: resultT.preview,
|
|
783
|
+
resultTruncated: resultT.truncated,
|
|
784
|
+
resultChars: resultT.chars,
|
|
785
|
+
batchIndex: typeof info.batchIndex === "number" ? info.batchIndex : 0,
|
|
786
|
+
batchSize: typeof info.batchSize === "number" && info.batchSize > 0 ? info.batchSize : 1,
|
|
787
|
+
};
|
|
788
|
+
turn.toolCalls.push(call);
|
|
789
|
+
this.upsertIteration(turn, call.iteration, null, call.id);
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
// never throw
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
// Attach the loop-harness per-turn rollup (LoopStats from the agent loop,
|
|
796
|
+
// wired via AgenticOpts.onLoopStats in the App). Merges onto the open turn;
|
|
797
|
+
// later reports overwrite (the loop reports once, but a retry-safe merge
|
|
798
|
+
// keeps the newest). No-op when disabled, unknown turn, or bad input.
|
|
799
|
+
// Never throws. Safe to call before endTurn (success path) — endTurn keeps
|
|
800
|
+
// turn.loop intact; failed/cancelled turns keep it too (what was attempted).
|
|
801
|
+
recordLoopStats(turnId, summary) {
|
|
802
|
+
try {
|
|
803
|
+
if (!this.enabled || !turnId)
|
|
804
|
+
return;
|
|
805
|
+
const turn = this.openTurns.get(turnId);
|
|
806
|
+
if (!turn)
|
|
807
|
+
return;
|
|
808
|
+
if (typeof summary !== "object" || summary === null)
|
|
809
|
+
return;
|
|
810
|
+
const s = summary;
|
|
811
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : undefined;
|
|
812
|
+
const signed = (v) => typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : undefined;
|
|
813
|
+
const loop = {
|
|
814
|
+
cacheHits: num(s["cacheHits"]) ?? 0,
|
|
815
|
+
repetitionHits: num(s["repetitionHits"]) ?? 0,
|
|
816
|
+
failures: num(s["failures"]) ?? 0,
|
|
817
|
+
truncations: num(s["truncationNotices"] ?? s["truncations"]) ?? 0,
|
|
818
|
+
contextGrowthChars: signed(s["contextGrowthChars"]) ?? 0,
|
|
819
|
+
loopDurationMs: num(s["durationMs"] ?? s["loopDurationMs"]) ?? 0,
|
|
820
|
+
};
|
|
821
|
+
const bottleneck = s["bottleneck"];
|
|
822
|
+
const bName = typeof bottleneck?.["name"] === "string" ? bottleneck["name"] : undefined;
|
|
823
|
+
const bMs = num(bottleneck?.["durationMs"] ?? bottleneck?.["ms"] ?? s["bottleneckMs"]) ?? undefined;
|
|
824
|
+
if (bName && bName.length > 0) {
|
|
825
|
+
loop.bottleneckName = bName.slice(0, 80);
|
|
826
|
+
if (bMs !== undefined)
|
|
827
|
+
loop.bottleneckMs = bMs;
|
|
828
|
+
}
|
|
829
|
+
else if (typeof s["bottleneckName"] === "string" && s["bottleneckName"].length > 0) {
|
|
830
|
+
loop.bottleneckName = s["bottleneckName"].slice(0, 80);
|
|
831
|
+
if (bMs !== undefined)
|
|
832
|
+
loop.bottleneckMs = bMs;
|
|
833
|
+
}
|
|
834
|
+
turn.loop = loop;
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
// never throw
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
upsertIteration(turn, step, modelCallId, toolCallId) {
|
|
841
|
+
try {
|
|
842
|
+
let iter = turn.iterations.find((i) => i.step === step);
|
|
843
|
+
if (!iter) {
|
|
844
|
+
const now = toIso(this.safeNow());
|
|
845
|
+
iter = { step, startedAt: now, endedAt: now, durationMs: 0, modelCallId: null, toolCallIds: [] };
|
|
846
|
+
turn.iterations.push(iter);
|
|
847
|
+
turn.iterations.sort((a, b) => a.step - b.step);
|
|
848
|
+
}
|
|
849
|
+
if (modelCallId && !iter.modelCallId) {
|
|
850
|
+
iter.modelCallId = modelCallId;
|
|
851
|
+
const call = turn.modelCalls.find((m) => m.id === modelCallId);
|
|
852
|
+
if (call)
|
|
853
|
+
iter.startedAt = call.startedAt;
|
|
854
|
+
}
|
|
855
|
+
if (toolCallId)
|
|
856
|
+
iter.toolCallIds.push(toolCallId);
|
|
857
|
+
// Iteration window spans its model call start through its latest event end.
|
|
858
|
+
const ends = [];
|
|
859
|
+
const model = iter.modelCallId ? turn.modelCalls.find((m) => m.id === iter.modelCallId) : null;
|
|
860
|
+
if (model)
|
|
861
|
+
ends.push(model.endedAt);
|
|
862
|
+
for (const id of iter.toolCallIds) {
|
|
863
|
+
const tool = turn.toolCalls.find((c) => c.id === id);
|
|
864
|
+
if (tool)
|
|
865
|
+
ends.push(tool.endedAt);
|
|
866
|
+
}
|
|
867
|
+
if (ends.length > 0) {
|
|
868
|
+
iter.endedAt = ends.sort().pop();
|
|
869
|
+
try {
|
|
870
|
+
const ms = Date.parse(iter.endedAt) - Date.parse(iter.startedAt);
|
|
871
|
+
iter.durationMs = Number.isFinite(ms) && ms >= 0 ? Math.floor(ms) : 0;
|
|
872
|
+
}
|
|
873
|
+
catch {
|
|
874
|
+
iter.durationMs = 0;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
// never throw
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
// Compaction summary spend is session-level (it summarizes many turns and
|
|
883
|
+
// often runs after the turn that triggered it ended). Recorded separately
|
|
884
|
+
// so per-turn usage stays exactly "this turn's main-loop POSTs".
|
|
885
|
+
recordCompactionUsage(usage, kind = "manual") {
|
|
886
|
+
try {
|
|
887
|
+
if (!this.enabled)
|
|
888
|
+
return;
|
|
889
|
+
const clean = cleanUsage(usage);
|
|
890
|
+
if (clean && addUsageInto(this.session.compactionUsage, clean)) {
|
|
891
|
+
this.session.compactionReported = true;
|
|
892
|
+
this.recordEvent("compact", `${kind} compaction summary spend recorded`);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
catch {
|
|
896
|
+
// never throw
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
recordSubagent(sub) {
|
|
900
|
+
try {
|
|
901
|
+
if (!this.enabled || !sub)
|
|
902
|
+
return;
|
|
903
|
+
const now = toIso(this.safeNow());
|
|
904
|
+
const summary = this.scrub(typeof sub.summary === "string" ? sub.summary : "");
|
|
905
|
+
const preview = truncatePreview(summary, TELEMETRY_RESULT_PREVIEW_CHARS);
|
|
906
|
+
let durationMs = null;
|
|
907
|
+
try {
|
|
908
|
+
if (sub.startedAt && sub.endedAt) {
|
|
909
|
+
const ms = Date.parse(sub.endedAt) - Date.parse(sub.startedAt);
|
|
910
|
+
durationMs = Number.isFinite(ms) && ms >= 0 ? Math.floor(ms) : null;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
durationMs = null;
|
|
915
|
+
}
|
|
916
|
+
this.session.subagents.push({
|
|
917
|
+
id: `sub-${this.session.subagents.length + 1}`,
|
|
918
|
+
name: typeof sub.name === "string" && sub.name.length > 0 ? sub.name : "(unknown)",
|
|
919
|
+
startedAt: sub.startedAt ?? now,
|
|
920
|
+
endedAt: sub.endedAt ?? null,
|
|
921
|
+
durationMs,
|
|
922
|
+
status: sub.status === "failed" || sub.status === "running" ? sub.status : "completed",
|
|
923
|
+
summaryPreview: preview.preview.length > 0 ? preview.preview : null,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
catch {
|
|
927
|
+
// never throw
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
recordEvent(kind, detail, usage) {
|
|
931
|
+
try {
|
|
932
|
+
if (!this.enabled)
|
|
933
|
+
return;
|
|
934
|
+
const clean = cleanUsage(usage);
|
|
935
|
+
this.session.events.push({
|
|
936
|
+
at: toIso(this.safeNow()),
|
|
937
|
+
kind,
|
|
938
|
+
detail: typeof detail === "string" ? detail.slice(0, 500) : String(detail ?? "").slice(0, 500),
|
|
939
|
+
...(clean ? { usage: clean } : {}),
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
// never throw
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
endTurn(turnId, outcome, replyOrError) {
|
|
947
|
+
try {
|
|
948
|
+
if (!this.enabled || !turnId)
|
|
949
|
+
return;
|
|
950
|
+
const turn = this.openTurns.get(turnId);
|
|
951
|
+
if (!turn)
|
|
952
|
+
return;
|
|
953
|
+
const endedMs = this.safeNow();
|
|
954
|
+
turn.endedAt = toIso(endedMs);
|
|
955
|
+
try {
|
|
956
|
+
const ms = endedMs - Date.parse(turn.startedAt);
|
|
957
|
+
turn.durationMs = Number.isFinite(ms) && ms >= 0 ? Math.floor(ms) : 0;
|
|
958
|
+
}
|
|
959
|
+
catch {
|
|
960
|
+
turn.durationMs = 0;
|
|
961
|
+
}
|
|
962
|
+
turn.outcome = outcome;
|
|
963
|
+
if (outcome === "failed" || outcome === "cancelled") {
|
|
964
|
+
turn.error =
|
|
965
|
+
typeof replyOrError === "string" && replyOrError.length > 0
|
|
966
|
+
? replyOrError.slice(0, 500)
|
|
967
|
+
: outcome === "cancelled"
|
|
968
|
+
? "(cancelled)"
|
|
969
|
+
: "(failed)";
|
|
970
|
+
turn.replyPreview = null;
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
const scrubbed = this.scrub(typeof replyOrError === "string" ? replyOrError : "");
|
|
974
|
+
turn.replyPreview = truncatePreview(scrubbed, TELEMETRY_INPUT_PREVIEW_CHARS).preview;
|
|
975
|
+
turn.error = null;
|
|
976
|
+
}
|
|
977
|
+
// Any retries that never met their completion (failed POST) stay on the
|
|
978
|
+
// turn counter; attach leftovers to the last model call for visibility.
|
|
979
|
+
if (this.pendingRetries.length > 0) {
|
|
980
|
+
const last = turn.modelCalls.length > 0 ? turn.modelCalls[turn.modelCalls.length - 1] : null;
|
|
981
|
+
if (last)
|
|
982
|
+
last.retries.push(...this.pendingRetries);
|
|
983
|
+
this.pendingRetries = [];
|
|
984
|
+
}
|
|
985
|
+
this.openTurns.delete(turnId);
|
|
986
|
+
}
|
|
987
|
+
catch {
|
|
988
|
+
// never throw
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
endSession() {
|
|
992
|
+
try {
|
|
993
|
+
if (!this.enabled)
|
|
994
|
+
return;
|
|
995
|
+
if (!this.session.endedAt)
|
|
996
|
+
this.session.endedAt = toIso(this.safeNow());
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
// never throw
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
getSnapshot() {
|
|
1003
|
+
try {
|
|
1004
|
+
return JSON.parse(JSON.stringify(this.session));
|
|
1005
|
+
}
|
|
1006
|
+
catch {
|
|
1007
|
+
return this.session;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
// True when nothing worth persisting was recorded: no turns, no events, no
|
|
1011
|
+
// subagents, no compaction spend. An untouched mount (open + close, e.g. a
|
|
1012
|
+
// crashed or immediately-exited process) leaves no file behind, so the
|
|
1013
|
+
// store never fills with phantom empty sessions.
|
|
1014
|
+
isEmpty() {
|
|
1015
|
+
try {
|
|
1016
|
+
return (this.session.turns.length === 0 &&
|
|
1017
|
+
this.session.events.length === 0 &&
|
|
1018
|
+
this.session.subagents.length === 0 &&
|
|
1019
|
+
!this.session.compactionReported);
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
return true;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
// Persist the current session atomically + prune old files. Never throws.
|
|
1026
|
+
// Skips empty sessions (returns false, writes nothing).
|
|
1027
|
+
flush() {
|
|
1028
|
+
try {
|
|
1029
|
+
if (!this.enabled)
|
|
1030
|
+
return false;
|
|
1031
|
+
if (this.isEmpty())
|
|
1032
|
+
return false;
|
|
1033
|
+
const ok = saveTelemetrySession(this.session, this.home);
|
|
1034
|
+
try {
|
|
1035
|
+
pruneTelemetrySessions(this.home);
|
|
1036
|
+
}
|
|
1037
|
+
catch {
|
|
1038
|
+
// prune is best-effort
|
|
1039
|
+
}
|
|
1040
|
+
return ok;
|
|
1041
|
+
}
|
|
1042
|
+
catch {
|
|
1043
|
+
return false;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
export function createTelemetryRecorder(opts = {}) {
|
|
1048
|
+
try {
|
|
1049
|
+
return new TelemetryRecorder(opts);
|
|
1050
|
+
}
|
|
1051
|
+
catch {
|
|
1052
|
+
// Constructor never throws by design; this is belt-and-braces so a
|
|
1053
|
+
// telemetry failure can never break startup. Return a disabled recorder.
|
|
1054
|
+
return new TelemetryRecorder({ ...opts, enabled: false });
|
|
1055
|
+
}
|
|
1056
|
+
}
|