@yeaft/webchat-agent 0.1.747 → 0.1.749

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.749",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,242 @@
1
+ /**
2
+ * compactor.js — per-group post-turn history compactor.
3
+ *
4
+ * Owns the orchestration that previously lived inline in
5
+ * `agent/unify/web-bridge.js` as `getCompactState` /
6
+ * `scheduleCompactAfterTurn` / `runCompactNow`. Bridge keeps WS knowledge
7
+ * and history ownership; this class owns:
8
+ * - per-group single-flight + anti-starvation `pending` chain,
9
+ * - the precheck via `shouldCompactHistory`,
10
+ * - the call into `compactHistory` (which itself calls the supplied
11
+ * `summarize` injectable — bound to `Engine.summarizeForCompact` by
12
+ * `session.js`),
13
+ * - the race-guard that bails when the live history reference / length
14
+ * diverges from the snapshot we captured before awaiting the LLM.
15
+ *
16
+ * History is passed in PER CALL via a `historyHandle = { get, set }` so
17
+ * Compactor never has to know about `groupContexts`, `historyHydrated`,
18
+ * or any other bridge-internal field. The WS event sink is wired
19
+ * separately via `setOnCompacted`.
20
+ *
21
+ * Engine instances are per-VP-per-group (`vpEngines` keyed by
22
+ * `${groupId}::${vpId}`), so this orchestration cannot live on `Engine`
23
+ * itself: a per-group single-flight slot pinned to a per-VP Engine is a
24
+ * category error. Compactor is constructed once per session, beside the
25
+ * `dreamScheduler`.
26
+ */
27
+
28
+ import { compactHistory, shouldCompactHistory } from '../history-compact.js';
29
+
30
+ /**
31
+ * Output cap for the summarizer call. The full compacted history is
32
+ * built around the LLM-produced summary, so the summary itself must be
33
+ * bounded — too long defeats the point of compacting, too short loses
34
+ * context. 1024 tokens is the value that lived inline in the old
35
+ * `runCompactNow` helper in `web-bridge.js` and matches the budget
36
+ * `compactHistory` plans around.
37
+ */
38
+ const SUMMARIZER_MAX_TOKENS = 1024;
39
+
40
+ /**
41
+ * @typedef {object} CompactorHistoryHandle
42
+ * @property {() => Array<object>} get — return the current live history array
43
+ * @property {(next: Array<object>) => void} set — replace the array reference
44
+ */
45
+
46
+ /**
47
+ * @typedef {object} CompactedResult
48
+ * @property {string|null} reason
49
+ * @property {number} beforeTurns
50
+ * @property {number} afterTurns
51
+ * @property {number} beforeTokens
52
+ * @property {number} afterTokens
53
+ * @property {number} archivedCount
54
+ */
55
+
56
+ export class Compactor {
57
+ /**
58
+ * @param {object} opts
59
+ * @param {(args: {system: string, prompt: string, maxTokens?: number}) => Promise<string>} opts.summarize
60
+ * Bound to `engine.summarizeForCompact` by `session.js`. Returns
61
+ * the trimmed summary text (or '' on failure — `compactHistory`
62
+ * treats that as a soft failure).
63
+ * @param {() => number|undefined} [opts.getMaxContextTokens]
64
+ * Returns `config.maxContextTokens` for `shouldCompactHistory`.
65
+ * @param {(groupId: string, result: CompactedResult) => void} [opts.onCompacted]
66
+ * Optional sink. Bridge wires this to send the
67
+ * `unify_history_compacted` WS event. Default: no-op. Can be
68
+ * replaced post-construction via `setOnCompacted`.
69
+ */
70
+ constructor({ summarize, getMaxContextTokens, onCompacted } = {}) {
71
+ if (typeof summarize !== 'function') {
72
+ throw new TypeError('Compactor: summarize is required');
73
+ }
74
+ this._summarize = summarize;
75
+ this._getMaxContextTokens = typeof getMaxContextTokens === 'function'
76
+ ? getMaxContextTokens
77
+ : () => undefined;
78
+ this._onCompacted = typeof onCompacted === 'function' ? onCompacted : () => {};
79
+ /** @type {Map<string, { inFlight: Promise<void>|null, pending: boolean }>} */
80
+ this._states = new Map();
81
+ }
82
+
83
+ /**
84
+ * Replace the post-success sink. Bridge calls this from
85
+ * `installUnifyRuntimeBridge` once the bridge-local
86
+ * `sendToServer` + `unifyConversationId` are available — keeps WS
87
+ * knowledge out of Compactor's constructor and avoids a circular
88
+ * import between `session.js` and `web-bridge.js`.
89
+ */
90
+ setOnCompacted(fn) {
91
+ this._onCompacted = typeof fn === 'function' ? fn : () => {};
92
+ }
93
+
94
+ /** Get-or-create the per-group state record. */
95
+ _state(groupId) {
96
+ let s = this._states.get(groupId);
97
+ if (!s) {
98
+ s = { inFlight: null, pending: false };
99
+ this._states.set(groupId, s);
100
+ }
101
+ return s;
102
+ }
103
+
104
+ /**
105
+ * Entry-gate await. Bridge calls at the top of `handleUnifyGroupChat`
106
+ * so a brand-new turn never reads a half-mutated history mid-compact.
107
+ * Other groups' compacts never block this gate (per-group keying).
108
+ *
109
+ * @param {string} groupId
110
+ */
111
+ async awaitInFlight(groupId) {
112
+ if (!groupId) return;
113
+ const s = this._states.get(groupId);
114
+ if (s && s.inFlight) {
115
+ try { await s.inFlight; } catch { /* _runOnce already logs */ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Post-turn fire-and-forget. Bridge calls after the per-VP fanout
121
+ * completes for a turn. The `historyHandle` MUST be a per-call value:
122
+ * its `get` / `set` close over the bridge's groupId-scoped helpers
123
+ * (`getOrCreateGroupHistory` / `setGroupHistory`), not over a frozen
124
+ * snapshot, so a chained follow-up sees fresh state.
125
+ *
126
+ * Anti-starvation: while a compact is in flight, additional
127
+ * `scheduleAfterTurn` calls collapse to a single `pending=true`. Once
128
+ * the in-flight finishes, exactly one follow-up runs (no matter how
129
+ * many turns piled up during the await).
130
+ *
131
+ * @param {string} groupId
132
+ * @param {CompactorHistoryHandle} historyHandle
133
+ */
134
+ scheduleAfterTurn(groupId, historyHandle) {
135
+ if (!groupId || !historyHandle) return;
136
+ const s = this._state(groupId);
137
+ if (s.inFlight) {
138
+ // Anti-starvation: compact is already running. Mark a follow-up
139
+ // so when it finishes, it re-evaluates and runs again if still
140
+ // triggered.
141
+ s.pending = true;
142
+ return;
143
+ }
144
+ s.inFlight = this._runOnce(groupId, historyHandle).finally(() => {
145
+ s.inFlight = null;
146
+ // If turns piled up while we were running and compaction is still
147
+ // needed, chain a follow-up. Use a microtask so the .finally
148
+ // chain settles cleanly before the next promise is created.
149
+ if (s.pending) {
150
+ s.pending = false;
151
+ queueMicrotask(() => this.scheduleAfterTurn(groupId, historyHandle));
152
+ }
153
+ });
154
+ }
155
+
156
+ /**
157
+ * One compact pass. Captures snapshot reference + length, runs the
158
+ * precheck, calls `compactHistory`, race-guards, applies via
159
+ * `historyHandle.set`, then notifies `onCompacted`. All errors are
160
+ * swallowed (logged) so `inFlight` always clears.
161
+ *
162
+ * @param {string} groupId
163
+ * @param {CompactorHistoryHandle} historyHandle
164
+ */
165
+ async _runOnce(groupId, historyHandle) {
166
+ try {
167
+ // Capture reference AND length. If a different code path swaps the
168
+ // array (`consolidate` event, session reset, manual clear) the
169
+ // reference will differ. If a driver path push-mutates new
170
+ // messages in place (e.g. `route_forward` triggering a new VP
171
+ // turn during compact), the reference is the same but the length
172
+ // grew. Both cases mean our snapshot is no longer canonical.
173
+ const snapshot = historyHandle.get();
174
+ if (!Array.isArray(snapshot) || snapshot.length === 0) return;
175
+ const snapshotLen = snapshot.length;
176
+
177
+ const maxContextTokens = this._getMaxContextTokens();
178
+
179
+ // Cheap O(n) precheck so we don't bother engaging the LLM at all
180
+ // when the conversation is still small. `compactHistory` runs the
181
+ // same check internally, but only after building the summarizer
182
+ // input — this keeps small chats off the LLM altogether.
183
+ const triage = shouldCompactHistory(snapshot, { maxContextTokens });
184
+ if (!triage.trigger) return;
185
+
186
+ const summarize = ({ system, prompt }) =>
187
+ this._summarize({ system, prompt, maxTokens: SUMMARIZER_MAX_TOKENS });
188
+
189
+ const result = await compactHistory(snapshot, { summarize, maxContextTokens });
190
+ if (!result || !result.compacted) {
191
+ if (result && result.error) {
192
+ console.warn(
193
+ `[Unify] history compact: summarizer failed (${result.error}); ` +
194
+ `keeping ${result.beforeTurns} turns / ~${result.beforeTokens} tokens`
195
+ );
196
+ }
197
+ return;
198
+ }
199
+
200
+ // Race guard: if the group's history was reassigned during the
201
+ // await (e.g. consolidate / reset), or push-mutated by a driver
202
+ // path, do NOT overwrite the fresh state with our stale compacted
203
+ // snapshot. These discards are EXPECTED during normal session
204
+ // resets / route_forward bursts — log at debug, not info.
205
+ const current = historyHandle.get();
206
+ if (current !== snapshot) {
207
+ console.debug('[Unify] history compact: history was reset during compact — discarding stale summary');
208
+ return;
209
+ }
210
+ if (current.length !== snapshotLen) {
211
+ console.debug('[Unify] history compact: history was appended-to during compact — discarding stale summary');
212
+ return;
213
+ }
214
+
215
+ historyHandle.set(result.messages);
216
+ console.log(
217
+ `[Unify] history compacted (reason=${result.reason}): ` +
218
+ `turns ${result.beforeTurns}→${result.afterTurns}, ` +
219
+ `tokens ~${result.beforeTokens}→${result.afterTokens}, ` +
220
+ `archived ${result.archivedCount} messages`
221
+ );
222
+
223
+ try {
224
+ this._onCompacted(groupId, {
225
+ reason: result.reason,
226
+ beforeTurns: result.beforeTurns,
227
+ afterTurns: result.afterTurns,
228
+ beforeTokens: result.beforeTokens,
229
+ afterTokens: result.afterTokens,
230
+ archivedCount: result.archivedCount,
231
+ });
232
+ } catch { /* sink failure must not abort the orchestrator */ }
233
+ } catch (err) {
234
+ console.warn(`[Unify] history compact: unexpected failure (groupId=${groupId})`, err?.message || err);
235
+ }
236
+ }
237
+
238
+ /** Test-only: clear all per-group state. */
239
+ __testReset() {
240
+ this._states.clear();
241
+ }
242
+ }
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.
package/unify/session.js CHANGED
@@ -23,6 +23,7 @@ import { MCPManager } from './mcp.js';
23
23
  import { createFullRegistry } from './tools/index.js';
24
24
  import { initFeatureStore } from './tools/feature-tools.js';
25
25
  import { Engine } from './engine.js';
26
+ import { Compactor } from './compact/compactor.js';
26
27
  // H2.f.5: threads/, pipeline/dispatcher and input-queue retired. The
27
28
  // session now exposes a single Engine.
28
29
  //
@@ -65,6 +66,7 @@ import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe } from '
65
66
  /**
66
67
  * @typedef {Object} Session
67
68
  * @property {Engine} engine — The wired engine, ready for .query()
69
+ * @property {import('./compact/compactor.js').Compactor} compactor — Per-group history compactor
68
70
  * @property {import('./llm/adapter.js').LLMAdapter} adapter — The LLM adapter
69
71
  * @property {object} config — Resolved configuration
70
72
  * @property {ConversationStore} conversationStore — Conversation persistence
@@ -303,6 +305,18 @@ export async function loadSession(options = {}) {
303
305
  yeaftDir,
304
306
  });
305
307
 
308
+ // ─── 9a-pre. Create per-group history Compactor ────────
309
+ // Owns the post-turn single-flight + race-guarded compactor that used
310
+ // to live inline in web-bridge.js. Bridge keeps history ownership and
311
+ // wires the WS sink (`unify_history_compacted`) via
312
+ // `compactor.setOnCompacted` from `installUnifyRuntimeBridge`.
313
+ const compactor = new Compactor({
314
+ summarize: ({ system, prompt, maxTokens } = {}) =>
315
+ engine.summarizeForCompact({ system, prompt, maxTokens }),
316
+ getMaxContextTokens: () =>
317
+ typeof config.maxContextTokens === 'number' ? config.maxContextTokens : undefined,
318
+ });
319
+
306
320
  // ─── 9a. Create dream scheduler ────────────
307
321
  // The legacy R6 dream-scheduler was retired alongside recall-r6;
308
322
  // dream-v2 is the only active path (the `config.memoryV2` opt-out
@@ -396,6 +410,7 @@ export async function loadSession(options = {}) {
396
410
  config,
397
411
  conversationStore,
398
412
  dreamScheduler,
413
+ compactor,
399
414
  skillManager,
400
415
  mcpManager,
401
416
  toolRegistry,
@@ -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 =
@@ -46,8 +46,6 @@ import { openGroup, loadGroupMeta } from './groups/group-store.js';
46
46
  import { createCoordinator } from './groups/coordinator.js';
47
47
  import { seedDefaultGroup } from './groups/seed-default.js';
48
48
  import {
49
- shouldCompactHistory,
50
- compactHistory,
51
49
  trimSnapshotForBudget,
52
50
  } from './history-compact.js';
53
51
  import { persistUnifyAttachments, attachmentsForPersistence } from './attachments.js';
@@ -236,36 +234,189 @@ let unifyConversationId = null;
236
234
  let _vpUnsubscribe = null;
237
235
 
238
236
  /**
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}>}
237
+ * Per-group conversation history lives on the GroupContext entry
238
+ * (`groupContexts.get(groupId).history`). The pre-refactor module-level
239
+ * `conversationMessages` was a single array shared across every group —
240
+ * a user prompt in group-A would leak into group-B's next-turn snapshot
241
+ * because the bridge appended every turn to the same array regardless
242
+ * of which group it belonged to. Disk was group-tagged correctly, but
243
+ * the in-memory tape was unified.
244
+ *
245
+ * Post-refactor: each GroupContext owns its own `history`, lazily
246
+ * hydrated from `conversationStore.loadRecentByGroup(groupId)` on first
247
+ * access. Group-A and group-B are isolated.
248
+ *
249
+ * @typedef {Array<{role:'user'|'assistant'|'tool', content:string|Array, toolCalls?:Array, toolCallId?:string, isError?:boolean}>} GroupHistory
242
250
  */
243
- let conversationMessages = [];
244
251
 
245
252
  /**
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).
253
+ * @typedef {Object} GroupContextEntry
254
+ * @property {object|null} coord group coordinator (lazily built by getOrCreateGroupContext)
255
+ * @property {object|null} router message router (lazily built by getOrCreateGroupContext)
256
+ * @property {object|null} groupHandle — opened group handle (lazily built by getOrCreateGroupContext)
257
+ * @property {GroupHistory} history — per-group conversation tape
258
+ * @property {boolean} historyHydrated — true once history has been loaded
259
+ * from disk (or explicitly assigned). The flag is required because an
260
+ * empty array is legitimate post-consolidate / post-clear state and
261
+ * MUST NOT trigger a re-hydrate. Without the flag, a partial entry
262
+ * would short-circuit `getOrCreateGroupHistory` on truthy `[]` and skip
263
+ * the disk load.
264
+ */
265
+
266
+ /** Build a fresh stub entry with no coord/router/history loaded. */
267
+ function makeGroupContextStub() {
268
+ return {
269
+ coord: null,
270
+ router: null,
271
+ groupHandle: null,
272
+ history: [],
273
+ historyHydrated: false,
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Project a persisted message record into the in-memory history shape.
279
+ * Accepts `role:'tool'` and preserves `toolCalls`/`toolCallId` so the
280
+ * next chat-completions serialization includes paired tool messages
281
+ * (avoids "No tool output found for function call" 400s).
250
282
  *
251
- * @param {Array<object>} recentoutput of conversationStore.loadRecent()
283
+ * @param {object} mrecord from conversationStore.loadRecent*()
284
+ * @returns {object|null} history-shape entry, or null to skip
252
285
  */
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);
286
+ function projectPersistedToHistoryEntry(m) {
287
+ if (!m) return null;
288
+ if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') return null;
289
+ const entry = { role: m.role, content: m.content };
290
+ if (m.toolCallId) entry.toolCallId = m.toolCallId;
291
+ if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
292
+ entry.toolCalls = m.toolCalls.map(tc => ({
293
+ id: tc.id,
294
+ name: tc.name,
295
+ input: tc.input,
296
+ }));
268
297
  }
298
+ if (m.isError) entry.isError = true;
299
+ return entry;
300
+ }
301
+
302
+ /**
303
+ * Hydrate a freshly-created GroupContext's history from the on-disk
304
+ * conversation store. Returns an empty array if the session isn't
305
+ * loaded yet (sub-agent / test paths) or if the load throws.
306
+ *
307
+ * @param {string} groupId
308
+ * @returns {GroupHistory}
309
+ */
310
+ function hydrateGroupHistory(groupId) {
311
+ if (!session?.conversationStore || !groupId) return [];
312
+ let recent;
313
+ try {
314
+ recent = session.conversationStore.loadRecentByGroup(groupId);
315
+ } catch (err) {
316
+ console.warn('[Unify] hydrateGroupHistory failed (groupId=%s):', groupId, err?.message || err);
317
+ return [];
318
+ }
319
+ const out = [];
320
+ for (const m of recent || []) {
321
+ const entry = projectPersistedToHistoryEntry(m);
322
+ if (entry) out.push(entry);
323
+ }
324
+ return out;
325
+ }
326
+
327
+ /**
328
+ * Get-or-create the per-group history array. Used everywhere the bridge
329
+ * needs to read/append/snapshot a group's conversation tape. Lazily
330
+ * inserts an entry into `groupContexts` on first access — no
331
+ * `groupHandle` required (history is independent of coord/router
332
+ * lifecycle, so a sub-agent / route_forward path that hasn't yet
333
+ * opened the group can still read history).
334
+ *
335
+ * Returns the SAME array reference across calls within the same
336
+ * lifecycle, so consumers can mutate-in-place. Reassigned only by
337
+ * compact (race guard checks reference equality), `consolidate`
338
+ * events, and session reset.
339
+ *
340
+ * @param {string} groupId
341
+ * @returns {GroupHistory}
342
+ */
343
+ function getOrCreateGroupHistory(groupId) {
344
+ if (!groupId) return [];
345
+ let entry = groupContexts.get(groupId);
346
+ // Use `historyHydrated` rather than truthiness on `history` itself —
347
+ // an empty array (post-consolidate, post-clear, or a partial entry
348
+ // seeded by an early `getOrCreateGroupContext` call before data was
349
+ // loaded) is legitimate state that does NOT mean "needs hydration"...
350
+ // unless we never loaded from disk in the first place. The flag
351
+ // separates the two cases.
352
+ if (entry && entry.historyHydrated) return entry.history;
353
+ if (!entry) {
354
+ entry = makeGroupContextStub();
355
+ groupContexts.set(groupId, entry);
356
+ }
357
+ entry.history = hydrateGroupHistory(groupId);
358
+ entry.historyHydrated = true;
359
+ return entry.history;
360
+ }
361
+
362
+ /**
363
+ * Reassign a group's history reference. Used by compact + consolidate +
364
+ * clear paths that need to swap the array (not just mutate it). Returns
365
+ * the new reference. Idempotent if the entry doesn't exist (creates one).
366
+ *
367
+ * Sets `historyHydrated = true` because an explicit assignment is itself
368
+ * a hydration — even setting `[]` after `consolidate` means "this is the
369
+ * canonical state right now, don't re-load from disk".
370
+ *
371
+ * @param {string} groupId
372
+ * @param {GroupHistory} next
373
+ */
374
+ function setGroupHistory(groupId, next) {
375
+ if (!groupId) return;
376
+ let entry = groupContexts.get(groupId);
377
+ if (!entry) {
378
+ entry = makeGroupContextStub();
379
+ groupContexts.set(groupId, entry);
380
+ }
381
+ entry.history = next;
382
+ entry.historyHydrated = true;
383
+ }
384
+
385
+ /**
386
+ * Test-only access to a group's history array. Re-exported below as
387
+ * `__testGroupHistory`. Lets tests pin the per-group isolation contract
388
+ * without booting a full session.
389
+ *
390
+ * @param {string} groupId
391
+ */
392
+ export function __testGroupHistory(groupId) {
393
+ return getOrCreateGroupHistory(groupId);
394
+ }
395
+
396
+ /**
397
+ * Test-only: install a minimal `session` so `hydrateGroupHistory` can
398
+ * read from a real `ConversationStore`. Pass `null` to clear.
399
+ *
400
+ * Tests that need to verify the hydrate-from-disk path can construct a
401
+ * `ConversationStore` against a tmp dir, write per-group records via
402
+ * `store.append({groupId, ...})`, then call this helper to wire the
403
+ * store into the bridge before calling `__testGroupHistory(groupId)`.
404
+ *
405
+ * @param {{ conversationStore: object } | null} sessionLike
406
+ */
407
+ export function __testSetSession(sessionLike) {
408
+ session = sessionLike;
409
+ }
410
+
411
+ /**
412
+ * Test-only: peek at the GroupContext entry for a group (or undefined
413
+ * if never seeded). Lets tests assert the `historyHydrated` flag without
414
+ * exporting the entire `groupContexts` Map.
415
+ *
416
+ * @param {string} groupId
417
+ */
418
+ export function __testGroupContextEntry(groupId) {
419
+ return groupContexts.get(groupId);
269
420
  }
270
421
 
271
422
  /** Whether we've already sent a permission warning to the UI */
@@ -334,13 +485,29 @@ function getOrCreateVpEngine(groupId, vpId) {
334
485
  */
335
486
  function getOrCreateGroupContext(groupId, groupHandle) {
336
487
  let entry = groupContexts.get(groupId);
337
- if (entry) return entry;
488
+ if (entry && entry.coord && entry.router) return entry;
489
+ // Either no entry, or a partial entry seeded by `getOrCreateGroupHistory`
490
+ // (no coord/router yet). Build the coord/router and merge into the
491
+ // existing record so the per-group history reference and hydration
492
+ // flag are preserved.
338
493
  const coord = createCoordinator(groupHandle, {
339
494
  deliver: (vpId, envelope) => enqueueForVp(groupId, vpId, envelope),
340
495
  });
341
496
  const router = createRouter({ coordinator: coord });
342
- entry = { coord, router, groupHandle };
343
- groupContexts.set(groupId, entry);
497
+ if (!entry) {
498
+ entry = makeGroupContextStub();
499
+ groupContexts.set(groupId, entry);
500
+ }
501
+ entry.coord = coord;
502
+ entry.router = router;
503
+ entry.groupHandle = groupHandle;
504
+ // Defend against a future caller that builds a coord/router without
505
+ // having gone through `getOrCreateGroupHistory` first: a partial entry
506
+ // could exist with `historyHydrated:false`, so do the load now.
507
+ if (!entry.historyHydrated) {
508
+ entry.history = hydrateGroupHistory(groupId);
509
+ entry.historyHydrated = true;
510
+ }
344
511
  return entry;
345
512
  }
346
513
 
@@ -411,8 +578,9 @@ function ensureDriverRunning(groupId, vpId) {
411
578
  turnAbortCtrls.set(turnId, vpAbort);
412
579
  // Snapshot history at the moment this turn starts. Later turns in
413
580
  // the same driver loop see updated history (post-append from the
414
- // previous turn).
415
- const baseSnapshot = [...conversationMessages];
581
+ // previous turn). Per-group: each driver only sees its own group's
582
+ // tape, so cross-group prompts never leak into a VP's snapshot.
583
+ const baseSnapshot = [...getOrCreateGroupHistory(groupId)];
416
584
  const trigger = envelope?.trigger || 'fallback';
417
585
  // Synthesize the prompt. For coordinator-emitted envelopes the
418
586
  // text lives at envelope.msg.text. We prefix `@vp-<id>` to mirror
@@ -559,6 +727,12 @@ export async function __testResetVpState() {
559
727
  vpEngines.clear();
560
728
  vpAborts.clear();
561
729
  groupContexts.clear();
730
+ // Per-group compact in-flight + pending state lives on the session's
731
+ // Compactor. Clear it so a follow-on test doesn't see ghost in-flight
732
+ // promises from a prior run.
733
+ if (session?.compactor && typeof session.compactor.__testReset === 'function') {
734
+ session.compactor.__testReset();
735
+ }
562
736
  }
563
737
 
564
738
 
@@ -924,6 +1098,28 @@ export function installUnifyRuntimeBridge(s) {
924
1098
  } catch { /* never let event delivery throw */ }
925
1099
  };
926
1100
 
1101
+ // Wire the post-compact WS sink. Compactor is constructed in
1102
+ // session.js with a no-op sink; bridge owns `sendUnifyEvent` /
1103
+ // `unifyConversationId`, so the sink is wired here once a session is
1104
+ // present. Per-group `unify_history_compacted` events fire after a
1105
+ // successful summarize+swap.
1106
+ if (s.compactor && typeof s.compactor.setOnCompacted === 'function') {
1107
+ s.compactor.setOnCompacted((groupId, result) => {
1108
+ try {
1109
+ sendUnifyEvent({
1110
+ type: 'unify_history_compacted',
1111
+ reason: result?.reason ?? null,
1112
+ beforeTurns: result?.beforeTurns,
1113
+ afterTurns: result?.afterTurns,
1114
+ beforeTokens: result?.beforeTokens,
1115
+ afterTokens: result?.afterTokens,
1116
+ archivedCount: result?.archivedCount,
1117
+ ts: Date.now(),
1118
+ }, { groupId });
1119
+ } catch { /* WS pipeline failure must not crash compact */ }
1120
+ });
1121
+ }
1122
+
927
1123
  ctx.unifyRuntimeSettings = {
928
1124
  // No multi-thread settings to surface anymore. Stub for back-compat
929
1125
  // with message-router's update_unify_settings branch — assignments are
@@ -1081,8 +1277,9 @@ function handleEngineEvent(event, hctx) {
1081
1277
  break;
1082
1278
 
1083
1279
  case 'consolidate':
1084
- // Engine compressed the context — clear our accumulated history.
1085
- conversationMessages = [];
1280
+ // Engine compressed the context — clear THIS group's accumulated
1281
+ // history. Other groups' histories stay intact.
1282
+ if (hctx.groupId) setGroupHistory(hctx.groupId, []);
1086
1283
  sendUnifyEvent({
1087
1284
  type: 'consolidate',
1088
1285
  archivedCount: event.archivedCount,
@@ -1254,13 +1451,16 @@ export async function handleUnifyGroupChat(msg) {
1254
1451
  ? msg.groupId.trim()
1255
1452
  : 'grp_default';
1256
1453
 
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 */ }
1454
+ // Entry gate: if a compact is in flight from the previous turn IN
1455
+ // THIS GROUP, wait for it to finish before reading the group's
1456
+ // history. Compact runs at turn END (post-fanout) so it does not
1457
+ // block the user's current message latency, but a fast double-send
1458
+ // from the user must not race with the swap. Other groups' compacts
1459
+ // never block this gate. Compactor is created in session.js — until
1460
+ // a session has loaded (or in test paths that never call
1461
+ // `ensureSessionLoaded`) it may be unavailable; skip gracefully.
1462
+ if (session?.compactor) {
1463
+ await session.compactor.awaitInFlight(groupId);
1264
1464
  }
1265
1465
 
1266
1466
  // yeaftDir is a hard prerequisite for both session boot and group seeding;
@@ -1524,12 +1724,21 @@ export async function handleUnifyGroupChat(msg) {
1524
1724
  await waitForVpDrivers(groupId, primaryTargets);
1525
1725
 
1526
1726
  // 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
1531
- // baseSnapshot capture. Errors are swallowed; next turn retries.
1532
- scheduleCompactAfterTurn(groupId);
1727
+ // history past the configured token thresholds for THIS group. Runs
1728
+ // in the background — does not block the response to this message.
1729
+ // The next user message in the same group awaits its per-group
1730
+ // in-flight at the entry gate (handleUnifyGroupChat top) via
1731
+ // `compactor.awaitInFlight`, so the swap is guaranteed to be
1732
+ // observed before the next baseSnapshot capture. Errors are
1733
+ // swallowed; next turn retries. The Compactor takes a per-call
1734
+ // historyHandle so the bridge keeps history ownership and the
1735
+ // Compactor stays ignorant of `groupContexts` / `historyHydrated`.
1736
+ if (session?.compactor && groupId) {
1737
+ session.compactor.scheduleAfterTurn(groupId, {
1738
+ get: () => getOrCreateGroupHistory(groupId),
1739
+ set: (next) => setGroupHistory(groupId, next),
1740
+ });
1741
+ }
1533
1742
  }
1534
1743
 
1535
1744
  /**
@@ -1696,7 +1905,8 @@ async function ensureSessionLoaded() {
1696
1905
 
1697
1906
  unifyConversationId = `unify-${Date.now()}`;
1698
1907
 
1699
- restoreHistoryFromRecent(session.conversationStore.loadRecent());
1908
+ // Per-group history is hydrated lazily on first `getOrCreateGroupHistory`
1909
+ // — there's no global "all conversations" tape any more.
1700
1910
 
1701
1911
  sendUnifyEvent({
1702
1912
  type: 'session_ready',
@@ -1902,7 +2112,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
1902
2112
  }
1903
2113
 
1904
2114
  // Turn completed — atomically append this VP's output to shared history.
1905
- appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolResultsAccum);
2115
+ appendTurnToGroupHistory(groupId, prompt, assistantTextParts, toolCallsAccum, toolResultsAccum);
1906
2116
 
1907
2117
  sendUnifyOutput({
1908
2118
  type: 'assistant',
@@ -1960,11 +2170,21 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
1960
2170
  }
1961
2171
 
1962
2172
  /**
1963
- * Atomically append a completed VP-turn's messages to the shared
2173
+ * Atomically append a completed VP-turn's messages to the GROUP'S
1964
2174
  * conversation history. Called once at turn end (not during streaming).
2175
+ *
2176
+ * Note: this does NOT see the engine's collapsed form — it appends the
2177
+ * raw user prompt + the per-VP assistant text + tool results. The
2178
+ * engine's own `conversationMessages` (with T1/T2 collapse applied)
2179
+ * is persisted to disk via stop-hooks, so the next turn's history is
2180
+ * read from disk via `loadRecentByGroup` on next session boot. Within
2181
+ * a session, this in-memory tape carries the un-collapsed form — which
2182
+ * is fine because each VP turn's `engine.query` re-collapses on the fly.
1965
2183
  */
1966
- function appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolResultsAccum) {
1967
- conversationMessages.push({ role: 'user', content: prompt });
2184
+ function appendTurnToGroupHistory(groupId, prompt, assistantTextParts, toolCallsAccum, toolResultsAccum) {
2185
+ if (!groupId) return;
2186
+ const history = getOrCreateGroupHistory(groupId);
2187
+ history.push({ role: 'user', content: prompt });
1968
2188
 
1969
2189
  const fullText = assistantTextParts.join('');
1970
2190
  if (fullText || toolCallsAccum.length > 0) {
@@ -1976,10 +2196,10 @@ function appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolRes
1976
2196
  input: tc.input,
1977
2197
  }));
1978
2198
  }
1979
- conversationMessages.push(assistantMsg);
2199
+ history.push(assistantMsg);
1980
2200
 
1981
2201
  for (const tr of toolResultsAccum) {
1982
- conversationMessages.push({
2202
+ history.push({
1983
2203
  role: 'tool',
1984
2204
  toolCallId: tr.toolCallId,
1985
2205
  content: tr.content,
@@ -2070,160 +2290,13 @@ function persistUserMessageOnceByMsgId({ msgId, text, groupId }) {
2070
2290
  */
2071
2291
  const _persistedUserMsgIds = new Set();
2072
2292
 
2073
- /**
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.
2078
- *
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.
2083
- *
2084
- * @type {Promise<void>|null}
2085
- */
2086
- let _compactInFlight = null;
2087
-
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;
2097
-
2098
- /**
2099
- * Fire-and-forget post-turn compaction. Called once at the end of each
2100
- * `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
2103
- * completion (anti-starvation).
2104
- *
2105
- * The promise is stored in `_compactInFlight` so the next user message
2106
- * can await it before reading `conversationMessages`.
2107
- *
2108
- * @param {string} groupId — for envelope tagging on the emitted event
2109
- */
2110
- function scheduleCompactAfterTurn(groupId) {
2111
- if (_compactInFlight) {
2112
- // A compact is already running. Mark a follow-up so when it
2113
- // finishes, it re-evaluates and runs again if still triggered.
2114
- _compactPending = true;
2115
- return;
2116
- }
2117
- // Cheap O(n) precheck so we don't bother engaging the LLM at all
2118
- // when the conversation is still small. Mirrors the policy that
2119
- // `runCompactNow` will apply: 30K soft floor / 40 % of configured
2120
- // context / 200K hard ceiling. (The turn-count trigger is off by
2121
- // default — DEFAULT_TURN_LIMIT=Infinity — pin `turnLimit` via opts
2122
- // to re-enable it.)
2123
- const maxContextTokens =
2124
- typeof session?.config?.maxContextTokens === 'number'
2125
- ? session.config.maxContextTokens
2126
- : undefined;
2127
- const triage = shouldCompactHistory(conversationMessages, { maxContextTokens });
2128
- if (!triage.trigger) return;
2129
- if (!session?.engine || typeof session.engine.summarizeForCompact !== 'function') {
2130
- console.warn('[Unify] history compact: engine.summarizeForCompact unavailable — skipping');
2131
- return;
2132
- }
2133
-
2134
- _compactInFlight = runCompactNow(groupId).finally(() => {
2135
- _compactInFlight = null;
2136
- // If turns piled up while we were running and compaction is still
2137
- // needed, chain a follow-up. Use a microtask so the .finally chain
2138
- // settles cleanly before the next promise is created.
2139
- if (_compactPending) {
2140
- _compactPending = false;
2141
- queueMicrotask(() => scheduleCompactAfterTurn(groupId));
2142
- }
2143
- });
2144
- }
2145
-
2146
- /**
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.
2151
- *
2152
- * Behaviour:
2153
- * - If summarization fails, leaves history untouched.
2154
- * - On success, emits a `unify_history_compacted` event so dev tools
2155
- * can show what happened (frontend currently ignores it).
2156
- *
2157
- * 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.
2164
- *
2165
- * @param {string} groupId
2166
- * @returns {Promise<void>}
2167
- */
2168
- async function runCompactNow(groupId) {
2169
- const summarize = ({ system, prompt }) =>
2170
- session.engine.summarizeForCompact({ system, prompt, maxTokens: 1024 });
2171
-
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;
2177
-
2178
- // Pull the user-configured context width so the 40 %-of-context
2179
- // threshold auto-adjusts to whatever model they're on. Falls back to
2180
- // the module default when missing.
2181
- const maxContextTokens =
2182
- typeof session?.config?.maxContextTokens === 'number'
2183
- ? session.config.maxContextTokens
2184
- : undefined;
2185
-
2186
- try {
2187
- const result = await compactHistory(snapshot, { summarize, maxContextTokens });
2188
- if (!result.compacted) {
2189
- if (result.error) {
2190
- console.warn(
2191
- `[Unify] history compact: summarizer failed (${result.error}); ` +
2192
- `keeping ${result.beforeTurns} turns / ~${result.beforeTokens} tokens`
2193
- );
2194
- }
2195
- return;
2196
- }
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) {
2201
- console.log('[Unify] history compact: history was reset during compact — discarding stale summary');
2202
- return;
2203
- }
2204
- conversationMessages = result.messages;
2205
- console.log(
2206
- `[Unify] history compacted (reason=${result.reason}): ` +
2207
- `turns ${result.beforeTurns}→${result.afterTurns}, ` +
2208
- `tokens ~${result.beforeTokens}→${result.afterTokens}, ` +
2209
- `archived ${result.archivedCount} messages`
2210
- );
2211
- try {
2212
- sendUnifyEvent({
2213
- type: 'unify_history_compacted',
2214
- reason: result.reason,
2215
- beforeTurns: result.beforeTurns,
2216
- afterTurns: result.afterTurns,
2217
- beforeTokens: result.beforeTokens,
2218
- afterTokens: result.afterTokens,
2219
- archivedCount: result.archivedCount,
2220
- ts: Date.now(),
2221
- }, { groupId });
2222
- } catch { /* WS pipeline failure must not crash compact */ }
2223
- } catch (err) {
2224
- console.warn('[Unify] history compact: unexpected failure', err?.message || err);
2225
- }
2226
- }
2293
+ // Per-group post-turn compaction lives on `session.compactor`
2294
+ // (`agent/unify/compact/compactor.js`), constructed in `session.js`.
2295
+ // Bridge passes a per-call `historyHandle = { get, set }` and wires the
2296
+ // `unify_history_compacted` WS sink via `compactor.setOnCompacted` from
2297
+ // `installUnifyRuntimeBridge`. The bridge keeps history ownership; the
2298
+ // Compactor owns single-flight, anti-starvation, race-guard, and the
2299
+ // LLM summarize call.
2227
2300
 
2228
2301
  /**
2229
2302
  * Abort every in-flight VP turn and clear all queued envelopes across
@@ -2593,12 +2666,17 @@ export async function handleUnifyLoadHistory(msg) {
2593
2666
 
2594
2667
  unifyConversationId = `unify-${Date.now()}`;
2595
2668
 
2596
- restoreHistoryFromRecent(pickRecent(session.conversationStore, undefined));
2669
+ // Per-group history hydrates lazily via getOrCreateGroupHistory.
2670
+ // When the load-history call carries a groupId, force-refresh THAT
2671
+ // group's tape so the next user message sees on-disk state. When
2672
+ // it doesn't (legacy callers), do nothing — the per-group lazy
2673
+ // hydration handles it.
2674
+ if (groupId) setGroupHistory(groupId, hydrateGroupHistory(groupId));
2597
2675
  } else if (groupId) {
2598
2676
  // 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));
2677
+ // re-seed THIS group's history from disk so it doesn't carry stale
2678
+ // in-memory state into the next turn's context.
2679
+ setGroupHistory(groupId, hydrateGroupHistory(groupId));
2602
2680
  }
2603
2681
 
2604
2682
  // Always replay session_ready so refresh / reconnect rebuilds UI state.
@@ -2753,7 +2831,9 @@ export async function resetUnifySession() {
2753
2831
  session = null;
2754
2832
  }
2755
2833
  unifyConversationId = null;
2756
- conversationMessages = [];
2834
+ // Per-group histories live on groupContexts entries — clearing the
2835
+ // map (a few lines below) drops every group's history with it. No
2836
+ // separate global tape to clear.
2757
2837
  // Re-arm the permission warning. The user might have fixed the
2758
2838
  // ~/.yeaft/ permissions in the interim and is now restarting the
2759
2839
  // session — they should see the diagnostic again if it still fails.
@@ -2786,7 +2866,8 @@ export async function resetUnifySession() {
2786
2866
 
2787
2867
  unifyConversationId = `unify-${Date.now()}`;
2788
2868
 
2789
- restoreHistoryFromRecent(session.conversationStore.loadRecent());
2869
+ // Per-group history hydrates lazily via getOrCreateGroupHistory on
2870
+ // first read. Nothing to seed here.
2790
2871
 
2791
2872
  sendUnifyEvent({
2792
2873
  type: 'session_ready',