@yeaft/webchat-agent 1.0.371 → 1.0.372

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.
@@ -2,7 +2,6 @@ import WebSocket from 'ws';
2
2
  import ctx from '../context.js';
3
3
  import { encrypt, decrypt, isEncrypted } from '../encryption.js';
4
4
 
5
- // 需要在断连期间缓冲的消息类型(CLI / Session 输出相关的关键消息)
6
5
  export const BUFFERABLE_TYPES = new Set([
7
6
  'claude_output', 'yeaft_output', 'yeaft_session_output', 'session_output',
8
7
  'yeaft_history_chunk',
@@ -10,43 +9,56 @@ export const BUFFERABLE_TYPES = new Set([
10
9
  'session_id_update', 'compact_status', 'slash_commands_update',
11
10
  'background_task_started', 'background_task_output',
12
11
  'subagent_started', 'subagent_message', 'subagent_completed',
13
- // Work Center broadcasts are projections over Agent-local SQLite. Buffering
14
- // prevents a terminal transition from disappearing during a short reconnect;
15
- // clients still refresh with `list` after reconnect for authoritative state.
16
12
  'work_center_event'
17
13
  ]);
18
14
 
15
+ function messageBytes(msg) {
16
+ try { return Buffer.byteLength(JSON.stringify(msg), 'utf8'); }
17
+ catch { return 0; }
18
+ }
19
+
20
+ const TERMINAL_TYPES = new Set(['turn_completed', 'conversation_closed']);
21
+
22
+ function removeBufferedAt(index) {
23
+ const [removed] = ctx.messageBuffer.splice(index, 1);
24
+ ctx.messageBufferBytes = Math.max(0, Number(ctx.messageBufferBytes || 0) - messageBytes(removed));
25
+ }
26
+
27
+ function removeOutboundAt(index, outcome = 'dropped') {
28
+ const [removed] = ctx.outboundSendQueue.splice(index, 1);
29
+ ctx.outboundSendQueueBytes = Math.max(0, Number(ctx.outboundSendQueueBytes || 0) - Number(removed?.bytes || 0));
30
+ removed?.resolve?.(outcome);
31
+ }
32
+
19
33
  function bufferMessage(msg, reason) {
20
34
  if (!BUFFERABLE_TYPES.has(msg.type)) {
21
35
  console.warn(`[WS] Cannot send message, WebSocket not open: ${msg.type}`);
22
36
  return 'dropped';
23
37
  }
24
- if (ctx.messageBuffer.length < ctx.messageBufferMaxSize) {
25
- ctx.messageBuffer.push(msg);
26
- console.log(`[WS] ${reason}, buffered: ${msg.type} (queue: ${ctx.messageBuffer.length})`);
27
- return 'buffered';
38
+ const bytes = messageBytes(msg);
39
+ const maxBytes = Math.max(1, Number(ctx.messageBufferMaxBytes) || 8 * 1024 * 1024);
40
+ if (bytes > maxBytes) {
41
+ console.warn(`[WS] Message exceeds disconnected buffer byte budget, dropping: ${msg.type}`);
42
+ return 'dropped';
28
43
  }
29
- // Buffer full: drop oldest non-status messages to make room
30
- const dropIdx = ctx.messageBuffer.findIndex(m => m.type !== 'turn_completed');
31
- if (dropIdx >= 0) {
32
- ctx.messageBuffer.splice(dropIdx, 1);
33
- ctx.messageBuffer.push(msg);
34
- console.warn(`[WS] Buffer full, dropped oldest to make room for: ${msg.type}`);
35
- return 'buffered';
44
+ while (ctx.messageBuffer.length > 0 && (
45
+ ctx.messageBuffer.length >= ctx.messageBufferMaxSize
46
+ || Number(ctx.messageBufferBytes || 0) + bytes > maxBytes
47
+ )) {
48
+ const nonTerminal = ctx.messageBuffer.findIndex(m => !TERMINAL_TYPES.has(m.type));
49
+ if (nonTerminal < 0) break;
50
+ removeBufferedAt(nonTerminal);
36
51
  }
37
- console.warn(`[WS] Buffer full (${ctx.messageBufferMaxSize}), dropping: ${msg.type}`);
38
- return 'dropped';
52
+ if (ctx.messageBuffer.length >= ctx.messageBufferMaxSize
53
+ || Number(ctx.messageBufferBytes || 0) + bytes > maxBytes) return 'dropped';
54
+ ctx.messageBuffer.push(msg);
55
+ ctx.messageBufferBytes = Number(ctx.messageBufferBytes || 0) + bytes;
56
+ console.log(`[WS] ${reason}, buffered: ${msg.type} (queue: ${ctx.messageBuffer.length})`);
57
+ return 'buffered';
39
58
  }
40
59
 
41
60
  async function sendNow(msg) {
42
- if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN) {
43
- return bufferMessage(msg, 'Disconnected');
44
- }
45
-
46
- // feat-ws-plaintext-negotiation: encrypt only when the server has
47
- // NOT advertised plaintext acceptance. Defaults to encrypted for
48
- // back-compat with old servers; flipped to plaintext when the
49
- // `registered` frame includes `acceptPlaintext: true`.
61
+ if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN) return bufferMessage(msg, 'Disconnected');
50
62
  if (ctx.serverEncryptionRequired && ctx.sessionKey) {
51
63
  const encrypted = await encrypt(msg, ctx.sessionKey);
52
64
  ctx.ws.send(JSON.stringify(encrypted));
@@ -63,6 +75,7 @@ function scheduleOutboundDrain() {
63
75
  try {
64
76
  while (ctx.outboundSendQueue.length > 0) {
65
77
  const item = ctx.outboundSendQueue.shift();
78
+ ctx.outboundSendQueueBytes = Math.max(0, Number(ctx.outboundSendQueueBytes || 0) - Number(item?.bytes || 0));
66
79
  const msg = item?.msg ?? item;
67
80
  try {
68
81
  const outcome = await sendNow(msg);
@@ -72,8 +85,6 @@ function scheduleOutboundDrain() {
72
85
  const outcome = msg ? bufferMessage(msg, 'Send failed') : 'dropped';
73
86
  item?.resolve?.(outcome);
74
87
  }
75
- // Yield between frames so ping/pong, inbound control messages and UI
76
- // events cannot be starved by a reconnect flush or a burst of tool output.
77
88
  await new Promise(resolve => setImmediate(resolve));
78
89
  }
79
90
  } finally {
@@ -83,42 +94,45 @@ function scheduleOutboundDrain() {
83
94
  });
84
95
  }
85
96
 
86
- // Send message to server (with encryption if available)
87
- // 断连时对关键消息类型进行缓冲,重连后自动 flush
88
97
  export async function sendToServer(msg) {
89
- if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN) {
90
- return bufferMessage(msg, 'Disconnected');
98
+ if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN) return bufferMessage(msg, 'Disconnected');
99
+ const bytes = messageBytes(msg);
100
+ const maxBytes = Math.max(1, Number(ctx.outboundSendQueueMaxBytes) || 8 * 1024 * 1024);
101
+ if (bytes > maxBytes) {
102
+ console.warn(`[WS] Outbound message exceeds byte budget, dropping: ${msg.type}`);
103
+ return 'dropped';
104
+ }
105
+ while (TERMINAL_TYPES.has(msg.type)
106
+ && Number(ctx.outboundSendQueueBytes || 0) + bytes > maxBytes) {
107
+ const nonTerminal = ctx.outboundSendQueue.findIndex(item => !TERMINAL_TYPES.has(item?.msg?.type));
108
+ if (nonTerminal < 0) break;
109
+ removeOutboundAt(nonTerminal);
110
+ }
111
+ if (Number(ctx.outboundSendQueueBytes || 0) + bytes > maxBytes) {
112
+ console.warn(`[WS] Outbound queue byte budget exceeded, dropping: ${msg.type}`);
113
+ return 'dropped';
91
114
  }
92
115
  const promise = new Promise((resolve, reject) => {
93
- ctx.outboundSendQueue.push({ msg, resolve, reject });
116
+ ctx.outboundSendQueue.push({ msg, bytes, resolve, reject });
117
+ ctx.outboundSendQueueBytes = Number(ctx.outboundSendQueueBytes || 0) + bytes;
94
118
  });
95
119
  scheduleOutboundDrain();
96
120
  return promise;
97
121
  }
98
122
 
99
- // Flush 断连期间缓冲的消息
100
123
  export async function flushMessageBuffer() {
101
124
  if (ctx.messageBuffer.length === 0) return;
102
-
103
125
  const buffered = ctx.messageBuffer.splice(0);
126
+ ctx.messageBufferBytes = 0;
104
127
  console.log(`[WS] Flushing ${buffered.length} buffered messages...`);
105
-
106
- for (const msg of buffered) {
107
- await sendToServer(msg);
108
- }
109
-
110
- console.log(`[WS] Flush queued`);
128
+ for (const msg of buffered) await sendToServer(msg);
129
+ console.log('[WS] Flush queued');
111
130
  }
112
131
 
113
- // Parse incoming message (decrypt if encrypted)
114
132
  export async function parseMessage(data) {
115
133
  try {
116
134
  const parsed = JSON.parse(data.toString());
117
-
118
- if (ctx.sessionKey && isEncrypted(parsed)) {
119
- return await decrypt(parsed, ctx.sessionKey);
120
- }
121
-
135
+ if (ctx.sessionKey && isEncrypted(parsed)) return await decrypt(parsed, ctx.sessionKey);
122
136
  return parsed;
123
137
  } catch (e) {
124
138
  console.error('Failed to parse message:', e);
package/context.js CHANGED
@@ -47,11 +47,15 @@ export default {
47
47
  lastHeartbeatStallAt: 0,
48
48
  lastHeartbeatStallMs: 0,
49
49
  outboundSendQueue: [],
50
+ outboundSendQueueBytes: 0,
51
+ outboundSendQueueMaxBytes: 8 * 1024 * 1024,
50
52
  outboundSendQueueActive: false,
51
53
  assetOutbox: null,
52
54
  // 断连期间的消息缓冲队列(重连后 flush)
53
55
  messageBuffer: [],
54
- messageBufferMaxSize: 5000, // 防止内存无限增长
56
+ messageBufferBytes: 0,
57
+ messageBufferMaxSize: 5000,
58
+ messageBufferMaxBytes: 8 * 1024 * 1024,
55
59
  // 由 connection.js 注册的通信函数
56
60
  sendToServer: null,
57
61
  // 由 index.js 注册的配置保存函数
@@ -1 +1 @@
1
- {"version":"1.0.371"}
1
+ {"version":"1.0.372"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.371",
3
+ "version": "1.0.372",
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",
@@ -127,9 +127,19 @@ function regexHasUnsafeQuantifiedGroup(pattern) {
127
127
  function buildTraceSearchDocument(trace) {
128
128
  const loops = Array.isArray(trace?.loops) ? trace.loops : [];
129
129
  const tools = Array.isArray(trace?.tools) ? trace.tools : [];
130
- const toolNames = tools.map(t => t?.toolName || t?.name || '').filter(Boolean).join(' ');
131
- const loopModels = loops.map(l => l?.model || '').filter(Boolean).join(' ');
132
- const stopReasons = loops.map(l => l?.stopReason || '').filter(Boolean).join(' ');
130
+ const toolNames = [
131
+ ...tools.map(t => t?.toolName || t?.name || '').filter(Boolean),
132
+ ...(Array.isArray(trace?.toolNames) ? trace.toolNames : []),
133
+ ].join(' ');
134
+ const loopModels = [
135
+ ...loops.map(l => l?.model || '').filter(Boolean),
136
+ ...(Array.isArray(trace?.loopModels) ? trace.loopModels : []),
137
+ ].join(' ');
138
+ const stopReasons = [
139
+ ...loops.map(l => l?.stopReason || '').filter(Boolean),
140
+ ...(Array.isArray(trace?.stopReasons) ? trace.stopReasons : []),
141
+ trace?.finalStopReason || '',
142
+ ].filter(Boolean).join(' ');
133
143
  return [
134
144
  trace?.requestId,
135
145
  trace?.traceId,
@@ -596,7 +606,24 @@ function turnLocatorPath(rootDir, sessionId, turnId) {
596
606
  }
597
607
 
598
608
  function serializableTraceMeta(trace) {
599
- const meta = { ...trace };
609
+ const loops = Array.isArray(trace?.loops) ? trace.loops : [];
610
+ const tools = Array.isArray(trace?.tools) ? trace.tools : [];
611
+ const usage = loops.reduce((acc, loop) => {
612
+ const normalized = normalizeUsage(loop?.usage || {});
613
+ acc.totalMs += Number(loop?.latencyMs || 0);
614
+ acc.totalTokens += Number(normalized.totalTokens || 0);
615
+ acc.summaryInputTokens += Number(normalized.totalInputTokens || 0);
616
+ acc.summaryOutputTokens += Number(normalized.outputTokens || 0);
617
+ return acc;
618
+ }, { totalMs: 0, totalTokens: 0, summaryInputTokens: 0, summaryOutputTokens: 0 });
619
+ const meta = {
620
+ ...trace,
621
+ loopCount: loops.length,
622
+ ...usage,
623
+ loopModels: [...new Set(loops.map(loop => loop?.model).filter(Boolean))],
624
+ stopReasons: [...new Set(loops.map(loop => loop?.stopReason).filter(Boolean))],
625
+ toolNames: [...new Set(tools.map(tool => tool?.toolName || tool?.name).filter(Boolean))],
626
+ };
600
627
  delete meta._lastSnapshot;
601
628
  delete meta._persistedFormat;
602
629
  delete meta._persistedRequestDir;
@@ -689,11 +716,11 @@ function summarizeTrace(trace, detailsLoaded = false) {
689
716
  threadId: trace?.threadId || null,
690
717
  openedAt: trace?.openedAt || 0,
691
718
  closedAt: trace?.closedAt || null,
692
- totalMs: usage.totalMs,
693
- totalTokens: usage.totalTokens,
694
- summaryInputTokens: usage.summaryInputTokens,
695
- summaryOutputTokens: usage.summaryOutputTokens,
696
- loopCount: loops.length,
719
+ totalMs: loops.length > 0 ? usage.totalMs : Number(trace?.totalMs || 0),
720
+ totalTokens: loops.length > 0 ? usage.totalTokens : Number(trace?.totalTokens || 0),
721
+ summaryInputTokens: loops.length > 0 ? usage.summaryInputTokens : Number(trace?.summaryInputTokens || 0),
722
+ summaryOutputTokens: loops.length > 0 ? usage.summaryOutputTokens : Number(trace?.summaryOutputTokens || 0),
723
+ loopCount: loops.length > 0 ? loops.length : Number(trace?.loopCount || 0),
697
724
  memoryLoaded: null,
698
725
  memoryAdjust: null,
699
726
  tools: Array.isArray(trace?.tools) ? trace.tools.map(t => ({
@@ -1123,33 +1150,39 @@ export class DebugTrace {
1123
1150
  }
1124
1151
 
1125
1152
  async queryByMessage(messageId) {
1126
- await this.#ensureHydrated();
1127
1153
  await this.#drainWrites();
1128
- const traces = this.#traceSummaries()
1129
- .filter(({ trace }) => trace.messageId === messageId)
1130
- .map(({ trace }) => trace);
1154
+ const traces = (await readTraceSummaries(this.#rootDir)).map(item => item.trace)
1155
+ .filter(trace => trace.messageId === messageId);
1131
1156
  return this.#expandLegacy(traces);
1132
1157
  }
1133
1158
 
1134
1159
  async queryByTrace(traceId) {
1135
- await this.#ensureHydrated();
1136
1160
  await this.#drainWrites();
1137
- const traces = this.#traceSummaries()
1138
- .filter(({ trace }) => trace.traceId === traceId || trace.requestId === traceId)
1139
- .map(({ trace }) => trace);
1161
+ const traces = (await readTraceSummaries(this.#rootDir)).map(item => item.trace)
1162
+ .filter(trace => trace.traceId === traceId || trace.requestId === traceId);
1140
1163
  return this.#expandLegacy(traces);
1141
1164
  }
1142
1165
 
1143
1166
  async queryRecent(limit = 20) {
1144
- await this.#ensureHydrated();
1145
1167
  await this.#drainWrites();
1146
1168
  const lim = Math.max(1, Math.min(MAX_HISTORY_LIMIT, Number(limit) || MAX_HISTORY_LIMIT));
1147
- return this.#traceSummaries()
1169
+ return (await readTraceSummaries(this.#rootDir))
1148
1170
  .slice(-lim)
1149
1171
  .reverse()
1150
1172
  .flatMap(({ trace }) => traceToLegacyRows(trace));
1151
1173
  }
1152
1174
 
1175
+ finalizeQuery(traceId, { sessionId = null, stopReason = 'end_turn' } = {}) {
1176
+ for (const trace of this.#requestCache.values()) {
1177
+ if (trace.traceId !== traceId || (sessionId != null && trace.sessionId !== sessionId)) continue;
1178
+ trace.active = false;
1179
+ trace.closedAt ||= Date.now();
1180
+ trace.updatedAt = Date.now();
1181
+ trace.finalStopReason = stopReason;
1182
+ this.#appendTraceRecord(trace, 'finalize', { at: trace.updatedAt, stopReason }, { writeMeta: true, evictAfterWrite: true });
1183
+ }
1184
+ }
1185
+
1153
1186
  async fetchTurnDebug({ sessionId, turnId, dreamLimit = 0 } = {}) {
1154
1187
  const requestedSessionId = typeof sessionId === 'string' && sessionId ? sessionId : null;
1155
1188
  const requestedTurnId = typeof turnId === 'string' && turnId ? turnId : null;
@@ -1166,10 +1199,7 @@ export class DebugTrace {
1166
1199
  const locator = await readJson(turnLocatorPath(this.#rootDir, requestedSessionId, requestedTurnId));
1167
1200
  if (locator?.requestKey && locator.sessionId === requestedSessionId && locator.requestId === requestedTurnId) {
1168
1201
  const located = await readRequestDir(requestDirFor(this.#rootDir, requestedSessionId, locator.requestKey));
1169
- if (traceMatchesIdentity(located, requestedSessionId, requestedTurnId)) {
1170
- trace = located;
1171
- this.#requestCache.set(trace.requestKey, trace);
1172
- }
1202
+ if (traceMatchesIdentity(located, requestedSessionId, requestedTurnId)) trace = located;
1173
1203
  }
1174
1204
  }
1175
1205
  if (!trace) {
@@ -1178,7 +1208,6 @@ export class DebugTrace {
1178
1208
  for (const item of await readTraceSummaries(this.#rootDir, requestedSessionId)) {
1179
1209
  if (traceMatchesIdentity(item.trace, requestedSessionId, requestedTurnId)) {
1180
1210
  trace = item.trace;
1181
- this.#requestCache.set(trace.requestKey, trace);
1182
1211
  break;
1183
1212
  }
1184
1213
  }
@@ -1195,14 +1224,13 @@ export class DebugTrace {
1195
1224
  const detail = await this.fetchTurnDebug({ sessionId, turnId: requestedDetailTurnId, dreamLimit });
1196
1225
  return { ...detail, hasMore: false, limit: detail.loops.length, indexOnly: false };
1197
1226
  }
1198
- await this.#ensureHydrated();
1199
1227
  await this.#drainWrites();
1200
1228
  const lim = Math.max(1, Math.min(MAX_HISTORY_LIMIT, Number(limit) || MAX_HISTORY_LIMIT));
1201
1229
  const searchRegex = requestedDetailTurnId ? null : compileTraceSearchRegex(search);
1202
- const traces = this.#traceSummaries(sessionId)
1203
- .filter(({ trace }) => !threadId || trace.threadId === threadId)
1204
- .filter(({ trace }) => requestedDetailTurnId || traceMatchesRegex(trace, searchRegex))
1205
- .map(({ trace }) => trace);
1230
+ const traces = (await readTraceHeaders(this.#rootDir, sessionId))
1231
+ .map(({ trace }) => trace)
1232
+ .filter(trace => !threadId || trace.threadId === threadId)
1233
+ .filter(trace => requestedDetailTurnId || traceMatchesRegex(trace, searchRegex));
1206
1234
  const dreamEvents = this.#readDreamEvents({ sessionId, dreamLimit });
1207
1235
  if (requestedDetailTurnId) {
1208
1236
  const trace = traces.find(t => t.requestId === requestedDetailTurnId || t.traceId === requestedDetailTurnId);
@@ -1214,14 +1242,22 @@ export class DebugTrace {
1214
1242
  if (indexOnly) {
1215
1243
  return {
1216
1244
  loops: [],
1217
- turns: selected.map(trace => summarizeTrace(trace, false)),
1245
+ turns: selected.map(trace => ({
1246
+ ...summarizeTrace(trace, false),
1247
+ loopCount: Number(trace.loopCount || 0),
1248
+ tools: [],
1249
+ })),
1218
1250
  dreamEvents,
1219
1251
  hasMore: traces.length > selected.length,
1220
1252
  limit: lim,
1221
1253
  indexOnly: true,
1222
1254
  };
1223
1255
  }
1224
- const expanded = selected.reduce((acc, trace) => {
1256
+ const selectedKeys = new Set(selected.map(trace => trace.requestKey));
1257
+ const detailed = (await readTraceSummaries(this.#rootDir, sessionId))
1258
+ .map(({ trace }) => trace)
1259
+ .filter(trace => selectedKeys.has(trace.requestKey));
1260
+ const expanded = detailed.reduce((acc, trace) => {
1225
1261
  const item = expandTrace(trace);
1226
1262
  acc.loops.push(...item.loops);
1227
1263
  acc.turns.push(...item.turns);
@@ -1324,6 +1360,7 @@ export class DebugTrace {
1324
1360
  const isUsableExisting = (t) => (
1325
1361
  t?.sessionId === normalizedSessionId
1326
1362
  && t?.traceId === traceId
1363
+ && t.active !== false
1327
1364
  && !(turnNumber === 1 && (t.loops || []).some(l => l.loopNumber === 1))
1328
1365
  );
1329
1366
  // Cache-only: the write path must NEVER touch disk (that was the O(N^2)
@@ -1374,6 +1411,23 @@ export class DebugTrace {
1374
1411
  return this.#requestCache.get(requestKey) || null;
1375
1412
  }
1376
1413
 
1414
+ async resumeTrace({ sessionId, turnId } = {}) {
1415
+ if (!sessionId || !turnId) return false;
1416
+ const locator = await readJson(turnLocatorPath(this.#rootDir, sessionId, turnId));
1417
+ let trace = null;
1418
+ if (locator?.requestKey && locator.sessionId === sessionId) {
1419
+ trace = await readRequestDir(requestDirFor(this.#rootDir, sessionId, locator.requestKey));
1420
+ }
1421
+ if (!traceMatchesIdentity(trace, sessionId, turnId)) {
1422
+ trace = (await readTraceSummaries(this.#rootDir, sessionId))
1423
+ .map(item => item.trace)
1424
+ .find(item => traceMatchesIdentity(item, sessionId, turnId)) || null;
1425
+ }
1426
+ if (!trace) return false;
1427
+ this.#requestCache.set(trace.requestKey, trace);
1428
+ return true;
1429
+ }
1430
+
1377
1431
  #traceSummaries(sessionId = null) {
1378
1432
  // Cache-only: #ensureHydrated() has already merged every on-disk trace
1379
1433
  // into #requestCache exactly once, so we never touch disk here. This is
@@ -1448,7 +1502,7 @@ export class DebugTrace {
1448
1502
  return tracePathFor(this.#rootDir, trace.sessionId || null, trace.requestKey);
1449
1503
  }
1450
1504
 
1451
- #appendTraceRecord(trace, type, record, { writeMeta = false } = {}) {
1505
+ #appendTraceRecord(trace, type, record, { writeMeta = false, evictAfterWrite = false } = {}) {
1452
1506
  if (!this.#acceptingWrites || !trace?.requestKey || !record) return;
1453
1507
  this.#requestCache.set(trace.requestKey, trace);
1454
1508
  const initialize = !this.#initializedRequestKeys.has(trace.requestKey);
@@ -1459,6 +1513,7 @@ export class DebugTrace {
1459
1513
  record: cloneJsonValue(record),
1460
1514
  initialize,
1461
1515
  writeMeta: !!writeMeta,
1516
+ evictAfterWrite: !!evictAfterWrite,
1462
1517
  });
1463
1518
  if (writeMeta) {
1464
1519
  this.#flushPending();
@@ -1498,15 +1553,16 @@ export class DebugTrace {
1498
1553
  for (const entry of entries) {
1499
1554
  if (!this.#requestCache.has(entry.trace.requestKey)) continue;
1500
1555
  const requestDir = requestDirFor(this.#rootDir, entry.trace.sessionId || null, entry.trace.requestKey);
1501
- const batch = batches.get(requestDir) || { trace: entry.trace, initialize: false, writeMeta: false, lines: [] };
1556
+ const batch = batches.get(requestDir) || { trace: entry.trace, initialize: false, writeMeta: false, evictAfterWrite: false, lines: [] };
1502
1557
  batch.trace = entry.trace;
1503
1558
  batch.initialize ||= entry.initialize;
1504
1559
  batch.writeMeta ||= entry.writeMeta;
1560
+ batch.evictAfterWrite ||= entry.evictAfterWrite;
1505
1561
  batch.lines.push(`${JSON.stringify({ type: entry.type, record: entry.record })}\n`);
1506
1562
  batches.set(requestDir, batch);
1507
1563
  }
1508
1564
  for (const [requestDir, batch] of batches) {
1509
- const { trace, initialize, writeMeta } = batch;
1565
+ const { trace, initialize, writeMeta, evictAfterWrite } = batch;
1510
1566
  const lines = [...batch.lines];
1511
1567
  const legacyRequestDir = trace._persistedFormat === 'legacy'
1512
1568
  ? trace._persistedRequestDir || null
@@ -1542,6 +1598,7 @@ export class DebugTrace {
1542
1598
  if (legacyRequestDir && legacyRequestDir !== requestDir) {
1543
1599
  await removeRequestDirIfIdentityMatches(legacyRequestDir, trace);
1544
1600
  }
1601
+ if (evictAfterWrite) this.#requestCache.delete(trace.requestKey);
1545
1602
  } catch (err) {
1546
1603
  console.warn('[Yeaft] debug trace append failed:', err?.message || err);
1547
1604
  }
@@ -1721,6 +1778,8 @@ export class DebugTrace {
1721
1778
  export class NullTrace {
1722
1779
  startTurn() { return 'null'; }
1723
1780
  endTurn() {}
1781
+ finalizeQuery() {}
1782
+ async resumeTrace() { return false; }
1724
1783
  logTool() { return 'null'; }
1725
1784
  logEvent() { return 'null'; }
1726
1785
  event() { return 'null'; }
package/yeaft/engine.js CHANGED
@@ -1972,12 +1972,18 @@ export class Engine {
1972
1972
  * @yields {EngineEvent}
1973
1973
  */
1974
1974
  async *query(params = {}) {
1975
+ const queryTraceId = typeof params.vpTurnId === 'string' && params.vpTurnId
1976
+ ? params.vpTurnId : null;
1975
1977
  let terminalEmitted = false;
1978
+ let terminalStopReason = 'error';
1976
1979
  let lastTurnNumber = 0;
1977
1980
  try {
1978
1981
  for await (const event of this.#queryLifecycle(params)) {
1979
1982
  if (Number.isFinite(event?.turnNumber)) lastTurnNumber = event.turnNumber;
1980
- if (event?.type === 'turn_end' && event.terminal === true) terminalEmitted = true;
1983
+ if (event?.type === 'turn_end' && event.terminal === true) {
1984
+ terminalEmitted = true;
1985
+ terminalStopReason = event.stopReason || terminalStopReason;
1986
+ }
1981
1987
  yield event;
1982
1988
  }
1983
1989
  } catch (err) {
@@ -2001,6 +2007,13 @@ export class Engine {
2001
2007
  // hook must not retroactively turn the completed answer into an error.
2002
2008
  console.warn('[Engine] post-turn maintenance failed:', err?.message || err);
2003
2009
  }
2010
+ } finally {
2011
+ if (queryTraceId && typeof this.#trace?.finalizeQuery === 'function') {
2012
+ this.#trace.finalizeQuery(queryTraceId, {
2013
+ sessionId: params.sessionId || null,
2014
+ stopReason: terminalEmitted ? terminalStopReason : 'interrupted',
2015
+ });
2016
+ }
2004
2017
  }
2005
2018
  }
2006
2019