@yeaft/webchat-agent 0.1.399 → 0.1.409

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,65 @@
1
+ /**
2
+ * search.js — Conversation history search
3
+ *
4
+ * Simple keyword search across hot and cold messages.
5
+ * Reference: yeaft-unify-core-systems.md §4.3
6
+ */
7
+
8
+ import { existsSync, readdirSync, readFileSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { parseMessage } from './persist.js';
11
+
12
+ /**
13
+ * Search messages in a directory for a keyword.
14
+ *
15
+ * @param {string} dir — messages directory
16
+ * @param {string} keyword — search term (case-insensitive)
17
+ * @param {number} limit — max results
18
+ * @returns {object[]} — matching messages
19
+ */
20
+ function searchDir(dir, keyword, limit) {
21
+ if (!existsSync(dir)) return [];
22
+
23
+ const lowerKeyword = keyword.toLowerCase();
24
+ const files = readdirSync(dir)
25
+ .filter(f => f.endsWith('.md'))
26
+ .sort()
27
+ .reverse(); // newest first
28
+
29
+ const results = [];
30
+ for (const file of files) {
31
+ if (results.length >= limit) break;
32
+
33
+ const raw = readFileSync(join(dir, file), 'utf8');
34
+ if (raw.toLowerCase().includes(lowerKeyword)) {
35
+ const msg = parseMessage(raw);
36
+ if (msg) results.push(msg);
37
+ }
38
+ }
39
+
40
+ return results;
41
+ }
42
+
43
+ /**
44
+ * Search conversation history (hot + cold) for a keyword.
45
+ *
46
+ * @param {string} dir — Yeaft root directory (e.g. ~/.yeaft)
47
+ * @param {string} keyword — search term
48
+ * @param {number} [limit=20] — max results
49
+ * @returns {object[]} — matching messages, newest first
50
+ */
51
+ export function searchMessages(dir, keyword, limit = 20) {
52
+ if (!keyword || !keyword.trim()) return [];
53
+
54
+ const msgDir = join(dir, 'conversation', 'messages');
55
+ const coldDir = join(dir, 'conversation', 'cold');
56
+
57
+ // Search hot first (more recent), then cold
58
+ const hotResults = searchDir(msgDir, keyword, limit);
59
+ const remaining = limit - hotResults.length;
60
+ const coldResults = remaining > 0
61
+ ? searchDir(coldDir, keyword, remaining)
62
+ : [];
63
+
64
+ return [...hotResults, ...coldResults];
65
+ }
@@ -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
+ }