@yeaft/webchat-agent 1.0.351 → 1.0.352

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.
Files changed (45) hide show
  1. package/connection/message-router.js +29 -1
  2. package/index.js +2 -0
  3. package/local-runtime/server/handlers/agent-sync.js +14 -0
  4. package/local-runtime/server/handlers/client-misc.js +21 -0
  5. package/local-runtime/version.json +1 -1
  6. package/local-runtime/web/app.bundle.js +52 -33
  7. package/local-runtime/web/app.bundle.js.gz +0 -0
  8. package/local-runtime/web/index.html +2 -2
  9. package/local-runtime/web/style.bundle.css +1 -1
  10. package/local-runtime/web/style.bundle.css.gz +0 -0
  11. package/package.json +1 -1
  12. package/yeaft/config-api.js +53 -1
  13. package/yeaft/config.js +54 -0
  14. package/yeaft/conversation/history-index-worker.js +13 -10
  15. package/yeaft/conversation/internal-control.js +1 -0
  16. package/yeaft/debug-trace.js +164 -47
  17. package/yeaft/engine.js +318 -28
  18. package/yeaft/llm/adapter.js +38 -0
  19. package/yeaft/llm/anthropic.js +11 -8
  20. package/yeaft/llm/openai-responses.js +11 -8
  21. package/yeaft/llm/router.js +1 -1
  22. package/yeaft/perf-trace.js +156 -24
  23. package/yeaft/session.js +7 -0
  24. package/yeaft/sessions/session-crud.js +19 -4
  25. package/yeaft/sub-agent/runner.js +4 -0
  26. package/yeaft/tools/agent.js +4 -0
  27. package/yeaft/tools/ask-user.js +1 -0
  28. package/yeaft/tools/bash.js +4 -0
  29. package/yeaft/tools/create-work-item.js +3 -0
  30. package/yeaft/tools/file-read.js +1 -0
  31. package/yeaft/tools/glob.js +1 -0
  32. package/yeaft/tools/grep.js +1 -0
  33. package/yeaft/tools/history-search.js +74 -20
  34. package/yeaft/tools/js-repl.js +1 -0
  35. package/yeaft/tools/list-agents.js +1 -0
  36. package/yeaft/tools/list-dir.js +1 -0
  37. package/yeaft/tools/list-tasks.js +1 -0
  38. package/yeaft/tools/read-task-log.js +1 -0
  39. package/yeaft/tools/route-forward.js +4 -0
  40. package/yeaft/tools/send-message.js +3 -0
  41. package/yeaft/tools/types.js +8 -0
  42. package/yeaft/tools/wait-agent.js +1 -0
  43. package/yeaft/utf8.js +44 -0
  44. package/yeaft/web-bridge.js +6 -0
  45. package/yeaft/work-center/runner.js +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.351",
3
+ "version": "1.0.352",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -12,7 +12,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
12
12
  import { join } from 'path';
13
13
  import { DEFAULT_YEAFT_DIR } from './init.js';
14
14
  import { normalizeProviderModels, parseModelRef, serializeModelForPersistence } from './models.js';
15
- import { normaliseYeaftSection } from './config.js';
15
+ import { normaliseTelemetrySection, normaliseYeaftSection } from './config.js';
16
16
  import { isGitHubCopilotProvider, serializeKnownProviderForPersistence } from './llm/known-providers.js';
17
17
 
18
18
  /**
@@ -293,6 +293,58 @@ export function updateYeaftSettings(update, dir) {
293
293
  return merged;
294
294
  }
295
295
 
296
+ /**
297
+ * Read the bounded local telemetry settings.
298
+ *
299
+ * Raw provider exchanges are kept only for the debug trace and are bounded by
300
+ * bytes. This endpoint never returns secrets or trace payloads.
301
+ *
302
+ * @param {string} [dir]
303
+ * @returns {object | { error: string }}
304
+ */
305
+ export function getTelemetrySettings(dir) {
306
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
307
+ const configPath = join(root, 'config.json');
308
+ if (!existsSync(configPath)) return normaliseTelemetrySection(null);
309
+ try {
310
+ const json = JSON.parse(readFileSync(configPath, 'utf8'));
311
+ return normaliseTelemetrySection(json.telemetry);
312
+ } catch (e) {
313
+ return { error: `Failed to read config.json: ${e.message}` };
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Update only the telemetry section of config.json.
319
+ *
320
+ * @param {object} update
321
+ * @param {string} [dir]
322
+ * @returns {object | { error: string }}
323
+ */
324
+ export function updateTelemetrySettings(update, dir) {
325
+ if (!update || typeof update !== 'object' || Array.isArray(update)) {
326
+ return { error: 'update payload required' };
327
+ }
328
+ const allowed = new Set(['enabled', 'retentionDays', 'flushIntervalMs', 'maxQueueSize', 'rawExchangeMaxBytes', 'traceTextMaxBytes']);
329
+ if (Object.keys(update).some(key => !allowed.has(key))) {
330
+ return { error: 'unknown telemetry setting' };
331
+ }
332
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
333
+ const configPath = join(root, 'config.json');
334
+ const existing = readConfigJson(configPath);
335
+ const merged = normaliseTelemetrySection({
336
+ ...(existing.telemetry && typeof existing.telemetry === 'object' ? existing.telemetry : {}),
337
+ ...update,
338
+ });
339
+ existing.telemetry = merged;
340
+ try {
341
+ writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
342
+ } catch (e) {
343
+ return { error: `Failed to write config.json: ${e.message}` };
344
+ }
345
+ return merged;
346
+ }
347
+
296
348
  // ─── Search settings (web-search backend selection + Tavily key) ────
297
349
 
298
350
  /**
package/yeaft/config.js CHANGED
@@ -57,6 +57,18 @@ const DEFAULTS = {
57
57
  // block is injected). Hand-edited values are NOT clamped — we let
58
58
  // power users opt into larger docs at their own context-window risk.
59
59
  projectDocMaxBytes: 32 * 1024,
60
+ // ─── Performance telemetry ─────────────────────────────
61
+ // Telemetry is local, best-effort diagnostics. The agent buffers events and
62
+ // flushes them in batches; raw provider responses are bounded separately so
63
+ // a long SSE stream cannot grow the debug payload without limit.
64
+ telemetry: {
65
+ enabled: true,
66
+ retentionDays: 3,
67
+ flushIntervalMs: 1_000,
68
+ maxQueueSize: 5_000,
69
+ rawExchangeMaxBytes: 512 * 1024,
70
+ traceTextMaxBytes: 256 * 1024,
71
+ },
60
72
  // ─── LLM retry policy ──────────────────────────────────────
61
73
  // How the engine reacts when adapter.stream()/call() throws a
62
74
  // retryable error (429 / 529 / 5xx / transport failure). Each field
@@ -280,6 +292,46 @@ export function clampYeaftField(v, field) {
280
292
  return Math.min(hi, Math.max(lo, Math.floor(n)));
281
293
  }
282
294
 
295
+ /**
296
+ * Normalize the local performance telemetry section.
297
+ *
298
+ * `debug` controls human-facing verbosity; this section controls bounded,
299
+ * local timing diagnostics. Unknown keys are dropped so hand-edited config
300
+ * cannot leak arbitrary values into the engine hot path.
301
+ *
302
+ * @param {unknown} raw
303
+ * @returns {{ enabled: boolean, retentionDays: number, flushIntervalMs: number, maxQueueSize: number, rawExchangeMaxBytes: number, traceTextMaxBytes: number }}
304
+ */
305
+ export function normaliseTelemetrySection(raw) {
306
+ const defaults = DEFAULTS.telemetry;
307
+ const out = { ...defaults };
308
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
309
+ const value = /** @type {Record<string, unknown>} */ (raw);
310
+ if (typeof value.enabled === 'boolean') out.enabled = value.enabled;
311
+
312
+ const normalizeInteger = (candidate, min, max, fallback) => {
313
+ const number = Number(candidate);
314
+ if (!Number.isFinite(number)) return fallback;
315
+ return Math.min(max, Math.max(min, Math.floor(number)));
316
+ };
317
+ out.retentionDays = normalizeInteger(value.retentionDays, 1, 3650, defaults.retentionDays);
318
+ out.flushIntervalMs = normalizeInteger(value.flushIntervalMs, 0, 60_000, defaults.flushIntervalMs);
319
+ out.maxQueueSize = normalizeInteger(value.maxQueueSize, 100, 50_000, defaults.maxQueueSize);
320
+ out.rawExchangeMaxBytes = normalizeInteger(
321
+ value.rawExchangeMaxBytes,
322
+ 0,
323
+ 4 * 1024 * 1024,
324
+ defaults.rawExchangeMaxBytes,
325
+ );
326
+ out.traceTextMaxBytes = normalizeInteger(
327
+ value.traceTextMaxBytes,
328
+ 0,
329
+ 4 * 1024 * 1024,
330
+ defaults.traceTextMaxBytes,
331
+ );
332
+ return out;
333
+ }
334
+
283
335
  /**
284
336
  * Build config from legacy config.md + .env + env vars.
285
337
  * @deprecated — used only when config.json doesn't exist.
@@ -313,6 +365,7 @@ function loadLegacyConfig(dir, overrides) {
313
365
  projectDocMaxBytes: overrides.projectDocMaxBytes ?? fileConfig.projectDocMaxBytes ?? DEFAULTS.projectDocMaxBytes,
314
366
  // task-318: legacy path never had the `yeaft` section — defaults.
315
367
  yeaft: normaliseYeaftSection(null),
368
+ telemetry: normaliseTelemetrySection(null),
316
369
  providers: null,
317
370
  primaryModel: null,
318
371
  fastModel: null,
@@ -446,6 +499,7 @@ export function loadConfig(overrides = {}) {
446
499
  // task-318: Yeaft runtime caps. `yeaft` is a nested section so we
447
500
  // don't pollute the flat config namespace used by chat code.
448
501
  yeaft: normaliseYeaftSection(jsonConfig.yeaft),
502
+ telemetry: normaliseTelemetrySection(jsonConfig.telemetry),
449
503
 
450
504
  // Legacy fields (null when using config.json)
451
505
  apiKey: overrides.apiKey || null,
@@ -325,8 +325,9 @@ function queryIndex(request) {
325
325
  const boundary = pageBoundary(db, request, generation);
326
326
  const senderKey = typeof request.senderKey === 'string' ? request.senderKey : '';
327
327
  const normalized = normalizeLiteralSearch(String(request.query || '').trim());
328
- const queryLength = codePoints(normalized).length;
329
- const limit = Math.min(50, Math.max(1, Number(request.limit) || 20));
328
+ const terms = normalized.split(/\s+/u).filter(Boolean);
329
+ const queryLength = codePoints(terms[0] || '').length;
330
+ const limit = Math.min(100, Math.max(1, Number(request.limit) || 20));
330
331
  const matches = [];
331
332
  let candidateRowsRead = 0;
332
333
  let candidateBytesRead = 0;
@@ -344,12 +345,14 @@ function queryIndex(request) {
344
345
  params.push(batchEndSeq, batchEndSeq, batchEntryId);
345
346
  }
346
347
  if (queryLength >= 3) {
347
- const firstTrigram = codePoints(normalized).slice(0, 3).join('');
348
+ const grams = terms
349
+ .filter(term => codePoints(term).length >= 3)
350
+ .map(term => codePoints(term).slice(0, 3).join(''));
348
351
  sql += ' AND rowid IN (SELECT rowid FROM entry_fts WHERE entry_fts MATCH ?)';
349
- params.push(`"${firstTrigram.replaceAll('"', '""')}"`);
352
+ params.push(grams.map(gram => `"${gram.replaceAll('"', '""')}"`).join(' AND '));
350
353
  } else if (queryLength > 0) {
351
354
  sql += ' AND yeaft_bloom_contains(short_bloom, ?) = 1';
352
- params.push(normalized);
355
+ params.push(terms[0]);
353
356
  }
354
357
  if (senderKey === 'user') sql += " AND role='user'";
355
358
  else if (senderKey.startsWith('vp:')) {
@@ -374,9 +377,9 @@ function queryIndex(request) {
374
377
  candidateRowsRead += 1;
375
378
  candidateBytesRead += bytes;
376
379
  lastRow = row;
377
- const matchIndex = normalized ? findLiteralSearch(row.text_body, normalized) : 0;
378
- if (matchIndex < 0) continue;
379
- matches.push({ row, matchIndex });
380
+ const matchIndexes = terms.map(term => findLiteralSearch(row.text_body, term));
381
+ if (matchIndexes.some(index => index < 0)) continue;
382
+ matches.push({ row, matchIndex: matchIndexes[0] || 0 });
380
383
  if (matches.length > limit) break;
381
384
  }
382
385
  maxBatchRows = Math.max(maxBatchRows, batchRows);
@@ -395,10 +398,10 @@ function queryIndex(request) {
395
398
  const result = parseEntry(row, generation);
396
399
  const radius = 90;
397
400
  const start = Math.max(0, matchIndex - radius);
398
- const end = Math.min(row.text_body.length, matchIndex + normalized.length + radius);
401
+ const end = Math.min(row.text_body.length, matchIndex + (terms[0] || '').length + radius);
399
402
  return {
400
403
  ...result,
401
- snippet: normalized
404
+ snippet: terms.length > 0
402
405
  ? `${start > 0 ? '…' : ''}${row.text_body.slice(start, end)}${end < row.text_body.length ? '…' : ''}`
403
406
  : row.text_body.slice(0, 180),
404
407
  };
@@ -11,6 +11,7 @@ export function isInternalControlContent(content) {
11
11
  if (typeof content !== 'string') return false;
12
12
  const text = content.trimStart();
13
13
  return text.startsWith('<task-result ')
14
+ || text.startsWith('[system note] Async task completion for ')
14
15
  || /^\[system note\] You have called \S+ with the same arguments \d+ times\./.test(text);
15
16
  }
16
17
 
@@ -13,6 +13,7 @@
13
13
  import { promises as fsp } from 'fs';
14
14
  import { basename, dirname, extname, join } from 'path';
15
15
  import { createHash, randomUUID } from 'crypto';
16
+ import { truncateUtf8Text } from './perf-trace.js';
16
17
 
17
18
  const TRACE_VERSION = 3;
18
19
  const REQUEST_RETENTION = 10;
@@ -33,11 +34,18 @@ const MAX_RAW_RESPONSE_BYTES = 64 * 1024;
33
34
  const TRACE_APPEND_BATCH_MS = 100;
34
35
  const EVENT_FLUSH_INTERVAL_MS = 30_000;
35
36
  const MAX_SEARCH_PATTERN_CHARS = 300;
37
+ const DEFAULT_TRACE_TEXT_MAX_BYTES = 256 * 1024;
36
38
 
37
39
  function isPlainObject(value) {
38
40
  return value && typeof value === 'object' && !Array.isArray(value);
39
41
  }
40
42
 
43
+ function normalizeTextMaxBytes(value, fallback = DEFAULT_TRACE_TEXT_MAX_BYTES) {
44
+ const parsed = Number(value);
45
+ if (!Number.isFinite(parsed)) return fallback;
46
+ return Math.min(4 * 1024 * 1024, Math.max(0, Math.floor(parsed)));
47
+ }
48
+
41
49
  function safeDirComponent(value, fallback = 'unknown') {
42
50
  const raw = String(value || '').trim();
43
51
  if (!raw) return fallback;
@@ -149,10 +157,13 @@ function traceMatchesRegex(trace, regex) {
149
157
  function truncateText(value, maxBytes = MAX_TEXT_BYTES) {
150
158
  if (value == null) return value ?? null;
151
159
  const str = String(value);
152
- if (Buffer.byteLength(str, 'utf8') <= maxBytes) return str;
153
- let out = str.slice(0, maxBytes);
154
- while (Buffer.byteLength(out, 'utf8') > maxBytes && out.length > 0) out = out.slice(0, -1);
155
- return `${out}\n... [truncated to ${maxBytes} bytes]`;
160
+ const budget = Math.max(0, Number(maxBytes) || 0);
161
+ if (Buffer.byteLength(str, 'utf8') <= budget) return str;
162
+ if (budget <= 0) return '';
163
+ const marker = `\n... [truncated to ${budget} bytes]`;
164
+ const markerBytes = Buffer.byteLength(marker, 'utf8');
165
+ if (markerBytes >= budget) return truncateUtf8Text(str, budget).value;
166
+ return `${truncateUtf8Text(str, budget - markerBytes).value}${marker}`;
156
167
  }
157
168
 
158
169
  function cloneJsonValue(value) {
@@ -165,12 +176,26 @@ function safeJsonValue(value, maxBytes = MAX_INLINE_VALUE_BYTES) {
165
176
  if (value == null) return value;
166
177
  try {
167
178
  const json = JSON.stringify(value);
168
- if (Buffer.byteLength(json, 'utf8') <= maxBytes) return JSON.parse(json);
169
- return {
170
- __truncated: true,
171
- originalBytes: Buffer.byteLength(json, 'utf8'),
172
- maxBytes,
173
- };
179
+ const originalBytes = Buffer.byteLength(json, 'utf8');
180
+ if (originalBytes <= maxBytes) return JSON.parse(json);
181
+ // A bounded raw-exchange sentinel already carries the useful preview.
182
+ // Preserve a re-bounded preview rather than reducing persisted history to
183
+ // metadata after an outer trace record crosses its storage budget.
184
+ if (value && typeof value === 'object' && value.__truncated === true
185
+ && typeof value.preview === 'string') {
186
+ const previewBudget = Math.max(0, maxBytes - 512);
187
+ const preview = truncateText(value.preview, previewBudget);
188
+ return {
189
+ __truncated: true,
190
+ ...(value.reason ? { reason: value.reason } : {}),
191
+ originalBytes: Number.isFinite(Number(value.originalBytes))
192
+ ? Number(value.originalBytes)
193
+ : originalBytes,
194
+ maxBytes,
195
+ ...(preview ? { preview } : {}),
196
+ };
197
+ }
198
+ return { __truncated: true, originalBytes, maxBytes };
174
199
  } catch {
175
200
  return null;
176
201
  }
@@ -207,8 +232,11 @@ function jsonByteLength(value) {
207
232
  }
208
233
 
209
234
  function rawRequestSentinel(reason, value = null, maxBytes = MAX_RAW_REQUEST_BYTES) {
210
- const preview = typeof value === 'string'
211
- ? truncateText(value, Math.min(64 * 1024, maxBytes))
235
+ const previewSource = typeof value === 'string'
236
+ ? value
237
+ : (() => { try { return JSON.stringify(value); } catch { return null; } })();
238
+ const preview = typeof previewSource === 'string'
239
+ ? truncateText(previewSource, Math.min(64 * 1024, maxBytes))
212
240
  : null;
213
241
  const originalBytes = value == null ? null : jsonByteLength(value);
214
242
  return {
@@ -228,25 +256,6 @@ function boundRawValue(value, reason = 'raw_request_budget') {
228
256
  return rawRequestSentinel(reason, value);
229
257
  }
230
258
 
231
- function buildRawRequestBase(value) {
232
- if (value == null) return null;
233
- if (typeof value === 'string') return truncateText(value, MAX_RAW_REQUEST_BYTES);
234
- if (!isPlainObject(value)) return boundRawValue(value);
235
- const base = {};
236
- for (const [key, item] of Object.entries(value)) {
237
- if (key === 'body' && isPlainObject(item)) {
238
- const body = {};
239
- for (const [bodyKey, bodyValue] of Object.entries(item)) {
240
- body[bodyKey] = boundRawValue(bodyValue, `raw_request_body_${bodyKey}_budget`);
241
- }
242
- base.body = body;
243
- } else {
244
- base[key] = boundRawValue(item, `raw_request_${key}_budget`);
245
- }
246
- }
247
- return base;
248
- }
249
-
250
259
  function buildRawMessagesDelta(previousMessages, nextMessages) {
251
260
  if (!Array.isArray(nextMessages)) return null;
252
261
  const priorMessages = Array.isArray(previousMessages) ? previousMessages : [];
@@ -373,6 +382,11 @@ export function applyRawRequestDelta(previous, delta) {
373
382
  const existing = Array.isArray(body[messageKey]) ? body[messageKey] : [];
374
383
  const from = Number.isFinite(Number(delta.body.messagesFrom)) ? Number(delta.body.messagesFrom) : existing.length;
375
384
  body[messageKey] = existing.slice(0, from).concat(cloneJsonValue(delta.body.messagesAppend) || []);
385
+ } else if (Object.prototype.hasOwnProperty.call(delta.body, 'messagesAppend')) {
386
+ // A huge append is represented by a bounded raw-request sentinel, not
387
+ // an array. Preserve that sentinel rather than silently dropping the
388
+ // entire request body during hydration.
389
+ body[messageKey] = cloneJsonValue(delta.body.messagesAppend) ?? delta.body.messagesAppend;
376
390
  }
377
391
  next.body = body;
378
392
  }
@@ -389,10 +403,82 @@ function messagesPrefixLength(prevMessages, nextMessages) {
389
403
  return i;
390
404
  }
391
405
 
392
- function buildRequestSnapshot(info = {}) {
406
+ function snapshotTruncation(path, maxBytes, originalBytes) {
407
+ return { __truncated: true, path, maxBytes, originalBytes };
408
+ }
409
+
410
+ function boundedSnapshotString(value, maxBytes, path) {
411
+ const originalBytes = Buffer.byteLength(value, 'utf8');
412
+ if (originalBytes <= maxBytes) return value;
413
+ const marker = snapshotTruncation(path, maxBytes, originalBytes);
414
+ if (jsonByteLength(marker) > maxBytes) return null;
415
+ const markerText = `\n... [truncated to ${maxBytes} bytes]`;
416
+ const markerBytes = Buffer.byteLength(markerText, 'utf8');
417
+ if (markerBytes >= maxBytes) return marker;
418
+ return truncateUtf8Text(value, maxBytes - markerBytes).value + markerText;
419
+ }
420
+
421
+ function boundSnapshotValue(value, maxBytes, path = 'messages') {
422
+ if (maxBytes <= 0) return null;
423
+ const sourceBytes = jsonByteLength(value);
424
+ if (sourceBytes <= maxBytes) return cloneJsonValue(value);
425
+ const marker = snapshotTruncation(path, maxBytes, sourceBytes);
426
+ if (jsonByteLength(marker) > maxBytes) return null;
427
+ if (value == null || typeof value === 'number' || typeof value === 'boolean') return marker;
428
+ if (typeof value === 'string') return boundedSnapshotString(value, maxBytes, path);
429
+
430
+ // Account JSON framing incrementally. The old clone-and-stringify-prefix
431
+ // loop reserialized the full growing array/object per member and blocked
432
+ // DebugTrace.endTurn for tens of seconds on a large provider history.
433
+ if (Array.isArray(value)) {
434
+ const out = [];
435
+ let usedBytes = 2; // []
436
+ for (let index = 0; index < value.length; index += 1) {
437
+ const separatorBytes = out.length > 0 ? 1 : 0;
438
+ const remaining = Math.max(0, maxBytes - usedBytes - separatorBytes);
439
+ const candidate = boundSnapshotValue(value[index], remaining, `${path}[${index}]`);
440
+ if (candidate == null) break;
441
+ let encoded;
442
+ try { encoded = JSON.stringify(candidate); } catch { break; }
443
+ const candidateBytes = Buffer.byteLength(encoded, 'utf8');
444
+ if (usedBytes + separatorBytes + candidateBytes > maxBytes) break;
445
+ out.push(candidate);
446
+ usedBytes += separatorBytes + candidateBytes;
447
+ }
448
+ return out.length > 0 ? out : marker;
449
+ }
450
+ if (typeof value === 'object') {
451
+ const out = {};
452
+ let usedBytes = 2; // {}
453
+ let count = 0;
454
+ for (const [key, item] of Object.entries(value)) {
455
+ let keyJson;
456
+ try { keyJson = JSON.stringify(key); } catch { break; }
457
+ const keyBytes = Buffer.byteLength(keyJson, 'utf8');
458
+ const separatorBytes = count > 0 ? 1 : 0;
459
+ const remaining = Math.max(0, maxBytes - usedBytes - separatorBytes - keyBytes - 1);
460
+ const candidate = boundSnapshotValue(item, remaining, `${path}.${key}`);
461
+ if (candidate == null) break;
462
+ let encoded;
463
+ try { encoded = JSON.stringify(candidate); } catch { break; }
464
+ const candidateBytes = Buffer.byteLength(encoded, 'utf8');
465
+ if (usedBytes + separatorBytes + keyBytes + 1 + candidateBytes > maxBytes) break;
466
+ out[key] = candidate;
467
+ usedBytes += separatorBytes + keyBytes + 1 + candidateBytes;
468
+ count += 1;
469
+ }
470
+ return count > 0 ? out : marker;
471
+ }
472
+ return marker;
473
+ }
474
+
475
+ function buildRequestSnapshot(info = {}, textMaxBytes = DEFAULT_TRACE_TEXT_MAX_BYTES) {
476
+ const messageBudget = normalizeTextMaxBytes(textMaxBytes);
393
477
  return {
394
- systemPrompt: truncateText(info.systemPrompt || '', MAX_TEXT_BYTES),
395
- messages: Array.isArray(info.messages) ? cloneJsonValue(info.messages) : [],
478
+ systemPrompt: truncateText(info.systemPrompt || '', messageBudget),
479
+ messages: Array.isArray(info.messages)
480
+ ? boundSnapshotValue(info.messages, messageBudget, 'messages')
481
+ : [],
396
482
  rawRequest: info.rawRequest ?? null,
397
483
  };
398
484
  }
@@ -438,7 +524,7 @@ function applyRequestDelta(previous, delta = {}) {
438
524
  const nextBase = {
439
525
  systemPrompt: delta.systemPrompt || '',
440
526
  messages: Array.isArray(delta.messages) ? delta.messages : [],
441
- rawRequest: null,
527
+ rawRequest: Object.prototype.hasOwnProperty.call(delta, 'rawRequest') ? delta.rawRequest : null,
442
528
  };
443
529
  if (Object.prototype.hasOwnProperty.call(delta, 'rawRequestDelta')) {
444
530
  nextBase.rawRequest = applyRawRequestDelta(null, delta.rawRequestDelta);
@@ -627,9 +713,13 @@ function summarizeTrace(trace, detailsLoaded = false) {
627
713
  function expandTrace(trace) {
628
714
  const turnsById = new Map([[trace.requestId || trace.traceId, summarizeTrace(trace, true)]]);
629
715
  let snapshot = null;
716
+ let rawRequest = trace?.baseRequest?.rawRequest ?? null;
630
717
  const loops = [];
631
718
  for (const loop of Array.isArray(trace?.loops) ? trace.loops : []) {
632
719
  snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
720
+ if (loop?.requestDelta && Object.prototype.hasOwnProperty.call(loop.requestDelta, 'rawRequestDelta')) {
721
+ rawRequest = reconstructDebugRawRequest(rawRequest, loop.requestDelta);
722
+ }
633
723
  const usage = normalizeUsage(loop?.usage || {});
634
724
  loops.push({
635
725
  turnId: trace.requestId || trace.traceId,
@@ -645,7 +735,7 @@ function expandTrace(trace) {
645
735
  ttfbMs: loop.ttfbMs || null,
646
736
  stopReason: loop.stopReason || null,
647
737
  at: loop.at || null,
648
- rawRequest: null,
738
+ rawRequest,
649
739
  rawResponse: loop.rawResponse ?? null,
650
740
  requestDelta: loop.requestDelta || {},
651
741
  requestBase: trace.baseRequest || null,
@@ -659,8 +749,12 @@ function expandTrace(trace) {
659
749
 
660
750
  function traceToLegacyRows(trace) {
661
751
  let snapshot = null;
752
+ let rawRequest = trace?.baseRequest?.rawRequest ?? null;
662
753
  return (Array.isArray(trace?.loops) ? trace.loops : []).map((loop) => {
663
754
  snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
755
+ if (loop?.requestDelta && Object.prototype.hasOwnProperty.call(loop.requestDelta, 'rawRequestDelta')) {
756
+ rawRequest = reconstructDebugRawRequest(rawRequest, loop.requestDelta);
757
+ }
664
758
  const u = normalizeUsage(loop?.usage || {});
665
759
  return {
666
760
  id: loop.turnRowId || loop.loopInstanceId || randomUUID(),
@@ -686,7 +780,7 @@ function traceToLegacyRows(trace) {
686
780
  tool_calls_json: JSON.stringify(loop.toolCalls || []),
687
781
  usage_json: JSON.stringify(u),
688
782
  ttfb_ms: loop.ttfbMs || null,
689
- raw_request: null,
783
+ raw_request: rawRequest == null ? null : JSON.stringify(rawRequest),
690
784
  raw_response: typeof loop.rawResponse === 'string' ? loop.rawResponse : JSON.stringify(loop.rawResponse ?? null),
691
785
  user_prompt: trace.userPrompt || '',
692
786
  };
@@ -864,6 +958,8 @@ export class DebugTrace {
864
958
  * @type {Promise<void>}
865
959
  */
866
960
  #flushChain = Promise.resolve();
961
+ /** @type {number} */
962
+ #textMaxBytes = DEFAULT_TRACE_TEXT_MAX_BYTES;
867
963
  /** @type {boolean} */
868
964
  #acceptingWrites = true;
869
965
 
@@ -871,15 +967,23 @@ export class DebugTrace {
871
967
  * @param {string} tracePath — Back-compatible path. If it looks like a DB
872
968
  * file, traces are stored in a sibling `debug/` directory.
873
969
  */
874
- constructor(tracePath) {
970
+ constructor(tracePath, options = {}) {
875
971
  const rootDir = fileTraceRoot(tracePath);
876
972
  if (!rootDir) throw new Error('DebugTrace requires a storage path');
877
973
  this.#rootDir = rootDir;
974
+ this.#textMaxBytes = normalizeTextMaxBytes(options?.textMaxBytes);
878
975
  // Best-effort, fire-and-forget: atomicWriteText re-ensures the request
879
976
  // subdir before every write, so a missed mkdir here is harmless.
880
977
  ensureDir(rootDir).catch(() => {});
881
978
  }
882
979
 
980
+ refreshConfig(config = {}) {
981
+ const nextValue = config && typeof config === 'object'
982
+ ? config.traceTextMaxBytes
983
+ : config;
984
+ this.#textMaxBytes = normalizeTextMaxBytes(nextValue, this.#textMaxBytes);
985
+ }
986
+
883
987
  startTurn({ traceId, messageId = null, mode = null, turnNumber = null, sessionId = null, vpId = null, threadId = null, userPrompt = null } = {}) {
884
988
  if (!this.#acceptingWrites) return 'null';
885
989
  const turnRowId = randomUUID();
@@ -911,13 +1015,21 @@ export class DebugTrace {
911
1015
  const trace = this.#loadRequest(ctx.sessionId, ctx.requestKey);
912
1016
  if (!trace) return;
913
1017
  const loopNumber = ctx.loopNumber || Number(info.turnNumber || 0);
914
- const snapshot = buildRequestSnapshot(info);
1018
+ const snapshot = buildRequestSnapshot({
1019
+ ...info,
1020
+ // An explicit null describes this loop: no provider raw request was
1021
+ // captured. Do not inherit a previous loop's body and falsely attribute
1022
+ // it to a transport failure or another uncaptured attempt.
1023
+ rawRequest: Object.prototype.hasOwnProperty.call(info, 'rawRequest')
1024
+ ? info.rawRequest
1025
+ : (trace.baseRequest?.rawRequest ?? null),
1026
+ }, this.#textMaxBytes);
915
1027
  const previousSnapshot = trace._lastSnapshot || this.#reconstructLastSnapshot(trace);
916
1028
  if (!trace.baseRequest) {
917
1029
  trace.baseRequest = {
918
1030
  systemPrompt: snapshot.systemPrompt,
919
1031
  messages: Array.isArray(snapshot.messages) ? cloneJsonValue(snapshot.messages) : [],
920
- rawRequest: null,
1032
+ rawRequest: snapshot.rawRequest ?? null,
921
1033
  };
922
1034
  }
923
1035
  const loopIndex = (trace.loops || []).findIndex(l => l.turnRowId === turnId);
@@ -927,7 +1039,7 @@ export class DebugTrace {
927
1039
  loopNumber,
928
1040
  startedAt: trace.openedAt || Date.now(),
929
1041
  model: info.model || null,
930
- response: truncateText(info.responseText || '', MAX_TEXT_BYTES),
1042
+ response: truncateText(info.responseText || '', this.#textMaxBytes),
931
1043
  toolCalls: cloneJsonValue(Array.isArray(info.toolCalls) ? info.toolCalls : []),
932
1044
  usage: normalizeUsage(info.usage || {}, {
933
1045
  inputTokens: info.inputTokens || 0,
@@ -940,8 +1052,12 @@ export class DebugTrace {
940
1052
  stopReason: info.stopReason || null,
941
1053
  at: Date.now(),
942
1054
  rawResponse: typeof info.rawResponse === 'string'
943
- ? truncateText(info.rawResponse, MAX_RAW_RESPONSE_BYTES)
944
- : safeJsonValue(info.rawResponse, MAX_RAW_RESPONSE_BYTES),
1055
+ ? truncateText(info.rawResponse, Math.min(this.#textMaxBytes, MAX_RAW_RESPONSE_BYTES))
1056
+ : safeJsonValue(info.rawResponse, Math.min(this.#textMaxBytes, MAX_RAW_RESPONSE_BYTES)),
1057
+ // Raw request is canonical base-plus-delta data. Persisting the full
1058
+ // snapshot on every loop made a 512 KiB request consume N × 512 KiB for
1059
+ // an N-loop trace and kept the same duplication in the hydrated cache.
1060
+ rawRequest: null,
945
1061
  requestDelta: buildRequestDelta(previousSnapshot, snapshot),
946
1062
  };
947
1063
  if (loopIndex >= 0) trace.loops[loopIndex] = loop;
@@ -968,7 +1084,7 @@ export class DebugTrace {
968
1084
  toolName: toolName || '?',
969
1085
  toolCallId,
970
1086
  toolInput: truncateText(toolInput == null ? null : String(toolInput), MAX_TOOL_INPUT),
971
- toolOutput: truncateText(toolOutput == null ? null : String(toolOutput), MAX_TEXT_BYTES),
1087
+ toolOutput: truncateText(toolOutput == null ? null : String(toolOutput), this.#textMaxBytes),
972
1088
  durationMs: Number(durationMs || 0),
973
1089
  isError: !!isError,
974
1090
  createdAt: Date.now(),
@@ -1238,7 +1354,7 @@ export class DebugTrace {
1238
1354
  sessionId: normalizedSessionId,
1239
1355
  vpId: vpId || null,
1240
1356
  threadId: threadId || null,
1241
- userPrompt: truncateText(userPrompt || '', MAX_TEXT_BYTES),
1357
+ userPrompt: truncateText(userPrompt || '', this.#textMaxBytes),
1242
1358
  openedAt: now,
1243
1359
  closedAt: null,
1244
1360
  updatedAt: now,
@@ -1618,13 +1734,14 @@ export class NullTrace {
1618
1734
  async compact() { return { before: 0, after: 0 }; }
1619
1735
  async purge() {}
1620
1736
  async close() {}
1737
+ refreshConfig() {}
1621
1738
  async flush() {}
1622
1739
  async fetchTurnDebug() { return { loops: [], turns: [], dreamEvents: [] }; }
1623
1740
  async fetchRecentDebugHistory() { return { loops: [], turns: [], dreamEvents: [] }; }
1624
1741
  }
1625
1742
 
1626
- export function createTrace({ enabled, dbPath, dirPath }) {
1743
+ export function createTrace({ enabled, dbPath, dirPath, textMaxBytes }) {
1627
1744
  const path = dirPath || dbPath;
1628
1745
  if (!enabled || !path) return new NullTrace();
1629
- return new DebugTrace(path);
1746
+ return new DebugTrace(path, { textMaxBytes });
1630
1747
  }