@yeaft/webchat-agent 1.0.213 → 1.0.215

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.213"}
1
+ {"version":"1.0.215"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.213",
3
+ "version": "1.0.215",
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",
@@ -20,6 +20,7 @@
20
20
  import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync, appendFileSync } from 'fs';
21
21
  import { join, basename } from 'path';
22
22
  import { isPermissionError } from '../init.js';
23
+ import { writeAtomic } from '../storage/atomic.js';
23
24
  import { pairSanitize } from '../pair-sanitize.js';
24
25
  import { sliceLastNTurns, stripVpMentionPrefix } from '../turn-utils.js';
25
26
  import { isHiddenConversationRow } from './internal-control.js';
@@ -76,6 +77,7 @@ function emptySegmentIndex() {
76
77
  lastMessageId: null,
77
78
  activeSegment: SEGMENT_FIRST_NAME,
78
79
  segments: [],
80
+ foldedMessageIds: [],
79
81
  };
80
82
  }
81
83
 
@@ -91,6 +93,26 @@ function normalizeSegmentRecord(msg) {
91
93
  return out;
92
94
  }
93
95
 
96
+ function foldedMessageIdsFrom(rows) {
97
+ const ids = new Set();
98
+ for (const row of rows || []) {
99
+ if (!row?._reflection || !Array.isArray(row.foldedMessageIds)) continue;
100
+ for (const id of row.foldedMessageIds) {
101
+ if (typeof id === 'string' && id) ids.add(id);
102
+ }
103
+ }
104
+ return ids;
105
+ }
106
+
107
+ function applyFoldedMessageTombstones(rows, additionalIds = []) {
108
+ const foldedIds = foldedMessageIdsFrom(rows);
109
+ for (const id of additionalIds || []) {
110
+ if (typeof id === 'string' && id) foldedIds.add(id);
111
+ }
112
+ if (foldedIds.size === 0) return rows;
113
+ return rows.filter(row => !foldedIds.has(row?.id));
114
+ }
115
+
94
116
  function segmentNameForNumber(n) {
95
117
  return `${String(n).padStart(6, '0')}.jsonl`;
96
118
  }
@@ -363,6 +385,8 @@ function serializeMessage(msg) {
363
385
  if (msg.sessionId) fm.push(`sessionId: ${msg.sessionId}`);
364
386
  if (msg.chatId) fm.push(`chatId: ${msg.chatId}`);
365
387
  if (msg.clientMessageId) fm.push(`clientMessageId: ${msg.clientMessageId}`);
388
+ if (msg.incomplete) fm.push('incomplete: true');
389
+ if (msg.stopReason) fm.push(`stopReason: ${msg.stopReason}`);
366
390
  // Session attribution: when a VP authors an assistant turn (either
367
391
  // its own reply or a route_forward injection from another VP), stamp
368
392
  // the speaker so the UI can render the message on the correct VP track.
@@ -492,6 +516,8 @@ export function parseMessage(raw) {
492
516
  case 'sessionId': msg.sessionId = value; break;
493
517
  case 'chatId': msg.chatId = value; break;
494
518
  case 'clientMessageId': msg.clientMessageId = value; break;
519
+ case 'incomplete': msg.incomplete = value === 'true'; break;
520
+ case 'stopReason': msg.stopReason = value; break;
495
521
  case 'speakerVpId': msg.speakerVpId = value; break;
496
522
  case 'attachmentsB64':
497
523
  try {
@@ -621,8 +647,9 @@ class SegmentStore {
621
647
  idx = null;
622
648
  }
623
649
  }
624
- this.index = this.#normalizeIndex(idx || this.#rebuildIndex());
625
- if (!existsSync(this.indexPath) && this.hasData()) this.saveIndex();
650
+ const indexWasStale = idx && !this.#indexMatchesDisk(idx);
651
+ this.index = this.#normalizeIndex(!idx || indexWasStale ? this.#rebuildIndex() : idx);
652
+ if ((!existsSync(this.indexPath) || indexWasStale) && this.hasData()) this.saveIndex();
626
653
  return this.index;
627
654
  }
628
655
 
@@ -659,6 +686,12 @@ class SegmentStore {
659
686
  idx.totalMessages = (idx.totalMessages || 0) + 1;
660
687
  idx.lastMessageId = msg.id || null;
661
688
  idx.nextSeq = Math.max(Number(idx.nextSeq) || 1, seq + 1);
689
+ if (msg._reflection && Array.isArray(msg.foldedMessageIds)) {
690
+ idx.foldedMessageIds = Array.from(new Set([
691
+ ...(Array.isArray(idx.foldedMessageIds) ? idx.foldedMessageIds : []),
692
+ ...msg.foldedMessageIds.filter(id => typeof id === 'string' && id),
693
+ ]));
694
+ }
662
695
  this.saveIndex();
663
696
  }
664
697
 
@@ -672,9 +705,11 @@ class SegmentStore {
672
705
  const out = [];
673
706
  for (const seg of segments) {
674
707
  const rows = this.#readSegment(seg.file, { beforeSeq, afterSeq, desc, includeCold });
675
- out.push(...rows);
708
+ out.push(...applyFoldedMessageTombstones(rows, idx.foldedMessageIds));
676
709
  }
677
- return desc ? out.sort((a, b) => parseSeqFromId(b.id) - parseSeqFromId(a.id)) : out.sort(compareMessagesBySeq);
710
+ return desc
711
+ ? out.sort((a, b) => parseSeqFromId(b.id) - parseSeqFromId(a.id))
712
+ : out.sort(compareMessagesBySeq);
678
713
  }
679
714
 
680
715
  *scan({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false } = {}) {
@@ -685,7 +720,8 @@ class SegmentStore {
685
720
  .slice()
686
721
  .sort((a, b) => desc ? (b.lastSeq || 0) - (a.lastSeq || 0) : (a.firstSeq || 0) - (b.firstSeq || 0));
687
722
  for (const seg of segments) {
688
- for (const row of this.#readSegment(seg.file, { beforeSeq, afterSeq, desc, includeCold })) yield row;
723
+ const rows = this.#readSegment(seg.file, { beforeSeq, afterSeq, desc, includeCold });
724
+ yield* applyFoldedMessageTombstones(rows, idx.foldedMessageIds);
689
725
  }
690
726
  }
691
727
 
@@ -705,18 +741,39 @@ class SegmentStore {
705
741
  for (const msg of (rows || []).filter(Boolean).sort(compareMessagesBySeq)) this.append(msg);
706
742
  }
707
743
 
744
+ updateById(id, updater) {
745
+ if (!id || typeof updater !== 'function' || !this.hasData()) return null;
746
+ const targetSeq = parseSeqFromId(id);
747
+ const idx = this.loadIndex();
748
+ const segment = (idx.segments || []).find((candidate) => {
749
+ const first = Number(candidate.firstSeq);
750
+ const last = Number(candidate.lastSeq);
751
+ return Number.isFinite(targetSeq)
752
+ && Number.isFinite(first)
753
+ && Number.isFinite(last)
754
+ && targetSeq >= first
755
+ && targetSeq <= last;
756
+ });
757
+ if (!segment?.file) return null;
758
+
759
+ const path = join(this.segmentDir, segment.file);
760
+ const rows = this.#readSegment(segment.file, { includeCold: true });
761
+ const rowIndex = rows.findIndex(row => row?.id === id);
762
+ if (rowIndex < 0) return null;
763
+ const next = updater({ ...rows[rowIndex] });
764
+ if (!next || typeof next !== 'object') return null;
765
+ const updated = { ...next, id: rows[rowIndex].id };
766
+ rows[rowIndex] = updated;
767
+
768
+ const body = rows.map(row => JSON.stringify(row)).join('\n') + (rows.length > 0 ? '\n' : '');
769
+ writeAtomic(path, body);
770
+ segment.bytes = Buffer.byteLength(body);
771
+ this.saveIndex();
772
+ return updated;
773
+ }
774
+
708
775
  markCold(id) {
709
- if (!id || !this.hasData()) return 0;
710
- const rows = this.readAll({ includeCold: true });
711
- let changed = 0;
712
- for (const msg of rows) {
713
- if (msg?.id === id && msg.cold !== true) {
714
- msg.cold = true;
715
- changed += 1;
716
- }
717
- }
718
- if (changed > 0) this.replaceAll(rows);
719
- return changed;
776
+ return this.updateById(id, msg => ({ ...msg, cold: true })) ? 1 : 0;
720
777
  }
721
778
 
722
779
  clear() {
@@ -729,6 +786,19 @@ class SegmentStore {
729
786
  this.index = emptySegmentIndex();
730
787
  }
731
788
 
789
+ #indexMatchesDisk(idx) {
790
+ if (!existsSync(this.segmentDir)) return !(idx?.segments?.length > 0);
791
+ const diskFiles = readdirSync(this.segmentDir).filter(file => file.endsWith('.jsonl')).sort();
792
+ const indexedSegments = Array.isArray(idx?.segments) ? idx.segments : [];
793
+ const indexedFiles = indexedSegments.map(segment => segment?.file).filter(Boolean).sort();
794
+ if (diskFiles.length !== indexedFiles.length
795
+ || diskFiles.some((file, index) => file !== indexedFiles[index])) return false;
796
+ return indexedSegments.every(segment => {
797
+ const path = join(this.segmentDir, segment.file);
798
+ return existsSync(path) && statSync(path).size === Number(segment.bytes);
799
+ });
800
+ }
801
+
732
802
  #normalizeIndex(idx) {
733
803
  const out = { ...emptySegmentIndex(), ...(idx || {}) };
734
804
  out.segments = Array.isArray(out.segments) ? out.segments.filter(s => s && s.file) : [];
@@ -737,6 +807,9 @@ class SegmentStore {
737
807
  out.nextSeq = Math.max(Number(out.nextSeq) || 1, maxSeq + 1);
738
808
  out.activeSegment = out.activeSegment || out.segments[out.segments.length - 1]?.file || SEGMENT_FIRST_NAME;
739
809
  out.totalMessages = Number(out.totalMessages) || out.segments.reduce((sum, seg) => sum + (Number(seg.count) || 0), 0);
810
+ out.foldedMessageIds = Array.isArray(out.foldedMessageIds)
811
+ ? Array.from(new Set(out.foldedMessageIds.filter(id => typeof id === 'string' && id)))
812
+ : [];
740
813
  return out;
741
814
  }
742
815
 
@@ -758,10 +831,12 @@ class SegmentStore {
758
831
  };
759
832
  idx.segments.push(seg);
760
833
  idx.totalMessages += rows.length;
834
+ idx.foldedMessageIds.push(...foldedMessageIdsFrom(rows));
761
835
  idx.lastMessageId = rows[rows.length - 1]?.id || idx.lastMessageId;
762
836
  idx.nextSeq = Math.max(idx.nextSeq, seg.lastSeq + 1);
763
837
  idx.activeSegment = file;
764
838
  }
839
+ idx.foldedMessageIds = Array.from(new Set(idx.foldedMessageIds));
765
840
  return idx;
766
841
  }
767
842
 
@@ -931,6 +1006,51 @@ export class ConversationStore {
931
1006
  return messages.map(m => this.append(m));
932
1007
  }
933
1008
 
1009
+ /**
1010
+ * Replace fields on one persisted message while preserving its id/order.
1011
+ * Rewrites only the owning conversation segment set; used for async tool
1012
+ * results that complete after their initial tool row was appended.
1013
+ *
1014
+ * @param {object} message — persisted row returned by append()
1015
+ * @param {object} patch — fields to merge into the row
1016
+ * @returns {object|null}
1017
+ */
1018
+ update(message, patch) {
1019
+ if (!message?.id || !patch || typeof patch !== 'object') return null;
1020
+ try {
1021
+ const store = this.#segmentStoreFor(message, { create: false });
1022
+ return store.updateById(message.id, current => ({ ...current, ...patch }));
1023
+ } catch (err) {
1024
+ if (isPermissionError(err)) {
1025
+ if (!_permissionWarned) {
1026
+ console.warn(`[Yeaft] Cannot update message ${message.id}: ${err.code}`);
1027
+ _permissionWarned = true;
1028
+ }
1029
+ return null;
1030
+ }
1031
+ throw err;
1032
+ }
1033
+ }
1034
+
1035
+ /**
1036
+ * Atomically publish a logical range replacement for tool folding.
1037
+ *
1038
+ * The original rows stay append-only on disk. A single reflection row owns
1039
+ * their ids as tombstones, so readers either observe the complete old arc or
1040
+ * the complete reflection — never a half-rewritten tool pair.
1041
+ *
1042
+ * @param {object[]} messages — persisted rows being folded
1043
+ * @param {object} reflection — synthetic `_reflection` user row
1044
+ * @returns {object|null}
1045
+ */
1046
+ foldMessages(messages, reflection) {
1047
+ const foldedMessageIds = Array.from(new Set(
1048
+ (messages || []).map(message => message?.id).filter(id => typeof id === 'string' && id),
1049
+ ));
1050
+ if (foldedMessageIds.length === 0 || !reflection || reflection._reflection !== true) return null;
1051
+ return this.append({ ...reflection, foldedMessageIds });
1052
+ }
1053
+
934
1054
  /**
935
1055
  * Move a message from hot (messages/) to cold (cold/).
936
1056
  *
@@ -1264,11 +1384,12 @@ export class ConversationStore {
1264
1384
  * @param {number} [turnsLimit=DEFAULT_RECENT_TURNS]
1265
1385
  * @returns {object[]}
1266
1386
  */
1267
- loadRecentBySession(sessionId, turnsLimit = DEFAULT_RECENT_TURNS) {
1387
+ loadRecentBySession(sessionId, turnsLimit = DEFAULT_RECENT_TURNS, { includeReflections = false } = {}) {
1268
1388
  if (!sessionId) return [];
1269
1389
  if (turnsLimit === Infinity || turnsLimit < 0) {
1270
1390
  const all = this.#loadSessionMessages(sessionId);
1271
- const filtered = all.filter(m => m && m.sessionId === sessionId && !isHiddenConversationRow(m));
1391
+ const filtered = all.filter(m => m && m.sessionId === sessionId
1392
+ && (!isHiddenConversationRow(m) || (includeReflections && m._reflection === true)));
1272
1393
  return pairSanitize(filtered);
1273
1394
  }
1274
1395
  if (!(turnsLimit > 0)) return [];
@@ -1276,6 +1397,7 @@ export class ConversationStore {
1276
1397
  const { messages, truncated } = this.#loadRecentSessionWindow(sessionId, turnsLimit, {
1277
1398
  roles: null,
1278
1399
  stripAssistantToolCalls: false,
1400
+ includeReflections,
1279
1401
  });
1280
1402
  if (truncated) {
1281
1403
  const hasCompact = this.hasAnyCompactSummaryForSession(sessionId);
@@ -1324,9 +1446,14 @@ export class ConversationStore {
1324
1446
  loadSessionHistoryForVp(sessionId, vpId) {
1325
1447
  if (!sessionId || !vpId) return [];
1326
1448
  const all = this.#loadSessionMessages(sessionId);
1449
+ const foldedIds = foldedMessageIdsFrom(all);
1327
1450
  const out = [];
1328
1451
  for (const m of all) {
1329
- if (!m || m.sessionId !== sessionId) continue;
1452
+ if (!m || m.sessionId !== sessionId || foldedIds.has(m.id)) continue;
1453
+ if (m._reflection === true) {
1454
+ out.push(m);
1455
+ continue;
1456
+ }
1330
1457
  if (isHiddenConversationRow(m)) continue;
1331
1458
  if (m.role === 'user') {
1332
1459
  out.push(m);
@@ -2320,7 +2447,12 @@ export class ConversationStore {
2320
2447
  return parseMessage(readFileSync(path, 'utf8'));
2321
2448
  }
2322
2449
 
2323
- #loadRecentSessionWindow(sessionId, turnsLimit, { beforeSeq = Infinity, roles = null, stripAssistantToolCalls = false } = {}) {
2450
+ #loadRecentSessionWindow(sessionId, turnsLimit, {
2451
+ beforeSeq = Infinity,
2452
+ roles = null,
2453
+ stripAssistantToolCalls = false,
2454
+ includeReflections = false,
2455
+ } = {}) {
2324
2456
  const kept = [];
2325
2457
  const pendingBoundaryRows = [];
2326
2458
  let turnsFromEnd = 0;
@@ -2366,7 +2498,7 @@ export class ConversationStore {
2366
2498
  if (!m || m.sessionId !== sessionId) continue;
2367
2499
 
2368
2500
  const boundaryComplete = turnsFromEnd >= turnsLimit;
2369
- if (isHiddenConversationRow(m)) {
2501
+ if (isHiddenConversationRow(m) && !(includeReflections && m._reflection === true)) {
2370
2502
  if (boundaryComplete) {
2371
2503
  truncated = true;
2372
2504
  break;