@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.850",
3
+ "version": "0.1.852",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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 = conversationStore.loadAll();
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
- const groupId = messages.find(m => m && typeof m.groupId === 'string' && m.groupId)?.groupId || null;
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
- for (const m of groupMsgs) if (m.id) archiveIds.push(m.id);
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
- if (archiveIds.length > 0) conversationStore.moveToColdBatch(archiveIds);
1154
- if (out.compactSummary) conversationStore.updateCompactSummary(out.compactSummary);
1155
- const lastKept = messages[messages.length - 1];
1156
- conversationStore.updateIndex({ lastMessageId: lastKept?.id || null });
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,
@@ -602,10 +602,16 @@ function loadVisibleGroupHistoryPage(store, groupId, limit, beforeSeq = null) {
602
602
 
603
603
  let rows = [];
604
604
  try {
605
- if (typeof store.loadOlderByGroup === 'function') {
606
- // Use an unbounded raw prefix, then project/slice visible rows below.
607
- // This preserves loadOlderByGroup's hot+cold scan without letting raw
608
- // reflection/internal rows consume the UI-visible page window.
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). Semantics changed (2026-05-01):
3580
- // now expressed in TURNS. The previous default (50 messages) maps to
3581
- // ~20–25 turns; in the turn-count world 50 turns of UI scrollback is
3582
- // still cheap and matches what the frontend already passes through.
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: !!compactSummary,
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 : 20;
3699
+ const turns = (typeof msg.turns === 'number' && msg.turns > 0) ? msg.turns : 10;
3678
3700
 
3679
3701
  let result;
3680
3702
  try {