@yeaft/webchat-agent 1.0.17 → 1.0.19

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.
@@ -1,364 +1,676 @@
1
1
  /**
2
- * debug-trace.js — SQLite-backed debug trace for Yeaft
2
+ * debug-trace.js — file-backed debug trace for Yeaft
3
3
  *
4
- * Records every LLM turn, tool call, and event for debugging and analytics.
5
- * When disabled, uses NullTrace (same interface, zero overhead).
4
+ * Stores bounded request traces on disk without SQLite. Each request owns one
5
+ * compact JSON file under the session's debug folder:
6
+ * <yeaftDir>/sessions/<sessionId>/debug/requests/<requestKey>/trace.json
6
7
  *
7
- * Reference: server/db/connection.js Database(path), pragma WAL
8
+ * The file keeps one base request snapshot plus per-loop deltas. This avoids
9
+ * 100-200 tiny loop files and avoids repeating the whole cumulative message
10
+ * array for every loop. Debug history remains best-effort: failures are logged
11
+ * and dropped, never allowed to stop the agent.
8
12
  */
9
13
 
10
- import { DatabaseSync } from 'node:sqlite';
14
+ import {
15
+ existsSync,
16
+ mkdirSync,
17
+ readFileSync,
18
+ readdirSync,
19
+ renameSync,
20
+ rmSync,
21
+ statSync,
22
+ writeFileSync,
23
+ } from 'fs';
24
+ import { rename as renameAsync, writeFile as writeFileAsync } from 'fs/promises';
25
+ import { basename, dirname, extname, join } from 'path';
11
26
  import { randomUUID } from 'crypto';
12
- import { statSync } from 'fs';
13
-
14
- /** Schema DDL — 3 tables + indexes */
15
- const SCHEMA = `
16
- CREATE TABLE IF NOT EXISTS trace_turns (
17
- id TEXT PRIMARY KEY,
18
- trace_id TEXT NOT NULL,
19
- message_id TEXT,
20
- mode TEXT,
21
- turn_number INTEGER,
22
- model TEXT,
23
- input_tokens INTEGER,
24
- output_tokens INTEGER,
25
- cache_read_tokens INTEGER DEFAULT 0,
26
- cache_write_tokens INTEGER DEFAULT 0,
27
- stop_reason TEXT,
28
- latency_ms INTEGER,
29
- response_text TEXT,
30
- started_at INTEGER NOT NULL,
31
- ended_at INTEGER,
32
- group_id TEXT,
33
- vp_id TEXT,
34
- thread_id TEXT,
35
- system_prompt TEXT,
36
- messages_json TEXT,
37
- tool_calls_json TEXT,
38
- usage_json TEXT,
39
- ttfb_ms INTEGER,
40
- raw_request TEXT,
41
- raw_response TEXT,
42
- user_prompt TEXT
43
- );
44
-
45
- CREATE TABLE IF NOT EXISTS trace_tools (
46
- id TEXT PRIMARY KEY,
47
- turn_id TEXT NOT NULL,
48
- tool_name TEXT NOT NULL,
49
- tool_input TEXT,
50
- tool_output TEXT,
51
- tool_call_id TEXT,
52
- duration_ms INTEGER,
53
- is_error INTEGER DEFAULT 0,
54
- created_at INTEGER NOT NULL,
55
- FOREIGN KEY (turn_id) REFERENCES trace_turns(id)
56
- );
57
-
58
- CREATE TABLE IF NOT EXISTS trace_events (
59
- id TEXT PRIMARY KEY,
60
- trace_id TEXT NOT NULL,
61
- event_type TEXT NOT NULL,
62
- event_data TEXT,
63
- created_at INTEGER NOT NULL
64
- );
65
-
66
- CREATE INDEX IF NOT EXISTS idx_turns_trace_id ON trace_turns(trace_id);
67
- CREATE INDEX IF NOT EXISTS idx_turns_message_id ON trace_turns(message_id);
68
- CREATE INDEX IF NOT EXISTS idx_turns_started_at ON trace_turns(started_at);
69
- CREATE INDEX IF NOT EXISTS idx_turns_model ON trace_turns(model);
70
- CREATE INDEX IF NOT EXISTS idx_tools_turn_id ON trace_tools(turn_id);
71
- CREATE INDEX IF NOT EXISTS idx_tools_name ON trace_tools(tool_name);
72
- CREATE INDEX IF NOT EXISTS idx_events_trace_id ON trace_events(trace_id);
73
- CREATE INDEX IF NOT EXISTS idx_events_type ON trace_events(event_type);
74
- `;
75
27
 
76
- /**
77
- * Indexes that reference columns added by the v0.1.x fix-vp-multi-thread
78
- * migration. Must be executed AFTER `migrateAddColumn` for those columns
79
- * running them inside the main SCHEMA block would fail on an old DB
80
- * whose `trace_turns` table predates `group_id` / `vp_id` / `thread_id`
81
- * (`CREATE TABLE IF NOT EXISTS` is a no-op when the table already
82
- * exists, so the columns are never added by SCHEMA alone).
83
- */
84
- const POST_MIGRATION_INDEXES = `
85
- CREATE INDEX IF NOT EXISTS idx_turns_group_id ON trace_turns(group_id);
86
- CREATE INDEX IF NOT EXISTS idx_turns_vp_id ON trace_turns(vp_id);
87
- CREATE INDEX IF NOT EXISTS idx_turns_thread_id ON trace_turns(thread_id);
88
- `;
28
+ const TRACE_VERSION = 2;
29
+ const REQUEST_RETENTION = 10;
30
+ const MAX_HISTORY_LIMIT = 10;
31
+ const MAX_DREAM_EVENTS = 100;
32
+ const MAX_TEXT_BYTES = 1024 * 1024;
33
+ const MAX_TOOL_INPUT = 10 * 1024;
34
+ const MAX_INLINE_VALUE_BYTES = 1024 * 1024;
35
+ const MAX_RAW_REQUEST_BYTES = 2 * 1024 * 1024;
36
+ const TRACE_FLUSH_DELAY_MS = 150;
37
+
38
+ function isPlainObject(value) {
39
+ return value && typeof value === 'object' && !Array.isArray(value);
40
+ }
89
41
 
90
- /**
91
- * Idempotent column-adds for pre-existing trace databases. `ALTER TABLE …
92
- * ADD COLUMN` throws if the column already exists, so we wrap each call.
93
- * Mirrors the columns introduced above so older DBs upgrade in place.
94
- */
95
- function migrateAddColumn(db, table, column, type) {
42
+ function safeDirComponent(value, fallback = 'unknown') {
43
+ const raw = String(value || '').trim();
44
+ if (!raw) return fallback;
45
+ return raw.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 120) || fallback;
46
+ }
47
+
48
+ function fileTraceRoot(inputPath) {
49
+ const raw = String(inputPath || '').trim();
50
+ if (!raw) return null;
51
+ // Back-compat: callers and tests historically pass a concrete debug.db
52
+ // path. Do NOT collapse that to dirname(raw), or unrelated temp DB paths all
53
+ // share /tmp/debug and traces bleed across tests/sessions. Explicit dirPath
54
+ // callers pass the Yeaft root and get session-adjacent paths.
55
+ return extname(basename(raw)) ? `${raw}.files` : raw;
56
+ }
57
+
58
+ function ensureDir(dir) {
59
+ mkdirSync(dir, { recursive: true });
60
+ }
61
+
62
+ function atomicWriteJson(filePath, value) {
63
+ ensureDir(dirname(filePath));
64
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
65
+ writeFileSync(tmp, JSON.stringify(value), 'utf8');
66
+ renameSync(tmp, filePath);
67
+ }
68
+
69
+ async function atomicWriteJsonAsync(filePath, value) {
70
+ ensureDir(dirname(filePath));
71
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
72
+ await writeFileAsync(tmp, JSON.stringify(value), 'utf8');
73
+ await renameAsync(tmp, filePath);
74
+ }
75
+
76
+ function readJson(filePath) {
96
77
  try {
97
- db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
98
- } catch (err) {
99
- if (!String(err?.message || err).match(/duplicate column name/i)) {
100
- throw err;
101
- }
78
+ return JSON.parse(readFileSync(filePath, 'utf8'));
79
+ } catch {
80
+ return null;
102
81
  }
103
82
  }
104
83
 
105
- /** Max tool input size stored inline. Tool output is persisted raw. */
106
- const MAX_TOOL_INPUT = 10240;
84
+ function truncateText(value, maxBytes = MAX_TEXT_BYTES) {
85
+ if (value == null) return value ?? null;
86
+ const str = String(value);
87
+ if (Buffer.byteLength(str, 'utf8') <= maxBytes) return str;
88
+ let out = str.slice(0, maxBytes);
89
+ while (Buffer.byteLength(out, 'utf8') > maxBytes && out.length > 0) out = out.slice(0, -1);
90
+ return `${out}\n... [truncated to ${maxBytes} bytes]`;
91
+ }
107
92
 
108
- /**
109
- * Max per-loop payload (system prompt, messages JSON, raw request /
110
- * response, response text) stored per row. Larger than MAX_TOOL_INPUT
111
- * because real-world LLM exchanges (system prompt + 30K-token message
112
- * trail + raw response) routinely cross 10KB. 256KB lets us replay the
113
- * panel verbatim for the most recent traces without bloating the DB.
114
- */
115
- const MAX_LOOP_PAYLOAD = 256 * 1024;
93
+ function cloneJsonValue(value) {
94
+ if (value == null) return value;
95
+ try { return JSON.parse(JSON.stringify(value)); }
96
+ catch { return null; }
97
+ }
116
98
 
117
- /**
118
- * Truncate a string to a max length, appending "... [truncated]" if needed.
119
- * @param {string|null|undefined} str
120
- * @param {number} max
121
- * @returns {string|null}
122
- */
123
- function truncate(str, max) {
124
- if (!str) return str ?? null;
125
- if (str.length <= max) return str;
126
- return str.slice(0, max) + '... [truncated]';
99
+ function safeJsonValue(value, maxBytes = MAX_INLINE_VALUE_BYTES) {
100
+ if (value == null) return value;
101
+ try {
102
+ const json = JSON.stringify(value);
103
+ if (Buffer.byteLength(json, 'utf8') <= maxBytes) return JSON.parse(json);
104
+ return {
105
+ __truncated: true,
106
+ originalBytes: Buffer.byteLength(json, 'utf8'),
107
+ maxBytes,
108
+ };
109
+ } catch {
110
+ return null;
111
+ }
127
112
  }
128
113
 
129
- function truncatedJsonSentinel(originalBytes) {
114
+ function normalizeUsage(usage = {}, fallback = {}) {
115
+ const inputTokens = Number.isFinite(Number(usage?.inputTokens)) ? Number(usage.inputTokens) : Number(fallback.inputTokens || 0);
116
+ const outputTokens = Number.isFinite(Number(usage?.outputTokens)) ? Number(usage.outputTokens) : Number(fallback.outputTokens || 0);
117
+ const cacheReadTokens = Number.isFinite(Number(usage?.cacheReadTokens)) ? Number(usage.cacheReadTokens) : Number(fallback.cacheReadTokens || 0);
118
+ const cacheWriteTokens = Number.isFinite(Number(usage?.cacheWriteTokens)) ? Number(usage.cacheWriteTokens) : Number(fallback.cacheWriteTokens || 0);
119
+ const totalInputTokens = Number.isFinite(Number(usage?.totalInputTokens))
120
+ ? Number(usage.totalInputTokens)
121
+ : inputTokens + cacheReadTokens + cacheWriteTokens;
122
+ return {
123
+ inputTokens,
124
+ outputTokens,
125
+ cacheReadTokens,
126
+ cacheWriteTokens,
127
+ totalInputTokens,
128
+ totalTokens: Number.isFinite(Number(usage?.totalTokens))
129
+ ? Number(usage.totalTokens)
130
+ : totalInputTokens + outputTokens,
131
+ };
132
+ }
133
+
134
+ function stableEqual(a, b) {
135
+ try { return JSON.stringify(a) === JSON.stringify(b); }
136
+ catch { return false; }
137
+ }
138
+
139
+ function jsonByteLength(value) {
140
+ try { return Buffer.byteLength(JSON.stringify(value), 'utf8'); }
141
+ catch { return Infinity; }
142
+ }
143
+
144
+ function rawRequestSentinel(reason, value = null, maxBytes = MAX_RAW_REQUEST_BYTES) {
145
+ const preview = typeof value === 'string'
146
+ ? truncateText(value, Math.min(64 * 1024, maxBytes))
147
+ : null;
148
+ const originalBytes = value == null ? null : jsonByteLength(value);
130
149
  return {
131
150
  __truncated: true,
132
- originalBytes,
133
- maxBytes: MAX_LOOP_PAYLOAD,
151
+ reason,
152
+ ...(originalBytes != null ? { originalBytes } : {}),
153
+ maxBytes,
154
+ ...(preview ? { preview } : {}),
134
155
  };
135
156
  }
136
157
 
137
- function truncateJsonValue(value) {
158
+ function boundRawValue(value, reason = 'raw_request_budget') {
138
159
  if (value == null) return value;
139
- if (typeof value === 'string') return truncate(value, MAX_LOOP_PAYLOAD);
140
- try {
141
- const s = JSON.stringify(value);
142
- if (s.length <= MAX_LOOP_PAYLOAD) return value;
143
- return truncatedJsonSentinel(s.length);
144
- } catch {
145
- return null;
160
+ const cloned = typeof value === 'string' ? value : cloneJsonValue(value);
161
+ if (cloned == null) return null;
162
+ if (jsonByteLength(cloned) <= MAX_RAW_REQUEST_BYTES) return cloned;
163
+ return rawRequestSentinel(reason, value);
164
+ }
165
+
166
+ function buildRawRequestBase(value) {
167
+ if (value == null) return null;
168
+ if (typeof value === 'string') return truncateText(value, MAX_RAW_REQUEST_BYTES);
169
+ if (!isPlainObject(value)) return boundRawValue(value);
170
+ const base = {};
171
+ for (const [key, item] of Object.entries(value)) {
172
+ if (key === 'body' && isPlainObject(item)) {
173
+ const body = {};
174
+ for (const [bodyKey, bodyValue] of Object.entries(item)) {
175
+ body[bodyKey] = boundRawValue(bodyValue, `raw_request_body_${bodyKey}_budget`);
176
+ }
177
+ base.body = body;
178
+ } else {
179
+ base[key] = boundRawValue(item, `raw_request_${key}_budget`);
180
+ }
181
+ }
182
+ return base;
183
+ }
184
+
185
+ function buildRawMessagesDelta(previousMessages, nextMessages) {
186
+ if (!Array.isArray(previousMessages) || !Array.isArray(nextMessages)) return null;
187
+ const prefix = messagesPrefixLength(previousMessages, nextMessages);
188
+ if (prefix === previousMessages.length && prefix <= nextMessages.length) {
189
+ return { messagesFrom: prefix, messagesAppend: boundRawValue(nextMessages.slice(prefix), 'raw_request_messages_append_budget') };
190
+ }
191
+ return { messages: boundRawValue(nextMessages, 'raw_request_messages_budget') };
192
+ }
193
+
194
+ function rawComparableRequest(value) {
195
+ if (value == null) return null;
196
+ if (!isPlainObject(value)) return value;
197
+ const out = { ...value };
198
+ if (isPlainObject(value.body)) out.body = { ...value.body };
199
+ return out;
200
+ }
201
+
202
+ function buildRawRequestDelta(previous, next) {
203
+ if (next == null) return previous == null ? null : { replacement: null };
204
+ if (previous == null) return { base: buildRawRequestBase(next) };
205
+ const comparablePrevious = rawComparableRequest(previous);
206
+ const comparableNext = rawComparableRequest(next);
207
+ if (typeof comparablePrevious === 'string' || typeof comparableNext === 'string') {
208
+ return comparablePrevious === comparableNext ? null : { replacement: rawRequestSentinel('raw_request_string_replaced', comparableNext) };
209
+ }
210
+ if (!isPlainObject(comparablePrevious) || !isPlainObject(comparableNext)) {
211
+ return stableEqual(comparablePrevious, comparableNext) ? null : { replacement: rawRequestSentinel('raw_request_replaced') };
212
+ }
213
+
214
+ const delta = { set: {}, body: {} };
215
+ for (const key of Object.keys(comparableNext)) {
216
+ if (key === 'body') continue;
217
+ if (!stableEqual(comparablePrevious[key], comparableNext[key])) delta.set[key] = boundRawValue(comparableNext[key], `raw_request_${key}_budget`);
218
+ }
219
+ for (const key of Object.keys(comparablePrevious)) {
220
+ if (key !== 'body' && !Object.prototype.hasOwnProperty.call(comparableNext, key)) delta.set[key] = null;
221
+ }
222
+
223
+ const prevBody = isPlainObject(comparablePrevious.body) ? comparablePrevious.body : null;
224
+ const nextBody = isPlainObject(comparableNext.body) ? comparableNext.body : null;
225
+ if (prevBody && nextBody) {
226
+ for (const key of Object.keys(nextBody)) {
227
+ if (key === 'messages') continue;
228
+ if (!stableEqual(prevBody[key], nextBody[key])) delta.body[key] = boundRawValue(nextBody[key], `raw_request_body_${key}_budget`);
229
+ }
230
+ for (const key of Object.keys(prevBody)) {
231
+ if (key !== 'messages' && !Object.prototype.hasOwnProperty.call(nextBody, key)) delta.body[key] = null;
232
+ }
233
+ const msgDelta = buildRawMessagesDelta(prevBody.messages, nextBody.messages);
234
+ if (msgDelta) Object.assign(delta.body, msgDelta);
235
+ } else if (!stableEqual(previous.body, next.body)) {
236
+ delta.set.body = rawRequestSentinel('raw_request_body_replaced');
237
+ }
238
+
239
+ if (Object.keys(delta.set).length === 0) delete delta.set;
240
+ if (Object.keys(delta.body).length === 0) delete delta.body;
241
+ if (!delta.set && !delta.body) return null;
242
+ if (jsonByteLength(delta) > MAX_RAW_REQUEST_BYTES) {
243
+ return { replacement: rawRequestSentinel('raw_request_delta_budget') };
146
244
  }
245
+ return delta;
147
246
  }
148
247
 
149
- function boundDreamEventData(eventType, eventData) {
150
- if (eventType !== 'dream_loop' || !eventData || typeof eventData !== 'object') return eventData;
248
+ function applyRawRequestDelta(previous, delta) {
249
+ if (!delta) return previous ?? null;
250
+ if (Object.prototype.hasOwnProperty.call(delta, 'base')) return cloneJsonValue(delta.base) ?? delta.base ?? null;
251
+ if (Object.prototype.hasOwnProperty.call(delta, 'replacement')) return cloneJsonValue(delta.replacement) ?? delta.replacement ?? null;
252
+ const next = isPlainObject(previous) ? cloneJsonValue(previous) || {} : {};
253
+ if (isPlainObject(delta.set)) {
254
+ for (const [key, value] of Object.entries(delta.set)) next[key] = cloneJsonValue(value) ?? value;
255
+ }
256
+ if (isPlainObject(delta.body)) {
257
+ const body = isPlainObject(next.body) ? { ...next.body } : {};
258
+ for (const [key, value] of Object.entries(delta.body)) {
259
+ if (key === 'messagesFrom' || key === 'messagesAppend' || key === 'messages') continue;
260
+ body[key] = cloneJsonValue(value) ?? value;
261
+ }
262
+ if (Array.isArray(delta.body.messages)) {
263
+ body.messages = cloneJsonValue(delta.body.messages) || [];
264
+ } else if (Array.isArray(delta.body.messagesAppend)) {
265
+ const from = Number.isFinite(Number(delta.body.messagesFrom)) ? Number(delta.body.messagesFrom) : (Array.isArray(body.messages) ? body.messages.length : 0);
266
+ body.messages = (Array.isArray(body.messages) ? body.messages.slice(0, from) : []).concat(cloneJsonValue(delta.body.messagesAppend) || []);
267
+ }
268
+ next.body = body;
269
+ }
270
+ return next;
271
+ }
272
+
273
+ function messagesPrefixLength(prevMessages, nextMessages) {
274
+ if (!Array.isArray(prevMessages) || !Array.isArray(nextMessages)) return 0;
275
+ const max = Math.min(prevMessages.length, nextMessages.length);
276
+ let i = 0;
277
+ for (; i < max; i++) {
278
+ if (!stableEqual(prevMessages[i], nextMessages[i])) break;
279
+ }
280
+ return i;
281
+ }
282
+
283
+ function buildRequestSnapshot(info = {}) {
151
284
  return {
152
- ...eventData,
153
- systemPrompt: truncateJsonValue(eventData.systemPrompt),
154
- messages: truncateJsonValue(eventData.messages),
155
- response: truncateJsonValue(eventData.response),
156
- rawRequest: truncateJsonValue(eventData.rawRequest),
157
- rawResponse: truncateJsonValue(eventData.rawResponse),
285
+ systemPrompt: truncateText(info.systemPrompt || '', MAX_TEXT_BYTES),
286
+ messages: Array.isArray(info.messages) ? cloneJsonValue(info.messages) : [],
287
+ rawRequest: info.rawRequest ?? null,
158
288
  };
159
289
  }
160
290
 
161
- /**
162
- * DebugTrace — SQLite-backed debug trace.
163
- */
164
- export class DebugTrace {
165
- /** @type {import('node:sqlite').DatabaseSync} */
166
- #db;
291
+ function buildRequestDelta(previous, next) {
292
+ if (!previous) {
293
+ return {
294
+ base: true,
295
+ systemPrompt: next.systemPrompt || '',
296
+ messages: Array.isArray(next.messages) ? next.messages : [],
297
+ };
298
+ }
299
+ const delta = {};
300
+ if ((next.systemPrompt || '') !== (previous.systemPrompt || '')) {
301
+ delta.systemPrompt = next.systemPrompt || '';
302
+ }
303
+ const prevMessages = Array.isArray(previous.messages) ? previous.messages : [];
304
+ const nextMessages = Array.isArray(next.messages) ? next.messages : [];
305
+ const prefix = messagesPrefixLength(prevMessages, nextMessages);
306
+ if (prefix === prevMessages.length && prefix <= nextMessages.length) {
307
+ const appended = nextMessages.slice(prefix);
308
+ delta.messagesFrom = prefix;
309
+ delta.messagesAppend = appended;
310
+ } else {
311
+ delta.messages = nextMessages;
312
+ }
313
+ const rawRequestDelta = buildRawRequestDelta(previous.rawRequest, next.rawRequest);
314
+ if (rawRequestDelta) delta.rawRequestDelta = rawRequestDelta;
315
+ return delta;
316
+ }
167
317
 
168
- /** @type {string} */
169
- #dbPath;
318
+ function applyRequestDelta(previous, delta = {}) {
319
+ const base = previous || { systemPrompt: '', messages: [], rawRequest: null };
320
+ const next = {
321
+ systemPrompt: base.systemPrompt || '',
322
+ messages: Array.isArray(base.messages) ? [...base.messages] : [],
323
+ rawRequest: base.rawRequest ?? null,
324
+ };
325
+ if (delta.base) {
326
+ return {
327
+ systemPrompt: delta.systemPrompt || '',
328
+ messages: Array.isArray(delta.messages) ? delta.messages : [],
329
+ rawRequest: base.rawRequest ?? null,
330
+ };
331
+ }
332
+ if (typeof delta.systemPrompt === 'string') next.systemPrompt = delta.systemPrompt;
333
+ if (Array.isArray(delta.messages)) {
334
+ next.messages = delta.messages;
335
+ } else if (Array.isArray(delta.messagesAppend)) {
336
+ const from = Number.isFinite(Number(delta.messagesFrom)) ? Number(delta.messagesFrom) : next.messages.length;
337
+ next.messages = next.messages.slice(0, from).concat(delta.messagesAppend);
338
+ }
339
+ if (Object.prototype.hasOwnProperty.call(delta, 'rawRequestDelta')) next.rawRequest = applyRawRequestDelta(next.rawRequest, delta.rawRequestDelta);
340
+ return next;
341
+ }
170
342
 
171
- // Prepared statements (created lazily)
172
- #stmts = {};
343
+ function sessionRequestsDir(rootDir, sessionId) {
344
+ if (sessionId) return join(rootDir, 'sessions', safeDirComponent(sessionId), 'debug', 'requests');
345
+ return join(rootDir, 'debug', 'requests');
346
+ }
173
347
 
174
- /**
175
- * @param {string} dbPath — Path to the SQLite database file.
176
- */
177
- constructor(dbPath) {
178
- this.#dbPath = dbPath;
179
- this.#db = new DatabaseSync(dbPath);
180
- // INCREMENTAL auto-vacuum lets cleanup() return freed pages to the OS via
181
- // `PRAGMA incremental_vacuum` instead of leaving the file at its historical
182
- // peak. SQLite only honours an auto_vacuum *change* before the first table
183
- // is created (a fresh DB) — on a pre-existing store it is a silent no-op, so
184
- // existing databases keep their default `auto_vacuum=NONE` and are
185
- // unaffected (they only shrink under a manual compact()/VACUUM). Databases
186
- // created from this version on are self-trimming. Must precede SCHEMA.
187
- this.#db.exec('PRAGMA auto_vacuum = INCREMENTAL');
188
- this.#db.exec('PRAGMA journal_mode = WAL');
189
- this.#db.exec('PRAGMA foreign_keys = ON');
190
- this.#db.exec(SCHEMA);
191
- // Forward-compat: a DB created by an older version of the bridge
192
- // will be missing the group/vp/thread + per-loop snapshot columns.
193
- // Add them on open; no-op for fresh DBs (column already exists
194
- // from SCHEMA above).
195
- migrateAddColumn(this.#db, 'trace_turns', 'group_id', 'TEXT');
196
- migrateAddColumn(this.#db, 'trace_turns', 'vp_id', 'TEXT');
197
- migrateAddColumn(this.#db, 'trace_turns', 'thread_id', 'TEXT');
198
- migrateAddColumn(this.#db, 'trace_turns', 'system_prompt', 'TEXT');
199
- migrateAddColumn(this.#db, 'trace_turns', 'messages_json', 'TEXT');
200
- migrateAddColumn(this.#db, 'trace_turns', 'tool_calls_json', 'TEXT');
201
- migrateAddColumn(this.#db, 'trace_turns', 'usage_json', 'TEXT');
202
- migrateAddColumn(this.#db, 'trace_turns', 'ttfb_ms', 'INTEGER');
203
- migrateAddColumn(this.#db, 'trace_turns', 'raw_request', 'TEXT');
204
- migrateAddColumn(this.#db, 'trace_turns', 'raw_response', 'TEXT');
205
- // C2 fix: explicit `user_prompt` column. Deriving the prompt from
206
- // `messages_json` is unsafe because every loop after turn 1 in a
207
- // multi-loop tool-call cycle persists the *cumulative* conversation
208
- // snapshot — `messages.find(role==='user')` would return turn 1's
209
- // text for every subsequent turn, mislabeling every Turn header.
210
- migrateAddColumn(this.#db, 'trace_turns', 'user_prompt', 'TEXT');
211
- migrateAddColumn(this.#db, 'trace_tools', 'tool_call_id', 'TEXT');
212
- // Indexes on the just-added columns. Must run AFTER the ALTER TABLEs
213
- // — running them inside SCHEMA's CREATE INDEX IF NOT EXISTS block
214
- // would fail with "no such column: group_id" on a pre-bugfix DB.
215
- this.#db.exec(POST_MIGRATION_INDEXES);
216
- }
217
-
218
- // ─── Write API ───────────────────────────────────────────────
348
+ function requestFilePath(requestDir) {
349
+ return join(requestDir, 'trace.json');
350
+ }
219
351
 
220
- /**
221
- * Start a new turn.
222
- * @param {{ traceId: string, messageId?: string, mode?: string, turnNumber?: number, sessionId?: string, vpId?: string, threadId?: string, userPrompt?: string }} opts
223
- * @returns {string} — turnId
224
- */
225
- startTurn({ traceId, messageId = null, mode = null, turnNumber = null, sessionId = null, vpId = null, threadId = null, userPrompt = null }) {
226
- const id = randomUUID();
227
- const now = Date.now();
228
- this.#prepare('insertTurn', `
229
- INSERT INTO trace_turns (id, trace_id, message_id, mode, turn_number, started_at, group_id, vp_id, thread_id, user_prompt)
230
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
231
- `).run(id, traceId, messageId, mode, turnNumber, now, sessionId, vpId, threadId, truncate(userPrompt, MAX_LOOP_PAYLOAD));
232
- return id;
352
+ function tracePathFor(rootDir, sessionId, requestKey) {
353
+ return requestFilePath(join(sessionRequestsDir(rootDir, sessionId), safeDirComponent(requestKey, 'request')));
354
+ }
355
+
356
+ function summarizeTrace(trace, detailsLoaded = false) {
357
+ const loops = Array.isArray(trace?.loops) ? trace.loops : [];
358
+ const usage = loops.reduce((acc, loop) => {
359
+ const u = normalizeUsage(loop?.usage || {});
360
+ acc.totalMs += Number(loop?.latencyMs || 0);
361
+ acc.totalTokens += u.totalTokens || 0;
362
+ acc.summaryInputTokens += u.totalInputTokens || 0;
363
+ acc.summaryOutputTokens += u.outputTokens || 0;
364
+ return acc;
365
+ }, { totalMs: 0, totalTokens: 0, summaryInputTokens: 0, summaryOutputTokens: 0 });
366
+ return {
367
+ turnId: trace?.requestId || trace?.traceId || '',
368
+ userPrompt: trace?.userPrompt || '',
369
+ sessionId: trace?.sessionId || null,
370
+ vpId: trace?.vpId || null,
371
+ threadId: trace?.threadId || null,
372
+ openedAt: trace?.openedAt || 0,
373
+ closedAt: trace?.closedAt || null,
374
+ totalMs: usage.totalMs,
375
+ totalTokens: usage.totalTokens,
376
+ summaryInputTokens: usage.summaryInputTokens,
377
+ summaryOutputTokens: usage.summaryOutputTokens,
378
+ loopCount: loops.length,
379
+ memoryLoaded: null,
380
+ memoryAdjust: null,
381
+ tools: Array.isArray(trace?.tools) ? trace.tools.map(t => ({
382
+ loopNumber: t.loopNumber || 0,
383
+ callId: t.toolCallId || t.id || null,
384
+ traceToolId: t.id || null,
385
+ name: t.toolName || t.name || '?',
386
+ toolOutput: t.toolOutput == null ? null : String(t.toolOutput),
387
+ durationMs: t.durationMs || 0,
388
+ isError: !!t.isError,
389
+ })) : [],
390
+ detailsLoaded,
391
+ requestBase: trace?.baseRequest || null,
392
+ };
393
+ }
394
+
395
+ function expandTrace(trace) {
396
+ const turnsById = new Map([[trace.requestId || trace.traceId, summarizeTrace(trace, true)]]);
397
+ let snapshot = null;
398
+ const loops = [];
399
+ for (const loop of Array.isArray(trace?.loops) ? trace.loops : []) {
400
+ snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
401
+ const usage = normalizeUsage(loop?.usage || {});
402
+ loops.push({
403
+ turnId: trace.requestId || trace.traceId,
404
+ loopInstanceId: loop.loopInstanceId || loop.turnRowId || null,
405
+ loopNumber: loop.loopNumber || 0,
406
+ model: loop.model || null,
407
+ systemPrompt: snapshot.systemPrompt || '',
408
+ messages: Array.isArray(snapshot.messages) ? snapshot.messages : [],
409
+ response: loop.response || '',
410
+ toolCalls: Array.isArray(loop.toolCalls) ? loop.toolCalls : [],
411
+ usage,
412
+ latencyMs: loop.latencyMs || 0,
413
+ ttfbMs: loop.ttfbMs || null,
414
+ stopReason: loop.stopReason || null,
415
+ at: loop.at || null,
416
+ rawRequest: snapshot.rawRequest ?? null,
417
+ rawResponse: loop.rawResponse ?? null,
418
+ requestDelta: loop.requestDelta || {},
419
+ requestBase: trace.baseRequest || null,
420
+ sessionId: trace.sessionId || null,
421
+ vpId: trace.vpId || null,
422
+ threadId: trace.threadId || null,
423
+ });
424
+ }
425
+ return { loops, turns: Array.from(turnsById.values()) };
426
+ }
427
+
428
+ function traceToLegacyRows(trace) {
429
+ let snapshot = null;
430
+ return (Array.isArray(trace?.loops) ? trace.loops : []).map((loop) => {
431
+ snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
432
+ const u = normalizeUsage(loop?.usage || {});
433
+ return {
434
+ id: loop.turnRowId || loop.loopInstanceId || randomUUID(),
435
+ trace_id: trace.traceId || trace.requestId,
436
+ message_id: trace.messageId || null,
437
+ mode: trace.mode || null,
438
+ turn_number: loop.loopNumber || 0,
439
+ model: loop.model || null,
440
+ input_tokens: u.inputTokens || 0,
441
+ output_tokens: u.outputTokens || 0,
442
+ cache_read_tokens: u.cacheReadTokens || 0,
443
+ cache_write_tokens: u.cacheWriteTokens || 0,
444
+ stop_reason: loop.stopReason || null,
445
+ latency_ms: loop.latencyMs || 0,
446
+ response_text: loop.response || '',
447
+ started_at: loop.startedAt || trace.openedAt || 0,
448
+ ended_at: loop.at || trace.closedAt || null,
449
+ group_id: trace.sessionId || null,
450
+ vp_id: trace.vpId || null,
451
+ thread_id: trace.threadId || null,
452
+ system_prompt: snapshot.systemPrompt || '',
453
+ messages_json: JSON.stringify(snapshot.messages || []),
454
+ tool_calls_json: JSON.stringify(loop.toolCalls || []),
455
+ usage_json: JSON.stringify(u),
456
+ ttfb_ms: loop.ttfbMs || null,
457
+ raw_request: typeof snapshot.rawRequest === 'string' ? snapshot.rawRequest : JSON.stringify(snapshot.rawRequest ?? null),
458
+ raw_response: typeof loop.rawResponse === 'string' ? loop.rawResponse : JSON.stringify(loop.rawResponse ?? null),
459
+ user_prompt: trace.userPrompt || '',
460
+ };
461
+ });
462
+ }
463
+
464
+ function traceToolToLegacy(trace, tool) {
465
+ return {
466
+ id: tool.id || randomUUID(),
467
+ turn_id: tool.turnRowId || null,
468
+ tool_name: tool.toolName || tool.name || '?',
469
+ tool_input: tool.toolInput == null ? null : String(tool.toolInput),
470
+ tool_output: tool.toolOutput == null ? null : String(tool.toolOutput),
471
+ tool_call_id: tool.toolCallId || null,
472
+ duration_ms: tool.durationMs || 0,
473
+ is_error: tool.isError ? 1 : 0,
474
+ created_at: tool.createdAt || trace.openedAt || 0,
475
+ };
476
+ }
477
+
478
+ function collectTraceFiles(rootDir, sessionId = null) {
479
+ const files = [];
480
+ const addFromRequestsDir = (requestsDir) => {
481
+ let entries = [];
482
+ try { entries = readdirSync(requestsDir, { withFileTypes: true }); }
483
+ catch { return; }
484
+ for (const entry of entries) {
485
+ if (!entry.isDirectory()) continue;
486
+ const file = requestFilePath(join(requestsDir, entry.name));
487
+ if (existsSync(file)) files.push(file);
488
+ }
489
+ };
490
+ if (sessionId) {
491
+ addFromRequestsDir(sessionRequestsDir(rootDir, sessionId));
492
+ return files;
493
+ }
494
+ addFromRequestsDir(sessionRequestsDir(rootDir, null));
495
+ const sessionsRoot = join(rootDir, 'sessions');
496
+ let sessionEntries = [];
497
+ try { sessionEntries = readdirSync(sessionsRoot, { withFileTypes: true }); }
498
+ catch { return files; }
499
+ for (const entry of sessionEntries) {
500
+ if (!entry.isDirectory()) continue;
501
+ addFromRequestsDir(join(sessionsRoot, entry.name, 'debug', 'requests'));
233
502
  }
503
+ return files;
504
+ }
505
+
506
+ function readTraceSummaries(rootDir, sessionId = null) {
507
+ const traces = [];
508
+ for (const file of collectTraceFiles(rootDir, sessionId)) {
509
+ const trace = readJson(file);
510
+ if (!trace || !trace.requestId) continue;
511
+ traces.push({ trace, file, openedAt: Number(trace.openedAt || 0) });
512
+ }
513
+ traces.sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.trace?.requestKey || a.file).localeCompare(String(b.trace?.requestKey || b.file)));
514
+ return traces;
515
+ }
516
+
517
+ function countDirFiles(rootDir) {
518
+ let files = 0;
519
+ let bytes = 0;
520
+ const walk = (dir) => {
521
+ let entries = [];
522
+ try { entries = readdirSync(dir, { withFileTypes: true }); }
523
+ catch { return; }
524
+ for (const entry of entries) {
525
+ const p = join(dir, entry.name);
526
+ if (entry.isDirectory()) walk(p);
527
+ else {
528
+ files += 1;
529
+ try { bytes += statSync(p).size; } catch { /* ignore */ }
530
+ }
531
+ }
532
+ };
533
+ walk(rootDir);
534
+ return { files, bytes };
535
+ }
536
+
537
+ export class DebugTrace {
538
+ /** @type {string} */
539
+ #rootDir;
540
+ /** @type {Map<string, { requestKey: string, sessionId: string|null, traceId: string, loopNumber: number }>} */
541
+ #turnIndex = new Map();
542
+ /** @type {Map<string, object>} */
543
+ #requestCache = new Map();
544
+ /** @type {Map<string, object>} */
545
+ #pendingWrites = new Map();
546
+ /** @type {Map<string, NodeJS.Timeout>} */
547
+ #flushTimers = new Map();
548
+ /** @type {number} */
549
+ #sequence = 0;
234
550
 
235
551
  /**
236
- * End a turn with model response info.
237
- * @param {string} turnId
238
- * @param {{ model?: string, inputTokens?: number, outputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, stopReason?: string, latencyMs?: number, responseText?: string, systemPrompt?: string, messages?: unknown, toolCalls?: unknown, usage?: unknown, ttfbMs?: number, rawRequest?: unknown, rawResponse?: unknown }} info
552
+ * @param {string} tracePath Back-compatible path. If it looks like a DB
553
+ * file, traces are stored in a sibling `debug/` directory.
239
554
  */
240
- endTurn(turnId, {
241
- model = null,
242
- inputTokens = null,
243
- outputTokens = null,
244
- cacheReadTokens = 0,
245
- cacheWriteTokens = 0,
246
- stopReason = null,
247
- latencyMs = null,
248
- responseText = null,
249
- systemPrompt = null,
250
- messages = null,
251
- toolCalls = null,
252
- usage = null,
253
- ttfbMs = null,
254
- rawRequest = null,
255
- rawResponse = null,
256
- } = {}) {
555
+ constructor(tracePath) {
556
+ const rootDir = fileTraceRoot(tracePath);
557
+ if (!rootDir) throw new Error('DebugTrace requires a storage path');
558
+ this.#rootDir = rootDir;
559
+ ensureDir(rootDir);
560
+ }
561
+
562
+ startTurn({ traceId, messageId = null, mode = null, turnNumber = null, sessionId = null, vpId = null, threadId = null, userPrompt = null } = {}) {
563
+ const turnRowId = randomUUID();
257
564
  const now = Date.now();
258
- // JSON-stringify the structured fields so they round-trip through
259
- // SQLite as TEXT. JSON serialisation might fail (cyclic structure /
260
- // BigInt) guard with try/catch and persist null on failure so a
261
- // single bad message can never tank the whole turn record.
262
- //
263
- // I2 fix: if the serialised JSON exceeds MAX_LOOP_PAYLOAD, the naïve
264
- // `truncate(s, MAX)` would append `... [truncated]` mid-string and
265
- // make the row's JSON unparseable. The reader's `parseJsonSafe` would
266
- // then silently return null and the panel would render the loop with
267
- // empty messages / toolCalls / usage. Persist a structured sentinel
268
- // instead so the panel can render a "[truncated, N bytes]" notice.
269
- const safeStringify = (v) => {
270
- if (v == null) return null;
271
- try {
272
- const s = JSON.stringify(v);
273
- if (s.length <= MAX_LOOP_PAYLOAD) return s;
274
- return JSON.stringify(truncatedJsonSentinel(s.length));
275
- } catch { return null; }
276
- };
277
- // For raw request/response, accept either a pre-stringified blob
278
- // (treat as opaque text — truncation here is fine because
279
- // parseJsonSafe is not used on raw_*) or a structured object (route
280
- // through safeStringify which preserves JSON validity).
281
- const stringifyRaw = (v) => {
282
- if (v == null) return null;
283
- if (typeof v === 'string') return truncate(v, MAX_LOOP_PAYLOAD);
284
- return safeStringify(v);
285
- };
286
- this.#prepare('endTurn', `
287
- UPDATE trace_turns SET
288
- model = ?, input_tokens = ?, output_tokens = ?,
289
- cache_read_tokens = ?, cache_write_tokens = ?,
290
- stop_reason = ?, latency_ms = ?, response_text = ?, ended_at = ?,
291
- system_prompt = ?, messages_json = ?, tool_calls_json = ?,
292
- usage_json = ?, ttfb_ms = ?, raw_request = ?, raw_response = ?
293
- WHERE id = ?
294
- `).run(
295
- model, inputTokens, outputTokens,
296
- cacheReadTokens, cacheWriteTokens,
297
- stopReason, latencyMs, truncate(responseText, MAX_LOOP_PAYLOAD),
565
+ const request = this.#getOrCreateRequest({
566
+ traceId: traceId || turnRowId,
567
+ turnNumber: Number(turnNumber || 0),
568
+ messageId,
569
+ mode,
570
+ sessionId,
571
+ vpId,
572
+ threadId,
573
+ userPrompt,
298
574
  now,
299
- truncate(systemPrompt, MAX_LOOP_PAYLOAD),
300
- safeStringify(messages),
301
- safeStringify(toolCalls),
302
- safeStringify(usage),
303
- ttfbMs,
304
- stringifyRaw(rawRequest),
305
- stringifyRaw(rawResponse),
306
- turnId,
307
- );
575
+ turnRowId,
576
+ });
577
+ this.#turnIndex.set(turnRowId, {
578
+ requestKey: request.requestKey,
579
+ sessionId: request.sessionId || null,
580
+ traceId: request.traceId,
581
+ loopNumber: Number(turnNumber || 0),
582
+ });
583
+ return turnRowId;
308
584
  }
309
585
 
310
- /**
311
- * Log a tool call within a turn.
312
- * @param {string} turnId
313
- * @param {{ toolName: string, toolCallId?: string|null, toolInput?: string, toolOutput?: string, durationMs?: number, isError?: boolean }} info
314
- * @returns {string} — tool record id
315
- */
316
- logTool(turnId, {
317
- toolName,
318
- toolCallId = null,
319
- toolInput = null,
320
- toolOutput = null,
321
- durationMs = null,
322
- isError = false,
323
- }) {
586
+ endTurn(turnId, info = {}) {
587
+ const ctx = this.#turnIndex.get(turnId);
588
+ if (!ctx) return;
589
+ const trace = this.#loadRequest(ctx.sessionId, ctx.requestKey);
590
+ if (!trace) return;
591
+ const loopNumber = ctx.loopNumber || Number(info.turnNumber || 0);
592
+ const snapshot = buildRequestSnapshot(info);
593
+ const previousSnapshot = trace._lastSnapshot || this.#reconstructLastSnapshot(trace);
594
+ if (!trace.baseRequest) {
595
+ const rawRequestBaseDelta = buildRawRequestDelta(null, snapshot.rawRequest);
596
+ trace.baseRequest = { ...snapshot, rawRequest: applyRawRequestDelta(null, rawRequestBaseDelta) };
597
+ }
598
+ const loopIndex = (trace.loops || []).findIndex(l => l.turnRowId === turnId);
599
+ const loop = {
600
+ loopInstanceId: turnId,
601
+ turnRowId: turnId,
602
+ loopNumber,
603
+ startedAt: trace.openedAt || Date.now(),
604
+ model: info.model || null,
605
+ response: truncateText(info.responseText || '', MAX_TEXT_BYTES),
606
+ toolCalls: cloneJsonValue(Array.isArray(info.toolCalls) ? info.toolCalls : []),
607
+ usage: normalizeUsage(info.usage || {}, {
608
+ inputTokens: info.inputTokens || 0,
609
+ outputTokens: info.outputTokens || 0,
610
+ cacheReadTokens: info.cacheReadTokens || 0,
611
+ cacheWriteTokens: info.cacheWriteTokens || 0,
612
+ }),
613
+ latencyMs: Number(info.latencyMs || 0),
614
+ ttfbMs: Number.isFinite(Number(info.ttfbMs)) ? Number(info.ttfbMs) : null,
615
+ stopReason: info.stopReason || null,
616
+ at: Date.now(),
617
+ rawResponse: typeof info.rawResponse === 'string'
618
+ ? truncateText(info.rawResponse, MAX_TEXT_BYTES)
619
+ : safeJsonValue(info.rawResponse),
620
+ requestDelta: buildRequestDelta(previousSnapshot, snapshot),
621
+ };
622
+ if (loopIndex >= 0) trace.loops[loopIndex] = loop;
623
+ else trace.loops.push(loop);
624
+ trace.loops.sort((a, b) => (a.loopNumber || 0) - (b.loopNumber || 0) || String(a.turnRowId || '').localeCompare(String(b.turnRowId || '')));
625
+ trace.closedAt = loop.at;
626
+ trace.updatedAt = loop.at;
627
+ trace.active = info.stopReason ? !['end_turn', 'error', 'aborted'].includes(String(info.stopReason)) : false;
628
+ trace._lastSnapshot = snapshot;
629
+ if (!trace.active) this.#scheduleSave(trace);
630
+ }
631
+
632
+ logTool(turnId, { toolName, toolCallId = null, toolInput = null, toolOutput = null, durationMs = null, isError = false } = {}) {
324
633
  const id = randomUUID();
325
- const now = Date.now();
326
- this.#prepare('insertTool', `
327
- INSERT INTO trace_tools (id, turn_id, tool_name, tool_input, tool_output, tool_call_id, duration_ms, is_error, created_at)
328
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
329
- `).run(
330
- id, turnId, toolName,
331
- truncate(toolInput, MAX_TOOL_INPUT),
332
- toolOutput == null ? null : String(toolOutput),
634
+ const ctx = this.#turnIndex.get(turnId);
635
+ if (!ctx) return id;
636
+ const trace = this.#loadRequest(ctx.sessionId, ctx.requestKey);
637
+ if (!trace) return id;
638
+ if (!Array.isArray(trace.tools)) trace.tools = [];
639
+ trace.tools.push({
640
+ id,
641
+ turnRowId: turnId,
642
+ loopNumber: ctx.loopNumber || 0,
643
+ toolName: toolName || '?',
333
644
  toolCallId,
334
- durationMs, isError ? 1 : 0, now,
335
- );
645
+ toolInput: truncateText(toolInput == null ? null : String(toolInput), MAX_TOOL_INPUT),
646
+ toolOutput: truncateText(toolOutput == null ? null : String(toolOutput), MAX_TEXT_BYTES),
647
+ durationMs: Number(durationMs || 0),
648
+ isError: !!isError,
649
+ createdAt: Date.now(),
650
+ });
651
+ trace.updatedAt = Date.now();
652
+ if (!trace.active) this.#scheduleSave(trace);
336
653
  return id;
337
654
  }
338
655
 
339
- /**
340
- * Log a freeform event.
341
- * @param {{ traceId: string, eventType: string, eventData?: unknown }} info
342
- * @returns {string} — event id
343
- */
344
- logEvent({ traceId, eventType, eventData = null }) {
656
+ logEvent({ traceId, eventType, eventData = null } = {}) {
345
657
  const id = randomUUID();
346
- const now = Date.now();
347
- const boundedData = boundDreamEventData(eventType, eventData);
348
- const data = boundedData != null ? JSON.stringify(boundedData) : null;
349
- this.#prepare('insertEvent', `
350
- INSERT INTO trace_events (id, trace_id, event_type, event_data, created_at)
351
- VALUES (?, ?, ?, ?, ?)
352
- `).run(id, traceId, eventType, data, now);
658
+ const file = join(this.#rootDir, 'events.json');
659
+ const existingEvents = readJson(file);
660
+ const events = Array.isArray(existingEvents) ? existingEvents : [];
661
+ events.push({
662
+ id,
663
+ traceId: traceId || String(eventType || 'event'),
664
+ eventType: eventType || 'event',
665
+ eventData: safeJsonValue(eventData),
666
+ createdAt: Date.now(),
667
+ });
668
+ const trimmed = events.slice(-MAX_DREAM_EVENTS);
669
+ try { atomicWriteJson(file, trimmed); }
670
+ catch (err) { console.warn('[Yeaft] debug trace event write failed:', err?.message || err); }
353
671
  return id;
354
672
  }
355
673
 
356
- /**
357
- * Compatibility helper used by older engine/dream call sites.
358
- * @param {string} eventType
359
- * @param {unknown} eventData
360
- * @returns {string}
361
- */
362
674
  event(eventType, eventData = null) {
363
675
  const traceId = (eventData && typeof eventData === 'object' && (eventData.turnId || eventData.runId))
364
676
  ? String(eventData.turnId || eventData.runId)
@@ -366,470 +678,323 @@ export class DebugTrace {
366
678
  return this.logEvent({ traceId, eventType, eventData });
367
679
  }
368
680
 
369
- // ─── Read API ────────────────────────────────────────────────
370
-
371
- /**
372
- * Query all data for a specific message.
373
- * @param {string} messageId
374
- * @returns {{ turns: object[], tools: object[], events: object[] }}
375
- */
376
681
  queryByMessage(messageId) {
377
- const turns = this.#prepare('turnsByMessage', `
378
- SELECT * FROM trace_turns WHERE message_id = ? ORDER BY started_at
379
- `).all(messageId);
380
- return this.#expandTurns(turns);
682
+ this.#flushPendingSync();
683
+ const traces = this.#traceSummaries()
684
+ .filter(({ trace }) => trace.messageId === messageId)
685
+ .map(({ trace }) => trace);
686
+ return this.#expandLegacy(traces);
381
687
  }
382
688
 
383
- /**
384
- * Query all data for a trace.
385
- * @param {string} traceId
386
- * @returns {{ turns: object[], tools: object[], events: object[] }}
387
- */
388
689
  queryByTrace(traceId) {
389
- const turns = this.#prepare('turnsByTrace', `
390
- SELECT * FROM trace_turns WHERE trace_id = ? ORDER BY started_at
391
- `).all(traceId);
392
- const events = this.#prepare('eventsByTrace', `
393
- SELECT * FROM trace_events WHERE trace_id = ? ORDER BY created_at
394
- `).all(traceId);
395
- const turnIds = turns.map(t => t.id);
396
- const tools = turnIds.length > 0
397
- ? this.#db.prepare(
398
- `SELECT * FROM trace_tools WHERE turn_id IN (${turnIds.map(() => '?').join(',')}) ORDER BY created_at`
399
- ).all(...turnIds)
400
- : [];
401
- return { turns, tools, events };
690
+ this.#flushPendingSync();
691
+ const traces = this.#traceSummaries()
692
+ .filter(({ trace }) => trace.traceId === traceId || trace.requestId === traceId)
693
+ .map(({ trace }) => trace);
694
+ return this.#expandLegacy(traces);
402
695
  }
403
696
 
404
- /**
405
- * Query recent turns.
406
- * @param {number} [limit=20]
407
- * @returns {object[]}
408
- */
409
697
  queryRecent(limit = 20) {
410
- return this.#prepare('recentTurns', `
411
- SELECT * FROM trace_turns ORDER BY started_at DESC LIMIT ?
412
- `).all(limit);
698
+ this.#flushPendingSync();
699
+ const lim = Math.max(1, Math.min(MAX_HISTORY_LIMIT, Number(limit) || MAX_HISTORY_LIMIT));
700
+ return this.#traceSummaries()
701
+ .slice(-lim)
702
+ .reverse()
703
+ .flatMap(({ trace }) => traceToLegacyRows(trace));
413
704
  }
414
705
 
415
- /**
416
- * Fetch debug history for the YeaftDebugPanel.
417
- *
418
- * Default mode returns recent loop details for backward compatibility.
419
- * `indexOnly` returns all matching request summaries without loop payloads
420
- * so the panel can list every past request cheaply. `limit` is intentionally
421
- * ignored in index-only mode; the returned `limit` only reports the requested
422
- * retention window used by older/detail paths. `detailTurnId` returns the
423
- * full loop/tool payload for one request on demand.
424
- *
425
- * @param {{ limit?: number, dreamLimit?: number, sessionId?: string|null, threadId?: string|null, indexOnly?: boolean, detailTurnId?: string|null }} [opts]
426
- * @returns {{ loops: object[], turns: object[], dreamEvents: object[], hasMore?: boolean, indexOnly?: boolean, detailTurnId?: string|null }}
427
- */
428
- fetchRecentDebugHistory({ limit = 100, dreamLimit = 5, sessionId = null, threadId = null, indexOnly = false, detailTurnId = null } = {}) {
429
- const lim = Math.max(1, Math.min(500, Number(limit) || 100));
430
- const dreamLim = Number.isFinite(Number(dreamLimit))
431
- ? Math.max(0, Math.min(50, Number(dreamLimit)))
432
- : 5;
706
+ fetchRecentDebugHistory({ limit = MAX_HISTORY_LIMIT, dreamLimit = 5, sessionId = null, threadId = null, indexOnly = false, detailTurnId = null } = {}) {
707
+ this.#flushPendingSync();
708
+ const lim = Math.max(1, Math.min(MAX_HISTORY_LIMIT, Number(limit) || MAX_HISTORY_LIMIT));
433
709
  const requestedDetailTurnId = typeof detailTurnId === 'string' && detailTurnId ? detailTurnId : null;
434
- const parseJsonSafe = (s) => {
435
- if (s == null) return null;
436
- try { return JSON.parse(s); }
437
- catch { return null; }
438
- };
439
- const normalizeUsage = (usage, row) => {
440
- const inputTokens = Number.isFinite(Number(usage?.inputTokens)) ? Number(usage.inputTokens) : (row.input_tokens || 0);
441
- const outputTokens = Number.isFinite(Number(usage?.outputTokens)) ? Number(usage.outputTokens) : (row.output_tokens || 0);
442
- const cacheReadTokens = Number.isFinite(Number(usage?.cacheReadTokens)) ? Number(usage.cacheReadTokens) : (row.cache_read_tokens || 0);
443
- const cacheWriteTokens = Number.isFinite(Number(usage?.cacheWriteTokens)) ? Number(usage.cacheWriteTokens) : (row.cache_write_tokens || 0);
444
- const totalInputTokens = Number.isFinite(Number(usage?.totalInputTokens))
445
- ? Number(usage.totalInputTokens)
446
- : inputTokens + cacheReadTokens + cacheWriteTokens;
710
+ const traces = this.#traceSummaries(sessionId)
711
+ .filter(({ trace }) => !threadId || trace.threadId === threadId)
712
+ .map(({ trace }) => trace);
713
+ const dreamEvents = this.#readDreamEvents({ sessionId, dreamLimit });
714
+ if (requestedDetailTurnId) {
715
+ const trace = traces.find(t => t.requestId === requestedDetailTurnId || t.traceId === requestedDetailTurnId);
716
+ if (!trace) return { loops: [], turns: [], dreamEvents, hasMore: false, limit: 0, indexOnly: false, detailTurnId: requestedDetailTurnId };
717
+ const expanded = expandTrace(trace);
718
+ return { ...expanded, dreamEvents, hasMore: false, limit: expanded.loops.length, indexOnly: false, detailTurnId: requestedDetailTurnId };
719
+ }
720
+ const selected = traces.slice(-lim);
721
+ if (indexOnly) {
447
722
  return {
448
- inputTokens,
449
- outputTokens,
450
- cacheReadTokens,
451
- cacheWriteTokens,
452
- totalInputTokens,
453
- totalTokens: Number.isFinite(Number(usage?.totalTokens))
454
- ? Number(usage.totalTokens)
455
- : totalInputTokens + outputTokens,
723
+ loops: [],
724
+ turns: selected.map(trace => summarizeTrace(trace, false)),
725
+ dreamEvents,
726
+ hasMore: traces.length > selected.length,
727
+ limit: lim,
728
+ indexOnly: true,
456
729
  };
457
- };
458
- const scopedWhere = [];
459
- const scopedArgs = [];
460
- if (sessionId) { scopedWhere.push('group_id = ?'); scopedArgs.push(sessionId); }
461
- if (threadId) { scopedWhere.push('thread_id = ?'); scopedArgs.push(threadId); }
462
- const whereSql = scopedWhere.length ? `WHERE ${scopedWhere.join(' AND ')}` : '';
463
-
464
- const dreamEvents = [];
465
- if (dreamLim > 0) {
466
- const eventRows = this.#db.prepare(`
467
- SELECT * FROM trace_events
468
- WHERE event_type IN ('dream_progress', 'dream_loop', 'dream_turn_open', 'dream_turn_close', 'dream_run')
469
- ORDER BY created_at DESC, rowid DESC LIMIT ?
470
- `).all(Math.max(dreamLim * 5, dreamLim));
471
- for (const er of eventRows) {
472
- const data = parseJsonSafe(er.event_data) || {};
473
- const evtGroupId = typeof data.sessionId === 'string' && data.sessionId ? data.sessionId : null;
474
- const target = typeof data.target === 'string' ? data.target : '';
475
- if (sessionId) {
476
- const isBroadcast = !evtGroupId && !target;
477
- const isThisGroup = evtGroupId === sessionId || target === `sessions/${sessionId}`;
478
- if (!isBroadcast && !isThisGroup) continue;
479
- }
480
- dreamEvents.push({
481
- type: data.type || (er.event_type === 'dream_progress' ? 'dream_progress' : er.event_type),
482
- ...data,
483
- at: er.created_at,
484
- ts: data.ts || data.at || er.created_at,
485
- });
486
- if (dreamEvents.length >= dreamLim) break;
487
- }
488
- dreamEvents.reverse();
489
730
  }
731
+ const expanded = selected.reduce((acc, trace) => {
732
+ const item = expandTrace(trace);
733
+ acc.loops.push(...item.loops);
734
+ acc.turns.push(...item.turns);
735
+ return acc;
736
+ }, { loops: [], turns: [] });
737
+ return { ...expanded, dreamEvents, hasMore: traces.length > selected.length, limit: lim, indexOnly: false };
738
+ }
490
739
 
491
- const duplicateLoopTraceIdsForRows = (rows) => {
492
- const duplicateLoopTraceIds = new Set();
493
- const seenLoopKeys = new Set();
494
- for (const r of rows) {
495
- const traceId = r.trace_id || r.id;
496
- const key = `${traceId}#${r.turn_number || 0}`;
497
- if (seenLoopKeys.has(key)) duplicateLoopTraceIds.add(traceId);
498
- else seenLoopKeys.add(key);
499
- }
500
- return duplicateLoopTraceIds;
501
- };
502
- const detailRequestedByRowId = (rows) => {
503
- if (!requestedDetailTurnId || rows.length !== 1) return false;
504
- const row = rows[0];
505
- return row?.id === requestedDetailTurnId && row?.trace_id !== requestedDetailTurnId;
506
- };
507
- const summarizeRows = (rows, duplicateLoopTraceIds = duplicateLoopTraceIdsForRows(rows), forceRowId = false) => {
508
- const turnKeyForRow = (r) => {
509
- if (forceRowId) return r.id || r.trace_id;
510
- const baseTurnId = r.trace_id || r.id;
511
- return duplicateLoopTraceIds.has(baseTurnId) ? (r.id || baseTurnId) : baseTurnId;
512
- };
513
- const turnsById = new Map();
514
- for (const r of rows) {
515
- const hydratedTurnId = turnKeyForRow(r);
516
- const parsedUsage = parseJsonSafe(r.usage_json);
517
- const usage = normalizeUsage(parsedUsage, r);
518
- if (!turnsById.has(hydratedTurnId)) {
519
- turnsById.set(hydratedTurnId, {
520
- turnId: hydratedTurnId,
521
- userPrompt: r.user_prompt || '',
522
- sessionId: r.group_id || null,
523
- vpId: r.vp_id || null,
524
- threadId: r.thread_id || null,
525
- openedAt: r.started_at || 0,
526
- closedAt: r.ended_at || null,
527
- totalMs: 0,
528
- totalTokens: 0,
529
- summaryInputTokens: 0,
530
- summaryOutputTokens: 0,
531
- loopCount: 0,
532
- memoryLoaded: null,
533
- memoryAdjust: null,
534
- tools: [],
535
- detailsLoaded: false,
536
- });
537
- }
538
- const t = turnsById.get(hydratedTurnId);
539
- t.loopCount += 1;
540
- t.totalMs += r.latency_ms || 0;
541
- t.totalTokens += usage.totalTokens || 0;
542
- t.summaryInputTokens += usage.totalInputTokens || 0;
543
- t.summaryOutputTokens += usage.outputTokens || 0;
544
- if (r.started_at && (!t.openedAt || r.started_at < t.openedAt)) t.openedAt = r.started_at;
545
- if (r.ended_at && (!t.closedAt || r.ended_at > t.closedAt)) t.closedAt = r.ended_at;
546
- if (!t.userPrompt && r.user_prompt) t.userPrompt = r.user_prompt;
547
- }
548
- return Array.from(turnsById.values()).sort((a, b) => (a.openedAt || 0) - (b.openedAt || 0));
549
- };
550
- const expandRows = (rows) => {
551
- const duplicateLoopTraceIds = duplicateLoopTraceIdsForRows(rows);
552
- const forceRowId = detailRequestedByRowId(rows);
553
- const turnKeyForRow = (r) => {
554
- if (forceRowId) return r.id || r.trace_id;
555
- const baseTurnId = r.trace_id || r.id;
556
- return duplicateLoopTraceIds.has(baseTurnId) ? (r.id || baseTurnId) : baseTurnId;
557
- };
558
- const loopInstanceIdForRow = (r) => {
559
- const baseTurnId = r.trace_id || r.id;
560
- return forceRowId || duplicateLoopTraceIds.has(baseTurnId) ? (r.id || `${baseTurnId}#${r.turn_number || 0}`) : null;
561
- };
562
- const turnsById = new Map(summarizeRows(rows, duplicateLoopTraceIds, forceRowId).map((t) => [t.turnId, { ...t, detailsLoaded: true }]));
563
- const loops = rows.map((r) => {
564
- const parsedMessages = parseJsonSafe(r.messages_json) || [];
565
- const parsedUsage = parseJsonSafe(r.usage_json);
566
- const hydratedTurnId = turnKeyForRow(r);
567
- const loopInstanceId = loopInstanceIdForRow(r);
568
- return {
569
- turnId: hydratedTurnId,
570
- ...(loopInstanceId ? { loopInstanceId } : {}),
571
- loopNumber: r.turn_number || 0,
572
- model: r.model || null,
573
- systemPrompt: r.system_prompt || '',
574
- messages: parsedMessages,
575
- response: r.response_text || '',
576
- toolCalls: parseJsonSafe(r.tool_calls_json) || [],
577
- usage: normalizeUsage(parsedUsage, r),
578
- latencyMs: r.latency_ms || 0,
579
- ttfbMs: r.ttfb_ms || null,
580
- stopReason: r.stop_reason || null,
581
- rawRequest: r.raw_request || null,
582
- rawResponse: r.raw_response || null,
583
- sessionId: r.group_id || null,
584
- vpId: r.vp_id || null,
585
- threadId: r.thread_id || null,
586
- };
587
- });
588
- const turnIds = rows.map(r => r.id);
589
- const tools = turnIds.length > 0
590
- ? this.#db.prepare(
591
- `SELECT * FROM trace_tools WHERE turn_id IN (${turnIds.map(() => '?').join(',')}) ORDER BY created_at`
592
- ).all(...turnIds)
593
- : [];
594
- for (const tool of tools) {
595
- const owner = rows.find(r => r.id === tool.turn_id);
596
- if (!owner) continue;
597
- const t = turnsById.get(turnKeyForRow(owner));
598
- if (!t) continue;
599
- t.tools.push({
600
- loopNumber: owner.turn_number || 0,
601
- callId: tool.tool_call_id || tool.id,
602
- traceToolId: tool.id,
603
- name: tool.tool_name,
604
- toolOutput: tool.tool_output == null ? null : String(tool.tool_output),
605
- durationMs: tool.duration_ms || 0,
606
- isError: !!tool.is_error,
607
- });
740
+ queryTools({ name = null, since = null } = {}) {
741
+ this.#flushPendingSync();
742
+ const tools = [];
743
+ for (const { trace } of this.#traceSummaries()) {
744
+ for (const tool of Array.isArray(trace.tools) ? trace.tools : []) {
745
+ const row = traceToolToLegacy(trace, tool);
746
+ if (name && row.tool_name !== name) continue;
747
+ if (since && row.created_at < since) continue;
748
+ tools.push(row);
608
749
  }
609
- return { loops, turns: Array.from(turnsById.values()).sort((a, b) => (a.openedAt || 0) - (b.openedAt || 0)) };
610
- };
611
-
612
- if (requestedDetailTurnId) {
613
- const detailWhere = [`(trace_id = ? OR id = ?)`];
614
- const detailArgs = [requestedDetailTurnId, requestedDetailTurnId];
615
- if (sessionId) { detailWhere.push('group_id = ?'); detailArgs.push(sessionId); }
616
- if (threadId) { detailWhere.push('thread_id = ?'); detailArgs.push(threadId); }
617
- detailArgs.push(5000);
618
- const rows = this.#db.prepare(`
619
- SELECT * FROM trace_turns
620
- WHERE ${detailWhere.join(' AND ')}
621
- ORDER BY started_at ASC, turn_number ASC, rowid ASC
622
- LIMIT ?
623
- `).all(...detailArgs);
624
- const expanded = expandRows(rows);
625
- return { ...expanded, dreamEvents, hasMore: false, limit: rows.length, indexOnly: false, detailTurnId: requestedDetailTurnId };
626
750
  }
751
+ tools.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
752
+ return tools.slice(0, 100);
753
+ }
627
754
 
628
- if (indexOnly) {
629
- const rows = this.#db.prepare(`
630
- SELECT id, trace_id, message_id, mode, turn_number, model,
631
- input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
632
- stop_reason, latency_ms, started_at, ended_at, group_id, vp_id,
633
- thread_id, usage_json, user_prompt
634
- FROM trace_turns
635
- ${whereSql}
636
- ORDER BY started_at ASC, turn_number ASC, rowid ASC
637
- `).all(...scopedArgs);
638
- return { loops: [], turns: summarizeRows(rows), dreamEvents, hasMore: false, limit: lim, indexOnly: true };
639
- }
755
+ search(keyword) {
756
+ this.#flushPendingSync();
757
+ const needle = String(keyword || '').toLowerCase();
758
+ if (!needle) return [];
759
+ return this.#traceSummaries()
760
+ .filter(({ trace }) => JSON.stringify(trace).toLowerCase().includes(needle))
761
+ .slice(-50)
762
+ .reverse()
763
+ .flatMap(({ trace }) => traceToLegacyRows(trace));
764
+ }
640
765
 
641
- const args = [...scopedArgs, lim + 1];
642
- const fetchedRows = this.#db.prepare(`
643
- SELECT * FROM trace_turns
644
- ${whereSql}
645
- ORDER BY started_at DESC
646
- LIMIT ?
647
- `).all(...args);
648
- const hasMore = fetchedRows.length > lim;
649
- const rows = (hasMore ? fetchedRows.slice(0, lim) : fetchedRows).reverse();
650
- const expanded = expandRows(rows);
651
- return { ...expanded, dreamEvents, hasMore, limit: lim, indexOnly: false };
766
+ stats() {
767
+ this.#flushPendingSync();
768
+ const traces = this.#traceSummaries().map(({ trace }) => trace);
769
+ const turnCount = traces.reduce((n, trace) => n + (Array.isArray(trace.loops) ? trace.loops.length : 0), 0);
770
+ const toolCount = traces.reduce((n, trace) => n + (Array.isArray(trace.tools) ? trace.tools.length : 0), 0);
771
+ const events = readJson(join(this.#rootDir, 'events.json'));
772
+ const eventCount = Array.isArray(events) ? events.length : 0;
773
+ const { bytes } = countDirFiles(this.#rootDir);
774
+ return { turnCount, toolCount, eventCount, dbSizeBytes: bytes, fileSizeBytes: bytes, requestCount: traces.length };
652
775
  }
653
776
 
654
- /**
655
- * Query tool calls with optional filters.
656
- * @param {{ name?: string, since?: number }} [filters={}]
657
- * @returns {object[]}
658
- */
659
- queryTools({ name = null, since = null } = {}) {
660
- if (name && since) {
661
- return this.#prepare('toolsByNameSince', `
662
- SELECT * FROM trace_tools WHERE tool_name = ? AND created_at >= ? ORDER BY created_at DESC
663
- `).all(name, since);
777
+ cleanup(retention = REQUEST_RETENTION) {
778
+ this.#flushPendingSync();
779
+ const keep = Math.max(1, Math.min(REQUEST_RETENTION, Number(retention) || REQUEST_RETENTION));
780
+ const before = readTraceSummaries(this.#rootDir).length;
781
+ this.#pruneAll(keep);
782
+ const after = readTraceSummaries(this.#rootDir).length;
783
+ return { deletedTurns: Math.max(0, before - after), deletedTools: 0, deletedEvents: 0, deletedRequests: Math.max(0, before - after) };
784
+ }
785
+
786
+ compact() {
787
+ this.#flushPendingSync();
788
+ const before = countDirFiles(this.#rootDir).bytes;
789
+ this.cleanup(REQUEST_RETENTION);
790
+ const after = countDirFiles(this.#rootDir).bytes;
791
+ return { before, after };
792
+ }
793
+
794
+ purge() {
795
+ this.#flushPendingSync();
796
+ try { rmSync(this.#rootDir, { recursive: true, force: true }); }
797
+ catch { /* ignore */ }
798
+ ensureDir(this.#rootDir);
799
+ this.#turnIndex.clear();
800
+ this.#requestCache.clear();
801
+ }
802
+
803
+ close() { this.#flushPendingSync(); }
804
+
805
+ #getOrCreateRequest({ traceId, turnNumber, messageId, mode, sessionId, vpId, threadId, userPrompt, now, turnRowId }) {
806
+ const normalizedSessionId = sessionId || null;
807
+ let all = null;
808
+ const isUsableExisting = (t) => (
809
+ t?.sessionId === normalizedSessionId
810
+ && t?.traceId === traceId
811
+ && !(turnNumber === 1 && (t.loops || []).some(l => l.loopNumber === 1))
812
+ );
813
+ const newestTrace = (items) => items
814
+ .filter(isUsableExisting)
815
+ .sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.requestKey || '').localeCompare(String(b.requestKey || '')))
816
+ .at(-1) || null;
817
+ let existing = newestTrace(Array.from(this.#requestCache.values()));
818
+ if (!existing) {
819
+ all = this.#traceSummaries(normalizedSessionId).map(({ trace }) => trace);
820
+ existing = newestTrace(all);
664
821
  }
665
- if (name) {
666
- return this.#prepare('toolsByName', `
667
- SELECT * FROM trace_tools WHERE tool_name = ? ORDER BY created_at DESC
668
- `).all(name);
822
+ if (existing) {
823
+ existing.updatedAt = now;
824
+ this.#requestCache.set(existing.requestKey, existing);
825
+ return existing;
669
826
  }
670
- if (since) {
671
- return this.#prepare('toolsSince', `
672
- SELECT * FROM trace_tools WHERE created_at >= ? ORDER BY created_at DESC
673
- `).all(since);
827
+ const seq = (this.#sequence = (this.#sequence + 1) % 1_000_000);
828
+ const requestKey = `${String(now).padStart(13, '0')}-${String(seq).padStart(6, '0')}-${safeDirComponent(traceId || turnRowId, 'request')}-${turnRowId.slice(0, 8)}`;
829
+ const requestId = turnNumber === 1 && all.some(t => t.traceId === traceId) ? turnRowId : traceId;
830
+ const trace = {
831
+ version: TRACE_VERSION,
832
+ requestKey,
833
+ requestId,
834
+ traceId,
835
+ messageId,
836
+ mode,
837
+ sessionId: normalizedSessionId,
838
+ vpId: vpId || null,
839
+ threadId: threadId || null,
840
+ userPrompt: truncateText(userPrompt || '', MAX_TEXT_BYTES),
841
+ openedAt: now,
842
+ closedAt: null,
843
+ updatedAt: now,
844
+ active: true,
845
+ baseRequest: null,
846
+ loops: [],
847
+ tools: [],
848
+ };
849
+ this.#requestCache.set(requestKey, trace);
850
+ return trace;
851
+ }
852
+
853
+ #loadRequest(sessionId, requestKey) {
854
+ const cached = this.#requestCache.get(requestKey);
855
+ if (cached) return cached;
856
+ const file = tracePathFor(this.#rootDir, sessionId, requestKey);
857
+ const trace = readJson(file);
858
+ if (trace) this.#requestCache.set(requestKey, trace);
859
+ return trace;
860
+ }
861
+
862
+ #traceSummaries(sessionId = null) {
863
+ const byKey = new Map(readTraceSummaries(this.#rootDir, sessionId).map(item => [item.trace.requestKey, item]));
864
+ for (const trace of this.#requestCache.values()) {
865
+ if (sessionId && trace.sessionId !== sessionId) continue;
866
+ if (!trace?.requestId || !trace?.requestKey) continue;
867
+ byKey.set(trace.requestKey, {
868
+ trace,
869
+ file: this.#traceFile(trace),
870
+ openedAt: Number(trace.openedAt || 0),
871
+ });
674
872
  }
675
- return this.#prepare('allTools', `
676
- SELECT * FROM trace_tools ORDER BY created_at DESC LIMIT 100
677
- `).all();
873
+ return Array.from(byKey.values())
874
+ .sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.trace?.requestKey || a.file).localeCompare(String(b.trace?.requestKey || b.file)));
678
875
  }
679
876
 
680
- /**
681
- * Full-text search across response_text and tool_output.
682
- * @param {string} keyword
683
- * @returns {object[]}
684
- */
685
- search(keyword) {
686
- const like = `%${keyword}%`;
687
- return this.#prepare('search', `
688
- SELECT DISTINCT t.* FROM trace_turns t
689
- LEFT JOIN trace_tools tt ON tt.turn_id = t.id
690
- WHERE t.response_text LIKE ? OR tt.tool_output LIKE ?
691
- ORDER BY t.started_at DESC LIMIT 50
692
- `).all(like, like);
877
+ #traceWriteKey(trace) {
878
+ return `${trace.sessionId || ''}::${trace.requestKey}`;
693
879
  }
694
880
 
695
- /**
696
- * Get trace statistics.
697
- * @returns {{ turnCount: number, toolCount: number, eventCount: number, dbSizeBytes: number }}
698
- */
699
- stats() {
700
- const turnCount = Number(this.#db.prepare('SELECT COUNT(*) as c FROM trace_turns').get().c);
701
- const toolCount = Number(this.#db.prepare('SELECT COUNT(*) as c FROM trace_tools').get().c);
702
- const eventCount = Number(this.#db.prepare('SELECT COUNT(*) as c FROM trace_events').get().c);
703
- let dbSizeBytes = 0;
704
- try {
705
- dbSizeBytes = statSync(this.#dbPath).size;
706
- } catch { /* ignore */ }
707
- return { turnCount, toolCount, eventCount, dbSizeBytes };
881
+ #traceFile(trace) {
882
+ return tracePathFor(this.#rootDir, trace.sessionId || null, trace.requestKey);
708
883
  }
709
884
 
710
- // ─── Maintenance ─────────────────────────────────────────────
885
+ #serializableTrace(trace) {
886
+ const toWrite = { ...trace };
887
+ delete toWrite._lastSnapshot;
888
+ return toWrite;
889
+ }
711
890
 
712
- /**
713
- * Delete trajectory data older than retentionDays, then mark the freed pages
714
- * reclaimable.
715
- *
716
- * The always-on trace stamps every turn with the cumulative request/response
717
- * snapshot, so each long-session row is MB-scale and the file grows fast
718
- * (a real deployment hit 5GB in 15 days). A plain DELETE marks pages free but
719
- * leaves the file at its peak size; `PRAGMA incremental_vacuum` moves those
720
- * pages onto the freelist for return to the OS — but only when the DB was
721
- * created with `auto_vacuum=INCREMENTAL` (see constructor). On a legacy
722
- * `auto_vacuum=NONE` store the vacuum is a harmless no-op, so this is safe to
723
- * call unconditionally. Note: in WAL mode the on-disk file truncates at the
724
- * next checkpoint (the running agent's automatic checkpoints handle this), so
725
- * the page_count drops here but the file size catches up shortly after.
726
- *
727
- * @param {number} [retentionDays=10]
728
- * @returns {{ deletedTurns: number, deletedTools: number, deletedEvents: number }}
729
- */
730
- cleanup(retentionDays = 10) {
731
- const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
732
- const deletedTools = Number(this.#db.prepare(`
733
- DELETE FROM trace_tools WHERE turn_id IN (
734
- SELECT id FROM trace_turns WHERE started_at < ?
735
- )
736
- `).run(cutoff).changes);
737
- const deletedTurns = Number(this.#db.prepare(`
738
- DELETE FROM trace_turns WHERE started_at < ?
739
- `).run(cutoff).changes);
740
- const deletedEvents = Number(this.#db.prepare(`
741
- DELETE FROM trace_events WHERE created_at < ?
742
- `).run(cutoff).changes);
743
- // Reclaim freed pages (no-op on legacy auto_vacuum=NONE DBs). Wrapped so a
744
- // vacuum failure can never mask a successful delete, but surfaced as a warn
745
- // because this is the one operation the whole disk-growth fix relies on — a
746
- // silent persistent failure would look exactly like "the fix works".
747
- if (deletedTurns || deletedTools || deletedEvents) {
748
- try { this.#db.exec('PRAGMA incremental_vacuum'); }
749
- catch (err) { console.warn('[Yeaft] trace incremental_vacuum failed:', err?.message || err); }
891
+ #scheduleSave(trace) {
892
+ if (!trace?.requestKey) return;
893
+ const key = this.#traceWriteKey(trace);
894
+ this.#pendingWrites.set(key, trace);
895
+ this.#requestCache.set(trace.requestKey, trace);
896
+ if (this.#flushTimers.has(key)) return;
897
+ const timer = setTimeout(() => this.#flushOneAsync(key), TRACE_FLUSH_DELAY_MS);
898
+ this.#flushTimers.set(key, timer);
899
+ }
900
+
901
+ async #flushOneAsync(key) {
902
+ const timer = this.#flushTimers.get(key);
903
+ if (timer) clearTimeout(timer);
904
+ this.#flushTimers.delete(key);
905
+ const trace = this.#pendingWrites.get(key);
906
+ if (!trace) return;
907
+ this.#pendingWrites.delete(key);
908
+ try {
909
+ await atomicWriteJsonAsync(this.#traceFile(trace), this.#serializableTrace(trace));
910
+ this.#pruneSession(trace.sessionId || null, REQUEST_RETENTION);
911
+ } catch (err) {
912
+ console.warn('[Yeaft] debug trace write failed:', err?.message || err);
750
913
  }
751
- return { deletedTurns, deletedTools, deletedEvents };
752
914
  }
753
915
 
754
- /**
755
- * One-shot full compaction (VACUUM). Rebuilds the entire database file,
756
- * reclaiming all free space AND converting a legacy `auto_vacuum=NONE` store
757
- * to INCREMENTAL going forward. This is a HEAVY operation: it locks the DB
758
- * and needs temporary scratch space up to the current file size, so it is
759
- * NOT called automatically on session load — invoke it deliberately (e.g.
760
- * from the `yeaft --trace` CLI) when an oversized legacy debug.db needs to be
761
- * shrunk in place.
762
- * @returns {{ before: number, after: number }} file size in bytes
763
- */
764
- compact() {
765
- let before = 0;
766
- try { before = statSync(this.#dbPath).size; } catch { /* ignore */ }
767
- this.#db.exec('PRAGMA auto_vacuum = INCREMENTAL');
768
- this.#db.exec('VACUUM');
769
- // In WAL mode VACUUM writes the rebuilt (smaller) DB into the -wal file;
770
- // the main .db file does not shrink until a checkpoint folds the WAL back
771
- // in. TRUNCATE checkpoints and resets the WAL so the on-disk size we report
772
- // (and the user sees) reflects the reclaimed space immediately.
773
- try { this.#db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* best-effort */ }
774
- let after = 0;
775
- try { after = statSync(this.#dbPath).size; } catch { /* ignore */ }
776
- return { before, after };
916
+ #flushPendingSync() {
917
+ const entries = Array.from(this.#pendingWrites.entries());
918
+ if (entries.length === 0) return;
919
+ for (const [key, trace] of entries) {
920
+ const timer = this.#flushTimers.get(key);
921
+ if (timer) clearTimeout(timer);
922
+ this.#flushTimers.delete(key);
923
+ this.#pendingWrites.delete(key);
924
+ try {
925
+ atomicWriteJson(this.#traceFile(trace), this.#serializableTrace(trace));
926
+ } catch (err) {
927
+ console.warn('[Yeaft] debug trace write failed:', err?.message || err);
928
+ }
929
+ }
930
+ this.#pruneAll(REQUEST_RETENTION);
777
931
  }
778
932
 
779
- /** Delete all trace data. */
780
- purge() {
781
- this.#db.exec('DELETE FROM trace_tools');
782
- this.#db.exec('DELETE FROM trace_turns');
783
- this.#db.exec('DELETE FROM trace_events');
933
+ #reconstructLastSnapshot(trace) {
934
+ let snapshot = null;
935
+ for (const loop of Array.isArray(trace?.loops) ? trace.loops : []) {
936
+ snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
937
+ }
938
+ return snapshot;
784
939
  }
785
940
 
786
- /** Close the database connection. */
787
- close() {
788
- this.#db.close();
941
+ #readDreamEvents({ sessionId = null, dreamLimit = 5 } = {}) {
942
+ const limit = Number.isFinite(Number(dreamLimit)) ? Math.max(0, Math.min(50, Number(dreamLimit))) : 5;
943
+ if (limit <= 0) return [];
944
+ const storedEvents = readJson(join(this.#rootDir, 'events.json'));
945
+ const events = Array.isArray(storedEvents) ? storedEvents : [];
946
+ const out = [];
947
+ for (const event of events.slice().reverse()) {
948
+ const data = isPlainObject(event.eventData) ? event.eventData : {};
949
+ if (sessionId) {
950
+ const evtSessionId = typeof data.sessionId === 'string' && data.sessionId ? data.sessionId : null;
951
+ const target = typeof data.target === 'string' ? data.target : '';
952
+ const isBroadcast = !evtSessionId && !target;
953
+ const isThisSession = evtSessionId === sessionId || target === `sessions/${sessionId}` || target === `group/${sessionId}`;
954
+ if (!isBroadcast && !isThisSession) continue;
955
+ }
956
+ out.push({
957
+ type: data.type || event.eventType || 'event',
958
+ ...data,
959
+ at: event.createdAt,
960
+ ts: data.ts || data.at || event.createdAt,
961
+ });
962
+ if (out.length >= limit) break;
963
+ }
964
+ return out.reverse();
789
965
  }
790
966
 
791
- // ─── Internal ────────────────────────────────────────────────
967
+ #pruneAll(keep) {
968
+ const sessions = new Set([null]);
969
+ for (const { trace } of this.#traceSummaries()) sessions.add(trace.sessionId || null);
970
+ for (const sid of sessions) this.#pruneSession(sid, keep);
971
+ }
792
972
 
793
- /**
794
- * Get or create a prepared statement.
795
- * @param {string} key
796
- * @param {string} sql
797
- * @returns {import('node:sqlite').StatementSync}
798
- */
799
- #prepare(key, sql) {
800
- if (!this.#stmts[key]) {
801
- this.#stmts[key] = this.#db.prepare(sql);
973
+ #pruneSession(sessionId, keep = REQUEST_RETENTION) {
974
+ const traces = this.#traceSummaries(sessionId);
975
+ const activeCutoff = Date.now() - 6 * 60 * 60 * 1000;
976
+ const protectedItems = traces.filter(item => item.trace?.active && Number(item.trace?.updatedAt || 0) >= activeCutoff);
977
+ const pruneCandidates = traces.filter(item => !protectedItems.includes(item));
978
+ const stale = pruneCandidates.slice(0, Math.max(0, traces.length - protectedItems.length - keep));
979
+ for (const item of stale) {
980
+ try { rmSync(dirname(item.file), { recursive: true, force: true }); }
981
+ catch { /* ignore */ }
982
+ this.#requestCache.delete(item.trace.requestKey);
802
983
  }
803
- return this.#stmts[key];
804
984
  }
805
985
 
806
- /**
807
- * Expand turns with their tools.
808
- * @param {object[]} turns
809
- * @returns {{ turns: object[], tools: object[], events: object[] }}
810
- */
811
- #expandTurns(turns) {
812
- const turnIds = turns.map(t => t.id);
813
- const tools = turnIds.length > 0
814
- ? this.#db.prepare(
815
- `SELECT * FROM trace_tools WHERE turn_id IN (${turnIds.map(() => '?').join(',')}) ORDER BY created_at`
816
- ).all(...turnIds)
817
- : [];
818
- // Events need trace_ids from turns
819
- const traceIds = [...new Set(turns.map(t => t.trace_id))];
820
- const events = traceIds.length > 0
821
- ? this.#db.prepare(
822
- `SELECT * FROM trace_events WHERE trace_id IN (${traceIds.map(() => '?').join(',')}) ORDER BY created_at`
823
- ).all(...traceIds)
824
- : [];
986
+ #expandLegacy(traces) {
987
+ const turns = [];
988
+ const tools = [];
989
+ const events = [];
990
+ for (const trace of traces) {
991
+ turns.push(...traceToLegacyRows(trace));
992
+ for (const tool of Array.isArray(trace.tools) ? trace.tools : []) tools.push(traceToolToLegacy(trace, tool));
993
+ }
825
994
  return { turns, tools, events };
826
995
  }
827
996
  }
828
997
 
829
- /**
830
- * NullTrace — No-op implementation with the same interface.
831
- * Used when debug is disabled. Zero overhead.
832
- */
833
998
  export class NullTrace {
834
999
  startTurn() { return 'null'; }
835
1000
  endTurn() {}
@@ -841,22 +1006,16 @@ export class NullTrace {
841
1006
  queryRecent() { return []; }
842
1007
  queryTools() { return []; }
843
1008
  search() { return []; }
844
- stats() { return { turnCount: 0, toolCount: 0, eventCount: 0, dbSizeBytes: 0 }; }
845
- cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0 }; }
1009
+ stats() { return { turnCount: 0, toolCount: 0, eventCount: 0, dbSizeBytes: 0, fileSizeBytes: 0, requestCount: 0 }; }
1010
+ cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0, deletedRequests: 0 }; }
846
1011
  compact() { return { before: 0, after: 0 }; }
847
1012
  purge() {}
848
1013
  close() {}
849
1014
  fetchRecentDebugHistory() { return { loops: [], turns: [], dreamEvents: [] }; }
850
1015
  }
851
1016
 
852
- /**
853
- * Create a DebugTrace or NullTrace based on config.
854
- * @param {{ enabled: boolean, dbPath?: string }} opts
855
- * @returns {DebugTrace | NullTrace}
856
- */
857
- export function createTrace({ enabled, dbPath }) {
858
- if (!enabled || !dbPath) {
859
- return new NullTrace();
860
- }
861
- return new DebugTrace(dbPath);
1017
+ export function createTrace({ enabled, dbPath, dirPath }) {
1018
+ const path = dirPath || dbPath;
1019
+ if (!enabled || !path) return new NullTrace();
1020
+ return new DebugTrace(path);
862
1021
  }