@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.
- package/local-runtime/version.json +1 -1
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +153 -21
- package/yeaft/engine.js +259 -164
- package/yeaft/router/continuity.js +2 -2
- package/yeaft/web-bridge.js +13 -6
- package/yeaft/work-center/action-identity.js +25 -0
- package/yeaft/work-center/mainline-projection.js +6 -11
- package/yeaft/work-center/projection.js +36 -8
- package/yeaft/work-center/service.js +5 -2
- package/yeaft/work-center/store.js +106 -11
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.215"}
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
625
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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;
|