@yeaft/webchat-agent 0.1.850 → 0.1.852
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 +240 -2
- package/unify/engine.js +70 -8
- package/unify/web-bridge.js +33 -11
package/package.json
CHANGED
|
@@ -22,7 +22,7 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rename
|
|
|
22
22
|
import { join, basename } from 'path';
|
|
23
23
|
import { isPermissionError } from '../init.js';
|
|
24
24
|
import { pairSanitize } from '../pair-sanitize.js';
|
|
25
|
-
import { sliceLastNTurns } from '../turn-utils.js';
|
|
25
|
+
import { indexOfNthTurnFromEnd, sliceLastNTurns } from '../turn-utils.js';
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Default cold-start "recent window" size, expressed in TURNS (not raw
|
|
@@ -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
|
|
@@ -695,6 +870,43 @@ export class ConversationStore {
|
|
|
695
870
|
return { messages: sliced, oldestSeq, hasMore };
|
|
696
871
|
}
|
|
697
872
|
|
|
873
|
+
/**
|
|
874
|
+
* Visible UI pagination read for one group. Unlike `loadOlderByGroup`, this
|
|
875
|
+
* projects out internal/reflection/system rows BEFORE applying the turn
|
|
876
|
+
* window, so a dense run of hidden metadata cannot force the first screen to
|
|
877
|
+
* scan and materialize the group's entire history in the web bridge.
|
|
878
|
+
*
|
|
879
|
+
* @param {string} groupId
|
|
880
|
+
* @param {number|null} beforeSeq — exclusive upper bound, or null for newest
|
|
881
|
+
* @param {number} [turnsLimit=DEFAULT_RECENT_TURNS]
|
|
882
|
+
* @returns {{ messages: object[], oldestSeq: number|null, hasMore: boolean }}
|
|
883
|
+
*/
|
|
884
|
+
loadVisibleByGroup(groupId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
|
|
885
|
+
if (!groupId || !(turnsLimit > 0)) return { messages: [], oldestSeq: null, hasMore: false };
|
|
886
|
+
|
|
887
|
+
const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
|
|
888
|
+
const hot = this.#loadVisibleFromDirByGroup(this.#msgDir, groupId, cutoff);
|
|
889
|
+
const cold = this.#loadVisibleFromDirByGroup(this.#coldDir, groupId, cutoff);
|
|
890
|
+
const visible = [...cold, ...hot];
|
|
891
|
+
if (visible.length === 0) return { messages: [], oldestSeq: null, hasMore: false };
|
|
892
|
+
|
|
893
|
+
const startIdx = indexOfNthTurnFromEnd(visible, turnsLimit);
|
|
894
|
+
const start = startIdx === -1 ? 0 : startIdx;
|
|
895
|
+
const messages = pairSanitize(visible.slice(start));
|
|
896
|
+
const oldestSeq = messages.length ? parseSeqFromId(messages[0].id) : null;
|
|
897
|
+
const firstVisibleSeq = parseSeqFromId(visible[0].id);
|
|
898
|
+
const hasMore = messages.length > 0
|
|
899
|
+
&& Number.isFinite(oldestSeq)
|
|
900
|
+
&& Number.isFinite(firstVisibleSeq)
|
|
901
|
+
&& oldestSeq > firstVisibleSeq;
|
|
902
|
+
|
|
903
|
+
return {
|
|
904
|
+
messages,
|
|
905
|
+
oldestSeq: Number.isFinite(oldestSeq) ? oldestSeq : null,
|
|
906
|
+
hasMore,
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
698
910
|
/**
|
|
699
911
|
* Count hot messages.
|
|
700
912
|
*
|
|
@@ -1144,6 +1356,32 @@ export class ConversationStore {
|
|
|
1144
1356
|
return messages;
|
|
1145
1357
|
}
|
|
1146
1358
|
|
|
1359
|
+
#loadVisibleFromDirByGroup(dir, groupId, beforeSeq) {
|
|
1360
|
+
if (!existsSync(dir)) return [];
|
|
1361
|
+
|
|
1362
|
+
const files = readdirSync(dir)
|
|
1363
|
+
.filter(f => f.endsWith('.md'))
|
|
1364
|
+
.sort();
|
|
1365
|
+
|
|
1366
|
+
const out = [];
|
|
1367
|
+
for (const file of files) {
|
|
1368
|
+
const seq = parseSeqFromId(basename(file, '.md'));
|
|
1369
|
+
if (!Number.isFinite(seq) || seq >= beforeSeq) continue;
|
|
1370
|
+
|
|
1371
|
+
const raw = readFileSync(join(dir, file), 'utf8');
|
|
1372
|
+
if (!raw.includes(`groupId: ${groupId}`)) continue;
|
|
1373
|
+
if (!raw.includes('role: user') && !raw.includes('role: assistant')) continue;
|
|
1374
|
+
|
|
1375
|
+
const parsed = parseMessage(raw);
|
|
1376
|
+
if (!parsed || parsed.groupId !== groupId) continue;
|
|
1377
|
+
if (parsed._reflection || parsed.internal || parsed.systemOnly || parsed.systemOnlyMessage) continue;
|
|
1378
|
+
if (parsed.role !== 'user' && parsed.role !== 'assistant') continue;
|
|
1379
|
+
out.push(parsed);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
return out;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1147
1385
|
/**
|
|
1148
1386
|
* Determine the next sequence number by scanning existing files.
|
|
1149
1387
|
* @returns {number}
|
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
|
@@ -602,10 +602,16 @@ function loadVisibleGroupHistoryPage(store, groupId, limit, beforeSeq = null) {
|
|
|
602
602
|
|
|
603
603
|
let rows = [];
|
|
604
604
|
try {
|
|
605
|
-
if (typeof store.
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
605
|
+
if (typeof store.loadVisibleByGroup === 'function') {
|
|
606
|
+
const page = store.loadVisibleByGroup(groupId, beforeSeq, limit);
|
|
607
|
+
return {
|
|
608
|
+
messages: (page.messages || []).map(projectPersistedToVisibleHistoryEntry).filter(Boolean),
|
|
609
|
+
oldestSeq: (typeof page.oldestSeq === 'number') ? page.oldestSeq : null,
|
|
610
|
+
hasMore: !!page.hasMore,
|
|
611
|
+
};
|
|
612
|
+
} else if (typeof store.loadOlderByGroup === 'function') {
|
|
613
|
+
// Compatibility fallback for older test doubles: use an unbounded raw
|
|
614
|
+
// prefix, then project/slice visible rows below.
|
|
609
615
|
rows = store.loadOlderByGroup(groupId, beforeSeq, Infinity).messages || [];
|
|
610
616
|
} else if (Number.isFinite(beforeSeq)) {
|
|
611
617
|
const all = typeof store.loadAllByGroup === 'function'
|
|
@@ -851,6 +857,12 @@ function getOrCreateVpEngine(groupId, vpId, threadId = 'main') {
|
|
|
851
857
|
// (`if (this.#toolStats && ...)`) is false and group VP tool calls
|
|
852
858
|
// are silently dropped.
|
|
853
859
|
toolStats: session.toolStats || null,
|
|
860
|
+
// Per-VP fan-out: bind the engine to its (groupId, vpId) so post-turn
|
|
861
|
+
// compact reads/writes a scoped summary instead of the legacy global
|
|
862
|
+
// compact.md (which every VP would otherwise share, producing
|
|
863
|
+
// identical, ever-growing summaries across groups).
|
|
864
|
+
groupId,
|
|
865
|
+
vpId,
|
|
854
866
|
});
|
|
855
867
|
vpEngines.set(key, eng);
|
|
856
868
|
return eng;
|
|
@@ -3576,11 +3588,10 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
3576
3588
|
}
|
|
3577
3589
|
|
|
3578
3590
|
// `msg.limit` is the replay-scrollback request from the frontend (UI
|
|
3579
|
-
// history pane, not engine context).
|
|
3580
|
-
//
|
|
3581
|
-
//
|
|
3582
|
-
|
|
3583
|
-
const limit = (typeof msg.limit === 'number') ? msg.limit : 50;
|
|
3591
|
+
// history pane, not engine context). Keep the bootstrap window small so
|
|
3592
|
+
// opening a group can paint the latest messages quickly; older rows are
|
|
3593
|
+
// paged via `unify_load_more_history` when the user scrolls upward.
|
|
3594
|
+
const limit = (typeof msg.limit === 'number') ? msg.limit : 10;
|
|
3584
3595
|
const visiblePage = groupId
|
|
3585
3596
|
? loadVisibleGroupHistoryPage(session.conversationStore, groupId, limit)
|
|
3586
3597
|
: { messages: limit > 0 ? pickRecent(session.conversationStore, limit) : [], oldestSeq: null, hasMore: false };
|
|
@@ -3634,10 +3645,21 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
3634
3645
|
oldestSeq = visiblePage.oldestSeq;
|
|
3635
3646
|
}
|
|
3636
3647
|
|
|
3648
|
+
// hasCompactSummary used to read a single session-global file, so it
|
|
3649
|
+
// was always true once ANY group/VP in the session had compacted.
|
|
3650
|
+
// Now we check (a) the scoped dir for any per-VP summary file in this
|
|
3651
|
+
// group, falling back to (b) the legacy global file for sessions that
|
|
3652
|
+
// pre-date the per-(group, vp) split.
|
|
3653
|
+
let hasCompactSummaryFlag = !!compactSummary;
|
|
3654
|
+
if (groupId && typeof session.conversationStore.hasAnyCompactSummaryForGroup === 'function') {
|
|
3655
|
+
hasCompactSummaryFlag = session.conversationStore.hasAnyCompactSummaryForGroup(groupId)
|
|
3656
|
+
|| hasCompactSummaryFlag;
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3637
3659
|
sendUnifyEvent({
|
|
3638
3660
|
type: 'history_loaded',
|
|
3639
3661
|
count: replayEntries.length,
|
|
3640
|
-
hasCompactSummary:
|
|
3662
|
+
hasCompactSummary: hasCompactSummaryFlag,
|
|
3641
3663
|
totalHot: session.conversationStore.countHot(),
|
|
3642
3664
|
totalCold: session.conversationStore.countCold(),
|
|
3643
3665
|
groupId,
|
|
@@ -3674,7 +3696,7 @@ export async function handleUnifyLoadMoreHistory(msg) {
|
|
|
3674
3696
|
}
|
|
3675
3697
|
|
|
3676
3698
|
const beforeSeq = (typeof msg.beforeSeq === 'number') ? msg.beforeSeq : null;
|
|
3677
|
-
const turns = (typeof msg.turns === 'number' && msg.turns > 0) ? msg.turns :
|
|
3699
|
+
const turns = (typeof msg.turns === 'number' && msg.turns > 0) ? msg.turns : 10;
|
|
3678
3700
|
|
|
3679
3701
|
let result;
|
|
3680
3702
|
try {
|