@yeaft/webchat-agent 0.1.1104 → 0.1.1107

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