@yeaft/webchat-agent 0.1.747 → 0.1.748

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.747",
3
+ "version": "0.1.748",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -1755,6 +1755,17 @@ export class Engine {
1755
1755
  config: this.#fastConfig,
1756
1756
  primaryModel: this.#config.model,
1757
1757
  messages: conversationMessages,
1758
+ // Reflect-persist fix: tell stop-hooks the EXACT turn boundary
1759
+ // instead of letting it heuristically scan back to the last
1760
+ // role:'user'. With T1/T2 reflection collapse, the last
1761
+ // role:'user' is the synthetic reflection message — not the
1762
+ // original user prompt — so the heuristic was dropping
1763
+ // earlier reflection messages and the original prompt off
1764
+ // the persistence window. `turnStartIdx` is the index of
1765
+ // the original user prompt (set at query() entry); slicing
1766
+ // from there persists the full collapsed turn including all
1767
+ // reflection messages and the trailing assistant response.
1768
+ turnStartIdx,
1758
1769
  trace: this.#trace,
1759
1770
  // Bug 6: tag persisted messages with the originating group so
1760
1771
  // history replay can re-stamp them on reload.
@@ -30,6 +30,7 @@ let _permissionWarned = false;
30
30
  * config: object,
31
31
  * primaryModel?: string,
32
32
  * messages?: object[],
33
+ * turnStartIdx?: number,
33
34
  * taskId?: string,
34
35
  * workerId?: string,
35
36
  * trace?: object,
@@ -45,6 +46,19 @@ export async function runStopHooks(context) {
45
46
  config,
46
47
  primaryModel,
47
48
  messages = [],
49
+ // Reflect-persist fix: when the engine knows the exact turn boundary
50
+ // (it does — `turnStartIdx` is set at query() entry as
51
+ // `conversationMessages.length - 1`), pass it in. The legacy
52
+ // heuristic of "scan back for the last role:'user'" is wrong once
53
+ // T1/T2 reflection has collapsed the tool arc into a synthetic
54
+ // role:'user' message — that synthetic message would be picked as
55
+ // the turn start, dropping the original prompt AND any earlier
56
+ // reflection messages from the persistence window.
57
+ //
58
+ // When undefined, falls back to the legacy heuristic so older
59
+ // callers (sub-agents, workers) that don't pass it continue to
60
+ // work.
61
+ turnStartIdx,
48
62
  taskId,
49
63
  trace,
50
64
  // Bug 6: groupId/threadId stamped on every persisted message so
@@ -77,19 +91,33 @@ export async function runStopHooks(context) {
77
91
  // assistant's `toolCalls` and each paired `role:'tool'` result —
78
92
  // otherwise restoring history on session reload drops the pairing
79
93
  // and causes "No tool output found for function call" 400s on the
80
- // next chat-completions request. We walk back from the end of
81
- // `messages` to find the first `role:'user'` that marks the start
82
- // of the current turn, then persist everything from there forward.
94
+ // next chat-completions request.
95
+ //
96
+ // Reflect-persist fix: prefer the explicit `turnStartIdx` from the
97
+ // engine when given. The legacy heuristic of "find the last
98
+ // role:'user'" is broken in the presence of T1/T2 reflection
99
+ // collapse, because the collapsed reflection is itself a
100
+ // role:'user' message — using it as the turn start would drop the
101
+ // real user prompt and any earlier reflections.
83
102
  try {
84
103
  if (conversationStore && messages.length > 0) {
85
- // Find the start of the latest turn — the last `role:'user'`
86
- // message in the array. Everything from that index onward is
87
- // new this turn (user + assistant[+toolCalls] + tool results).
88
- let turnStart = messages.length - 1;
89
- for (let i = messages.length - 1; i >= 0; i--) {
90
- if (messages[i] && messages[i].role === 'user') {
91
- turnStart = i;
92
- break;
104
+ let turnStart;
105
+ if (typeof turnStartIdx === 'number'
106
+ && Number.isFinite(turnStartIdx)
107
+ && turnStartIdx >= 0
108
+ && turnStartIdx < messages.length) {
109
+ // Engine-supplied exact turn boundary preferred.
110
+ turnStart = turnStartIdx;
111
+ } else {
112
+ // Legacy heuristic — find the last role:'user' message.
113
+ // Used by sub-agent / worker callers that don't compute the
114
+ // boundary explicitly.
115
+ turnStart = messages.length - 1;
116
+ for (let i = messages.length - 1; i >= 0; i--) {
117
+ if (messages[i] && messages[i].role === 'user') {
118
+ turnStart = i;
119
+ break;
120
+ }
93
121
  }
94
122
  }
95
123
  const recentMessages = messages.slice(turnStart);
@@ -99,7 +127,18 @@ export async function runStopHooks(context) {
99
127
  // this turn (multi-VP fan-out: every VP's engine sees the same
100
128
  // user prompt at conversationMessages[turnStart] but only the
101
129
  // first writer should land on disk).
102
- if (userAlreadyPersisted && msg.role === 'user') continue;
130
+ //
131
+ // With the engine-supplied turnStartIdx, `messages[turnStart]`
132
+ // is the ORIGINAL user prompt (not a reflection placeholder),
133
+ // so the first message in `recentMessages` is the one that
134
+ // gets skipped on subsequent VPs. Reflection messages that
135
+ // come AFTER turnStart still have role:'user' — they are NOT
136
+ // skipped because `userAlreadyPersisted` only suppresses the
137
+ // first user-prompt copy; reflections are per-VP outputs and
138
+ // each VP's reflections are valid contributions to the
139
+ // shared history (this matches today's per-VP fan-out where
140
+ // each VP appends its own assistant + tool rows).
141
+ if (userAlreadyPersisted && msg.role === 'user' && msg === recentMessages[0]) continue;
103
142
  // Allow empty assistant content when toolCalls are present;
104
143
  // tool messages have content by construction.
105
144
  const hasContent =
@@ -236,36 +236,192 @@ let unifyConversationId = null;
236
236
  let _vpUnsubscribe = null;
237
237
 
238
238
  /**
239
- * Flat conversation history for engine context continuity. H2.f.2: replaces
240
- * the per-thread Map. Cleared on session reset or by a `consolidate` event.
241
- * @type {Array<{role:'user'|'assistant'|'tool', content:string|Array, toolCalls?:Array, toolCallId?:string, isError?:boolean}>}
239
+ * Per-group conversation history lives on the GroupContext entry
240
+ * (`groupContexts.get(groupId).history`). The pre-refactor module-level
241
+ * `conversationMessages` was a single array shared across every group —
242
+ * a user prompt in group-A would leak into group-B's next-turn snapshot
243
+ * because the bridge appended every turn to the same array regardless
244
+ * of which group it belonged to. Disk was group-tagged correctly, but
245
+ * the in-memory tape was unified.
246
+ *
247
+ * Post-refactor: each GroupContext owns its own `history`, lazily
248
+ * hydrated from `conversationStore.loadRecentByGroup(groupId)` on first
249
+ * access. Group-A and group-B are isolated.
250
+ *
251
+ * @typedef {Array<{role:'user'|'assistant'|'tool', content:string|Array, toolCalls?:Array, toolCallId?:string, isError?:boolean}>} GroupHistory
252
+ */
253
+
254
+ /**
255
+ * @typedef {Object} GroupContextEntry
256
+ * @property {object|null} coord — group coordinator (lazily built by getOrCreateGroupContext)
257
+ * @property {object|null} router — message router (lazily built by getOrCreateGroupContext)
258
+ * @property {object|null} groupHandle — opened group handle (lazily built by getOrCreateGroupContext)
259
+ * @property {GroupHistory} history — per-group conversation tape
260
+ * @property {boolean} historyHydrated — true once history has been loaded
261
+ * from disk (or explicitly assigned). The flag is required because an
262
+ * empty array is legitimate post-consolidate / post-clear state and
263
+ * MUST NOT trigger a re-hydrate. Without the flag, a partial entry
264
+ * seeded by `getCompactState` (which only needs `_compact`) would
265
+ * short-circuit `getOrCreateGroupHistory` on truthy `[]` and skip
266
+ * the disk load.
267
+ * @property {{inFlight: Promise<void>|null, pending: boolean}} [_compact]
268
+ * per-group compact state, lazily attached.
242
269
  */
243
- let conversationMessages = [];
270
+
271
+ /** Build a fresh stub entry with no coord/router/history loaded. */
272
+ function makeGroupContextStub() {
273
+ return {
274
+ coord: null,
275
+ router: null,
276
+ groupHandle: null,
277
+ history: [],
278
+ historyHydrated: false,
279
+ };
280
+ }
244
281
 
245
282
  /**
246
- * Restore conversation history from persisted store. Accepts `role:'tool'`
247
- * messages and preserves `toolCalls`/`toolCallId` so the next chat-completions
248
- * serialization includes paired tool messages (avoids "No tool output found
249
- * for function call" 400s).
283
+ * Project a persisted message record into the in-memory history shape.
284
+ * Accepts `role:'tool'` and preserves `toolCalls`/`toolCallId` so the
285
+ * next chat-completions serialization includes paired tool messages
286
+ * (avoids "No tool output found for function call" 400s).
250
287
  *
251
- * @param {Array<object>} recentoutput of conversationStore.loadRecent()
288
+ * @param {object} mrecord from conversationStore.loadRecent*()
289
+ * @returns {object|null} history-shape entry, or null to skip
252
290
  */
253
- function restoreHistoryFromRecent(recent) {
254
- conversationMessages = [];
255
- for (const m of recent) {
256
- if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') continue;
257
- const entry = { role: m.role, content: m.content };
258
- if (m.toolCallId) entry.toolCallId = m.toolCallId;
259
- if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
260
- entry.toolCalls = m.toolCalls.map(tc => ({
261
- id: tc.id,
262
- name: tc.name,
263
- input: tc.input,
264
- }));
265
- }
266
- if (m.isError) entry.isError = true;
267
- conversationMessages.push(entry);
291
+ function projectPersistedToHistoryEntry(m) {
292
+ if (!m) return null;
293
+ if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') return null;
294
+ const entry = { role: m.role, content: m.content };
295
+ if (m.toolCallId) entry.toolCallId = m.toolCallId;
296
+ if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
297
+ entry.toolCalls = m.toolCalls.map(tc => ({
298
+ id: tc.id,
299
+ name: tc.name,
300
+ input: tc.input,
301
+ }));
268
302
  }
303
+ if (m.isError) entry.isError = true;
304
+ return entry;
305
+ }
306
+
307
+ /**
308
+ * Hydrate a freshly-created GroupContext's history from the on-disk
309
+ * conversation store. Returns an empty array if the session isn't
310
+ * loaded yet (sub-agent / test paths) or if the load throws.
311
+ *
312
+ * @param {string} groupId
313
+ * @returns {GroupHistory}
314
+ */
315
+ function hydrateGroupHistory(groupId) {
316
+ if (!session?.conversationStore || !groupId) return [];
317
+ let recent;
318
+ try {
319
+ recent = session.conversationStore.loadRecentByGroup(groupId);
320
+ } catch (err) {
321
+ console.warn('[Unify] hydrateGroupHistory failed (groupId=%s):', groupId, err?.message || err);
322
+ return [];
323
+ }
324
+ const out = [];
325
+ for (const m of recent || []) {
326
+ const entry = projectPersistedToHistoryEntry(m);
327
+ if (entry) out.push(entry);
328
+ }
329
+ return out;
330
+ }
331
+
332
+ /**
333
+ * Get-or-create the per-group history array. Used everywhere the bridge
334
+ * needs to read/append/snapshot a group's conversation tape. Lazily
335
+ * inserts an entry into `groupContexts` on first access — no
336
+ * `groupHandle` required (history is independent of coord/router
337
+ * lifecycle, so a sub-agent / route_forward path that hasn't yet
338
+ * opened the group can still read history).
339
+ *
340
+ * Returns the SAME array reference across calls within the same
341
+ * lifecycle, so consumers can mutate-in-place. Reassigned only by
342
+ * compact (race guard checks reference equality), `consolidate`
343
+ * events, and session reset.
344
+ *
345
+ * @param {string} groupId
346
+ * @returns {GroupHistory}
347
+ */
348
+ function getOrCreateGroupHistory(groupId) {
349
+ if (!groupId) return [];
350
+ let entry = groupContexts.get(groupId);
351
+ // Use `historyHydrated` rather than truthiness on `history` itself —
352
+ // an empty array (post-consolidate, post-clear, or a partial entry
353
+ // seeded by `getCompactState` before any data was loaded) is
354
+ // legitimate state that does NOT mean "needs hydration"... unless we
355
+ // never loaded from disk in the first place. The flag separates the
356
+ // two cases.
357
+ if (entry && entry.historyHydrated) return entry.history;
358
+ if (!entry) {
359
+ entry = makeGroupContextStub();
360
+ groupContexts.set(groupId, entry);
361
+ }
362
+ entry.history = hydrateGroupHistory(groupId);
363
+ entry.historyHydrated = true;
364
+ return entry.history;
365
+ }
366
+
367
+ /**
368
+ * Reassign a group's history reference. Used by compact + consolidate +
369
+ * clear paths that need to swap the array (not just mutate it). Returns
370
+ * the new reference. Idempotent if the entry doesn't exist (creates one).
371
+ *
372
+ * Sets `historyHydrated = true` because an explicit assignment is itself
373
+ * a hydration — even setting `[]` after `consolidate` means "this is the
374
+ * canonical state right now, don't re-load from disk".
375
+ *
376
+ * @param {string} groupId
377
+ * @param {GroupHistory} next
378
+ */
379
+ function setGroupHistory(groupId, next) {
380
+ if (!groupId) return;
381
+ let entry = groupContexts.get(groupId);
382
+ if (!entry) {
383
+ entry = makeGroupContextStub();
384
+ groupContexts.set(groupId, entry);
385
+ }
386
+ entry.history = next;
387
+ entry.historyHydrated = true;
388
+ }
389
+
390
+ /**
391
+ * Test-only access to a group's history array. Re-exported below as
392
+ * `__testGroupHistory`. Lets tests pin the per-group isolation contract
393
+ * without booting a full session.
394
+ *
395
+ * @param {string} groupId
396
+ */
397
+ export function __testGroupHistory(groupId) {
398
+ return getOrCreateGroupHistory(groupId);
399
+ }
400
+
401
+ /**
402
+ * Test-only: install a minimal `session` so `hydrateGroupHistory` can
403
+ * read from a real `ConversationStore`. Pass `null` to clear.
404
+ *
405
+ * Tests that need to verify the hydrate-from-disk path can construct a
406
+ * `ConversationStore` against a tmp dir, write per-group records via
407
+ * `store.append({groupId, ...})`, then call this helper to wire the
408
+ * store into the bridge before calling `__testGroupHistory(groupId)`.
409
+ *
410
+ * @param {{ conversationStore: object } | null} sessionLike
411
+ */
412
+ export function __testSetSession(sessionLike) {
413
+ session = sessionLike;
414
+ }
415
+
416
+ /**
417
+ * Test-only: peek at the GroupContext entry for a group (or undefined
418
+ * if never seeded). Lets tests assert the `historyHydrated` flag without
419
+ * exporting the entire `groupContexts` Map.
420
+ *
421
+ * @param {string} groupId
422
+ */
423
+ export function __testGroupContextEntry(groupId) {
424
+ return groupContexts.get(groupId);
269
425
  }
270
426
 
271
427
  /** Whether we've already sent a permission warning to the UI */
@@ -334,13 +490,29 @@ function getOrCreateVpEngine(groupId, vpId) {
334
490
  */
335
491
  function getOrCreateGroupContext(groupId, groupHandle) {
336
492
  let entry = groupContexts.get(groupId);
337
- if (entry) return entry;
493
+ if (entry && entry.coord && entry.router) return entry;
494
+ // Either no entry, or a partial entry seeded by `getOrCreateGroupHistory`
495
+ // / `getCompactState` (no coord/router yet). Build the coord/router and
496
+ // merge into the existing record so the per-group history reference and
497
+ // hydration flag are preserved.
338
498
  const coord = createCoordinator(groupHandle, {
339
499
  deliver: (vpId, envelope) => enqueueForVp(groupId, vpId, envelope),
340
500
  });
341
501
  const router = createRouter({ coordinator: coord });
342
- entry = { coord, router, groupHandle };
343
- groupContexts.set(groupId, entry);
502
+ if (!entry) {
503
+ entry = makeGroupContextStub();
504
+ groupContexts.set(groupId, entry);
505
+ }
506
+ entry.coord = coord;
507
+ entry.router = router;
508
+ entry.groupHandle = groupHandle;
509
+ // Defend against a future caller that builds a coord/router without
510
+ // having gone through `getOrCreateGroupHistory` first: a partial entry
511
+ // could exist with `historyHydrated:false`, so do the load now.
512
+ if (!entry.historyHydrated) {
513
+ entry.history = hydrateGroupHistory(groupId);
514
+ entry.historyHydrated = true;
515
+ }
344
516
  return entry;
345
517
  }
346
518
 
@@ -411,8 +583,9 @@ function ensureDriverRunning(groupId, vpId) {
411
583
  turnAbortCtrls.set(turnId, vpAbort);
412
584
  // Snapshot history at the moment this turn starts. Later turns in
413
585
  // the same driver loop see updated history (post-append from the
414
- // previous turn).
415
- const baseSnapshot = [...conversationMessages];
586
+ // previous turn). Per-group: each driver only sees its own group's
587
+ // tape, so cross-group prompts never leak into a VP's snapshot.
588
+ const baseSnapshot = [...getOrCreateGroupHistory(groupId)];
416
589
  const trigger = envelope?.trigger || 'fallback';
417
590
  // Synthesize the prompt. For coordinator-emitted envelopes the
418
591
  // text lives at envelope.msg.text. We prefix `@vp-<id>` to mirror
@@ -1081,8 +1254,9 @@ function handleEngineEvent(event, hctx) {
1081
1254
  break;
1082
1255
 
1083
1256
  case 'consolidate':
1084
- // Engine compressed the context — clear our accumulated history.
1085
- conversationMessages = [];
1257
+ // Engine compressed the context — clear THIS group's accumulated
1258
+ // history. Other groups' histories stay intact.
1259
+ if (hctx.groupId) setGroupHistory(hctx.groupId, []);
1086
1260
  sendUnifyEvent({
1087
1261
  type: 'consolidate',
1088
1262
  archivedCount: event.archivedCount,
@@ -1254,13 +1428,15 @@ export async function handleUnifyGroupChat(msg) {
1254
1428
  ? msg.groupId.trim()
1255
1429
  : 'grp_default';
1256
1430
 
1257
- // Entry gate: if a compact is in flight from the previous turn,
1258
- // wait for it to finish before reading conversationMessages. Compact
1259
- // runs at turn END (post-fanout) so it does not block the user's
1260
- // current message latency, but a fast double-send from the user must
1261
- // not race with the swap.
1262
- if (_compactInFlight) {
1263
- try { await _compactInFlight; } catch { /* first caller logs */ }
1431
+ // Entry gate: if a compact is in flight from the previous turn IN
1432
+ // THIS GROUP, wait for it to finish before reading the group's
1433
+ // history. Compact runs at turn END (post-fanout) so it does not
1434
+ // block the user's current message latency, but a fast double-send
1435
+ // from the user must not race with the swap. Other groups' compacts
1436
+ // never block this gate.
1437
+ const _entryCompactState = getCompactState(groupId);
1438
+ if (_entryCompactState.inFlight) {
1439
+ try { await _entryCompactState.inFlight; } catch { /* first caller logs */ }
1264
1440
  }
1265
1441
 
1266
1442
  // yeaftDir is a hard prerequisite for both session boot and group seeding;
@@ -1524,10 +1700,11 @@ export async function handleUnifyGroupChat(msg) {
1524
1700
  await waitForVpDrivers(groupId, primaryTargets);
1525
1701
 
1526
1702
  // Post-turn compaction. Triggers when the JUST-APPENDED turn pushed
1527
- // history past 20 turns / 80K tokens. Runs in the background — does
1528
- // not block the response to this message. The next user message
1529
- // awaits `_compactInFlight` at the entry gate (handleUnifyGroupChat
1530
- // top), so the swap is guaranteed to be observed before the next
1703
+ // history past 20 turns / 80K tokens for THIS group. Runs in the
1704
+ // background — does not block the response to this message. The
1705
+ // next user message in the same group awaits its per-group
1706
+ // `_compact.inFlight` at the entry gate (handleUnifyGroupChat top),
1707
+ // so the swap is guaranteed to be observed before the next
1531
1708
  // baseSnapshot capture. Errors are swallowed; next turn retries.
1532
1709
  scheduleCompactAfterTurn(groupId);
1533
1710
  }
@@ -1696,7 +1873,8 @@ async function ensureSessionLoaded() {
1696
1873
 
1697
1874
  unifyConversationId = `unify-${Date.now()}`;
1698
1875
 
1699
- restoreHistoryFromRecent(session.conversationStore.loadRecent());
1876
+ // Per-group history is hydrated lazily on first `getOrCreateGroupHistory`
1877
+ // — there's no global "all conversations" tape any more.
1700
1878
 
1701
1879
  sendUnifyEvent({
1702
1880
  type: 'session_ready',
@@ -1902,7 +2080,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
1902
2080
  }
1903
2081
 
1904
2082
  // Turn completed — atomically append this VP's output to shared history.
1905
- appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolResultsAccum);
2083
+ appendTurnToGroupHistory(groupId, prompt, assistantTextParts, toolCallsAccum, toolResultsAccum);
1906
2084
 
1907
2085
  sendUnifyOutput({
1908
2086
  type: 'assistant',
@@ -1960,11 +2138,21 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
1960
2138
  }
1961
2139
 
1962
2140
  /**
1963
- * Atomically append a completed VP-turn's messages to the shared
2141
+ * Atomically append a completed VP-turn's messages to the GROUP'S
1964
2142
  * conversation history. Called once at turn end (not during streaming).
2143
+ *
2144
+ * Note: this does NOT see the engine's collapsed form — it appends the
2145
+ * raw user prompt + the per-VP assistant text + tool results. The
2146
+ * engine's own `conversationMessages` (with T1/T2 collapse applied)
2147
+ * is persisted to disk via stop-hooks, so the next turn's history is
2148
+ * read from disk via `loadRecentByGroup` on next session boot. Within
2149
+ * a session, this in-memory tape carries the un-collapsed form — which
2150
+ * is fine because each VP turn's `engine.query` re-collapses on the fly.
1965
2151
  */
1966
- function appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolResultsAccum) {
1967
- conversationMessages.push({ role: 'user', content: prompt });
2152
+ function appendTurnToGroupHistory(groupId, prompt, assistantTextParts, toolCallsAccum, toolResultsAccum) {
2153
+ if (!groupId) return;
2154
+ const history = getOrCreateGroupHistory(groupId);
2155
+ history.push({ role: 'user', content: prompt });
1968
2156
 
1969
2157
  const fullText = assistantTextParts.join('');
1970
2158
  if (fullText || toolCallsAccum.length > 0) {
@@ -1976,10 +2164,10 @@ function appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolRes
1976
2164
  input: tc.input,
1977
2165
  }));
1978
2166
  }
1979
- conversationMessages.push(assistantMsg);
2167
+ history.push(assistantMsg);
1980
2168
 
1981
2169
  for (const tr of toolResultsAccum) {
1982
- conversationMessages.push({
2170
+ history.push({
1983
2171
  role: 'tool',
1984
2172
  toolCallId: tr.toolCallId,
1985
2173
  content: tr.content,
@@ -2071,47 +2259,53 @@ function persistUserMessageOnceByMsgId({ msgId, text, groupId }) {
2071
2259
  const _persistedUserMsgIds = new Set();
2072
2260
 
2073
2261
  /**
2074
- * In-flight compact promise. Set by `scheduleCompactAfterTurn` when a
2075
- * turn ends and triggers compaction; awaited by the next
2076
- * `handleUnifyGroupChat` invocation at its entry gate so the next
2077
- * baseSnapshot reflects the compacted history.
2262
+ * Per-group compact state. Each group has its own in-flight promise +
2263
+ * pending flag so a compact in group-A doesn't block a compact in
2264
+ * group-B (and so the entry-gate await in `handleUnifyGroupChat` only
2265
+ * blocks on its own group's compact, not unrelated groups').
2078
2266
  *
2079
- * Compact runs at turn END (not before fan-out), so it does not add
2080
- * latency to the user's current message. The trade-off: the next user
2081
- * message may have to wait briefly for the compact to finish — but
2082
- * compact uses the fast model and typically completes in 1–3s.
2267
+ * Lives off `groupContexts.get(groupId)._compact = { inFlight, pending }`.
2083
2268
  *
2084
- * @type {Promise<void>|null}
2269
+ * @typedef {{ inFlight: Promise<void>|null, pending: boolean }} CompactState
2085
2270
  */
2086
- let _compactInFlight = null;
2087
2271
 
2088
- /**
2089
- * Re-trigger flag. If `scheduleCompactAfterTurn` is called while a
2090
- * compact is already in flight, set this so the in-flight one chains
2091
- * a follow-up immediately on completion. Without this, a sustained
2092
- * burst of turns could starve compaction: turn N triggers compact,
2093
- * turns N+1 / N+2 / … each find `_compactInFlight` set and skip,
2094
- * leaving history above threshold until the burst ends.
2095
- */
2096
- let _compactPending = false;
2272
+ /** Get-or-create the per-group compact state. */
2273
+ function getCompactState(groupId) {
2274
+ if (!groupId) return { inFlight: null, pending: false };
2275
+ let entry = groupContexts.get(groupId);
2276
+ if (!entry) {
2277
+ // Don't pre-hydrate history here. The stub leaves
2278
+ // `historyHydrated:false`, so the first `getOrCreateGroupHistory`
2279
+ // call still triggers disk hydration. (Pre-fix this seeded
2280
+ // `history: []` and `getOrCreateGroupHistory` short-circuited on
2281
+ // the truthy empty array, leaving the group amnesiac for any
2282
+ // entry path that didn't first go through `handleUnifyLoadHistory`.)
2283
+ entry = makeGroupContextStub();
2284
+ groupContexts.set(groupId, entry);
2285
+ }
2286
+ if (!entry._compact) entry._compact = { inFlight: null, pending: false };
2287
+ return entry._compact;
2288
+ }
2097
2289
 
2098
2290
  /**
2099
2291
  * Fire-and-forget post-turn compaction. Called once at the end of each
2100
2292
  * `handleUnifyGroupChat` after `Promise.all(runVpTurn)` resolves. If a
2101
- * compaction is still in flight from an earlier turn, we set
2102
- * `_compactPending` so the running compact chains a follow-up on
2293
+ * compaction is still in flight from an earlier turn IN THIS GROUP, we
2294
+ * set `_compact.pending` so the running compact chains a follow-up on
2103
2295
  * completion (anti-starvation).
2104
2296
  *
2105
- * The promise is stored in `_compactInFlight` so the next user message
2106
- * can await it before reading `conversationMessages`.
2297
+ * The promise is stored on the per-group state so the next user message
2298
+ * in the SAME group can await it before reading the group's history.
2107
2299
  *
2108
2300
  * @param {string} groupId — for envelope tagging on the emitted event
2109
2301
  */
2110
2302
  function scheduleCompactAfterTurn(groupId) {
2111
- if (_compactInFlight) {
2303
+ if (!groupId) return;
2304
+ const cs = getCompactState(groupId);
2305
+ if (cs.inFlight) {
2112
2306
  // A compact is already running. Mark a follow-up so when it
2113
2307
  // finishes, it re-evaluates and runs again if still triggered.
2114
- _compactPending = true;
2308
+ cs.pending = true;
2115
2309
  return;
2116
2310
  }
2117
2311
  // Cheap O(n) precheck so we don't bother engaging the LLM at all
@@ -2124,30 +2318,30 @@ function scheduleCompactAfterTurn(groupId) {
2124
2318
  typeof session?.config?.maxContextTokens === 'number'
2125
2319
  ? session.config.maxContextTokens
2126
2320
  : undefined;
2127
- const triage = shouldCompactHistory(conversationMessages, { maxContextTokens });
2321
+ const triage = shouldCompactHistory(getOrCreateGroupHistory(groupId), { maxContextTokens });
2128
2322
  if (!triage.trigger) return;
2129
2323
  if (!session?.engine || typeof session.engine.summarizeForCompact !== 'function') {
2130
2324
  console.warn('[Unify] history compact: engine.summarizeForCompact unavailable — skipping');
2131
2325
  return;
2132
2326
  }
2133
2327
 
2134
- _compactInFlight = runCompactNow(groupId).finally(() => {
2135
- _compactInFlight = null;
2328
+ cs.inFlight = runCompactNow(groupId).finally(() => {
2329
+ cs.inFlight = null;
2136
2330
  // If turns piled up while we were running and compaction is still
2137
2331
  // needed, chain a follow-up. Use a microtask so the .finally chain
2138
2332
  // settles cleanly before the next promise is created.
2139
- if (_compactPending) {
2140
- _compactPending = false;
2333
+ if (cs.pending) {
2334
+ cs.pending = false;
2141
2335
  queueMicrotask(() => scheduleCompactAfterTurn(groupId));
2142
2336
  }
2143
2337
  });
2144
2338
  }
2145
2339
 
2146
2340
  /**
2147
- * Run the in-memory history compactor. Replaces the older prefix of
2148
- * `conversationMessages` with a single user-role summary message,
2149
- * preserving the recent tail verbatim. Mutates the module-level
2150
- * variable in place via reassignment.
2341
+ * Run the in-memory history compactor for ONE group. Replaces the older
2342
+ * prefix of the group's history with a single user-role summary
2343
+ * message, preserving the recent tail verbatim. Mutates the per-group
2344
+ * array via reassignment (`setGroupHistory`).
2151
2345
  *
2152
2346
  * Behaviour:
2153
2347
  * - If summarization fails, leaves history untouched.
@@ -2155,12 +2349,17 @@ function scheduleCompactAfterTurn(groupId) {
2155
2349
  * can show what happened (frontend currently ignores it).
2156
2350
  *
2157
2351
  * Race safety:
2158
- * - Single-flight via `_compactInFlight` (only one runs at a time).
2159
- * - Reads the array reference once into `snapshot`. If anything else
2160
- * reassigns `conversationMessages` during the await (`consolidate`
2161
- * event from the engine, `clearUnifyMessages`, `resetUnifySession`),
2162
- * we detect the swap by reference comparison and bail without
2163
- * overwriting their fresh state.
2352
+ * - Single-flight via per-group `_compact.inFlight` (only one runs
2353
+ * at a time per group).
2354
+ * - Captures the array reference AND its length on entry. If anything
2355
+ * reassigns the group's history during the await (`consolidate` event
2356
+ * from the engine, `clearUnifyMessages`, `resetUnifySession`), we
2357
+ * detect the swap by reference comparison. If a `route_forward`
2358
+ * driver path appends new messages in place during the await
2359
+ * (push-mutate, not reassignment), the length grew — also bail,
2360
+ * because writing back the stale compacted view would silently drop
2361
+ * the in-flight messages. (Disk persistence is independent — the
2362
+ * stop-hooks already wrote those messages to disk.)
2164
2363
  *
2165
2364
  * @param {string} groupId
2166
2365
  * @returns {Promise<void>}
@@ -2169,11 +2368,15 @@ async function runCompactNow(groupId) {
2169
2368
  const summarize = ({ system, prompt }) =>
2170
2369
  session.engine.summarizeForCompact({ system, prompt, maxTokens: 1024 });
2171
2370
 
2172
- // Capture the current array reference. If anyone reassigns
2173
- // `conversationMessages` while we're summarizing (engine consolidate
2174
- // event, session reset, manual clear), the reference will differ
2175
- // and we abandon the swap.
2176
- const snapshot = conversationMessages;
2371
+ // Capture the current array reference AND its length. If anyone
2372
+ // reassigns the group's history while we're summarizing (engine
2373
+ // consolidate event, session reset, manual clear), the reference will
2374
+ // differ. If a driver path push-mutates new messages in place
2375
+ // (route_forward turning into a new VP turn during compact), the
2376
+ // reference is the same but the length grew. Both cases mean the
2377
+ // snapshot we summarized is no longer the canonical state — bail.
2378
+ const snapshot = getOrCreateGroupHistory(groupId);
2379
+ const snapshotLen = snapshot.length;
2177
2380
 
2178
2381
  // Pull the user-configured context width so the 40 %-of-context
2179
2382
  // threshold auto-adjusts to whatever model they're on. Falls back to
@@ -2194,14 +2397,20 @@ async function runCompactNow(groupId) {
2194
2397
  }
2195
2398
  return;
2196
2399
  }
2197
- // Race guard: if `conversationMessages` was reassigned during the
2198
- // await (e.g. consolidate / reset), do NOT overwrite the fresh
2199
- // state with our stale compacted snapshot.
2200
- if (conversationMessages !== snapshot) {
2400
+ // Race guard: if the group's history was reassigned during the
2401
+ // await (e.g. consolidate / reset), or push-mutated by a driver
2402
+ // path (e.g. a route_forward triggered VP turn appending), do NOT
2403
+ // overwrite the fresh state with our stale compacted snapshot.
2404
+ const current = getOrCreateGroupHistory(groupId);
2405
+ if (current !== snapshot) {
2201
2406
  console.log('[Unify] history compact: history was reset during compact — discarding stale summary');
2202
2407
  return;
2203
2408
  }
2204
- conversationMessages = result.messages;
2409
+ if (current.length !== snapshotLen) {
2410
+ console.log('[Unify] history compact: history was appended-to during compact — discarding stale summary');
2411
+ return;
2412
+ }
2413
+ setGroupHistory(groupId, result.messages);
2205
2414
  console.log(
2206
2415
  `[Unify] history compacted (reason=${result.reason}): ` +
2207
2416
  `turns ${result.beforeTurns}→${result.afterTurns}, ` +
@@ -2593,12 +2802,17 @@ export async function handleUnifyLoadHistory(msg) {
2593
2802
 
2594
2803
  unifyConversationId = `unify-${Date.now()}`;
2595
2804
 
2596
- restoreHistoryFromRecent(pickRecent(session.conversationStore, undefined));
2805
+ // Per-group history hydrates lazily via getOrCreateGroupHistory.
2806
+ // When the load-history call carries a groupId, force-refresh THAT
2807
+ // group's tape so the next user message sees on-disk state. When
2808
+ // it doesn't (legacy callers), do nothing — the per-group lazy
2809
+ // hydration handles it.
2810
+ if (groupId) setGroupHistory(groupId, hydrateGroupHistory(groupId));
2597
2811
  } else if (groupId) {
2598
2812
  // Re-entering an existing session with a (possibly new) group filter:
2599
- // re-seed the engine's flat history so it doesn't carry messages from
2600
- // another group into the next turn's context.
2601
- restoreHistoryFromRecent(pickRecent(session.conversationStore, undefined));
2813
+ // re-seed THIS group's history from disk so it doesn't carry stale
2814
+ // in-memory state into the next turn's context.
2815
+ setGroupHistory(groupId, hydrateGroupHistory(groupId));
2602
2816
  }
2603
2817
 
2604
2818
  // Always replay session_ready so refresh / reconnect rebuilds UI state.
@@ -2753,7 +2967,9 @@ export async function resetUnifySession() {
2753
2967
  session = null;
2754
2968
  }
2755
2969
  unifyConversationId = null;
2756
- conversationMessages = [];
2970
+ // Per-group histories live on groupContexts entries — clearing the
2971
+ // map (a few lines below) drops every group's history with it. No
2972
+ // separate global tape to clear.
2757
2973
  // Re-arm the permission warning. The user might have fixed the
2758
2974
  // ~/.yeaft/ permissions in the interim and is now restarting the
2759
2975
  // session — they should see the diagnostic again if it still fails.
@@ -2786,7 +3002,8 @@ export async function resetUnifySession() {
2786
3002
 
2787
3003
  unifyConversationId = `unify-${Date.now()}`;
2788
3004
 
2789
- restoreHistoryFromRecent(session.conversationStore.loadRecent());
3005
+ // Per-group history hydrates lazily via getOrCreateGroupHistory on
3006
+ // first read. Nothing to seed here.
2790
3007
 
2791
3008
  sendUnifyEvent({
2792
3009
  type: 'session_ready',