@yeaft/webchat-agent 1.0.376 → 1.0.377

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.
@@ -1 +1 @@
1
- {"version":"1.0.376"}
1
+ {"version":"1.0.377"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.376",
3
+ "version": "1.0.377",
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",
@@ -10,9 +10,10 @@
10
10
  * best-effort: failures are logged and never allowed to stop the agent.
11
11
  */
12
12
 
13
- import { promises as fsp } from 'fs';
13
+ import { createReadStream, promises as fsp } from 'fs';
14
14
  import { basename, dirname, extname, join } from 'path';
15
15
  import { createHash, randomUUID } from 'crypto';
16
+ import { createInterface } from 'readline';
16
17
  import { truncateUtf8Text } from './perf-trace.js';
17
18
 
18
19
  const TRACE_VERSION = 3;
@@ -619,6 +620,7 @@ function serializableTraceMeta(trace) {
619
620
  const meta = {
620
621
  ...trace,
621
622
  loopCount: loops.length,
623
+ toolCount: tools.length,
622
624
  ...usage,
623
625
  loopModels: [...new Set(loops.map(loop => loop?.model).filter(Boolean))],
624
626
  stopReasons: [...new Set(loops.map(loop => loop?.stopReason).filter(Boolean))],
@@ -668,6 +670,33 @@ async function readJsonLines(filePath) {
668
670
  return records;
669
671
  }
670
672
 
673
+ async function countRequestEvents(requestDir) {
674
+ const meta = await readJson(requestMetaPath(requestDir));
675
+ if (!meta?.requestId) {
676
+ const legacy = await readJson(requestFilePath(requestDir));
677
+ return {
678
+ loopCount: Array.isArray(legacy?.loops) ? legacy.loops.length : 0,
679
+ toolCount: Array.isArray(legacy?.tools) ? legacy.tools.length : 0,
680
+ };
681
+ }
682
+ let loopCount = 0;
683
+ let toolCount = 0;
684
+ const stream = createReadStream(requestEventsPath(requestDir), { encoding: 'utf8' });
685
+ stream.on('error', () => {});
686
+ try {
687
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
688
+ for await (const line of lines) {
689
+ if (!line.trim()) continue;
690
+ try {
691
+ const event = JSON.parse(line);
692
+ if (event?.type === 'loop' && event.record) loopCount += 1;
693
+ else if (event?.type === 'tool' && event.record) toolCount += 1;
694
+ } catch { /* Ignore one torn final append. */ }
695
+ }
696
+ } catch { /* Missing/unreadable event file counts as empty. */ }
697
+ return { loopCount, toolCount };
698
+ }
699
+
671
700
  async function readRequestDir(requestDir) {
672
701
  const meta = await readJson(requestMetaPath(requestDir));
673
702
  if (!meta?.requestId) {
@@ -894,19 +923,15 @@ async function removeRequestDirIfIdentityMatches(requestDir, expected) {
894
923
 
895
924
  async function readTraceHeaders(rootDir, sessionId) {
896
925
  const traces = [];
897
- const requestDirs = sessionId == null
898
- ? await (async () => {
899
- const dirs = [];
900
- for (const entry of await readdirSafe(sessionRequestsDir(rootDir, null))) {
901
- if (entry.isDirectory()) dirs.push(join(sessionRequestsDir(rootDir, null), entry.name));
902
- }
903
- return dirs;
904
- })()
905
- : await collectRequestDirs(rootDir, sessionId);
926
+ const requestDirs = await collectRequestDirs(rootDir, sessionId);
906
927
  for (const requestDir of requestDirs) {
907
928
  const meta = await readJson(requestMetaPath(requestDir));
908
- const trace = meta?.requestId ? meta : await readJson(requestFilePath(requestDir));
909
- if (!trace?.requestId || !trace?.requestKey || trace.sessionId !== sessionId) continue;
929
+ const stored = meta?.requestId ? meta : await readJson(requestFilePath(requestDir));
930
+ if (!stored?.requestId || !stored?.requestKey) continue;
931
+ if (sessionId != null && stored.sessionId !== sessionId) continue;
932
+ const trace = meta?.requestId ? { ...meta } : serializableTraceMeta(stored);
933
+ trace._persistedFormat = meta?.requestId ? 'events' : 'legacy';
934
+ trace._persistedRequestDir = requestDir;
910
935
  traces.push({
911
936
  trace,
912
937
  file: requestFilePath(requestDir),
@@ -917,6 +942,13 @@ async function readTraceHeaders(rootDir, sessionId) {
917
942
  return traces.sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.trace.requestKey).localeCompare(String(b.trace.requestKey)));
918
943
  }
919
944
 
945
+ async function readHeaderDetail(rootDir, header) {
946
+ const requestDir = header?._persistedRequestDir
947
+ || requestDirFor(rootDir, header?.sessionId || null, header?.requestKey);
948
+ const trace = await readRequestDir(requestDir);
949
+ return sameTraceIdentity(trace, header) ? trace : null;
950
+ }
951
+
920
952
  async function countDirFiles(rootDir) {
921
953
  let files = 0;
922
954
  let bytes = 0;
@@ -947,6 +979,8 @@ export class DebugTrace {
947
979
  #initializedRequestKeys = new Set();
948
980
  /** @type {Set<string>} */
949
981
  #reconciledRetentionSessions = new Set();
982
+ /** @type {Map<string, object>} Lightweight persisted metadata by request key. */
983
+ #diskHeaders = new Map();
950
984
  /** @type {Map<string, Map<string, { trace: object, requestDirs: Set<string>, openedAt: number }>>} */
951
985
  #retentionIndex = new Map();
952
986
  /** @type {NodeJS.Timeout|null} */
@@ -971,9 +1005,9 @@ export class DebugTrace {
971
1005
  /** @type {NodeJS.Timeout|null} */
972
1006
  #eventFlushTimer = null;
973
1007
  /**
974
- * One-time hydrate guard. Reads/maintenance load every on-disk trace into
975
- * #requestCache exactly once; afterwards every query is served from memory
976
- * with zero disk reads. The write path never hydrates it only appends.
1008
+ * One-time metadata hydrate guard. Reads/maintenance keep only bounded
1009
+ * request headers resident. Full loop/tool payloads are loaded for one
1010
+ * selected request at a time and are never installed in #requestCache.
977
1011
  * @type {boolean}
978
1012
  */
979
1013
  #hydrated = false;
@@ -1270,16 +1304,20 @@ export class DebugTrace {
1270
1304
  await this.#ensureHydrated();
1271
1305
  await this.#drainWrites();
1272
1306
  const tools = [];
1273
- for (const { trace } of this.#traceSummaries()) {
1307
+ for (const { trace: header } of this.#traceSummaries().slice().reverse()) {
1308
+ const trace = this.#requestCache.get(header.requestKey)
1309
+ || await readHeaderDetail(this.#rootDir, header);
1310
+ if (!trace) continue;
1274
1311
  for (const tool of Array.isArray(trace.tools) ? trace.tools : []) {
1275
1312
  const row = traceToolToLegacy(trace, tool);
1276
1313
  if (name && row.tool_name !== name) continue;
1277
1314
  if (since && row.created_at < since) continue;
1278
1315
  tools.push(row);
1279
1316
  }
1317
+ tools.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
1318
+ if (tools.length > 100) tools.length = 100;
1280
1319
  }
1281
- tools.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
1282
- return tools.slice(0, 100);
1320
+ return tools;
1283
1321
  }
1284
1322
 
1285
1323
  async search(keyword) {
@@ -1287,19 +1325,42 @@ export class DebugTrace {
1287
1325
  await this.#drainWrites();
1288
1326
  const needle = String(keyword || '').toLowerCase();
1289
1327
  if (!needle) return [];
1290
- return this.#traceSummaries()
1291
- .filter(({ trace }) => JSON.stringify(trace).toLowerCase().includes(needle))
1292
- .slice(-50)
1293
- .reverse()
1294
- .flatMap(({ trace }) => traceToLegacyRows(trace));
1328
+ const results = [];
1329
+ for (const { trace: header } of this.#traceSummaries().slice().reverse()) {
1330
+ const trace = this.#requestCache.get(header.requestKey)
1331
+ || await readHeaderDetail(this.#rootDir, header);
1332
+ if (!trace || !JSON.stringify(trace).toLowerCase().includes(needle)) continue;
1333
+ results.push(...traceToLegacyRows(trace));
1334
+ if (results.length >= 50) break;
1335
+ }
1336
+ return results.slice(0, 50);
1295
1337
  }
1296
1338
 
1297
1339
  async stats() {
1298
1340
  await this.#ensureHydrated();
1299
1341
  await this.#drainWrites();
1300
1342
  const traces = this.#traceSummaries().map(({ trace }) => trace);
1301
- const turnCount = traces.reduce((n, trace) => n + (Array.isArray(trace.loops) ? trace.loops.length : 0), 0);
1302
- const toolCount = traces.reduce((n, trace) => n + (Array.isArray(trace.tools) ? trace.tools.length : 0), 0);
1343
+ let turnCount = 0;
1344
+ let toolCount = 0;
1345
+ for (const header of traces) {
1346
+ const live = this.#requestCache.get(header.requestKey);
1347
+ if (live) {
1348
+ turnCount += Array.isArray(live.loops) ? live.loops.length : Number(live.loopCount || 0);
1349
+ toolCount += Array.isArray(live.tools) ? live.tools.length : Number(live.toolCount || 0);
1350
+ continue;
1351
+ }
1352
+ if (Number.isFinite(Number(header.loopCount)) && Number.isFinite(Number(header.toolCount))) {
1353
+ turnCount += Number(header.loopCount);
1354
+ toolCount += Number(header.toolCount);
1355
+ continue;
1356
+ }
1357
+ const counts = await countRequestEvents(
1358
+ header._persistedRequestDir
1359
+ || requestDirFor(this.#rootDir, header.sessionId || null, header.requestKey),
1360
+ );
1361
+ turnCount += counts.loopCount;
1362
+ toolCount += counts.toolCount;
1363
+ }
1303
1364
  const eventCount = this.#events.length;
1304
1365
  const { bytes } = await countDirFiles(this.#rootDir);
1305
1366
  return { turnCount, toolCount, eventCount, dbSizeBytes: bytes, fileSizeBytes: bytes, requestCount: traces.length };
@@ -1331,6 +1392,7 @@ export class DebugTrace {
1331
1392
  await ensureDir(this.#rootDir);
1332
1393
  this.#turnIndex.clear();
1333
1394
  this.#requestCache.clear();
1395
+ this.#diskHeaders.clear();
1334
1396
  this.#initializedRequestKeys.clear();
1335
1397
  this.#reconciledRetentionSessions.clear();
1336
1398
  this.#retentionIndex.clear();
@@ -1429,11 +1491,10 @@ export class DebugTrace {
1429
1491
  }
1430
1492
 
1431
1493
  #traceSummaries(sessionId = null) {
1432
- // Cache-only: #ensureHydrated() has already merged every on-disk trace
1433
- // into #requestCache exactly once, so we never touch disk here. This is
1434
- // what turns the old O(N^2) per-query rescan into an in-memory filter.
1494
+ const merged = new Map(this.#diskHeaders);
1495
+ for (const trace of this.#requestCache.values()) merged.set(trace.requestKey, trace);
1435
1496
  const out = [];
1436
- for (const trace of this.#requestCache.values()) {
1497
+ for (const trace of merged.values()) {
1437
1498
  if (sessionId && trace.sessionId !== sessionId) continue;
1438
1499
  if (!trace?.requestId || !trace?.requestKey) continue;
1439
1500
  out.push({
@@ -1446,34 +1507,24 @@ export class DebugTrace {
1446
1507
  }
1447
1508
 
1448
1509
  /**
1449
- * Load every on-disk trace into #requestCache exactly once. Live entries
1450
- * (created by this process) always win over their disk copy a flush may
1451
- * be mid-flight, and the in-memory trace is the newer truth. Idempotent and
1452
- * concurrency-safe: overlapping callers await the same promise.
1453
- *
1454
- * Footprint trade-off (deliberate): this pins every session's full trace
1455
- * payload (systemPrompt + cumulative messages + rawRequest, each already
1456
- * byte-bounded) in #requestCache for the instance's lifetime — O(retention ×
1457
- * total sessions) memory instead of the old O(N²) per-query CPU rescan that
1458
- * stalled the event loop. Retention (10/session) and the byte caps keep it
1459
- * bounded, and the web path uses a single module-level instance. Follow-up if
1460
- * footprint ever bites: hydrate per queried sessionId, or keep only a
1461
- * lightweight summary resident and lazy-load full payloads on detailTurnId.
1510
+ * Load lightweight persisted metadata once. Active requests stay in
1511
+ * #requestCache; completed payloads remain on disk and are lazy-loaded only
1512
+ * for detail/search/tool queries. This prevents retained debug history from
1513
+ * expanding into a process-lifetime multi-gigabyte object graph.
1462
1514
  */
1463
1515
  async #ensureHydrated() {
1464
1516
  if (this.#hydrated) return;
1465
1517
  if (this.#hydratePromise) return this.#hydratePromise;
1466
1518
  this.#hydratePromise = (async () => {
1467
- const [summaries, storedEvents] = await Promise.all([
1468
- readTraceSummaries(this.#rootDir),
1519
+ const [headers, storedEvents] = await Promise.all([
1520
+ readTraceHeaders(this.#rootDir, null),
1469
1521
  readJson(join(this.#rootDir, 'events.json')),
1470
1522
  ]);
1471
- for (const { trace } of summaries) {
1472
- if (!trace?.requestKey) continue;
1473
- // Never clobber a live in-memory trace with its older disk snapshot.
1474
- if (!this.#requestCache.has(trace.requestKey)) {
1475
- this.#requestCache.set(trace.requestKey, trace);
1476
- }
1523
+ for (const { trace } of headers) {
1524
+ if (!trace?.requestKey || this.#requestCache.has(trace.requestKey)) continue;
1525
+ this.#diskHeaders.set(trace.requestKey, trace.active
1526
+ ? { ...trace, active: false, interrupted: true }
1527
+ : trace);
1477
1528
  }
1478
1529
  if (Array.isArray(storedEvents)) this.#mergeStoredEvents(storedEvents);
1479
1530
  this.#hydrated = true;
@@ -1592,13 +1643,23 @@ export class DebugTrace {
1592
1643
  const eventPath = requestEventsPath(requestDir);
1593
1644
  if (initialize) await prepareJsonlAppend(eventPath);
1594
1645
  await fsp.appendFile(eventPath, lines.join(''), 'utf8');
1595
- if (writeMeta) await atomicWriteText(metaPath, JSON.stringify(serializableTraceMeta(trace)));
1646
+ if (writeMeta) {
1647
+ const meta = serializableTraceMeta(trace);
1648
+ await atomicWriteText(metaPath, JSON.stringify(meta));
1649
+ this.#diskHeaders.set(trace.requestKey, meta);
1650
+ }
1596
1651
  trace._persistedFormat = 'events';
1597
1652
  trace._persistedRequestDir = requestDir;
1598
1653
  if (legacyRequestDir && legacyRequestDir !== requestDir) {
1599
1654
  await removeRequestDirIfIdentityMatches(legacyRequestDir, trace);
1600
1655
  }
1601
- if (evictAfterWrite) this.#requestCache.delete(trace.requestKey);
1656
+ if (evictAfterWrite) {
1657
+ this.#requestCache.delete(trace.requestKey);
1658
+ this.#initializedRequestKeys.delete(trace.requestKey);
1659
+ for (const [turnId, ctx] of this.#turnIndex) {
1660
+ if (ctx.requestKey === trace.requestKey) this.#turnIndex.delete(turnId);
1661
+ }
1662
+ }
1602
1663
  } catch (err) {
1603
1664
  console.warn('[Yeaft] debug trace append failed:', err?.message || err);
1604
1665
  }
@@ -1694,6 +1755,7 @@ export class DebugTrace {
1694
1755
 
1695
1756
  async #pruneAll(keep) {
1696
1757
  const sessions = new Set();
1758
+ for (const trace of this.#diskHeaders.values()) sessions.add(trace.sessionId || null);
1697
1759
  for (const trace of this.#requestCache.values()) sessions.add(trace.sessionId || null);
1698
1760
  for (const sessionId of sessions) await this.#pruneSession(sessionId, keep);
1699
1761
  }
@@ -1743,6 +1805,7 @@ export class DebugTrace {
1743
1805
  const stale = pruneCandidates.slice(0, Math.max(0, traces.length - protectedItems.length - keep));
1744
1806
  for (const item of stale) {
1745
1807
  this.#requestCache.delete(item.trace.requestKey);
1808
+ this.#diskHeaders.delete(item.trace.requestKey);
1746
1809
  this.#initializedRequestKeys.delete(item.trace.requestKey);
1747
1810
  index.delete(item.trace.requestKey);
1748
1811
  for (const requestDir of item.requestDirs) {
@@ -954,7 +954,12 @@ function registerRoutePromise(msgId, promise) {
954
954
  routePromisesByMsgId.set(msgId, set);
955
955
  }
956
956
  set.add(promise);
957
- promise.finally(() => set.delete(promise)).catch(() => {});
957
+ promise.finally(() => {
958
+ set.delete(promise);
959
+ if (set.size === 0 && routePromisesByMsgId.get(msgId) === set) {
960
+ routePromisesByMsgId.delete(msgId);
961
+ }
962
+ }).catch(() => {});
958
963
  }
959
964
 
960
965
 
@@ -1859,7 +1864,12 @@ function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
1859
1864
  */
1860
1865
  function getOrCreateSessionContext(sessionId, sessionHandle) {
1861
1866
  let entry = sessionContexts.get(sessionId);
1862
- if (entry && entry.coord && entry.router) return entry;
1867
+ if (entry && entry.coord && entry.router) {
1868
+ if (sessionHandle && sessionHandle !== entry.sessionHandle) {
1869
+ try { sessionHandle.close?.(); } catch { /* best-effort unused handle cleanup */ }
1870
+ }
1871
+ return entry;
1872
+ }
1863
1873
  // Either no entry, or a partial entry seeded by `getOrCreateSessionHistory`
1864
1874
  // (no coord/router yet). Build the coord/router and merge into the
1865
1875
  // existing record so the per-group history reference and hydration
@@ -7820,6 +7830,20 @@ export const __testHooks = {
7820
7830
  async loadProjectRuntime(workDir) {
7821
7831
  return loadProjectRuntime(workDir);
7822
7832
  },
7833
+ registerRoutePromiseForTest(msgId, promise) {
7834
+ registerRoutePromise(msgId, promise);
7835
+ },
7836
+ routePromiseEntryCountForTest() {
7837
+ return routePromisesByMsgId.size;
7838
+ },
7839
+ getOrCreateSessionContextForTest(sessionId, sessionHandle) {
7840
+ return getOrCreateSessionContext(sessionId, sessionHandle);
7841
+ },
7842
+ clearSessionContextForTest(sessionId) {
7843
+ const entry = sessionContexts.get(sessionId);
7844
+ try { entry?.sessionHandle?.close?.(); } catch { /* best-effort test cleanup */ }
7845
+ sessionContexts.delete(sessionId);
7846
+ },
7823
7847
  seedSessionContext(sessionId, meta) {
7824
7848
  const group = {
7825
7849
  getMeta() { return structuredClone(meta); },