@yeaft/webchat-agent 0.1.398 → 0.1.408

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.
@@ -0,0 +1,256 @@
1
+ /**
2
+ * config.js — Yeaft configuration management
3
+ *
4
+ * Priority (high → low): CLI overrides > ENV vars > .env file > config.md frontmatter > defaults
5
+ *
6
+ * Note: "model" in Yeaft always means a model ID (e.g. "gpt-5", "claude-sonnet-4-20250514").
7
+ * Yeaft does not provide its own models — it routes to external LLM providers via adapters.
8
+ */
9
+
10
+ import { existsSync, readFileSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { DEFAULT_YEAFT_DIR } from './init.js';
13
+ import { resolveModel } from './models.js';
14
+
15
+ /** Default configuration values. */
16
+ const DEFAULTS = {
17
+ model: 'claude-sonnet-4-20250514',
18
+ fallbackModel: null,
19
+ language: 'en', // 'en' | 'zh'
20
+ apiKey: null,
21
+ openaiApiKey: null,
22
+ proxyUrl: 'http://localhost:6628',
23
+ baseUrl: null,
24
+ adapter: null, // auto-detect: 'anthropic' | 'openai' | 'proxy'
25
+ debug: false,
26
+ dir: DEFAULT_YEAFT_DIR,
27
+ maxContextTokens: 200000,
28
+ maxOutputTokens: 16384,
29
+ };
30
+
31
+ /**
32
+ * Parse YAML frontmatter from a markdown file.
33
+ * Simple parser — handles key: value pairs, no nested objects.
34
+ *
35
+ * @param {string} content — File content
36
+ * @returns {Record<string, string>} — Parsed frontmatter
37
+ */
38
+ export function parseFrontmatter(content) {
39
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
40
+ if (!match) return {};
41
+
42
+ const result = {};
43
+ for (const line of match[1].split('\n')) {
44
+ const trimmed = line.trim();
45
+ if (!trimmed || trimmed.startsWith('#')) continue;
46
+ const colonIdx = trimmed.indexOf(':');
47
+ if (colonIdx === -1) continue;
48
+ const key = trimmed.slice(0, colonIdx).trim();
49
+ let value = trimmed.slice(colonIdx + 1).trim();
50
+ // Parse booleans and numbers
51
+ if (value === 'true') value = true;
52
+ else if (value === 'false') value = false;
53
+ else if (value === 'null') value = null;
54
+ else if (/^\d+$/.test(value)) value = parseInt(value, 10);
55
+ result[key] = value;
56
+ }
57
+ return result;
58
+ }
59
+
60
+ /**
61
+ * Load configuration from config.md file.
62
+ *
63
+ * @param {string} dir — Yeaft data directory
64
+ * @returns {Record<string, unknown>} — Config from file
65
+ */
66
+ function loadConfigFile(dir) {
67
+ const configPath = join(dir, 'config.md');
68
+ if (!existsSync(configPath)) return {};
69
+
70
+ try {
71
+ const content = readFileSync(configPath, 'utf8');
72
+ return parseFrontmatter(content);
73
+ } catch {
74
+ return {};
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Load .env file from a directory. Sets process.env for any keys
80
+ * not already defined (env vars take precedence over .env file).
81
+ *
82
+ * ⚠️ Side-effect: mutates process.env globally. Values set by a previous
83
+ * call persist across subsequent loadConfig() calls within the same process.
84
+ * This is by design (matches dotenv behavior), but callers that need isolation
85
+ * (e.g. tests) must manually delete keys from process.env between calls.
86
+ *
87
+ * @param {string} dir — Directory containing .env file
88
+ */
89
+ function loadEnvFile(dir) {
90
+ const envPath = join(dir, '.env');
91
+ if (!existsSync(envPath)) return;
92
+
93
+ try {
94
+ const content = readFileSync(envPath, 'utf8');
95
+ for (const line of content.split('\n')) {
96
+ let trimmed = line.trim();
97
+ // Skip empty lines and comments
98
+ if (!trimmed || trimmed.startsWith('#')) continue;
99
+ // Strip optional 'export ' prefix (common in .env files)
100
+ if (trimmed.startsWith('export ')) trimmed = trimmed.slice(7);
101
+ const eqIdx = trimmed.indexOf('=');
102
+ if (eqIdx === -1) continue;
103
+
104
+ const key = trimmed.slice(0, eqIdx).trim();
105
+ let value = trimmed.slice(eqIdx + 1).trim();
106
+ // Remove surrounding quotes if present
107
+ if ((value.startsWith('"') && value.endsWith('"')) ||
108
+ (value.startsWith("'") && value.endsWith("'"))) {
109
+ value = value.slice(1, -1);
110
+ }
111
+
112
+ // Only set if not already defined (shell env takes precedence)
113
+ if (process.env[key] === undefined) {
114
+ process.env[key] = value;
115
+ }
116
+ }
117
+ } catch {
118
+ // Silently ignore .env read errors
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Helper: check if an env var is truthy.
124
+ * @param {string|undefined} val
125
+ * @returns {boolean}
126
+ */
127
+ function isTruthy(val) {
128
+ return val === '1' || val === 'true' || val === 'yes';
129
+ }
130
+
131
+ /**
132
+ * Load full configuration.
133
+ *
134
+ * @param {Record<string, unknown>} [overrides] — CLI overrides
135
+ * @returns {object} — Merged configuration
136
+ */
137
+ export function loadConfig(overrides = {}) {
138
+ const env = process.env;
139
+
140
+ // Determine data directory first (needed to load .env and config.md)
141
+ const dir = overrides.dir || env.YEAFT_DIR || DEFAULTS.dir;
142
+
143
+ // Load .env file (sets process.env for undefined keys — shell env takes precedence)
144
+ loadEnvFile(dir);
145
+
146
+ // Load from config.md
147
+ const fileConfig = loadConfigFile(dir);
148
+
149
+ // Build merged config: defaults < file < env < overrides
150
+ const config = {
151
+ model:
152
+ overrides.model ||
153
+ env.YEAFT_MODEL ||
154
+ fileConfig.model ||
155
+ DEFAULTS.model,
156
+
157
+ fallbackModel:
158
+ overrides.fallbackModel ||
159
+ env.YEAFT_FALLBACK_MODEL ||
160
+ fileConfig.fallbackModel ||
161
+ DEFAULTS.fallbackModel,
162
+
163
+ language:
164
+ overrides.language ||
165
+ env.YEAFT_LANGUAGE ||
166
+ fileConfig.language ||
167
+ DEFAULTS.language,
168
+
169
+ apiKey:
170
+ overrides.apiKey ||
171
+ env.YEAFT_API_KEY ||
172
+ fileConfig.apiKey ||
173
+ DEFAULTS.apiKey,
174
+
175
+ openaiApiKey:
176
+ overrides.openaiApiKey ||
177
+ env.YEAFT_OPENAI_API_KEY ||
178
+ fileConfig.openaiApiKey ||
179
+ DEFAULTS.openaiApiKey,
180
+
181
+ proxyUrl:
182
+ overrides.proxyUrl ||
183
+ env.YEAFT_PROXY_URL ||
184
+ fileConfig.proxyUrl ||
185
+ DEFAULTS.proxyUrl,
186
+
187
+ baseUrl:
188
+ overrides.baseUrl ||
189
+ env.YEAFT_BASE_URL ||
190
+ fileConfig.baseUrl ||
191
+ DEFAULTS.baseUrl,
192
+
193
+ adapter:
194
+ overrides.adapter ||
195
+ env.YEAFT_ADAPTER ||
196
+ fileConfig.adapter ||
197
+ DEFAULTS.adapter,
198
+
199
+ debug:
200
+ overrides.debug !== undefined
201
+ ? overrides.debug
202
+ : env.YEAFT_DEBUG !== undefined
203
+ ? isTruthy(env.YEAFT_DEBUG)
204
+ : fileConfig.debug !== undefined
205
+ ? fileConfig.debug
206
+ : DEFAULTS.debug,
207
+
208
+ dir,
209
+
210
+ maxContextTokens:
211
+ overrides.maxContextTokens ??
212
+ (env.YEAFT_MAX_CONTEXT ? parseInt(env.YEAFT_MAX_CONTEXT, 10) : null) ??
213
+ fileConfig.maxContextTokens ??
214
+ DEFAULTS.maxContextTokens,
215
+
216
+ maxOutputTokens:
217
+ overrides.maxOutputTokens ??
218
+ fileConfig.maxOutputTokens ??
219
+ DEFAULTS.maxOutputTokens,
220
+ };
221
+
222
+ // Auto-detect adapter using model registry + credential fallback
223
+ if (!config.adapter) {
224
+ const modelInfo = resolveModel(config.model);
225
+ if (modelInfo) {
226
+ // Known model → set adapter from registry
227
+ config.adapter = modelInfo.adapter === 'anthropic' ? 'anthropic' : 'openai';
228
+ // Use registry baseUrl if not explicitly overridden
229
+ if (!config.baseUrl) {
230
+ config.baseUrl = modelInfo.baseUrl;
231
+ }
232
+ // Use registry contextWindow if still at default
233
+ if (config.maxContextTokens === DEFAULTS.maxContextTokens) {
234
+ config.maxContextTokens = modelInfo.contextWindow;
235
+ }
236
+ // Use registry maxOutputTokens if still at default
237
+ if (config.maxOutputTokens === DEFAULTS.maxOutputTokens) {
238
+ config.maxOutputTokens = modelInfo.maxOutputTokens;
239
+ }
240
+ } else {
241
+ // Unknown model → fallback to credential-based detection
242
+ if (config.apiKey) {
243
+ config.adapter = 'anthropic';
244
+ } else if (config.openaiApiKey) {
245
+ config.adapter = 'openai';
246
+ } else if (config.proxyUrl) {
247
+ config.adapter = 'proxy';
248
+ }
249
+ }
250
+ }
251
+
252
+ // Store resolved model info for reference
253
+ config.modelInfo = resolveModel(config.model) || null;
254
+
255
+ return config;
256
+ }
@@ -0,0 +1,398 @@
1
+ /**
2
+ * debug-trace.js — SQLite-backed debug trace for Yeaft
3
+ *
4
+ * Records every LLM turn, tool call, and event for debugging and analytics.
5
+ * When disabled, uses NullTrace (same interface, zero overhead).
6
+ *
7
+ * Reference: server/db/connection.js — Database(path), pragma WAL
8
+ */
9
+
10
+ import Database from 'better-sqlite3';
11
+ 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
+ );
33
+
34
+ CREATE TABLE IF NOT EXISTS trace_tools (
35
+ id TEXT PRIMARY KEY,
36
+ turn_id TEXT NOT NULL,
37
+ tool_name TEXT NOT NULL,
38
+ tool_input TEXT,
39
+ tool_output TEXT,
40
+ duration_ms INTEGER,
41
+ is_error INTEGER DEFAULT 0,
42
+ created_at INTEGER NOT NULL,
43
+ FOREIGN KEY (turn_id) REFERENCES trace_turns(id)
44
+ );
45
+
46
+ CREATE TABLE IF NOT EXISTS trace_events (
47
+ id TEXT PRIMARY KEY,
48
+ trace_id TEXT NOT NULL,
49
+ event_type TEXT NOT NULL,
50
+ event_data TEXT,
51
+ created_at INTEGER NOT NULL
52
+ );
53
+
54
+ CREATE INDEX IF NOT EXISTS idx_turns_trace_id ON trace_turns(trace_id);
55
+ CREATE INDEX IF NOT EXISTS idx_turns_message_id ON trace_turns(message_id);
56
+ CREATE INDEX IF NOT EXISTS idx_turns_started_at ON trace_turns(started_at);
57
+ CREATE INDEX IF NOT EXISTS idx_turns_model ON trace_turns(model);
58
+ CREATE INDEX IF NOT EXISTS idx_tools_turn_id ON trace_tools(turn_id);
59
+ CREATE INDEX IF NOT EXISTS idx_tools_name ON trace_tools(tool_name);
60
+ CREATE INDEX IF NOT EXISTS idx_events_trace_id ON trace_events(trace_id);
61
+ CREATE INDEX IF NOT EXISTS idx_events_type ON trace_events(event_type);
62
+ `;
63
+
64
+ /** Max tool_output size stored (10KB). Longer outputs are truncated. */
65
+ const MAX_TOOL_OUTPUT = 10240;
66
+
67
+ /**
68
+ * Truncate a string to a max length, appending "... [truncated]" if needed.
69
+ * @param {string|null|undefined} str
70
+ * @param {number} max
71
+ * @returns {string|null}
72
+ */
73
+ function truncate(str, max) {
74
+ if (!str) return str ?? null;
75
+ if (str.length <= max) return str;
76
+ return str.slice(0, max) + '... [truncated]';
77
+ }
78
+
79
+ /**
80
+ * DebugTrace — SQLite-backed debug trace.
81
+ */
82
+ export class DebugTrace {
83
+ /** @type {Database.Database} */
84
+ #db;
85
+
86
+ /** @type {string} */
87
+ #dbPath;
88
+
89
+ // Prepared statements (created lazily)
90
+ #stmts = {};
91
+
92
+ /**
93
+ * @param {string} dbPath — Path to the SQLite database file.
94
+ */
95
+ constructor(dbPath) {
96
+ this.#dbPath = dbPath;
97
+ this.#db = new Database(dbPath);
98
+ this.#db.pragma('journal_mode = WAL');
99
+ this.#db.pragma('foreign_keys = ON');
100
+ this.#db.exec(SCHEMA);
101
+ }
102
+
103
+ // ─── Write API ───────────────────────────────────────────────
104
+
105
+ /**
106
+ * Start a new turn.
107
+ * @param {{ traceId: string, messageId?: string, mode?: string, turnNumber?: number }} opts
108
+ * @returns {string} — turnId
109
+ */
110
+ startTurn({ traceId, messageId = null, mode = null, turnNumber = null }) {
111
+ const id = randomUUID();
112
+ const now = Date.now();
113
+ this.#prepare('insertTurn', `
114
+ INSERT INTO trace_turns (id, trace_id, message_id, mode, turn_number, started_at)
115
+ VALUES (?, ?, ?, ?, ?, ?)
116
+ `).run(id, traceId, messageId, mode, turnNumber, now);
117
+ return id;
118
+ }
119
+
120
+ /**
121
+ * End a turn with model response info.
122
+ * @param {string} turnId
123
+ * @param {{ model?: string, inputTokens?: number, outputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, stopReason?: string, latencyMs?: number, responseText?: string }} info
124
+ */
125
+ endTurn(turnId, {
126
+ model = null,
127
+ inputTokens = null,
128
+ outputTokens = null,
129
+ cacheReadTokens = 0,
130
+ cacheWriteTokens = 0,
131
+ stopReason = null,
132
+ latencyMs = null,
133
+ responseText = null,
134
+ } = {}) {
135
+ const now = Date.now();
136
+ this.#prepare('endTurn', `
137
+ UPDATE trace_turns SET
138
+ model = ?, input_tokens = ?, output_tokens = ?,
139
+ cache_read_tokens = ?, cache_write_tokens = ?,
140
+ stop_reason = ?, latency_ms = ?, response_text = ?, ended_at = ?
141
+ WHERE id = ?
142
+ `).run(
143
+ model, inputTokens, outputTokens,
144
+ cacheReadTokens, cacheWriteTokens,
145
+ stopReason, latencyMs, truncate(responseText, MAX_TOOL_OUTPUT),
146
+ now, turnId,
147
+ );
148
+ }
149
+
150
+ /**
151
+ * Log a tool call within a turn.
152
+ * @param {string} turnId
153
+ * @param {{ toolName: string, toolInput?: string, toolOutput?: string, durationMs?: number, isError?: boolean }} info
154
+ * @returns {string} — tool record id
155
+ */
156
+ logTool(turnId, {
157
+ toolName,
158
+ toolInput = null,
159
+ toolOutput = null,
160
+ durationMs = null,
161
+ isError = false,
162
+ }) {
163
+ const id = randomUUID();
164
+ const now = Date.now();
165
+ this.#prepare('insertTool', `
166
+ INSERT INTO trace_tools (id, turn_id, tool_name, tool_input, tool_output, duration_ms, is_error, created_at)
167
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
168
+ `).run(
169
+ id, turnId, toolName,
170
+ truncate(toolInput, MAX_TOOL_OUTPUT),
171
+ truncate(toolOutput, MAX_TOOL_OUTPUT),
172
+ durationMs, isError ? 1 : 0, now,
173
+ );
174
+ return id;
175
+ }
176
+
177
+ /**
178
+ * Log a freeform event.
179
+ * @param {{ traceId: string, eventType: string, eventData?: unknown }} info
180
+ * @returns {string} — event id
181
+ */
182
+ logEvent({ traceId, eventType, eventData = null }) {
183
+ const id = randomUUID();
184
+ const now = Date.now();
185
+ const data = eventData != null ? JSON.stringify(eventData) : null;
186
+ this.#prepare('insertEvent', `
187
+ INSERT INTO trace_events (id, trace_id, event_type, event_data, created_at)
188
+ VALUES (?, ?, ?, ?, ?)
189
+ `).run(id, traceId, eventType, data, now);
190
+ return id;
191
+ }
192
+
193
+ // ─── Read API ────────────────────────────────────────────────
194
+
195
+ /**
196
+ * Query all data for a specific message.
197
+ * @param {string} messageId
198
+ * @returns {{ turns: object[], tools: object[], events: object[] }}
199
+ */
200
+ queryByMessage(messageId) {
201
+ const turns = this.#prepare('turnsByMessage', `
202
+ SELECT * FROM trace_turns WHERE message_id = ? ORDER BY started_at
203
+ `).all(messageId);
204
+ return this.#expandTurns(turns);
205
+ }
206
+
207
+ /**
208
+ * Query all data for a trace.
209
+ * @param {string} traceId
210
+ * @returns {{ turns: object[], tools: object[], events: object[] }}
211
+ */
212
+ queryByTrace(traceId) {
213
+ const turns = this.#prepare('turnsByTrace', `
214
+ SELECT * FROM trace_turns WHERE trace_id = ? ORDER BY started_at
215
+ `).all(traceId);
216
+ const events = this.#prepare('eventsByTrace', `
217
+ SELECT * FROM trace_events WHERE trace_id = ? ORDER BY created_at
218
+ `).all(traceId);
219
+ const turnIds = turns.map(t => t.id);
220
+ const tools = turnIds.length > 0
221
+ ? this.#db.prepare(
222
+ `SELECT * FROM trace_tools WHERE turn_id IN (${turnIds.map(() => '?').join(',')}) ORDER BY created_at`
223
+ ).all(...turnIds)
224
+ : [];
225
+ return { turns, tools, events };
226
+ }
227
+
228
+ /**
229
+ * Query recent turns.
230
+ * @param {number} [limit=20]
231
+ * @returns {object[]}
232
+ */
233
+ queryRecent(limit = 20) {
234
+ return this.#prepare('recentTurns', `
235
+ SELECT * FROM trace_turns ORDER BY started_at DESC LIMIT ?
236
+ `).all(limit);
237
+ }
238
+
239
+ /**
240
+ * Query tool calls with optional filters.
241
+ * @param {{ name?: string, since?: number }} [filters={}]
242
+ * @returns {object[]}
243
+ */
244
+ queryTools({ name = null, since = null } = {}) {
245
+ if (name && since) {
246
+ return this.#prepare('toolsByNameSince', `
247
+ SELECT * FROM trace_tools WHERE tool_name = ? AND created_at >= ? ORDER BY created_at DESC
248
+ `).all(name, since);
249
+ }
250
+ if (name) {
251
+ return this.#prepare('toolsByName', `
252
+ SELECT * FROM trace_tools WHERE tool_name = ? ORDER BY created_at DESC
253
+ `).all(name);
254
+ }
255
+ if (since) {
256
+ return this.#prepare('toolsSince', `
257
+ SELECT * FROM trace_tools WHERE created_at >= ? ORDER BY created_at DESC
258
+ `).all(since);
259
+ }
260
+ return this.#prepare('allTools', `
261
+ SELECT * FROM trace_tools ORDER BY created_at DESC LIMIT 100
262
+ `).all();
263
+ }
264
+
265
+ /**
266
+ * Full-text search across response_text and tool_output.
267
+ * @param {string} keyword
268
+ * @returns {object[]}
269
+ */
270
+ search(keyword) {
271
+ const like = `%${keyword}%`;
272
+ return this.#prepare('search', `
273
+ SELECT DISTINCT t.* FROM trace_turns t
274
+ LEFT JOIN trace_tools tt ON tt.turn_id = t.id
275
+ WHERE t.response_text LIKE ? OR tt.tool_output LIKE ?
276
+ ORDER BY t.started_at DESC LIMIT 50
277
+ `).all(like, like);
278
+ }
279
+
280
+ /**
281
+ * Get trace statistics.
282
+ * @returns {{ turnCount: number, toolCount: number, eventCount: number, dbSizeBytes: number }}
283
+ */
284
+ stats() {
285
+ const turnCount = this.#db.prepare('SELECT COUNT(*) as c FROM trace_turns').get().c;
286
+ const toolCount = this.#db.prepare('SELECT COUNT(*) as c FROM trace_tools').get().c;
287
+ const eventCount = this.#db.prepare('SELECT COUNT(*) as c FROM trace_events').get().c;
288
+ let dbSizeBytes = 0;
289
+ try {
290
+ dbSizeBytes = statSync(this.#dbPath).size;
291
+ } catch { /* ignore */ }
292
+ return { turnCount, toolCount, eventCount, dbSizeBytes };
293
+ }
294
+
295
+ // ─── Maintenance ─────────────────────────────────────────────
296
+
297
+ /**
298
+ * Delete data older than retentionDays.
299
+ * @param {number} [retentionDays=30]
300
+ * @returns {{ deletedTurns: number, deletedTools: number, deletedEvents: number }}
301
+ */
302
+ cleanup(retentionDays = 30) {
303
+ const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
304
+ const deletedTools = this.#db.prepare(`
305
+ DELETE FROM trace_tools WHERE turn_id IN (
306
+ SELECT id FROM trace_turns WHERE started_at < ?
307
+ )
308
+ `).run(cutoff).changes;
309
+ const deletedTurns = this.#db.prepare(`
310
+ DELETE FROM trace_turns WHERE started_at < ?
311
+ `).run(cutoff).changes;
312
+ const deletedEvents = this.#db.prepare(`
313
+ DELETE FROM trace_events WHERE created_at < ?
314
+ `).run(cutoff).changes;
315
+ return { deletedTurns, deletedTools, deletedEvents };
316
+ }
317
+
318
+ /** Delete all trace data. */
319
+ purge() {
320
+ this.#db.exec('DELETE FROM trace_tools');
321
+ this.#db.exec('DELETE FROM trace_turns');
322
+ this.#db.exec('DELETE FROM trace_events');
323
+ }
324
+
325
+ /** Close the database connection. */
326
+ close() {
327
+ this.#db.close();
328
+ }
329
+
330
+ // ─── Internal ────────────────────────────────────────────────
331
+
332
+ /**
333
+ * Get or create a prepared statement.
334
+ * @param {string} key
335
+ * @param {string} sql
336
+ * @returns {Database.Statement}
337
+ */
338
+ #prepare(key, sql) {
339
+ if (!this.#stmts[key]) {
340
+ this.#stmts[key] = this.#db.prepare(sql);
341
+ }
342
+ return this.#stmts[key];
343
+ }
344
+
345
+ /**
346
+ * Expand turns with their tools.
347
+ * @param {object[]} turns
348
+ * @returns {{ turns: object[], tools: object[], events: object[] }}
349
+ */
350
+ #expandTurns(turns) {
351
+ const turnIds = turns.map(t => t.id);
352
+ const tools = turnIds.length > 0
353
+ ? this.#db.prepare(
354
+ `SELECT * FROM trace_tools WHERE turn_id IN (${turnIds.map(() => '?').join(',')}) ORDER BY created_at`
355
+ ).all(...turnIds)
356
+ : [];
357
+ // Events need trace_ids from turns
358
+ const traceIds = [...new Set(turns.map(t => t.trace_id))];
359
+ const events = traceIds.length > 0
360
+ ? this.#db.prepare(
361
+ `SELECT * FROM trace_events WHERE trace_id IN (${traceIds.map(() => '?').join(',')}) ORDER BY created_at`
362
+ ).all(...traceIds)
363
+ : [];
364
+ return { turns, tools, events };
365
+ }
366
+ }
367
+
368
+ /**
369
+ * NullTrace — No-op implementation with the same interface.
370
+ * Used when debug is disabled. Zero overhead.
371
+ */
372
+ export class NullTrace {
373
+ startTurn() { return 'null'; }
374
+ endTurn() {}
375
+ logTool() { return 'null'; }
376
+ logEvent() { return 'null'; }
377
+ queryByMessage() { return { turns: [], tools: [], events: [] }; }
378
+ queryByTrace() { return { turns: [], tools: [], events: [] }; }
379
+ queryRecent() { return []; }
380
+ queryTools() { return []; }
381
+ search() { return []; }
382
+ stats() { return { turnCount: 0, toolCount: 0, eventCount: 0, dbSizeBytes: 0 }; }
383
+ cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0 }; }
384
+ purge() {}
385
+ close() {}
386
+ }
387
+
388
+ /**
389
+ * Create a DebugTrace or NullTrace based on config.
390
+ * @param {{ enabled: boolean, dbPath?: string }} opts
391
+ * @returns {DebugTrace | NullTrace}
392
+ */
393
+ export function createTrace({ enabled, dbPath }) {
394
+ if (!enabled || !dbPath) {
395
+ return new NullTrace();
396
+ }
397
+ return new DebugTrace(dbPath);
398
+ }