@yeaft/webchat-agent 0.1.794 → 0.1.797

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.
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
36
36
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
37
37
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
38
  import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../unify/config-api.js';
39
- import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyLoadMoreHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, handleUnifyFetchToolStats, broadcastLanguageChange } from '../unify/web-bridge.js';
39
+ import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyLoadMoreHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, handleUnifyFetchToolStats, handleUnifyFetchDebugHistory, broadcastLanguageChange } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -502,6 +502,14 @@ export async function handleMessage(msg) {
502
502
  await handleUnifyFetchToolStats(msg);
503
503
  break;
504
504
 
505
+ // fix-vp-multi-thread (bug 4): hydrate the Unify debug panel from
506
+ // the persistent SQLite trace. Without this, the panel only shows
507
+ // turns that happened after the panel was opened — every previous
508
+ // turn is invisible.
509
+ case 'unify_fetch_debug_history':
510
+ await handleUnifyFetchDebugHistory(msg);
511
+ break;
512
+
505
513
  // Expert roles definition (for ExpertPanel detail view)
506
514
  case 'get_expert_roles': {
507
515
  const { getExpertRolesDefinition } = await import('../expert-roles.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.794",
3
+ "version": "0.1.797",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -28,7 +28,18 @@ const SCHEMA = `
28
28
  latency_ms INTEGER,
29
29
  response_text TEXT,
30
30
  started_at INTEGER NOT NULL,
31
- ended_at INTEGER
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
32
43
  );
33
44
 
34
45
  CREATE TABLE IF NOT EXISTS trace_tools (
@@ -61,9 +72,47 @@ const SCHEMA = `
61
72
  CREATE INDEX IF NOT EXISTS idx_events_type ON trace_events(event_type);
62
73
  `;
63
74
 
75
+ /**
76
+ * Indexes that reference columns added by the v0.1.x fix-vp-multi-thread
77
+ * migration. Must be executed AFTER `migrateAddColumn` for those columns
78
+ * — running them inside the main SCHEMA block would fail on an old DB
79
+ * whose `trace_turns` table predates `group_id` / `vp_id` / `thread_id`
80
+ * (`CREATE TABLE IF NOT EXISTS` is a no-op when the table already
81
+ * exists, so the columns are never added by SCHEMA alone).
82
+ */
83
+ const POST_MIGRATION_INDEXES = `
84
+ CREATE INDEX IF NOT EXISTS idx_turns_group_id ON trace_turns(group_id);
85
+ CREATE INDEX IF NOT EXISTS idx_turns_vp_id ON trace_turns(vp_id);
86
+ CREATE INDEX IF NOT EXISTS idx_turns_thread_id ON trace_turns(thread_id);
87
+ `;
88
+
89
+ /**
90
+ * Idempotent column-adds for pre-existing trace databases. `ALTER TABLE …
91
+ * ADD COLUMN` throws if the column already exists, so we wrap each call.
92
+ * Mirrors the columns introduced above so older DBs upgrade in place.
93
+ */
94
+ function migrateAddColumn(db, table, column, type) {
95
+ try {
96
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
97
+ } catch (err) {
98
+ if (!String(err?.message || err).match(/duplicate column name/i)) {
99
+ throw err;
100
+ }
101
+ }
102
+ }
103
+
64
104
  /** Max tool_output size stored (10KB). Longer outputs are truncated. */
65
105
  const MAX_TOOL_OUTPUT = 10240;
66
106
 
107
+ /**
108
+ * Max per-loop payload (system prompt, messages JSON, raw request /
109
+ * response, response text) stored per row. Larger than MAX_TOOL_OUTPUT
110
+ * because real-world LLM exchanges (system prompt + 30K-token message
111
+ * trail + raw response) routinely cross 10KB. 256KB lets us replay the
112
+ * panel verbatim for the most recent traces without bloating the DB.
113
+ */
114
+ const MAX_LOOP_PAYLOAD = 256 * 1024;
115
+
67
116
  /**
68
117
  * Truncate a string to a max length, appending "... [truncated]" if needed.
69
118
  * @param {string|null|undefined} str
@@ -98,29 +147,53 @@ export class DebugTrace {
98
147
  this.#db.exec('PRAGMA journal_mode = WAL');
99
148
  this.#db.exec('PRAGMA foreign_keys = ON');
100
149
  this.#db.exec(SCHEMA);
150
+ // Forward-compat: a DB created by an older version of the bridge
151
+ // will be missing the group/vp/thread + per-loop snapshot columns.
152
+ // Add them on open; no-op for fresh DBs (column already exists
153
+ // from SCHEMA above).
154
+ migrateAddColumn(this.#db, 'trace_turns', 'group_id', 'TEXT');
155
+ migrateAddColumn(this.#db, 'trace_turns', 'vp_id', 'TEXT');
156
+ migrateAddColumn(this.#db, 'trace_turns', 'thread_id', 'TEXT');
157
+ migrateAddColumn(this.#db, 'trace_turns', 'system_prompt', 'TEXT');
158
+ migrateAddColumn(this.#db, 'trace_turns', 'messages_json', 'TEXT');
159
+ migrateAddColumn(this.#db, 'trace_turns', 'tool_calls_json', 'TEXT');
160
+ migrateAddColumn(this.#db, 'trace_turns', 'usage_json', 'TEXT');
161
+ migrateAddColumn(this.#db, 'trace_turns', 'ttfb_ms', 'INTEGER');
162
+ migrateAddColumn(this.#db, 'trace_turns', 'raw_request', 'TEXT');
163
+ migrateAddColumn(this.#db, 'trace_turns', 'raw_response', 'TEXT');
164
+ // C2 fix: explicit `user_prompt` column. Deriving the prompt from
165
+ // `messages_json` is unsafe because every loop after turn 1 in a
166
+ // multi-loop tool-call cycle persists the *cumulative* conversation
167
+ // snapshot — `messages.find(role==='user')` would return turn 1's
168
+ // text for every subsequent turn, mislabeling every Turn header.
169
+ migrateAddColumn(this.#db, 'trace_turns', 'user_prompt', 'TEXT');
170
+ // Indexes on the just-added columns. Must run AFTER the ALTER TABLEs
171
+ // — running them inside SCHEMA's CREATE INDEX IF NOT EXISTS block
172
+ // would fail with "no such column: group_id" on a pre-bugfix DB.
173
+ this.#db.exec(POST_MIGRATION_INDEXES);
101
174
  }
102
175
 
103
176
  // ─── Write API ───────────────────────────────────────────────
104
177
 
105
178
  /**
106
179
  * Start a new turn.
107
- * @param {{ traceId: string, messageId?: string, mode?: string, turnNumber?: number }} opts
180
+ * @param {{ traceId: string, messageId?: string, mode?: string, turnNumber?: number, groupId?: string, vpId?: string, threadId?: string, userPrompt?: string }} opts
108
181
  * @returns {string} — turnId
109
182
  */
110
- startTurn({ traceId, messageId = null, mode = null, turnNumber = null }) {
183
+ startTurn({ traceId, messageId = null, mode = null, turnNumber = null, groupId = null, vpId = null, threadId = null, userPrompt = null }) {
111
184
  const id = randomUUID();
112
185
  const now = Date.now();
113
186
  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);
187
+ INSERT INTO trace_turns (id, trace_id, message_id, mode, turn_number, started_at, group_id, vp_id, thread_id, user_prompt)
188
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
189
+ `).run(id, traceId, messageId, mode, turnNumber, now, groupId, vpId, threadId, truncate(userPrompt, MAX_LOOP_PAYLOAD));
117
190
  return id;
118
191
  }
119
192
 
120
193
  /**
121
194
  * End a turn with model response info.
122
195
  * @param {string} turnId
123
- * @param {{ model?: string, inputTokens?: number, outputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, stopReason?: string, latencyMs?: number, responseText?: string }} info
196
+ * @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
124
197
  */
125
198
  endTurn(turnId, {
126
199
  model = null,
@@ -131,19 +204,68 @@ export class DebugTrace {
131
204
  stopReason = null,
132
205
  latencyMs = null,
133
206
  responseText = null,
207
+ systemPrompt = null,
208
+ messages = null,
209
+ toolCalls = null,
210
+ usage = null,
211
+ ttfbMs = null,
212
+ rawRequest = null,
213
+ rawResponse = null,
134
214
  } = {}) {
135
215
  const now = Date.now();
216
+ // JSON-stringify the structured fields so they round-trip through
217
+ // SQLite as TEXT. JSON serialisation might fail (cyclic structure /
218
+ // BigInt) — guard with try/catch and persist null on failure so a
219
+ // single bad message can never tank the whole turn record.
220
+ //
221
+ // I2 fix: if the serialised JSON exceeds MAX_LOOP_PAYLOAD, the naïve
222
+ // `truncate(s, MAX)` would append `... [truncated]` mid-string and
223
+ // make the row's JSON unparseable. The reader's `parseJsonSafe` would
224
+ // then silently return null and the panel would render the loop with
225
+ // empty messages / toolCalls / usage. Persist a structured sentinel
226
+ // instead so the panel can render a "[truncated, N bytes]" notice.
227
+ const safeStringify = (v) => {
228
+ if (v == null) return null;
229
+ try {
230
+ const s = JSON.stringify(v);
231
+ if (s.length <= MAX_LOOP_PAYLOAD) return s;
232
+ return JSON.stringify({
233
+ __truncated: true,
234
+ originalBytes: s.length,
235
+ maxBytes: MAX_LOOP_PAYLOAD,
236
+ });
237
+ } catch { return null; }
238
+ };
239
+ // For raw request/response, accept either a pre-stringified blob
240
+ // (treat as opaque text — truncation here is fine because
241
+ // parseJsonSafe is not used on raw_*) or a structured object (route
242
+ // through safeStringify which preserves JSON validity).
243
+ const stringifyRaw = (v) => {
244
+ if (v == null) return null;
245
+ if (typeof v === 'string') return truncate(v, MAX_LOOP_PAYLOAD);
246
+ return safeStringify(v);
247
+ };
136
248
  this.#prepare('endTurn', `
137
249
  UPDATE trace_turns SET
138
250
  model = ?, input_tokens = ?, output_tokens = ?,
139
251
  cache_read_tokens = ?, cache_write_tokens = ?,
140
- stop_reason = ?, latency_ms = ?, response_text = ?, ended_at = ?
252
+ stop_reason = ?, latency_ms = ?, response_text = ?, ended_at = ?,
253
+ system_prompt = ?, messages_json = ?, tool_calls_json = ?,
254
+ usage_json = ?, ttfb_ms = ?, raw_request = ?, raw_response = ?
141
255
  WHERE id = ?
142
256
  `).run(
143
257
  model, inputTokens, outputTokens,
144
258
  cacheReadTokens, cacheWriteTokens,
145
- stopReason, latencyMs, truncate(responseText, MAX_TOOL_OUTPUT),
146
- now, turnId,
259
+ stopReason, latencyMs, truncate(responseText, MAX_LOOP_PAYLOAD),
260
+ now,
261
+ truncate(systemPrompt, MAX_LOOP_PAYLOAD),
262
+ safeStringify(messages),
263
+ safeStringify(toolCalls),
264
+ safeStringify(usage),
265
+ ttfbMs,
266
+ stringifyRaw(rawRequest),
267
+ stringifyRaw(rawResponse),
268
+ turnId,
147
269
  );
148
270
  }
149
271
 
@@ -236,6 +358,125 @@ export class DebugTrace {
236
358
  `).all(limit);
237
359
  }
238
360
 
361
+ /**
362
+ * Fetch the recent debug history for the UnifyDebugPanel. Returns one
363
+ * record per LLM loop (ordered oldest → newest) with the structured
364
+ * fields the panel expects. JSON columns are parsed; truncated /
365
+ * malformed payloads degrade to null instead of failing the call.
366
+ *
367
+ * @param {{ limit?: number, groupId?: string|null, threadId?: string|null }} [opts]
368
+ * @returns {{ loops: object[], turns: object[] }}
369
+ */
370
+ fetchRecentDebugHistory({ limit = 100, groupId = null, threadId = null } = {}) {
371
+ const lim = Math.max(1, Math.min(500, Number(limit) || 100));
372
+ const where = [];
373
+ const args = [];
374
+ if (groupId) { where.push('group_id = ?'); args.push(groupId); }
375
+ if (threadId) { where.push('thread_id = ?'); args.push(threadId); }
376
+ const sql = `
377
+ SELECT * FROM trace_turns
378
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
379
+ ORDER BY started_at DESC
380
+ LIMIT ?
381
+ `;
382
+ args.push(lim);
383
+ const rows = this.#db.prepare(sql).all(...args);
384
+ const turnIds = rows.map(r => r.id);
385
+ const tools = turnIds.length > 0
386
+ ? this.#db.prepare(
387
+ `SELECT * FROM trace_tools WHERE turn_id IN (${turnIds.map(() => '?').join(',')}) ORDER BY created_at`
388
+ ).all(...turnIds)
389
+ : [];
390
+ const parseJsonSafe = (s) => {
391
+ if (s == null) return null;
392
+ try { return JSON.parse(s); }
393
+ catch { return null; }
394
+ };
395
+ // Group rows by (turnId, threadId, groupId, vpId) → frontend Turn
396
+ // record. Each row is also surfaced as a Loop.
397
+ const turnsById = new Map();
398
+ const loops = rows.map((r) => {
399
+ const parsedMessages = parseJsonSafe(r.messages_json) || [];
400
+ const parsedUsage = parseJsonSafe(r.usage_json);
401
+ const loop = {
402
+ turnId: r.trace_id, // panel groups loops by trace_id-as-turnId
403
+ loopNumber: r.turn_number || 0,
404
+ model: r.model || null,
405
+ systemPrompt: r.system_prompt || '',
406
+ messages: parsedMessages,
407
+ response: r.response_text || '',
408
+ toolCalls: parseJsonSafe(r.tool_calls_json) || [],
409
+ usage: parsedUsage || {
410
+ inputTokens: r.input_tokens || 0,
411
+ outputTokens: r.output_tokens || 0,
412
+ totalTokens: (r.input_tokens || 0) + (r.output_tokens || 0),
413
+ },
414
+ latencyMs: r.latency_ms || 0,
415
+ ttfbMs: r.ttfb_ms || null,
416
+ stopReason: r.stop_reason || null,
417
+ rawRequest: r.raw_request || null,
418
+ rawResponse: r.raw_response || null,
419
+ groupId: r.group_id || null,
420
+ vpId: r.vp_id || null,
421
+ threadId: r.thread_id || null,
422
+ };
423
+ if (!turnsById.has(r.trace_id)) {
424
+ turnsById.set(r.trace_id, {
425
+ turnId: r.trace_id,
426
+ // C2 fix: read the explicit `user_prompt` column persisted at
427
+ // startTurn time. Deriving from messages_json is unsafe — each
428
+ // tool-loop iteration overwrites messages_json with the
429
+ // cumulative conversation snapshot, so `messages[0].content`
430
+ // would be turn-1's prompt for every subsequent turn header.
431
+ userPrompt: r.user_prompt || '',
432
+ groupId: r.group_id || null,
433
+ vpId: r.vp_id || null,
434
+ threadId: r.thread_id || null,
435
+ openedAt: r.started_at || 0,
436
+ closedAt: r.ended_at || null,
437
+ totalMs: 0,
438
+ totalTokens: 0,
439
+ loopCount: 0,
440
+ memoryLoaded: null,
441
+ memoryAdjust: null,
442
+ tools: [],
443
+ });
444
+ }
445
+ const t = turnsById.get(r.trace_id);
446
+ t.loopCount += 1;
447
+ // Aggregate per-loop latency / tokens so the Turn header shows the
448
+ // same totals the live `turn_close` event would have stamped.
449
+ t.totalMs += r.latency_ms || 0;
450
+ const usageTokens = parsedUsage && Number.isFinite(parsedUsage.totalTokens)
451
+ ? parsedUsage.totalTokens
452
+ : (r.input_tokens || 0) + (r.output_tokens || 0);
453
+ t.totalTokens += usageTokens;
454
+ if (r.ended_at && (!t.closedAt || r.ended_at > t.closedAt)) t.closedAt = r.ended_at;
455
+ return loop;
456
+ });
457
+ // Attach tools to their parent Turn so the panel can render per-tool
458
+ // timing without scanning the loop bodies.
459
+ for (const tool of tools) {
460
+ // Find which loop row this tool belongs to → that row's trace_id
461
+ // identifies the Turn.
462
+ const owner = rows.find(r => r.id === tool.turn_id);
463
+ if (!owner) continue;
464
+ const t = turnsById.get(owner.trace_id);
465
+ if (!t) continue;
466
+ t.tools.push({
467
+ loopNumber: owner.turn_number || 0,
468
+ callId: tool.id,
469
+ name: tool.tool_name,
470
+ durationMs: tool.duration_ms || 0,
471
+ isError: !!tool.is_error,
472
+ });
473
+ }
474
+ // Reverse to oldest-first so the panel's existing append-driven UI
475
+ // renders in chronological order on hydration.
476
+ loops.reverse();
477
+ return { loops, turns: Array.from(turnsById.values()) };
478
+ }
479
+
239
480
  /**
240
481
  * Query tool calls with optional filters.
241
482
  * @param {{ name?: string, since?: number }} [filters={}]
@@ -383,6 +624,7 @@ export class NullTrace {
383
624
  cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0 }; }
384
625
  purge() {}
385
626
  close() {}
627
+ fetchRecentDebugHistory() { return { loops: [], turns: [] }; }
386
628
  }
387
629
 
388
630
  /**