@yeaft/webchat-agent 1.0.18 → 1.0.20

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,669 @@
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 { basename, dirname, extname, join } from 'path';
11
25
  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
26
 
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
- `;
27
+ const TRACE_VERSION = 2;
28
+ const REQUEST_RETENTION = 10;
29
+ const MAX_HISTORY_LIMIT = 10;
30
+ const MAX_DREAM_EVENTS = 100;
31
+ const MAX_TEXT_BYTES = 1024 * 1024;
32
+ const MAX_TOOL_INPUT = 10 * 1024;
33
+ const MAX_INLINE_VALUE_BYTES = 1024 * 1024;
34
+ const MAX_RAW_REQUEST_BYTES = 2 * 1024 * 1024;
35
+ const TRACE_FLUSH_INTERVAL_MS = 5_000;
36
+ const TRACE_FLUSH_DIRTY_LOOPS = 10;
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
+ function readJson(filePath) {
96
70
  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
- }
71
+ return JSON.parse(readFileSync(filePath, 'utf8'));
72
+ } catch {
73
+ return null;
102
74
  }
103
75
  }
104
76
 
105
- /** Max tool input size stored inline. Tool output is persisted raw. */
106
- const MAX_TOOL_INPUT = 10240;
77
+ function truncateText(value, maxBytes = MAX_TEXT_BYTES) {
78
+ if (value == null) return value ?? null;
79
+ const str = String(value);
80
+ if (Buffer.byteLength(str, 'utf8') <= maxBytes) return str;
81
+ let out = str.slice(0, maxBytes);
82
+ while (Buffer.byteLength(out, 'utf8') > maxBytes && out.length > 0) out = out.slice(0, -1);
83
+ return `${out}\n... [truncated to ${maxBytes} bytes]`;
84
+ }
107
85
 
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;
86
+ function cloneJsonValue(value) {
87
+ if (value == null) return value;
88
+ try { return JSON.parse(JSON.stringify(value)); }
89
+ catch { return null; }
90
+ }
116
91
 
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]';
92
+ function safeJsonValue(value, maxBytes = MAX_INLINE_VALUE_BYTES) {
93
+ if (value == null) return value;
94
+ try {
95
+ const json = JSON.stringify(value);
96
+ if (Buffer.byteLength(json, 'utf8') <= maxBytes) return JSON.parse(json);
97
+ return {
98
+ __truncated: true,
99
+ originalBytes: Buffer.byteLength(json, 'utf8'),
100
+ maxBytes,
101
+ };
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ function normalizeUsage(usage = {}, fallback = {}) {
108
+ const inputTokens = Number.isFinite(Number(usage?.inputTokens)) ? Number(usage.inputTokens) : Number(fallback.inputTokens || 0);
109
+ const outputTokens = Number.isFinite(Number(usage?.outputTokens)) ? Number(usage.outputTokens) : Number(fallback.outputTokens || 0);
110
+ const cacheReadTokens = Number.isFinite(Number(usage?.cacheReadTokens)) ? Number(usage.cacheReadTokens) : Number(fallback.cacheReadTokens || 0);
111
+ const cacheWriteTokens = Number.isFinite(Number(usage?.cacheWriteTokens)) ? Number(usage.cacheWriteTokens) : Number(fallback.cacheWriteTokens || 0);
112
+ const totalInputTokens = Number.isFinite(Number(usage?.totalInputTokens))
113
+ ? Number(usage.totalInputTokens)
114
+ : inputTokens + cacheReadTokens + cacheWriteTokens;
115
+ return {
116
+ inputTokens,
117
+ outputTokens,
118
+ cacheReadTokens,
119
+ cacheWriteTokens,
120
+ totalInputTokens,
121
+ totalTokens: Number.isFinite(Number(usage?.totalTokens))
122
+ ? Number(usage.totalTokens)
123
+ : totalInputTokens + outputTokens,
124
+ };
125
+ }
126
+
127
+ function stableEqual(a, b) {
128
+ try { return JSON.stringify(a) === JSON.stringify(b); }
129
+ catch { return false; }
130
+ }
131
+
132
+ function jsonByteLength(value) {
133
+ try { return Buffer.byteLength(JSON.stringify(value), 'utf8'); }
134
+ catch { return Infinity; }
127
135
  }
128
136
 
129
- function truncatedJsonSentinel(originalBytes) {
137
+ function rawRequestSentinel(reason, value = null, maxBytes = MAX_RAW_REQUEST_BYTES) {
138
+ const preview = typeof value === 'string'
139
+ ? truncateText(value, Math.min(64 * 1024, maxBytes))
140
+ : null;
141
+ const originalBytes = value == null ? null : jsonByteLength(value);
130
142
  return {
131
143
  __truncated: true,
132
- originalBytes,
133
- maxBytes: MAX_LOOP_PAYLOAD,
144
+ reason,
145
+ ...(originalBytes != null ? { originalBytes } : {}),
146
+ maxBytes,
147
+ ...(preview ? { preview } : {}),
134
148
  };
135
149
  }
136
150
 
137
- function truncateJsonValue(value) {
151
+ function boundRawValue(value, reason = 'raw_request_budget') {
138
152
  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;
153
+ const cloned = typeof value === 'string' ? value : cloneJsonValue(value);
154
+ if (cloned == null) return null;
155
+ if (jsonByteLength(cloned) <= MAX_RAW_REQUEST_BYTES) return cloned;
156
+ return rawRequestSentinel(reason, value);
157
+ }
158
+
159
+ function buildRawRequestBase(value) {
160
+ if (value == null) return null;
161
+ if (typeof value === 'string') return truncateText(value, MAX_RAW_REQUEST_BYTES);
162
+ if (!isPlainObject(value)) return boundRawValue(value);
163
+ const base = {};
164
+ for (const [key, item] of Object.entries(value)) {
165
+ if (key === 'body' && isPlainObject(item)) {
166
+ const body = {};
167
+ for (const [bodyKey, bodyValue] of Object.entries(item)) {
168
+ body[bodyKey] = boundRawValue(bodyValue, `raw_request_body_${bodyKey}_budget`);
169
+ }
170
+ base.body = body;
171
+ } else {
172
+ base[key] = boundRawValue(item, `raw_request_${key}_budget`);
173
+ }
174
+ }
175
+ return base;
176
+ }
177
+
178
+ function buildRawMessagesDelta(previousMessages, nextMessages) {
179
+ if (!Array.isArray(previousMessages) || !Array.isArray(nextMessages)) return null;
180
+ const prefix = messagesPrefixLength(previousMessages, nextMessages);
181
+ if (prefix === previousMessages.length && prefix <= nextMessages.length) {
182
+ return { messagesFrom: prefix, messagesAppend: boundRawValue(nextMessages.slice(prefix), 'raw_request_messages_append_budget') };
146
183
  }
184
+ return { messages: boundRawValue(nextMessages, 'raw_request_messages_budget') };
185
+ }
186
+
187
+ function rawComparableRequest(value) {
188
+ if (value == null) return null;
189
+ if (!isPlainObject(value)) return value;
190
+ const out = { ...value };
191
+ if (isPlainObject(value.body)) out.body = { ...value.body };
192
+ return out;
147
193
  }
148
194
 
149
- function boundDreamEventData(eventType, eventData) {
150
- if (eventType !== 'dream_loop' || !eventData || typeof eventData !== 'object') return eventData;
195
+ function buildRawRequestDelta(previous, next) {
196
+ if (next == null) return previous == null ? null : { replacement: null };
197
+ if (previous == null) return { base: buildRawRequestBase(next) };
198
+ const comparablePrevious = rawComparableRequest(previous);
199
+ const comparableNext = rawComparableRequest(next);
200
+ if (typeof comparablePrevious === 'string' || typeof comparableNext === 'string') {
201
+ return comparablePrevious === comparableNext ? null : { replacement: rawRequestSentinel('raw_request_string_replaced', comparableNext) };
202
+ }
203
+ if (!isPlainObject(comparablePrevious) || !isPlainObject(comparableNext)) {
204
+ return stableEqual(comparablePrevious, comparableNext) ? null : { replacement: rawRequestSentinel('raw_request_replaced') };
205
+ }
206
+
207
+ const delta = { set: {}, body: {} };
208
+ for (const key of Object.keys(comparableNext)) {
209
+ if (key === 'body') continue;
210
+ if (!stableEqual(comparablePrevious[key], comparableNext[key])) delta.set[key] = boundRawValue(comparableNext[key], `raw_request_${key}_budget`);
211
+ }
212
+ for (const key of Object.keys(comparablePrevious)) {
213
+ if (key !== 'body' && !Object.prototype.hasOwnProperty.call(comparableNext, key)) delta.set[key] = null;
214
+ }
215
+
216
+ const prevBody = isPlainObject(comparablePrevious.body) ? comparablePrevious.body : null;
217
+ const nextBody = isPlainObject(comparableNext.body) ? comparableNext.body : null;
218
+ if (prevBody && nextBody) {
219
+ for (const key of Object.keys(nextBody)) {
220
+ if (key === 'messages') continue;
221
+ if (!stableEqual(prevBody[key], nextBody[key])) delta.body[key] = boundRawValue(nextBody[key], `raw_request_body_${key}_budget`);
222
+ }
223
+ for (const key of Object.keys(prevBody)) {
224
+ if (key !== 'messages' && !Object.prototype.hasOwnProperty.call(nextBody, key)) delta.body[key] = null;
225
+ }
226
+ const msgDelta = buildRawMessagesDelta(prevBody.messages, nextBody.messages);
227
+ if (msgDelta) Object.assign(delta.body, msgDelta);
228
+ } else if (!stableEqual(previous.body, next.body)) {
229
+ delta.set.body = rawRequestSentinel('raw_request_body_replaced');
230
+ }
231
+
232
+ if (Object.keys(delta.set).length === 0) delete delta.set;
233
+ if (Object.keys(delta.body).length === 0) delete delta.body;
234
+ if (!delta.set && !delta.body) return null;
235
+ if (jsonByteLength(delta) > MAX_RAW_REQUEST_BYTES) {
236
+ return { replacement: rawRequestSentinel('raw_request_delta_budget') };
237
+ }
238
+ return delta;
239
+ }
240
+
241
+ function applyRawRequestDelta(previous, delta) {
242
+ if (!delta) return previous ?? null;
243
+ if (Object.prototype.hasOwnProperty.call(delta, 'base')) return cloneJsonValue(delta.base) ?? delta.base ?? null;
244
+ if (Object.prototype.hasOwnProperty.call(delta, 'replacement')) return cloneJsonValue(delta.replacement) ?? delta.replacement ?? null;
245
+ const next = isPlainObject(previous) ? cloneJsonValue(previous) || {} : {};
246
+ if (isPlainObject(delta.set)) {
247
+ for (const [key, value] of Object.entries(delta.set)) next[key] = cloneJsonValue(value) ?? value;
248
+ }
249
+ if (isPlainObject(delta.body)) {
250
+ const body = isPlainObject(next.body) ? { ...next.body } : {};
251
+ for (const [key, value] of Object.entries(delta.body)) {
252
+ if (key === 'messagesFrom' || key === 'messagesAppend' || key === 'messages') continue;
253
+ body[key] = cloneJsonValue(value) ?? value;
254
+ }
255
+ if (Array.isArray(delta.body.messages)) {
256
+ body.messages = cloneJsonValue(delta.body.messages) || [];
257
+ } else if (Array.isArray(delta.body.messagesAppend)) {
258
+ const from = Number.isFinite(Number(delta.body.messagesFrom)) ? Number(delta.body.messagesFrom) : (Array.isArray(body.messages) ? body.messages.length : 0);
259
+ body.messages = (Array.isArray(body.messages) ? body.messages.slice(0, from) : []).concat(cloneJsonValue(delta.body.messagesAppend) || []);
260
+ }
261
+ next.body = body;
262
+ }
263
+ return next;
264
+ }
265
+
266
+ function messagesPrefixLength(prevMessages, nextMessages) {
267
+ if (!Array.isArray(prevMessages) || !Array.isArray(nextMessages)) return 0;
268
+ const max = Math.min(prevMessages.length, nextMessages.length);
269
+ let i = 0;
270
+ for (; i < max; i++) {
271
+ if (!stableEqual(prevMessages[i], nextMessages[i])) break;
272
+ }
273
+ return i;
274
+ }
275
+
276
+ function buildRequestSnapshot(info = {}) {
151
277
  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),
278
+ systemPrompt: truncateText(info.systemPrompt || '', MAX_TEXT_BYTES),
279
+ messages: Array.isArray(info.messages) ? cloneJsonValue(info.messages) : [],
280
+ rawRequest: info.rawRequest ?? null,
158
281
  };
159
282
  }
160
283
 
161
- /**
162
- * DebugTrace — SQLite-backed debug trace.
163
- */
164
- export class DebugTrace {
165
- /** @type {import('node:sqlite').DatabaseSync} */
166
- #db;
284
+ function buildRequestDelta(previous, next) {
285
+ if (!previous) {
286
+ return {
287
+ base: true,
288
+ systemPrompt: next.systemPrompt || '',
289
+ messages: Array.isArray(next.messages) ? next.messages : [],
290
+ };
291
+ }
292
+ const delta = {};
293
+ if ((next.systemPrompt || '') !== (previous.systemPrompt || '')) {
294
+ delta.systemPrompt = next.systemPrompt || '';
295
+ }
296
+ const prevMessages = Array.isArray(previous.messages) ? previous.messages : [];
297
+ const nextMessages = Array.isArray(next.messages) ? next.messages : [];
298
+ const prefix = messagesPrefixLength(prevMessages, nextMessages);
299
+ if (prefix === prevMessages.length && prefix <= nextMessages.length) {
300
+ const appended = nextMessages.slice(prefix);
301
+ delta.messagesFrom = prefix;
302
+ delta.messagesAppend = appended;
303
+ } else {
304
+ delta.messages = nextMessages;
305
+ }
306
+ const rawRequestDelta = buildRawRequestDelta(previous.rawRequest, next.rawRequest);
307
+ if (rawRequestDelta) delta.rawRequestDelta = rawRequestDelta;
308
+ return delta;
309
+ }
167
310
 
168
- /** @type {string} */
169
- #dbPath;
311
+ function applyRequestDelta(previous, delta = {}) {
312
+ const base = previous || { systemPrompt: '', messages: [], rawRequest: null };
313
+ const next = {
314
+ systemPrompt: base.systemPrompt || '',
315
+ messages: Array.isArray(base.messages) ? [...base.messages] : [],
316
+ rawRequest: base.rawRequest ?? null,
317
+ };
318
+ if (delta.base) {
319
+ return {
320
+ systemPrompt: delta.systemPrompt || '',
321
+ messages: Array.isArray(delta.messages) ? delta.messages : [],
322
+ rawRequest: base.rawRequest ?? null,
323
+ };
324
+ }
325
+ if (typeof delta.systemPrompt === 'string') next.systemPrompt = delta.systemPrompt;
326
+ if (Array.isArray(delta.messages)) {
327
+ next.messages = delta.messages;
328
+ } else if (Array.isArray(delta.messagesAppend)) {
329
+ const from = Number.isFinite(Number(delta.messagesFrom)) ? Number(delta.messagesFrom) : next.messages.length;
330
+ next.messages = next.messages.slice(0, from).concat(delta.messagesAppend);
331
+ }
332
+ if (Object.prototype.hasOwnProperty.call(delta, 'rawRequestDelta')) next.rawRequest = applyRawRequestDelta(next.rawRequest, delta.rawRequestDelta);
333
+ return next;
334
+ }
170
335
 
171
- // Prepared statements (created lazily)
172
- #stmts = {};
336
+ function sessionRequestsDir(rootDir, sessionId) {
337
+ if (sessionId) return join(rootDir, 'sessions', safeDirComponent(sessionId), 'debug', 'requests');
338
+ return join(rootDir, 'debug', 'requests');
339
+ }
173
340
 
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 ───────────────────────────────────────────────
341
+ function requestFilePath(requestDir) {
342
+ return join(requestDir, 'trace.json');
343
+ }
219
344
 
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;
345
+ function tracePathFor(rootDir, sessionId, requestKey) {
346
+ return requestFilePath(join(sessionRequestsDir(rootDir, sessionId), safeDirComponent(requestKey, 'request')));
347
+ }
348
+
349
+ function summarizeTrace(trace, detailsLoaded = false) {
350
+ const loops = Array.isArray(trace?.loops) ? trace.loops : [];
351
+ const usage = loops.reduce((acc, loop) => {
352
+ const u = normalizeUsage(loop?.usage || {});
353
+ acc.totalMs += Number(loop?.latencyMs || 0);
354
+ acc.totalTokens += u.totalTokens || 0;
355
+ acc.summaryInputTokens += u.totalInputTokens || 0;
356
+ acc.summaryOutputTokens += u.outputTokens || 0;
357
+ return acc;
358
+ }, { totalMs: 0, totalTokens: 0, summaryInputTokens: 0, summaryOutputTokens: 0 });
359
+ return {
360
+ turnId: trace?.requestId || trace?.traceId || '',
361
+ userPrompt: trace?.userPrompt || '',
362
+ sessionId: trace?.sessionId || null,
363
+ vpId: trace?.vpId || null,
364
+ threadId: trace?.threadId || null,
365
+ openedAt: trace?.openedAt || 0,
366
+ closedAt: trace?.closedAt || null,
367
+ totalMs: usage.totalMs,
368
+ totalTokens: usage.totalTokens,
369
+ summaryInputTokens: usage.summaryInputTokens,
370
+ summaryOutputTokens: usage.summaryOutputTokens,
371
+ loopCount: loops.length,
372
+ memoryLoaded: null,
373
+ memoryAdjust: null,
374
+ tools: Array.isArray(trace?.tools) ? trace.tools.map(t => ({
375
+ loopNumber: t.loopNumber || 0,
376
+ callId: t.toolCallId || t.id || null,
377
+ traceToolId: t.id || null,
378
+ name: t.toolName || t.name || '?',
379
+ toolOutput: t.toolOutput == null ? null : String(t.toolOutput),
380
+ durationMs: t.durationMs || 0,
381
+ isError: !!t.isError,
382
+ })) : [],
383
+ detailsLoaded,
384
+ requestBase: trace?.baseRequest || null,
385
+ };
386
+ }
387
+
388
+ function expandTrace(trace) {
389
+ const turnsById = new Map([[trace.requestId || trace.traceId, summarizeTrace(trace, true)]]);
390
+ let snapshot = null;
391
+ const loops = [];
392
+ for (const loop of Array.isArray(trace?.loops) ? trace.loops : []) {
393
+ snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
394
+ const usage = normalizeUsage(loop?.usage || {});
395
+ loops.push({
396
+ turnId: trace.requestId || trace.traceId,
397
+ loopInstanceId: loop.loopInstanceId || loop.turnRowId || null,
398
+ loopNumber: loop.loopNumber || 0,
399
+ model: loop.model || null,
400
+ systemPrompt: snapshot.systemPrompt || '',
401
+ messages: Array.isArray(snapshot.messages) ? snapshot.messages : [],
402
+ response: loop.response || '',
403
+ toolCalls: Array.isArray(loop.toolCalls) ? loop.toolCalls : [],
404
+ usage,
405
+ latencyMs: loop.latencyMs || 0,
406
+ ttfbMs: loop.ttfbMs || null,
407
+ stopReason: loop.stopReason || null,
408
+ at: loop.at || null,
409
+ rawRequest: snapshot.rawRequest ?? null,
410
+ rawResponse: loop.rawResponse ?? null,
411
+ requestDelta: loop.requestDelta || {},
412
+ requestBase: trace.baseRequest || null,
413
+ sessionId: trace.sessionId || null,
414
+ vpId: trace.vpId || null,
415
+ threadId: trace.threadId || null,
416
+ });
233
417
  }
418
+ return { loops, turns: Array.from(turnsById.values()) };
419
+ }
420
+
421
+ function traceToLegacyRows(trace) {
422
+ let snapshot = null;
423
+ return (Array.isArray(trace?.loops) ? trace.loops : []).map((loop) => {
424
+ snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
425
+ const u = normalizeUsage(loop?.usage || {});
426
+ return {
427
+ id: loop.turnRowId || loop.loopInstanceId || randomUUID(),
428
+ trace_id: trace.traceId || trace.requestId,
429
+ message_id: trace.messageId || null,
430
+ mode: trace.mode || null,
431
+ turn_number: loop.loopNumber || 0,
432
+ model: loop.model || null,
433
+ input_tokens: u.inputTokens || 0,
434
+ output_tokens: u.outputTokens || 0,
435
+ cache_read_tokens: u.cacheReadTokens || 0,
436
+ cache_write_tokens: u.cacheWriteTokens || 0,
437
+ stop_reason: loop.stopReason || null,
438
+ latency_ms: loop.latencyMs || 0,
439
+ response_text: loop.response || '',
440
+ started_at: loop.startedAt || trace.openedAt || 0,
441
+ ended_at: loop.at || trace.closedAt || null,
442
+ group_id: trace.sessionId || null,
443
+ vp_id: trace.vpId || null,
444
+ thread_id: trace.threadId || null,
445
+ system_prompt: snapshot.systemPrompt || '',
446
+ messages_json: JSON.stringify(snapshot.messages || []),
447
+ tool_calls_json: JSON.stringify(loop.toolCalls || []),
448
+ usage_json: JSON.stringify(u),
449
+ ttfb_ms: loop.ttfbMs || null,
450
+ raw_request: typeof snapshot.rawRequest === 'string' ? snapshot.rawRequest : JSON.stringify(snapshot.rawRequest ?? null),
451
+ raw_response: typeof loop.rawResponse === 'string' ? loop.rawResponse : JSON.stringify(loop.rawResponse ?? null),
452
+ user_prompt: trace.userPrompt || '',
453
+ };
454
+ });
455
+ }
456
+
457
+ function traceToolToLegacy(trace, tool) {
458
+ return {
459
+ id: tool.id || randomUUID(),
460
+ turn_id: tool.turnRowId || null,
461
+ tool_name: tool.toolName || tool.name || '?',
462
+ tool_input: tool.toolInput == null ? null : String(tool.toolInput),
463
+ tool_output: tool.toolOutput == null ? null : String(tool.toolOutput),
464
+ tool_call_id: tool.toolCallId || null,
465
+ duration_ms: tool.durationMs || 0,
466
+ is_error: tool.isError ? 1 : 0,
467
+ created_at: tool.createdAt || trace.openedAt || 0,
468
+ };
469
+ }
470
+
471
+ function collectTraceFiles(rootDir, sessionId = null) {
472
+ const files = [];
473
+ const addFromRequestsDir = (requestsDir) => {
474
+ let entries = [];
475
+ try { entries = readdirSync(requestsDir, { withFileTypes: true }); }
476
+ catch { return; }
477
+ for (const entry of entries) {
478
+ if (!entry.isDirectory()) continue;
479
+ const file = requestFilePath(join(requestsDir, entry.name));
480
+ if (existsSync(file)) files.push(file);
481
+ }
482
+ };
483
+ if (sessionId) {
484
+ addFromRequestsDir(sessionRequestsDir(rootDir, sessionId));
485
+ return files;
486
+ }
487
+ addFromRequestsDir(sessionRequestsDir(rootDir, null));
488
+ const sessionsRoot = join(rootDir, 'sessions');
489
+ let sessionEntries = [];
490
+ try { sessionEntries = readdirSync(sessionsRoot, { withFileTypes: true }); }
491
+ catch { return files; }
492
+ for (const entry of sessionEntries) {
493
+ if (!entry.isDirectory()) continue;
494
+ addFromRequestsDir(join(sessionsRoot, entry.name, 'debug', 'requests'));
495
+ }
496
+ return files;
497
+ }
498
+
499
+ function readTraceSummaries(rootDir, sessionId = null) {
500
+ const traces = [];
501
+ for (const file of collectTraceFiles(rootDir, sessionId)) {
502
+ const trace = readJson(file);
503
+ if (!trace || !trace.requestId) continue;
504
+ traces.push({ trace, file, openedAt: Number(trace.openedAt || 0) });
505
+ }
506
+ traces.sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.trace?.requestKey || a.file).localeCompare(String(b.trace?.requestKey || b.file)));
507
+ return traces;
508
+ }
509
+
510
+ function countDirFiles(rootDir) {
511
+ let files = 0;
512
+ let bytes = 0;
513
+ const walk = (dir) => {
514
+ let entries = [];
515
+ try { entries = readdirSync(dir, { withFileTypes: true }); }
516
+ catch { return; }
517
+ for (const entry of entries) {
518
+ const p = join(dir, entry.name);
519
+ if (entry.isDirectory()) walk(p);
520
+ else {
521
+ files += 1;
522
+ try { bytes += statSync(p).size; } catch { /* ignore */ }
523
+ }
524
+ }
525
+ };
526
+ walk(rootDir);
527
+ return { files, bytes };
528
+ }
529
+
530
+ export class DebugTrace {
531
+ /** @type {string} */
532
+ #rootDir;
533
+ /** @type {Map<string, { requestKey: string, sessionId: string|null, traceId: string, loopNumber: number }>} */
534
+ #turnIndex = new Map();
535
+ /** @type {Map<string, object>} */
536
+ #requestCache = new Map();
537
+ /** @type {Map<string, { trace: object, dirtyLoops: number, firstDirtyAt: number }>} */
538
+ #pendingWrites = new Map();
539
+ /** @type {NodeJS.Timeout|null} */
540
+ #flushTimer = null;
541
+ /** @type {number} */
542
+ #sequence = 0;
234
543
 
235
544
  /**
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
545
+ * @param {string} tracePath Back-compatible path. If it looks like a DB
546
+ * file, traces are stored in a sibling `debug/` directory.
239
547
  */
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
- } = {}) {
548
+ constructor(tracePath) {
549
+ const rootDir = fileTraceRoot(tracePath);
550
+ if (!rootDir) throw new Error('DebugTrace requires a storage path');
551
+ this.#rootDir = rootDir;
552
+ ensureDir(rootDir);
553
+ }
554
+
555
+ startTurn({ traceId, messageId = null, mode = null, turnNumber = null, sessionId = null, vpId = null, threadId = null, userPrompt = null } = {}) {
556
+ const turnRowId = randomUUID();
257
557
  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),
558
+ const request = this.#getOrCreateRequest({
559
+ traceId: traceId || turnRowId,
560
+ turnNumber: Number(turnNumber || 0),
561
+ messageId,
562
+ mode,
563
+ sessionId,
564
+ vpId,
565
+ threadId,
566
+ userPrompt,
298
567
  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
- );
568
+ turnRowId,
569
+ });
570
+ this.#turnIndex.set(turnRowId, {
571
+ requestKey: request.requestKey,
572
+ sessionId: request.sessionId || null,
573
+ traceId: request.traceId,
574
+ loopNumber: Number(turnNumber || 0),
575
+ });
576
+ return turnRowId;
308
577
  }
309
578
 
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
- }) {
579
+ endTurn(turnId, info = {}) {
580
+ const ctx = this.#turnIndex.get(turnId);
581
+ if (!ctx) return;
582
+ const trace = this.#loadRequest(ctx.sessionId, ctx.requestKey);
583
+ if (!trace) return;
584
+ const loopNumber = ctx.loopNumber || Number(info.turnNumber || 0);
585
+ const snapshot = buildRequestSnapshot(info);
586
+ const previousSnapshot = trace._lastSnapshot || this.#reconstructLastSnapshot(trace);
587
+ if (!trace.baseRequest) {
588
+ const rawRequestBaseDelta = buildRawRequestDelta(null, snapshot.rawRequest);
589
+ trace.baseRequest = { ...snapshot, rawRequest: applyRawRequestDelta(null, rawRequestBaseDelta) };
590
+ }
591
+ const loopIndex = (trace.loops || []).findIndex(l => l.turnRowId === turnId);
592
+ const loop = {
593
+ loopInstanceId: turnId,
594
+ turnRowId: turnId,
595
+ loopNumber,
596
+ startedAt: trace.openedAt || Date.now(),
597
+ model: info.model || null,
598
+ response: truncateText(info.responseText || '', MAX_TEXT_BYTES),
599
+ toolCalls: cloneJsonValue(Array.isArray(info.toolCalls) ? info.toolCalls : []),
600
+ usage: normalizeUsage(info.usage || {}, {
601
+ inputTokens: info.inputTokens || 0,
602
+ outputTokens: info.outputTokens || 0,
603
+ cacheReadTokens: info.cacheReadTokens || 0,
604
+ cacheWriteTokens: info.cacheWriteTokens || 0,
605
+ }),
606
+ latencyMs: Number(info.latencyMs || 0),
607
+ ttfbMs: Number.isFinite(Number(info.ttfbMs)) ? Number(info.ttfbMs) : null,
608
+ stopReason: info.stopReason || null,
609
+ at: Date.now(),
610
+ rawResponse: typeof info.rawResponse === 'string'
611
+ ? truncateText(info.rawResponse, MAX_TEXT_BYTES)
612
+ : safeJsonValue(info.rawResponse),
613
+ requestDelta: buildRequestDelta(previousSnapshot, snapshot),
614
+ };
615
+ if (loopIndex >= 0) trace.loops[loopIndex] = loop;
616
+ else trace.loops.push(loop);
617
+ trace.loops.sort((a, b) => (a.loopNumber || 0) - (b.loopNumber || 0) || String(a.turnRowId || '').localeCompare(String(b.turnRowId || '')));
618
+ trace.closedAt = loop.at;
619
+ trace.updatedAt = loop.at;
620
+ trace.active = info.stopReason ? !['end_turn', 'error', 'aborted'].includes(String(info.stopReason)) : false;
621
+ trace._lastSnapshot = snapshot;
622
+ this.#markDirty(trace, { dirtyLoops: 1, force: !trace.active });
623
+ }
624
+
625
+ logTool(turnId, { toolName, toolCallId = null, toolInput = null, toolOutput = null, durationMs = null, isError = false } = {}) {
324
626
  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),
627
+ const ctx = this.#turnIndex.get(turnId);
628
+ if (!ctx) return id;
629
+ const trace = this.#loadRequest(ctx.sessionId, ctx.requestKey);
630
+ if (!trace) return id;
631
+ if (!Array.isArray(trace.tools)) trace.tools = [];
632
+ trace.tools.push({
633
+ id,
634
+ turnRowId: turnId,
635
+ loopNumber: ctx.loopNumber || 0,
636
+ toolName: toolName || '?',
333
637
  toolCallId,
334
- durationMs, isError ? 1 : 0, now,
335
- );
638
+ toolInput: truncateText(toolInput == null ? null : String(toolInput), MAX_TOOL_INPUT),
639
+ toolOutput: truncateText(toolOutput == null ? null : String(toolOutput), MAX_TEXT_BYTES),
640
+ durationMs: Number(durationMs || 0),
641
+ isError: !!isError,
642
+ createdAt: Date.now(),
643
+ });
644
+ trace.updatedAt = Date.now();
645
+ this.#markDirty(trace, { dirtyLoops: 0, force: !trace.active });
336
646
  return id;
337
647
  }
338
648
 
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 }) {
649
+ logEvent({ traceId, eventType, eventData = null } = {}) {
345
650
  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);
651
+ const file = join(this.#rootDir, 'events.json');
652
+ const existingEvents = readJson(file);
653
+ const events = Array.isArray(existingEvents) ? existingEvents : [];
654
+ events.push({
655
+ id,
656
+ traceId: traceId || String(eventType || 'event'),
657
+ eventType: eventType || 'event',
658
+ eventData: safeJsonValue(eventData),
659
+ createdAt: Date.now(),
660
+ });
661
+ const trimmed = events.slice(-MAX_DREAM_EVENTS);
662
+ try { atomicWriteJson(file, trimmed); }
663
+ catch (err) { console.warn('[Yeaft] debug trace event write failed:', err?.message || err); }
353
664
  return id;
354
665
  }
355
666
 
356
- /**
357
- * Compatibility helper used by older engine/dream call sites.
358
- * @param {string} eventType
359
- * @param {unknown} eventData
360
- * @returns {string}
361
- */
362
667
  event(eventType, eventData = null) {
363
668
  const traceId = (eventData && typeof eventData === 'object' && (eventData.turnId || eventData.runId))
364
669
  ? String(eventData.turnId || eventData.runId)
@@ -366,470 +671,328 @@ export class DebugTrace {
366
671
  return this.logEvent({ traceId, eventType, eventData });
367
672
  }
368
673
 
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
674
  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);
675
+ this.#flushPendingSync();
676
+ const traces = this.#traceSummaries()
677
+ .filter(({ trace }) => trace.messageId === messageId)
678
+ .map(({ trace }) => trace);
679
+ return this.#expandLegacy(traces);
381
680
  }
382
681
 
383
- /**
384
- * Query all data for a trace.
385
- * @param {string} traceId
386
- * @returns {{ turns: object[], tools: object[], events: object[] }}
387
- */
388
682
  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 };
683
+ this.#flushPendingSync();
684
+ const traces = this.#traceSummaries()
685
+ .filter(({ trace }) => trace.traceId === traceId || trace.requestId === traceId)
686
+ .map(({ trace }) => trace);
687
+ return this.#expandLegacy(traces);
402
688
  }
403
689
 
404
- /**
405
- * Query recent turns.
406
- * @param {number} [limit=20]
407
- * @returns {object[]}
408
- */
409
690
  queryRecent(limit = 20) {
410
- return this.#prepare('recentTurns', `
411
- SELECT * FROM trace_turns ORDER BY started_at DESC LIMIT ?
412
- `).all(limit);
691
+ this.#flushPendingSync();
692
+ const lim = Math.max(1, Math.min(MAX_HISTORY_LIMIT, Number(limit) || MAX_HISTORY_LIMIT));
693
+ return this.#traceSummaries()
694
+ .slice(-lim)
695
+ .reverse()
696
+ .flatMap(({ trace }) => traceToLegacyRows(trace));
413
697
  }
414
698
 
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;
699
+ fetchRecentDebugHistory({ limit = MAX_HISTORY_LIMIT, dreamLimit = 5, sessionId = null, threadId = null, indexOnly = false, detailTurnId = null } = {}) {
700
+ this.#flushPendingSync();
701
+ const lim = Math.max(1, Math.min(MAX_HISTORY_LIMIT, Number(limit) || MAX_HISTORY_LIMIT));
433
702
  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;
703
+ const traces = this.#traceSummaries(sessionId)
704
+ .filter(({ trace }) => !threadId || trace.threadId === threadId)
705
+ .map(({ trace }) => trace);
706
+ const dreamEvents = this.#readDreamEvents({ sessionId, dreamLimit });
707
+ if (requestedDetailTurnId) {
708
+ const trace = traces.find(t => t.requestId === requestedDetailTurnId || t.traceId === requestedDetailTurnId);
709
+ if (!trace) return { loops: [], turns: [], dreamEvents, hasMore: false, limit: 0, indexOnly: false, detailTurnId: requestedDetailTurnId };
710
+ const expanded = expandTrace(trace);
711
+ return { ...expanded, dreamEvents, hasMore: false, limit: expanded.loops.length, indexOnly: false, detailTurnId: requestedDetailTurnId };
712
+ }
713
+ const selected = traces.slice(-lim);
714
+ if (indexOnly) {
447
715
  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,
716
+ loops: [],
717
+ turns: selected.map(trace => summarizeTrace(trace, false)),
718
+ dreamEvents,
719
+ hasMore: traces.length > selected.length,
720
+ limit: lim,
721
+ indexOnly: true,
456
722
  };
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
723
  }
724
+ const expanded = selected.reduce((acc, trace) => {
725
+ const item = expandTrace(trace);
726
+ acc.loops.push(...item.loops);
727
+ acc.turns.push(...item.turns);
728
+ return acc;
729
+ }, { loops: [], turns: [] });
730
+ return { ...expanded, dreamEvents, hasMore: traces.length > selected.length, limit: lim, indexOnly: false };
731
+ }
490
732
 
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
- });
733
+ queryTools({ name = null, since = null } = {}) {
734
+ this.#flushPendingSync();
735
+ const tools = [];
736
+ for (const { trace } of this.#traceSummaries()) {
737
+ for (const tool of Array.isArray(trace.tools) ? trace.tools : []) {
738
+ const row = traceToolToLegacy(trace, tool);
739
+ if (name && row.tool_name !== name) continue;
740
+ if (since && row.created_at < since) continue;
741
+ tools.push(row);
608
742
  }
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
743
  }
744
+ tools.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
745
+ return tools.slice(0, 100);
746
+ }
627
747
 
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
- }
748
+ search(keyword) {
749
+ this.#flushPendingSync();
750
+ const needle = String(keyword || '').toLowerCase();
751
+ if (!needle) return [];
752
+ return this.#traceSummaries()
753
+ .filter(({ trace }) => JSON.stringify(trace).toLowerCase().includes(needle))
754
+ .slice(-50)
755
+ .reverse()
756
+ .flatMap(({ trace }) => traceToLegacyRows(trace));
757
+ }
640
758
 
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 };
759
+ stats() {
760
+ this.#flushPendingSync();
761
+ const traces = this.#traceSummaries().map(({ trace }) => trace);
762
+ const turnCount = traces.reduce((n, trace) => n + (Array.isArray(trace.loops) ? trace.loops.length : 0), 0);
763
+ const toolCount = traces.reduce((n, trace) => n + (Array.isArray(trace.tools) ? trace.tools.length : 0), 0);
764
+ const events = readJson(join(this.#rootDir, 'events.json'));
765
+ const eventCount = Array.isArray(events) ? events.length : 0;
766
+ const { bytes } = countDirFiles(this.#rootDir);
767
+ return { turnCount, toolCount, eventCount, dbSizeBytes: bytes, fileSizeBytes: bytes, requestCount: traces.length };
652
768
  }
653
769
 
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);
770
+ cleanup(retention = REQUEST_RETENTION) {
771
+ this.#flushPendingSync();
772
+ const keep = Math.max(1, Math.min(REQUEST_RETENTION, Number(retention) || REQUEST_RETENTION));
773
+ const before = readTraceSummaries(this.#rootDir).length;
774
+ this.#pruneAll(keep);
775
+ const after = readTraceSummaries(this.#rootDir).length;
776
+ return { deletedTurns: Math.max(0, before - after), deletedTools: 0, deletedEvents: 0, deletedRequests: Math.max(0, before - after) };
777
+ }
778
+
779
+ compact() {
780
+ this.#flushPendingSync();
781
+ const before = countDirFiles(this.#rootDir).bytes;
782
+ this.cleanup(REQUEST_RETENTION);
783
+ const after = countDirFiles(this.#rootDir).bytes;
784
+ return { before, after };
785
+ }
786
+
787
+ purge() {
788
+ this.#flushPendingSync();
789
+ try { rmSync(this.#rootDir, { recursive: true, force: true }); }
790
+ catch { /* ignore */ }
791
+ ensureDir(this.#rootDir);
792
+ this.#turnIndex.clear();
793
+ this.#requestCache.clear();
794
+ }
795
+
796
+ close() { this.#flushPendingSync(); }
797
+
798
+ #getOrCreateRequest({ traceId, turnNumber, messageId, mode, sessionId, vpId, threadId, userPrompt, now, turnRowId }) {
799
+ const normalizedSessionId = sessionId || null;
800
+ let all = null;
801
+ const isUsableExisting = (t) => (
802
+ t?.sessionId === normalizedSessionId
803
+ && t?.traceId === traceId
804
+ && !(turnNumber === 1 && (t.loops || []).some(l => l.loopNumber === 1))
805
+ );
806
+ const newestTrace = (items) => items
807
+ .filter(isUsableExisting)
808
+ .sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.requestKey || '').localeCompare(String(b.requestKey || '')))
809
+ .at(-1) || null;
810
+ let existing = newestTrace(Array.from(this.#requestCache.values()));
811
+ if (!existing) {
812
+ all = this.#traceSummaries(normalizedSessionId).map(({ trace }) => trace);
813
+ existing = newestTrace(all);
664
814
  }
665
- if (name) {
666
- return this.#prepare('toolsByName', `
667
- SELECT * FROM trace_tools WHERE tool_name = ? ORDER BY created_at DESC
668
- `).all(name);
815
+ if (existing) {
816
+ existing.updatedAt = now;
817
+ this.#requestCache.set(existing.requestKey, existing);
818
+ return existing;
669
819
  }
670
- if (since) {
671
- return this.#prepare('toolsSince', `
672
- SELECT * FROM trace_tools WHERE created_at >= ? ORDER BY created_at DESC
673
- `).all(since);
820
+ const seq = (this.#sequence = (this.#sequence + 1) % 1_000_000);
821
+ const requestKey = `${String(now).padStart(13, '0')}-${String(seq).padStart(6, '0')}-${safeDirComponent(traceId || turnRowId, 'request')}-${turnRowId.slice(0, 8)}`;
822
+ const requestId = turnNumber === 1 && all.some(t => t.traceId === traceId) ? turnRowId : traceId;
823
+ const trace = {
824
+ version: TRACE_VERSION,
825
+ requestKey,
826
+ requestId,
827
+ traceId,
828
+ messageId,
829
+ mode,
830
+ sessionId: normalizedSessionId,
831
+ vpId: vpId || null,
832
+ threadId: threadId || null,
833
+ userPrompt: truncateText(userPrompt || '', MAX_TEXT_BYTES),
834
+ openedAt: now,
835
+ closedAt: null,
836
+ updatedAt: now,
837
+ active: true,
838
+ baseRequest: null,
839
+ loops: [],
840
+ tools: [],
841
+ };
842
+ this.#requestCache.set(requestKey, trace);
843
+ return trace;
844
+ }
845
+
846
+ #loadRequest(sessionId, requestKey) {
847
+ const cached = this.#requestCache.get(requestKey);
848
+ if (cached) return cached;
849
+ const file = tracePathFor(this.#rootDir, sessionId, requestKey);
850
+ const trace = readJson(file);
851
+ if (trace) this.#requestCache.set(requestKey, trace);
852
+ return trace;
853
+ }
854
+
855
+ #traceSummaries(sessionId = null) {
856
+ const byKey = new Map(readTraceSummaries(this.#rootDir, sessionId).map(item => [item.trace.requestKey, item]));
857
+ for (const trace of this.#requestCache.values()) {
858
+ if (sessionId && trace.sessionId !== sessionId) continue;
859
+ if (!trace?.requestId || !trace?.requestKey) continue;
860
+ byKey.set(trace.requestKey, {
861
+ trace,
862
+ file: this.#traceFile(trace),
863
+ openedAt: Number(trace.openedAt || 0),
864
+ });
674
865
  }
675
- return this.#prepare('allTools', `
676
- SELECT * FROM trace_tools ORDER BY created_at DESC LIMIT 100
677
- `).all();
866
+ return Array.from(byKey.values())
867
+ .sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.trace?.requestKey || a.file).localeCompare(String(b.trace?.requestKey || b.file)));
678
868
  }
679
869
 
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);
870
+ #traceWriteKey(trace) {
871
+ return `${trace.sessionId || ''}::${trace.requestKey}`;
693
872
  }
694
873
 
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 };
874
+ #traceFile(trace) {
875
+ return tracePathFor(this.#rootDir, trace.sessionId || null, trace.requestKey);
708
876
  }
709
877
 
710
- // ─── Maintenance ─────────────────────────────────────────────
878
+ #serializableTrace(trace) {
879
+ const toWrite = { ...trace };
880
+ delete toWrite._lastSnapshot;
881
+ return toWrite;
882
+ }
711
883
 
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); }
884
+ #markDirty(trace, { dirtyLoops = 0, force = false } = {}) {
885
+ if (!trace?.requestKey) return;
886
+ const key = this.#traceWriteKey(trace);
887
+ const existing = this.#pendingWrites.get(key);
888
+ const now = Date.now();
889
+ const item = existing || { trace, dirtyLoops: 0, firstDirtyAt: now };
890
+ item.trace = trace;
891
+ item.dirtyLoops += Math.max(0, Number(dirtyLoops) || 0);
892
+ this.#pendingWrites.set(key, item);
893
+ this.#requestCache.set(trace.requestKey, trace);
894
+
895
+ if (force || item.dirtyLoops >= TRACE_FLUSH_DIRTY_LOOPS) {
896
+ this.#flushPendingSync();
897
+ return;
750
898
  }
751
- return { deletedTurns, deletedTools, deletedEvents };
899
+ this.#scheduleFlushTimer(now);
752
900
  }
753
901
 
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 };
902
+ #scheduleFlushTimer(now = Date.now()) {
903
+ if (this.#flushTimer || this.#pendingWrites.size === 0) return;
904
+ const oldestDirtyAt = Math.min(...Array.from(this.#pendingWrites.values()).map(item => item.firstDirtyAt || now));
905
+ const dueIn = Math.max(0, TRACE_FLUSH_INTERVAL_MS - (now - oldestDirtyAt));
906
+ this.#flushTimer = setTimeout(() => {
907
+ this.#flushTimer = null;
908
+ this.#flushPendingSync();
909
+ }, dueIn);
910
+ if (typeof this.#flushTimer.unref === 'function') this.#flushTimer.unref();
777
911
  }
778
912
 
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');
913
+ #flushPendingSync() {
914
+ if (this.#flushTimer) {
915
+ clearTimeout(this.#flushTimer);
916
+ this.#flushTimer = null;
917
+ }
918
+ const entries = Array.from(this.#pendingWrites.values());
919
+ if (entries.length === 0) return;
920
+ this.#pendingWrites.clear();
921
+ for (const { trace } of entries) {
922
+ try {
923
+ atomicWriteJson(this.#traceFile(trace), this.#serializableTrace(trace));
924
+ } catch (err) {
925
+ console.warn('[Yeaft] debug trace write failed:', err?.message || err);
926
+ }
927
+ }
928
+ this.#pruneAll(REQUEST_RETENTION);
784
929
  }
785
930
 
786
- /** Close the database connection. */
787
- close() {
788
- this.#db.close();
931
+ #reconstructLastSnapshot(trace) {
932
+ let snapshot = null;
933
+ for (const loop of Array.isArray(trace?.loops) ? trace.loops : []) {
934
+ snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
935
+ }
936
+ return snapshot;
789
937
  }
790
938
 
791
- // ─── Internal ────────────────────────────────────────────────
939
+ #readDreamEvents({ sessionId = null, dreamLimit = 5 } = {}) {
940
+ const limit = Number.isFinite(Number(dreamLimit)) ? Math.max(0, Math.min(50, Number(dreamLimit))) : 5;
941
+ if (limit <= 0) return [];
942
+ const storedEvents = readJson(join(this.#rootDir, 'events.json'));
943
+ const events = Array.isArray(storedEvents) ? storedEvents : [];
944
+ const out = [];
945
+ for (const event of events.slice().reverse()) {
946
+ const data = isPlainObject(event.eventData) ? event.eventData : {};
947
+ if (sessionId) {
948
+ const evtSessionId = typeof data.sessionId === 'string' && data.sessionId ? data.sessionId : null;
949
+ const target = typeof data.target === 'string' ? data.target : '';
950
+ const isBroadcast = !evtSessionId && !target;
951
+ const isThisSession = evtSessionId === sessionId || target === `sessions/${sessionId}` || target === `group/${sessionId}`;
952
+ if (!isBroadcast && !isThisSession) continue;
953
+ }
954
+ out.push({
955
+ type: data.type || event.eventType || 'event',
956
+ ...data,
957
+ at: event.createdAt,
958
+ ts: data.ts || data.at || event.createdAt,
959
+ });
960
+ if (out.length >= limit) break;
961
+ }
962
+ return out.reverse();
963
+ }
792
964
 
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);
965
+ #pruneAll(keep) {
966
+ const sessions = new Set([null]);
967
+ for (const { trace } of this.#traceSummaries()) sessions.add(trace.sessionId || null);
968
+ for (const sid of sessions) this.#pruneSession(sid, keep);
969
+ }
970
+
971
+ #pruneSession(sessionId, keep = REQUEST_RETENTION) {
972
+ const traces = this.#traceSummaries(sessionId);
973
+ const activeCutoff = Date.now() - 6 * 60 * 60 * 1000;
974
+ const protectedItems = traces.filter(item => item.trace?.active && Number(item.trace?.updatedAt || 0) >= activeCutoff);
975
+ const pruneCandidates = traces.filter(item => !protectedItems.includes(item));
976
+ const stale = pruneCandidates.slice(0, Math.max(0, traces.length - protectedItems.length - keep));
977
+ for (const item of stale) {
978
+ try { rmSync(dirname(item.file), { recursive: true, force: true }); }
979
+ catch { /* ignore */ }
980
+ this.#requestCache.delete(item.trace.requestKey);
802
981
  }
803
- return this.#stmts[key];
804
982
  }
805
983
 
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
- : [];
984
+ #expandLegacy(traces) {
985
+ const turns = [];
986
+ const tools = [];
987
+ const events = [];
988
+ for (const trace of traces) {
989
+ turns.push(...traceToLegacyRows(trace));
990
+ for (const tool of Array.isArray(trace.tools) ? trace.tools : []) tools.push(traceToolToLegacy(trace, tool));
991
+ }
825
992
  return { turns, tools, events };
826
993
  }
827
994
  }
828
995
 
829
- /**
830
- * NullTrace — No-op implementation with the same interface.
831
- * Used when debug is disabled. Zero overhead.
832
- */
833
996
  export class NullTrace {
834
997
  startTurn() { return 'null'; }
835
998
  endTurn() {}
@@ -841,22 +1004,16 @@ export class NullTrace {
841
1004
  queryRecent() { return []; }
842
1005
  queryTools() { return []; }
843
1006
  search() { return []; }
844
- stats() { return { turnCount: 0, toolCount: 0, eventCount: 0, dbSizeBytes: 0 }; }
845
- cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0 }; }
1007
+ stats() { return { turnCount: 0, toolCount: 0, eventCount: 0, dbSizeBytes: 0, fileSizeBytes: 0, requestCount: 0 }; }
1008
+ cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0, deletedRequests: 0 }; }
846
1009
  compact() { return { before: 0, after: 0 }; }
847
1010
  purge() {}
848
1011
  close() {}
849
1012
  fetchRecentDebugHistory() { return { loops: [], turns: [], dreamEvents: [] }; }
850
1013
  }
851
1014
 
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);
1015
+ export function createTrace({ enabled, dbPath, dirPath }) {
1016
+ const path = dirPath || dbPath;
1017
+ if (!enabled || !path) return new NullTrace();
1018
+ return new DebugTrace(path);
862
1019
  }