@yeaft/webchat-agent 0.1.850 → 0.1.851
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/package.json +1 -1
- package/unify/conversation/persist.js +176 -1
- package/unify/engine.js +70 -8
- package/unify/web-bridge.js +18 -1
package/package.json
CHANGED
|
@@ -329,6 +329,7 @@ export class ConversationStore {
|
|
|
329
329
|
#coldDir; // ~/.yeaft/conversation/cold
|
|
330
330
|
#indexPath; // ~/.yeaft/conversation/index.md
|
|
331
331
|
#compactPath; // ~/.yeaft/conversation/compact.md
|
|
332
|
+
#compactScopedDir; // ~/.yeaft/conversation/compact/ (per-(group,vp))
|
|
332
333
|
#nextSeq; // next message sequence number (global, legacy)
|
|
333
334
|
#nextSeqByThread; // Map<threadId, number> — per-thread counters (task-314)
|
|
334
335
|
|
|
@@ -342,11 +343,15 @@ export class ConversationStore {
|
|
|
342
343
|
this.#coldDir = join(dir, 'conversation', 'cold');
|
|
343
344
|
this.#indexPath = join(dir, 'conversation', 'index.md');
|
|
344
345
|
this.#compactPath = join(dir, 'conversation', 'compact.md');
|
|
346
|
+
// Per-(groupId, vpId) compact summary files live here. The legacy
|
|
347
|
+
// single-file `compact.md` above is kept for backward compatibility
|
|
348
|
+
// and the "no groupId/vpId" fallback (sub-agents, legacy callers).
|
|
349
|
+
this.#compactScopedDir = join(dir, 'conversation', 'compact');
|
|
345
350
|
this.#nextSeq = null;
|
|
346
351
|
this.#nextSeqByThread = new Map();
|
|
347
352
|
|
|
348
353
|
// Ensure directories exist (graceful on permission errors)
|
|
349
|
-
for (const d of [this.#convDir, this.#msgDir, this.#coldDir]) {
|
|
354
|
+
for (const d of [this.#convDir, this.#msgDir, this.#coldDir, this.#compactScopedDir]) {
|
|
350
355
|
try {
|
|
351
356
|
if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
|
|
352
357
|
} catch (err) {
|
|
@@ -481,6 +486,111 @@ export class ConversationStore {
|
|
|
481
486
|
return readFileSync(this.#compactPath, 'utf8');
|
|
482
487
|
}
|
|
483
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Sanitize one id (groupId or vpId) into a safe filename component.
|
|
491
|
+
* Anything outside `[A-Za-z0-9._-]` collapses to `_`; max 120 chars.
|
|
492
|
+
* The result is only ever used as a basename joined to `compactScopedDir`
|
|
493
|
+
* — path traversal is blocked by the basename-only `join`, not by the
|
|
494
|
+
* regex (a literal `..` stays as `..` here and becomes part of a
|
|
495
|
+
* regular filename via the `__` separator + `.md` suffix).
|
|
496
|
+
*
|
|
497
|
+
* @param {string} s
|
|
498
|
+
* @returns {string}
|
|
499
|
+
*/
|
|
500
|
+
#safeIdComponent(s) {
|
|
501
|
+
return String(s).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Sanitize a (groupId, vpId) pair into a safe filename. We accept
|
|
506
|
+
* arbitrary user strings here (groupIds and vpIds are user-set), so
|
|
507
|
+
* the result is purely a basename — never parsed back.
|
|
508
|
+
*
|
|
509
|
+
* @param {string} groupId
|
|
510
|
+
* @param {string} vpId
|
|
511
|
+
* @returns {string|null} — full path, or null if either id missing
|
|
512
|
+
*/
|
|
513
|
+
#scopedCompactPath(groupId, vpId) {
|
|
514
|
+
if (!groupId || !vpId) return null;
|
|
515
|
+
return join(this.#compactScopedDir, `${this.#safeIdComponent(groupId)}__${this.#safeIdComponent(vpId)}.md`);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Read a per-(groupId, vpId) compact summary. Returns '' if no summary
|
|
520
|
+
* has been written yet. Falls back to nothing — callers that need the
|
|
521
|
+
* legacy global file should call `readCompactSummary()` explicitly.
|
|
522
|
+
*
|
|
523
|
+
* The (group, vp) scoping was introduced after we noticed the legacy
|
|
524
|
+
* single-file `compact.md` was shared across every group AND every VP
|
|
525
|
+
* in a session — so each new compact would clobber/append on top of
|
|
526
|
+
* unrelated content and every VP read the same merged blob. See
|
|
527
|
+
* `engine.#runOrchestratorCompact`.
|
|
528
|
+
*
|
|
529
|
+
* @param {string} groupId
|
|
530
|
+
* @param {string} vpId
|
|
531
|
+
* @returns {string}
|
|
532
|
+
*/
|
|
533
|
+
readCompactSummaryFor(groupId, vpId) {
|
|
534
|
+
const path = this.#scopedCompactPath(groupId, vpId);
|
|
535
|
+
if (!path) return '';
|
|
536
|
+
if (!existsSync(path)) return '';
|
|
537
|
+
try { return readFileSync(path, 'utf8'); }
|
|
538
|
+
catch { return ''; }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Append a per-(groupId, vpId) compact summary entry. Same append-only
|
|
543
|
+
* "## YYYY-MM-DD ..." structure as the legacy `updateCompactSummary`,
|
|
544
|
+
* but isolated to one file per (group, vp).
|
|
545
|
+
*
|
|
546
|
+
* @param {string} groupId
|
|
547
|
+
* @param {string} vpId
|
|
548
|
+
* @param {string} summary
|
|
549
|
+
*/
|
|
550
|
+
updateCompactSummaryFor(groupId, vpId, summary) {
|
|
551
|
+
const path = this.#scopedCompactPath(groupId, vpId);
|
|
552
|
+
if (!path) return;
|
|
553
|
+
let existing = '';
|
|
554
|
+
if (existsSync(path)) {
|
|
555
|
+
try { existing = readFileSync(path, 'utf8'); }
|
|
556
|
+
catch { existing = ''; }
|
|
557
|
+
}
|
|
558
|
+
const date = new Date().toISOString().split('T')[0];
|
|
559
|
+
const entry = `\n## ${date}\n\n${summary}\n`;
|
|
560
|
+
try {
|
|
561
|
+
writeFileSync(path, existing + entry, { encoding: 'utf8', mode: 0o644 });
|
|
562
|
+
} catch (err) {
|
|
563
|
+
if (isPermissionError(err)) {
|
|
564
|
+
if (!_permissionWarned) {
|
|
565
|
+
console.warn(`[Yeaft] Cannot write scoped compact summary: ${err.code}`);
|
|
566
|
+
_permissionWarned = true;
|
|
567
|
+
}
|
|
568
|
+
} else {
|
|
569
|
+
throw err;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Check whether ANY per-(group, vp) compact summary exists for `groupId`.
|
|
576
|
+
* Used by the history-replay path to decide whether to flag
|
|
577
|
+
* `hasCompactSummary` for the UI without committing to one VP's view.
|
|
578
|
+
*
|
|
579
|
+
* @param {string} groupId
|
|
580
|
+
* @returns {boolean}
|
|
581
|
+
*/
|
|
582
|
+
hasAnyCompactSummaryForGroup(groupId) {
|
|
583
|
+
if (!groupId) return false;
|
|
584
|
+
if (!existsSync(this.#compactScopedDir)) return false;
|
|
585
|
+
const prefix = `${this.#safeIdComponent(groupId)}__`;
|
|
586
|
+
try {
|
|
587
|
+
for (const f of readdirSync(this.#compactScopedDir)) {
|
|
588
|
+
if (f.startsWith(prefix) && f.endsWith('.md')) return true;
|
|
589
|
+
}
|
|
590
|
+
} catch { /* best-effort */ }
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
|
|
484
594
|
/**
|
|
485
595
|
* Update the conversation index.md with current state.
|
|
486
596
|
*
|
|
@@ -635,6 +745,71 @@ export class ConversationStore {
|
|
|
635
745
|
return this.loadRecentByGroup(groupId, Infinity);
|
|
636
746
|
}
|
|
637
747
|
|
|
748
|
+
/**
|
|
749
|
+
* VP-scoped view of group history, used by per-VP post-turn compact.
|
|
750
|
+
*
|
|
751
|
+
* Compact must operate on what the VP actually *saw* in its context,
|
|
752
|
+
* not the union of every VP's tool calls/results — otherwise compact
|
|
753
|
+
* tries to summarize tool transcripts that were never in this VP's
|
|
754
|
+
* prompt window. The rule we settled on (with the user, 2026-06-01):
|
|
755
|
+
*
|
|
756
|
+
* - User rows (no speakerVpId): KEEP — every VP sees the prompt.
|
|
757
|
+
* - This VP's own assistant rows + their paired tool rows: KEEP.
|
|
758
|
+
* - OTHER VPs' assistant rows: KEEP TEXT ONLY (strip toolCalls AND
|
|
759
|
+
* thinkingBlocks — thinking is VP-private per Anthropic's signed-
|
|
760
|
+
* block contract and would never appear in another VP's context).
|
|
761
|
+
* - OTHER VPs' tool result rows (role:'tool'): DROP — they pair with
|
|
762
|
+
* stripped tool_use ids and would orphan on replay.
|
|
763
|
+
* - Rows with `_reflection` / `internal` / `systemOnly`: DROP — they
|
|
764
|
+
* are engine-private and never enter another VP's context.
|
|
765
|
+
*
|
|
766
|
+
* The output is pair-safe by construction for THIS VP's tool arcs and
|
|
767
|
+
* carries only summary-relevant text for the other VPs.
|
|
768
|
+
*
|
|
769
|
+
* @param {string} groupId
|
|
770
|
+
* @param {string} vpId
|
|
771
|
+
* @returns {object[]}
|
|
772
|
+
*/
|
|
773
|
+
loadGroupHistoryForVp(groupId, vpId) {
|
|
774
|
+
if (!groupId || !vpId) return [];
|
|
775
|
+
const all = this.#loadFromDir(this.#msgDir, Infinity);
|
|
776
|
+
const out = [];
|
|
777
|
+
for (const m of all) {
|
|
778
|
+
if (!m || m.groupId !== groupId) continue;
|
|
779
|
+
if (m._reflection || m.internal || m.systemOnly || m.systemOnlyMessage) continue;
|
|
780
|
+
if (m.role === 'user') {
|
|
781
|
+
out.push(m);
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
if (m.role === 'assistant') {
|
|
785
|
+
if (m.speakerVpId === vpId) {
|
|
786
|
+
out.push(m);
|
|
787
|
+
} else {
|
|
788
|
+
// Other VP's assistant text only — drop their toolCalls so the
|
|
789
|
+
// following role:'tool' rows (which we also drop) don't leave
|
|
790
|
+
// orphan tool_use ids in the compact input.
|
|
791
|
+
const copy = { ...m };
|
|
792
|
+
delete copy.toolCalls;
|
|
793
|
+
delete copy.thinkingBlocks;
|
|
794
|
+
out.push(copy);
|
|
795
|
+
}
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
if (m.role === 'tool') {
|
|
799
|
+
// Tool results belong to the assistant turn that emitted the
|
|
800
|
+
// tool_use. Only keep ours; other VPs' results were dropped via
|
|
801
|
+
// their assistant's stripped toolCalls.
|
|
802
|
+
if (m.speakerVpId === vpId) out.push(m);
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// Note: we don't run `sliceLastNTurns` here. The caller
|
|
807
|
+
// (#runOrchestratorCompact) decides what's "cooling" via
|
|
808
|
+
// `partitionMessages`, and we don't want to pre-truncate before that
|
|
809
|
+
// budget calc sees the full picture.
|
|
810
|
+
return pairSanitize(out);
|
|
811
|
+
}
|
|
812
|
+
|
|
638
813
|
/**
|
|
639
814
|
* Pagination-cursor read: load the page of `turnsLimit` TURNS that ends
|
|
640
815
|
* just before `beforeSeq` (exclusive) for the given `groupId`. Used by
|
package/unify/engine.js
CHANGED
|
@@ -302,6 +302,10 @@ export class Engine {
|
|
|
302
302
|
|
|
303
303
|
/** @type {string|null} */
|
|
304
304
|
#yeaftDir;
|
|
305
|
+
/** @type {string|null} — set when this engine is bound to a specific group (per-VP fan-out path). */
|
|
306
|
+
#groupId = null;
|
|
307
|
+
/** @type {string|null} — set when this engine is bound to a specific VP (per-VP fan-out path). */
|
|
308
|
+
#vpId = null;
|
|
305
309
|
|
|
306
310
|
/** @type {import('./stats/tool-usage.js').ToolUsageStats|null} — per-tool call/latency counters */
|
|
307
311
|
#toolStats = null;
|
|
@@ -402,7 +406,7 @@ export class Engine {
|
|
|
402
406
|
* toolStats?: import('./stats/tool-usage.js').ToolUsageStats,
|
|
403
407
|
* }} params
|
|
404
408
|
*/
|
|
405
|
-
constructor({ adapter, trace, config, conversationStore, memoryIndex, amsRegistry, toolRegistry, skillManager, mcpManager, yeaftDir, toolStats = null }) {
|
|
409
|
+
constructor({ adapter, trace, config, conversationStore, memoryIndex, amsRegistry, toolRegistry, skillManager, mcpManager, yeaftDir, toolStats = null, groupId = null, vpId = null }) {
|
|
406
410
|
this.#adapter = adapter;
|
|
407
411
|
this.#trace = trace;
|
|
408
412
|
this.#config = config;
|
|
@@ -416,6 +420,14 @@ export class Engine {
|
|
|
416
420
|
this.#mcpManager = mcpManager || null;
|
|
417
421
|
this.#yeaftDir = yeaftDir || null;
|
|
418
422
|
this.#toolStats = toolStats || null;
|
|
423
|
+
// Per-VP fan-out (2026-06-01): engine instances in the group path are
|
|
424
|
+
// keyed by ${groupId}::${vpId}::${threadId}, so binding the engine to
|
|
425
|
+
// its (groupId, vpId) pair at construction lets post-turn compact
|
|
426
|
+
// scope its read/write to THIS VP's view of the conversation instead
|
|
427
|
+
// of clobbering a session-global compact.md. Legacy / sub-agent
|
|
428
|
+
// callers leave both null → fall back to the global file.
|
|
429
|
+
this.#groupId = (typeof groupId === 'string' && groupId) ? groupId : null;
|
|
430
|
+
this.#vpId = (typeof vpId === 'string' && vpId) ? vpId : null;
|
|
419
431
|
|
|
420
432
|
// PR-L: tool history reflection log. Keyed by traceId so distinct
|
|
421
433
|
// engine instances don't stomp on each other's jsonl files. When
|
|
@@ -978,6 +990,16 @@ export class Engine {
|
|
|
978
990
|
*/
|
|
979
991
|
#getCompactSummary() {
|
|
980
992
|
if (!this.#conversationStore) return '';
|
|
993
|
+
// Per-(group, vp) scoping: when this engine is bound to a fan-out VP,
|
|
994
|
+
// read its own summary file. On a miss (empty scoped file) we fall
|
|
995
|
+
// through to the legacy global file so pre-PR sessions whose only
|
|
996
|
+
// summary lives in compact.md still surface their context — matches
|
|
997
|
+
// the OR-fallback in web-bridge's `hasCompactSummary` flag.
|
|
998
|
+
if (this.#groupId && this.#vpId
|
|
999
|
+
&& typeof this.#conversationStore.readCompactSummaryFor === 'function') {
|
|
1000
|
+
const scoped = this.#conversationStore.readCompactSummaryFor(this.#groupId, this.#vpId);
|
|
1001
|
+
if (scoped) return scoped;
|
|
1002
|
+
}
|
|
981
1003
|
return this.#conversationStore.readCompactSummary();
|
|
982
1004
|
}
|
|
983
1005
|
|
|
@@ -1063,14 +1085,30 @@ export class Engine {
|
|
|
1063
1085
|
const adapter = this.#adapter;
|
|
1064
1086
|
const fastConfig = this.#fastConfig;
|
|
1065
1087
|
|
|
1088
|
+
// Per-(group, vp) scoping: when this engine is bound to a fan-out VP
|
|
1089
|
+
// (the common case in group mode), load only the rows THIS VP saw in
|
|
1090
|
+
// its context — user prompts + every VP's assistant text, with other
|
|
1091
|
+
// VPs' tool calls/results stripped (see persist.loadGroupHistoryForVp).
|
|
1092
|
+
//
|
|
1093
|
+
// Legacy / sub-agent callers (no groupId/vpId pair) keep the global
|
|
1094
|
+
// loadAll() behaviour so we don't break those flows.
|
|
1066
1095
|
let messages;
|
|
1096
|
+
const scoped = !!(this.#groupId && this.#vpId
|
|
1097
|
+
&& typeof conversationStore.loadGroupHistoryForVp === 'function');
|
|
1067
1098
|
try {
|
|
1068
|
-
messages =
|
|
1099
|
+
messages = scoped
|
|
1100
|
+
? conversationStore.loadGroupHistoryForVp(this.#groupId, this.#vpId)
|
|
1101
|
+
: conversationStore.loadAll();
|
|
1069
1102
|
} catch { return null; }
|
|
1070
1103
|
if (!Array.isArray(messages) || messages.length === 0) return null;
|
|
1071
1104
|
|
|
1072
1105
|
const tokenCount = conversationStore.hotTokens();
|
|
1073
|
-
|
|
1106
|
+
// In the scoped path, groupId is the engine's binding (authoritative).
|
|
1107
|
+
// In the legacy path, fall back to scanning the messages (best-effort,
|
|
1108
|
+
// used only for the group context-window gate).
|
|
1109
|
+
const groupId = this.#groupId
|
|
1110
|
+
|| messages.find(m => m && typeof m.groupId === 'string' && m.groupId)?.groupId
|
|
1111
|
+
|| null;
|
|
1074
1112
|
const groupContextGate = shouldAllowGroupReflection({
|
|
1075
1113
|
system: '',
|
|
1076
1114
|
messages,
|
|
@@ -1130,7 +1168,13 @@ export class Engine {
|
|
|
1130
1168
|
}
|
|
1131
1169
|
},
|
|
1132
1170
|
archive: async (_groupIdx, groupMsgs) => {
|
|
1133
|
-
|
|
1171
|
+
// Only collect archive ids when we'll actually use them. In the
|
|
1172
|
+
// scoped (per-VP) path we never call moveToColdBatch — those
|
|
1173
|
+
// rows are shared with sibling VPs in this group — so leaving
|
|
1174
|
+
// the push in would be dead state a future reader has to chase.
|
|
1175
|
+
if (!scoped) {
|
|
1176
|
+
for (const m of groupMsgs) if (m.id) archiveIds.push(m.id);
|
|
1177
|
+
}
|
|
1134
1178
|
const turnId = groupMsgs[0]?.id || `g_${Date.now()}`;
|
|
1135
1179
|
if (this.#yeaftDir) {
|
|
1136
1180
|
try {
|
|
@@ -1150,10 +1194,28 @@ export class Engine {
|
|
|
1150
1194
|
const out = await runCompactOrchestrator({
|
|
1151
1195
|
messages, keepHot: 10, hooks,
|
|
1152
1196
|
});
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1197
|
+
// Scoped path (per-(group, vp)): do NOT moveToColdBatch — those
|
|
1198
|
+
// archive ids include user rows and other VPs' assistant rows that
|
|
1199
|
+
// sibling VPs in this group still need in their hot context. The
|
|
1200
|
+
// per-VP summary written below is the durable win; physical
|
|
1201
|
+
// cold-archival across shared rows is the dream-level orchestrator's
|
|
1202
|
+
// job, not post-turn compact's.
|
|
1203
|
+
if (!scoped && archiveIds.length > 0) {
|
|
1204
|
+
conversationStore.moveToColdBatch(archiveIds);
|
|
1205
|
+
}
|
|
1206
|
+
if (out.compactSummary) {
|
|
1207
|
+
if (scoped && typeof conversationStore.updateCompactSummaryFor === 'function') {
|
|
1208
|
+
conversationStore.updateCompactSummaryFor(this.#groupId, this.#vpId, out.compactSummary);
|
|
1209
|
+
} else {
|
|
1210
|
+
conversationStore.updateCompactSummary(out.compactSummary);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
// Index update only makes sense for the legacy path that actually
|
|
1214
|
+
// moved rows to cold. In the scoped path, nothing on disk changed.
|
|
1215
|
+
if (!scoped) {
|
|
1216
|
+
const lastKept = messages[messages.length - 1];
|
|
1217
|
+
conversationStore.updateIndex({ lastMessageId: lastKept?.id || null });
|
|
1218
|
+
}
|
|
1157
1219
|
|
|
1158
1220
|
return {
|
|
1159
1221
|
archivedCount: out.archivedMessages,
|
package/unify/web-bridge.js
CHANGED
|
@@ -851,6 +851,12 @@ function getOrCreateVpEngine(groupId, vpId, threadId = 'main') {
|
|
|
851
851
|
// (`if (this.#toolStats && ...)`) is false and group VP tool calls
|
|
852
852
|
// are silently dropped.
|
|
853
853
|
toolStats: session.toolStats || null,
|
|
854
|
+
// Per-VP fan-out: bind the engine to its (groupId, vpId) so post-turn
|
|
855
|
+
// compact reads/writes a scoped summary instead of the legacy global
|
|
856
|
+
// compact.md (which every VP would otherwise share, producing
|
|
857
|
+
// identical, ever-growing summaries across groups).
|
|
858
|
+
groupId,
|
|
859
|
+
vpId,
|
|
854
860
|
});
|
|
855
861
|
vpEngines.set(key, eng);
|
|
856
862
|
return eng;
|
|
@@ -3634,10 +3640,21 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
3634
3640
|
oldestSeq = visiblePage.oldestSeq;
|
|
3635
3641
|
}
|
|
3636
3642
|
|
|
3643
|
+
// hasCompactSummary used to read a single session-global file, so it
|
|
3644
|
+
// was always true once ANY group/VP in the session had compacted.
|
|
3645
|
+
// Now we check (a) the scoped dir for any per-VP summary file in this
|
|
3646
|
+
// group, falling back to (b) the legacy global file for sessions that
|
|
3647
|
+
// pre-date the per-(group, vp) split.
|
|
3648
|
+
let hasCompactSummaryFlag = !!compactSummary;
|
|
3649
|
+
if (groupId && typeof session.conversationStore.hasAnyCompactSummaryForGroup === 'function') {
|
|
3650
|
+
hasCompactSummaryFlag = session.conversationStore.hasAnyCompactSummaryForGroup(groupId)
|
|
3651
|
+
|| hasCompactSummaryFlag;
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3637
3654
|
sendUnifyEvent({
|
|
3638
3655
|
type: 'history_loaded',
|
|
3639
3656
|
count: replayEntries.length,
|
|
3640
|
-
hasCompactSummary:
|
|
3657
|
+
hasCompactSummary: hasCompactSummaryFlag,
|
|
3641
3658
|
totalHot: session.conversationStore.countHot(),
|
|
3642
3659
|
totalCold: session.conversationStore.countCold(),
|
|
3643
3660
|
groupId,
|