@yeaft/webchat-agent 0.1.748 → 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.748",
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/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,
@@ -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';
@@ -261,11 +259,8 @@ let _vpUnsubscribe = null;
261
259
  * from disk (or explicitly assigned). The flag is required because an
262
260
  * empty array is legitimate post-consolidate / post-clear state and
263
261
  * 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
262
+ * would short-circuit `getOrCreateGroupHistory` on truthy `[]` and skip
266
263
  * the disk load.
267
- * @property {{inFlight: Promise<void>|null, pending: boolean}} [_compact]
268
- * per-group compact state, lazily attached.
269
264
  */
270
265
 
271
266
  /** Build a fresh stub entry with no coord/router/history loaded. */
@@ -350,10 +345,10 @@ function getOrCreateGroupHistory(groupId) {
350
345
  let entry = groupContexts.get(groupId);
351
346
  // Use `historyHydrated` rather than truthiness on `history` itself —
352
347
  // 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.
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.
357
352
  if (entry && entry.historyHydrated) return entry.history;
358
353
  if (!entry) {
359
354
  entry = makeGroupContextStub();
@@ -492,9 +487,9 @@ function getOrCreateGroupContext(groupId, groupHandle) {
492
487
  let entry = groupContexts.get(groupId);
493
488
  if (entry && entry.coord && entry.router) return entry;
494
489
  // 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.
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.
498
493
  const coord = createCoordinator(groupHandle, {
499
494
  deliver: (vpId, envelope) => enqueueForVp(groupId, vpId, envelope),
500
495
  });
@@ -732,6 +727,12 @@ export async function __testResetVpState() {
732
727
  vpEngines.clear();
733
728
  vpAborts.clear();
734
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
+ }
735
736
  }
736
737
 
737
738
 
@@ -1097,6 +1098,28 @@ export function installUnifyRuntimeBridge(s) {
1097
1098
  } catch { /* never let event delivery throw */ }
1098
1099
  };
1099
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
+
1100
1123
  ctx.unifyRuntimeSettings = {
1101
1124
  // No multi-thread settings to surface anymore. Stub for back-compat
1102
1125
  // with message-router's update_unify_settings branch — assignments are
@@ -1433,10 +1456,11 @@ export async function handleUnifyGroupChat(msg) {
1433
1456
  // history. Compact runs at turn END (post-fanout) so it does not
1434
1457
  // block the user's current message latency, but a fast double-send
1435
1458
  // 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 */ }
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);
1440
1464
  }
1441
1465
 
1442
1466
  // yeaftDir is a hard prerequisite for both session boot and group seeding;
@@ -1700,13 +1724,21 @@ export async function handleUnifyGroupChat(msg) {
1700
1724
  await waitForVpDrivers(groupId, primaryTargets);
1701
1725
 
1702
1726
  // Post-turn compaction. Triggers when the JUST-APPENDED turn pushed
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
1708
- // baseSnapshot capture. Errors are swallowed; next turn retries.
1709
- 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
+ }
1710
1742
  }
1711
1743
 
1712
1744
  /**
@@ -2258,181 +2290,13 @@ function persistUserMessageOnceByMsgId({ msgId, text, groupId }) {
2258
2290
  */
2259
2291
  const _persistedUserMsgIds = new Set();
2260
2292
 
2261
- /**
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').
2266
- *
2267
- * Lives off `groupContexts.get(groupId)._compact = { inFlight, pending }`.
2268
- *
2269
- * @typedef {{ inFlight: Promise<void>|null, pending: boolean }} CompactState
2270
- */
2271
-
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
- }
2289
-
2290
- /**
2291
- * Fire-and-forget post-turn compaction. Called once at the end of each
2292
- * `handleUnifyGroupChat` after `Promise.all(runVpTurn)` resolves. If a
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
2295
- * completion (anti-starvation).
2296
- *
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.
2299
- *
2300
- * @param {string} groupId — for envelope tagging on the emitted event
2301
- */
2302
- function scheduleCompactAfterTurn(groupId) {
2303
- if (!groupId) return;
2304
- const cs = getCompactState(groupId);
2305
- if (cs.inFlight) {
2306
- // A compact is already running. Mark a follow-up so when it
2307
- // finishes, it re-evaluates and runs again if still triggered.
2308
- cs.pending = true;
2309
- return;
2310
- }
2311
- // Cheap O(n) precheck so we don't bother engaging the LLM at all
2312
- // when the conversation is still small. Mirrors the policy that
2313
- // `runCompactNow` will apply: 30K soft floor / 40 % of configured
2314
- // context / 200K hard ceiling. (The turn-count trigger is off by
2315
- // default — DEFAULT_TURN_LIMIT=Infinity — pin `turnLimit` via opts
2316
- // to re-enable it.)
2317
- const maxContextTokens =
2318
- typeof session?.config?.maxContextTokens === 'number'
2319
- ? session.config.maxContextTokens
2320
- : undefined;
2321
- const triage = shouldCompactHistory(getOrCreateGroupHistory(groupId), { maxContextTokens });
2322
- if (!triage.trigger) return;
2323
- if (!session?.engine || typeof session.engine.summarizeForCompact !== 'function') {
2324
- console.warn('[Unify] history compact: engine.summarizeForCompact unavailable — skipping');
2325
- return;
2326
- }
2327
-
2328
- cs.inFlight = runCompactNow(groupId).finally(() => {
2329
- cs.inFlight = null;
2330
- // If turns piled up while we were running and compaction is still
2331
- // needed, chain a follow-up. Use a microtask so the .finally chain
2332
- // settles cleanly before the next promise is created.
2333
- if (cs.pending) {
2334
- cs.pending = false;
2335
- queueMicrotask(() => scheduleCompactAfterTurn(groupId));
2336
- }
2337
- });
2338
- }
2339
-
2340
- /**
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`).
2345
- *
2346
- * Behaviour:
2347
- * - If summarization fails, leaves history untouched.
2348
- * - On success, emits a `unify_history_compacted` event so dev tools
2349
- * can show what happened (frontend currently ignores it).
2350
- *
2351
- * Race safety:
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.)
2363
- *
2364
- * @param {string} groupId
2365
- * @returns {Promise<void>}
2366
- */
2367
- async function runCompactNow(groupId) {
2368
- const summarize = ({ system, prompt }) =>
2369
- session.engine.summarizeForCompact({ system, prompt, maxTokens: 1024 });
2370
-
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;
2380
-
2381
- // Pull the user-configured context width so the 40 %-of-context
2382
- // threshold auto-adjusts to whatever model they're on. Falls back to
2383
- // the module default when missing.
2384
- const maxContextTokens =
2385
- typeof session?.config?.maxContextTokens === 'number'
2386
- ? session.config.maxContextTokens
2387
- : undefined;
2388
-
2389
- try {
2390
- const result = await compactHistory(snapshot, { summarize, maxContextTokens });
2391
- if (!result.compacted) {
2392
- if (result.error) {
2393
- console.warn(
2394
- `[Unify] history compact: summarizer failed (${result.error}); ` +
2395
- `keeping ${result.beforeTurns} turns / ~${result.beforeTokens} tokens`
2396
- );
2397
- }
2398
- return;
2399
- }
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) {
2406
- console.log('[Unify] history compact: history was reset during compact — discarding stale summary');
2407
- return;
2408
- }
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);
2414
- console.log(
2415
- `[Unify] history compacted (reason=${result.reason}): ` +
2416
- `turns ${result.beforeTurns}→${result.afterTurns}, ` +
2417
- `tokens ~${result.beforeTokens}→${result.afterTokens}, ` +
2418
- `archived ${result.archivedCount} messages`
2419
- );
2420
- try {
2421
- sendUnifyEvent({
2422
- type: 'unify_history_compacted',
2423
- reason: result.reason,
2424
- beforeTurns: result.beforeTurns,
2425
- afterTurns: result.afterTurns,
2426
- beforeTokens: result.beforeTokens,
2427
- afterTokens: result.afterTokens,
2428
- archivedCount: result.archivedCount,
2429
- ts: Date.now(),
2430
- }, { groupId });
2431
- } catch { /* WS pipeline failure must not crash compact */ }
2432
- } catch (err) {
2433
- console.warn('[Unify] history compact: unexpected failure', err?.message || err);
2434
- }
2435
- }
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.
2436
2300
 
2437
2301
  /**
2438
2302
  * Abort every in-flight VP turn and clear all queued envelopes across