@yeaft/webchat-agent 0.1.685 → 0.1.687

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.685",
3
+ "version": "0.1.687",
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
@@ -1875,4 +1875,29 @@ export class Engine {
1875
1875
 
1876
1876
  /** @returns {object} — Config with fastModel as model (for internal tasks) */
1877
1877
  get fastConfig() { return this.#fastConfig; }
1878
+
1879
+ /**
1880
+ * Run a one-shot fast-model call to produce a compact summary.
1881
+ * Used by the web bridge's in-memory history compactor
1882
+ * (`agent/unify/history-compact.js`) — kept on the engine so callers
1883
+ * don't reach into the private adapter field.
1884
+ *
1885
+ * @param {{system: string, prompt: string, maxTokens?: number}} args
1886
+ * @returns {Promise<string>} — summary text (trimmed); '' on failure
1887
+ */
1888
+ async summarizeForCompact({ system, prompt, maxTokens = 1024 } = {}) {
1889
+ if (!system || !prompt) return '';
1890
+ try {
1891
+ const out = await this.#adapter.call({
1892
+ model: this.#fastConfig.model,
1893
+ system,
1894
+ messages: [{ role: 'user', content: prompt }],
1895
+ maxTokens,
1896
+ });
1897
+ return (out?.text || '').trim();
1898
+ } catch (err) {
1899
+ console.warn('[Engine] summarizeForCompact failed:', err?.message || err);
1900
+ return '';
1901
+ }
1902
+ }
1878
1903
  }
@@ -0,0 +1,461 @@
1
+ /**
2
+ * history-compact.js — In-memory conversation history compaction for the
3
+ * Unify group-chat fan-out path.
4
+ *
5
+ * Problem this solves:
6
+ * `agent/unify/web-bridge.js` keeps a flat module-level array
7
+ * `conversationMessages` that grows unbounded across the lifetime of the
8
+ * agent process. Every fan-out turn snapshots the whole thing into
9
+ * `baseSnapshot` and feeds it to `engine.query` for every VP. Without a
10
+ * cap, prompt size and token cost grow linearly with conversation length.
11
+ *
12
+ * Existing infrastructure (`agent/unify/compact/orchestrator.js`,
13
+ * `engine.js#runOrchestratorCompact`) compacts the on-disk
14
+ * `conversationStore` — a different surface. This helper compacts the
15
+ * in-memory array that actually gets passed to the LLM.
16
+ *
17
+ * Approach (Claude-Code-style compact):
18
+ * 1. Skip tool messages and the synthetic `_reflection`/`_compactSummary`
19
+ * wrappers when feeding the summarizer (tool result bodies are noise;
20
+ * reflection wrappers are already a summary).
21
+ * 2. Ask the fast model to produce a short structured summary of the
22
+ * conversation up to a cut-point.
23
+ * 3. Replace `messages[0..cutIdx]` with ONE synthetic user message
24
+ * carrying that summary, wrapped with the canonical recovery prompt
25
+ * ("This session is being continued from a previous conversation...").
26
+ * 4. Keep the last `keepRecent` user→assistant turns intact so the model
27
+ * has fresh, untransformed context for whatever the user just said.
28
+ *
29
+ * Triggers (either fires):
30
+ * - turn count > 20 (each user message in `conversationMessages` is a turn)
31
+ * - estimated tokens > 80,000
32
+ *
33
+ * Defaults match the user-stated requirement; both are overridable via the
34
+ * options bag for tests / future config plumbing.
35
+ *
36
+ * Why role='user' for the summary message:
37
+ * The Anthropic Messages API rejects assistant prefill at the tail
38
+ * ("messages must end with user before next assistant turn"). Wrapping
39
+ * as user mirrors what Claude Code does for compact summaries — and
40
+ * what `tool-folding/index.js#collapseRangeToReflection` already does
41
+ * for tool-arc reflections in this codebase. The opening sentence
42
+ * ("This session is being continued ...") makes the model treat it
43
+ * as a recovery directive rather than a fresh user prompt.
44
+ */
45
+
46
+ import { estimateTokens } from './conversation/persist.js';
47
+
48
+ /**
49
+ * Default trigger thresholds — match the user's stated policy:
50
+ * "如果 turn 超过 20 或者 message 上下文超过 80K,那么就 compact"
51
+ */
52
+ export const DEFAULT_TURN_LIMIT = 20;
53
+ export const DEFAULT_TOKEN_LIMIT = 80_000;
54
+
55
+ /**
56
+ * How many user→assistant pairs to leave intact at the tail. The summary
57
+ * replaces everything before this window. 2 keeps "what we were just
58
+ * talking about" lossless.
59
+ */
60
+ export const DEFAULT_KEEP_RECENT_TURNS = 2;
61
+
62
+ /**
63
+ * Estimate the token weight of a single message including role overhead
64
+ * and any tool-call structure. Mirrors `dream-v2/segment.js` approach: a
65
+ * couple of tokens per message for role/wrapping plus the body.
66
+ *
67
+ * @param {{role:string, content?:string, toolCalls?:Array, toolCallId?:string}} m
68
+ * @returns {number}
69
+ */
70
+ export function estimateMessageTokens(m) {
71
+ if (!m || typeof m !== 'object') return 0;
72
+ let n = 2; // role + framing
73
+ if (typeof m.content === 'string') n += estimateTokens(m.content);
74
+ if (Array.isArray(m.toolCalls)) {
75
+ for (const tc of m.toolCalls) {
76
+ n += 4; // call framing
77
+ try {
78
+ const inputJson = typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input || {});
79
+ n += estimateTokens(inputJson);
80
+ } catch { /* ignore — JSON.stringify failure on circular input */ }
81
+ if (tc.name) n += estimateTokens(tc.name);
82
+ }
83
+ }
84
+ if (m.toolCallId) n += 2;
85
+ return n;
86
+ }
87
+
88
+ /**
89
+ * Sum estimated tokens across all messages.
90
+ * @param {Array<object>} messages
91
+ * @returns {number}
92
+ */
93
+ export function estimateMessagesTokens(messages) {
94
+ if (!Array.isArray(messages)) return 0;
95
+ let total = 0;
96
+ for (const m of messages) total += estimateMessageTokens(m);
97
+ return total;
98
+ }
99
+
100
+ /**
101
+ * Strip a leading `@vp-<id> ` mention prefix from a user prompt. The
102
+ * web bridge prefixes each VP's per-turn prompt with `@vp-<id> ` so
103
+ * the engine knows which VP is replying. When counting "turns" we
104
+ * want the user-facing notion of a turn (one round-trip), not one per
105
+ * VP — so we strip the prefix before deduping consecutive identical
106
+ * user messages.
107
+ *
108
+ * Format mirrors `web-bridge.js#runVpTurn`:
109
+ * `@vp-${vpId} ${text}`
110
+ *
111
+ * @param {string} content
112
+ * @returns {string}
113
+ */
114
+ function stripVpMentionPrefix(content) {
115
+ if (typeof content !== 'string') return '';
116
+ return content.replace(/^@vp-[^\s]+\s+/, '');
117
+ }
118
+
119
+ /**
120
+ * Count "turns" — defined as a user-side round-trip, NOT one per
121
+ * user-role message. Multi-VP fan-out appends one user message per VP
122
+ * (each with an `@vp-<id>` prefix) for the same underlying user prompt;
123
+ * those collapse into a single turn here.
124
+ *
125
+ * Algorithm: walk user-role messages, strip the `@vp-` prefix, count
126
+ * a turn whenever the canonical text changes from the previous user
127
+ * message (or it's the first one).
128
+ *
129
+ * @param {Array<object>} messages
130
+ * @returns {number}
131
+ */
132
+ export function countTurns(messages) {
133
+ if (!Array.isArray(messages)) return 0;
134
+ let n = 0;
135
+ let prev = null;
136
+ for (const m of messages) {
137
+ if (!m || m.role !== 'user') continue;
138
+ const canonical = stripVpMentionPrefix(m.content || '');
139
+ if (canonical !== prev) {
140
+ n++;
141
+ prev = canonical;
142
+ }
143
+ }
144
+ return n;
145
+ }
146
+
147
+ /**
148
+ * Pure trigger evaluator. Decides whether the in-memory history needs
149
+ * compaction. No I/O, no LLM call.
150
+ *
151
+ * @param {Array<object>} messages
152
+ * @param {{turnLimit?: number, tokenLimit?: number}} [opts]
153
+ * @returns {{trigger: boolean, reason: 'turn_count'|'token_threshold'|null,
154
+ * turnCount: number, tokenCount: number,
155
+ * turnLimit: number, tokenLimit: number}}
156
+ */
157
+ export function shouldCompactHistory(messages, opts = {}) {
158
+ const turnLimit = opts.turnLimit ?? DEFAULT_TURN_LIMIT;
159
+ const tokenLimit = opts.tokenLimit ?? DEFAULT_TOKEN_LIMIT;
160
+ const turnCount = countTurns(messages);
161
+ const tokenCount = estimateMessagesTokens(messages);
162
+
163
+ let reason = null;
164
+ if (turnCount > turnLimit) reason = 'turn_count';
165
+ else if (tokenCount > tokenLimit) reason = 'token_threshold';
166
+
167
+ return {
168
+ trigger: reason !== null,
169
+ reason,
170
+ turnCount,
171
+ tokenCount,
172
+ turnLimit,
173
+ tokenLimit,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Strip noise from a message list before sending it to the summarizer:
179
+ * - drop `role: 'tool'` (raw tool results — too verbose, mostly redundant)
180
+ * - drop messages already tagged `_compactSummary` (avoid summarising
181
+ * a summary)
182
+ * - keep `_reflection` messages as-is (they're already a fold-summary
183
+ * of an earlier tool arc and contain real information)
184
+ * - elide `toolCalls` from assistant messages: replace each with a tag
185
+ * line like "[called tool: bash with input ...]" so the summarizer
186
+ * knows a tool ran without spending tokens on the full input
187
+ *
188
+ * @param {Array<object>} messages
189
+ * @returns {Array<{role:string, content:string}>}
190
+ */
191
+ export function buildSummarizerInput(messages) {
192
+ if (!Array.isArray(messages)) return [];
193
+ const out = [];
194
+ for (const m of messages) {
195
+ if (!m || typeof m !== 'object') continue;
196
+ if (m.role === 'tool') continue;
197
+ if (m._compactSummary) continue;
198
+ let content = typeof m.content === 'string' ? m.content : '';
199
+ if (m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
200
+ const callTags = m.toolCalls.map(tc => {
201
+ const name = tc.name || 'unknown';
202
+ let inputBrief = '';
203
+ try {
204
+ const json = typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input || {});
205
+ inputBrief = json.length > 120 ? json.slice(0, 120) + '…' : json;
206
+ } catch { inputBrief = '<input>'; }
207
+ return `[tool ${name}: ${inputBrief}]`;
208
+ }).join(' ');
209
+ content = content ? `${content}\n${callTags}` : callTags;
210
+ }
211
+ if (!content) continue;
212
+ out.push({ role: m.role, content });
213
+ }
214
+ return out;
215
+ }
216
+
217
+ /**
218
+ * Find the cut index: keep the last `keepRecent` user→assistant arcs
219
+ * intact, fold everything before. Returns the index that the cut starts
220
+ * AT, i.e. messages[0..cutIdx) gets summarised, messages[cutIdx..] stays.
221
+ *
222
+ * Strategy: walks user-role messages from the END backwards, counting
223
+ * DISTINCT turns by canonical text (after stripping `@vp-<id>` prefix
224
+ * via `stripVpMentionPrefix`). Multi-VP fan-out variants of the same
225
+ * underlying turn collapse into one turn — and the candidate cut index
226
+ * is extended backwards through them so all `@vp-X` variants of the
227
+ * kept turn stay together. If there aren't enough turns to fold (history
228
+ * shorter than keepRecent), returns -1 (caller treats as no-op).
229
+ *
230
+ * @param {Array<object>} messages
231
+ * @param {number} keepRecent
232
+ * @returns {number}
233
+ */
234
+ export function findCutIndex(messages, keepRecent) {
235
+ if (!Array.isArray(messages) || messages.length === 0) return -1;
236
+ if (keepRecent <= 0) return messages.length; // fold everything
237
+
238
+ // Walk from the end, counting DISTINCT turns (multiple consecutive
239
+ // user messages with the same canonical text — i.e. one fan-out's
240
+ // @vp-X variants — collapse into a single turn). Stop when we've
241
+ // started the (keepRecent)-th turn from the end; everything before
242
+ // its first user-message gets folded.
243
+ let turnsFromEnd = 0;
244
+ let nextCanonical = null; // canonical text of the turn we just opened
245
+ let candidateIdx = -1;
246
+ for (let i = messages.length - 1; i >= 0; i--) {
247
+ if (!messages[i] || messages[i].role !== 'user') continue;
248
+ const canonical = stripVpMentionPrefix(messages[i].content || '');
249
+ if (canonical !== nextCanonical) {
250
+ // New (older) turn boundary.
251
+ turnsFromEnd++;
252
+ nextCanonical = canonical;
253
+ if (turnsFromEnd === keepRecent) {
254
+ candidateIdx = i;
255
+ // Keep walking — the same turn might extend further back via
256
+ // earlier @vp variants of the same canonical text.
257
+ continue;
258
+ }
259
+ if (turnsFromEnd > keepRecent) {
260
+ // We've stepped into the (keepRecent+1)-th turn — stop. The
261
+ // last recorded `candidateIdx` is the start of the LAST
262
+ // keepRecent block.
263
+ break;
264
+ }
265
+ } else if (turnsFromEnd === keepRecent) {
266
+ // Same canonical text as the keepRecent-th-from-end turn — this
267
+ // is an earlier @vp-variant of that same turn. Extend candidate
268
+ // backwards to include it.
269
+ candidateIdx = i;
270
+ }
271
+ }
272
+ return candidateIdx;
273
+ }
274
+
275
+ /**
276
+ * Wrap a summary string into the canonical "session continued" recovery
277
+ * message. The wording is deliberately close to Claude Code's compact
278
+ * marker so frontend filters (already in `web/stores/helpers/claudeOutput.js`,
279
+ * `server/db/message-db.js`) recognise it.
280
+ *
281
+ * @param {string} summary
282
+ * @returns {{role:'user', content:string, _compactSummary: true}}
283
+ */
284
+ export function wrapSummaryAsUserMessage(summary) {
285
+ const body = (summary || '').trim() || '(no summary produced)';
286
+ const content =
287
+ 'This session is being continued from a previous conversation. ' +
288
+ 'The earlier context has been summarized for efficiency.\n\n' +
289
+ 'Summary of conversation so far:\n' +
290
+ body +
291
+ '\n\nContinue the conversation from where it left off without asking the user any further questions.';
292
+ return {
293
+ role: 'user',
294
+ content,
295
+ _compactSummary: true,
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Build the prompt fed to the fast-model summarizer. Kept in code (not in
301
+ * a template file) because it's small and lives alongside the call site.
302
+ *
303
+ * @param {Array<{role:string, content:string}>} cleanedMessages
304
+ * @returns {{system: string, prompt: string}}
305
+ */
306
+ export function buildSummaryPrompt(cleanedMessages) {
307
+ const transcript = cleanedMessages
308
+ .map(m => `[${m.role}]\n${m.content}`)
309
+ .join('\n\n---\n\n');
310
+ const system =
311
+ 'You are a conversation summarizer for a multi-agent group chat. ' +
312
+ 'Produce a concise (4–8 short bullet points) summary of the conversation ' +
313
+ 'so far. Preserve: (1) decisions made, (2) facts learned, (3) the user\'s ' +
314
+ 'current goal, (4) any open questions or pending actions, (5) which VPs ' +
315
+ 'are participating and what each contributed. Do NOT include raw tool ' +
316
+ 'output. Do NOT speculate. Be specific.';
317
+ const prompt =
318
+ 'Summarize the following conversation. Output ONLY the summary, no ' +
319
+ 'preamble.\n\n' +
320
+ transcript;
321
+ return { system, prompt };
322
+ }
323
+
324
+ /**
325
+ * Apply compaction to a messages array. Pure transform once `summarize`
326
+ * has produced text. Returns a new array — does not mutate the input.
327
+ *
328
+ * @param {Array<object>} messages
329
+ * @param {{
330
+ * summarize: (args: {system: string, prompt: string}) => Promise<string>,
331
+ * keepRecent?: number,
332
+ * turnLimit?: number,
333
+ * tokenLimit?: number,
334
+ * }} options
335
+ * @returns {Promise<{
336
+ * messages: Array<object>,
337
+ * compacted: boolean,
338
+ * reason: string|null,
339
+ * summary: string|null,
340
+ * archivedCount: number,
341
+ * beforeTurns: number,
342
+ * beforeTokens: number,
343
+ * afterTurns: number,
344
+ * afterTokens: number,
345
+ * }>}
346
+ */
347
+ export async function compactHistory(messages, options) {
348
+ const {
349
+ summarize,
350
+ keepRecent = DEFAULT_KEEP_RECENT_TURNS,
351
+ turnLimit = DEFAULT_TURN_LIMIT,
352
+ tokenLimit = DEFAULT_TOKEN_LIMIT,
353
+ } = options || {};
354
+
355
+ if (typeof summarize !== 'function') {
356
+ throw new TypeError('compactHistory: options.summarize must be a function');
357
+ }
358
+
359
+ const before = shouldCompactHistory(messages, { turnLimit, tokenLimit });
360
+ if (!before.trigger) {
361
+ return {
362
+ messages,
363
+ compacted: false,
364
+ reason: null,
365
+ summary: null,
366
+ archivedCount: 0,
367
+ beforeTurns: before.turnCount,
368
+ beforeTokens: before.tokenCount,
369
+ afterTurns: before.turnCount,
370
+ afterTokens: before.tokenCount,
371
+ };
372
+ }
373
+
374
+ const cutIdx = findCutIndex(messages, keepRecent);
375
+ if (cutIdx <= 0) {
376
+ // Not enough history to fold while preserving the recent window.
377
+ return {
378
+ messages,
379
+ compacted: false,
380
+ reason: before.reason,
381
+ summary: null,
382
+ archivedCount: 0,
383
+ beforeTurns: before.turnCount,
384
+ beforeTokens: before.tokenCount,
385
+ afterTurns: before.turnCount,
386
+ afterTokens: before.tokenCount,
387
+ };
388
+ }
389
+
390
+ const archived = messages.slice(0, cutIdx);
391
+ const tail = messages.slice(cutIdx);
392
+ const cleaned = buildSummarizerInput(archived);
393
+
394
+ let summaryText = '';
395
+ if (cleaned.length > 0) {
396
+ const { system, prompt } = buildSummaryPrompt(cleaned);
397
+ try {
398
+ summaryText = (await summarize({ system, prompt })) || '';
399
+ } catch (err) {
400
+ // Summarizer failure → return original messages, signal failure.
401
+ return {
402
+ messages,
403
+ compacted: false,
404
+ reason: before.reason,
405
+ summary: null,
406
+ archivedCount: 0,
407
+ beforeTurns: before.turnCount,
408
+ beforeTokens: before.tokenCount,
409
+ afterTurns: before.turnCount,
410
+ afterTokens: before.tokenCount,
411
+ error: err && err.message ? err.message : String(err),
412
+ };
413
+ }
414
+ // Treat an empty / whitespace-only summary as a soft failure rather
415
+ // than a successful compact. Otherwise we'd archive real history
416
+ // behind a "(no summary produced)" placeholder and the next turn
417
+ // would start from useless context.
418
+ if (!summaryText.trim()) {
419
+ return {
420
+ messages,
421
+ compacted: false,
422
+ reason: before.reason,
423
+ summary: null,
424
+ archivedCount: 0,
425
+ beforeTurns: before.turnCount,
426
+ beforeTokens: before.tokenCount,
427
+ afterTurns: before.turnCount,
428
+ afterTokens: before.tokenCount,
429
+ error: 'empty summary',
430
+ };
431
+ }
432
+ }
433
+
434
+ const summaryMsg = wrapSummaryAsUserMessage(summaryText);
435
+
436
+ // Defensive: if the tail starts with a `role: 'tool'` message, the
437
+ // adapter will reject it (tool messages must follow an assistant with
438
+ // a matching tool_call). Drop leading tool messages from the tail —
439
+ // their preceding assistant has been folded into the summary, so the
440
+ // tool result is orphaned anyway.
441
+ let tailStart = 0;
442
+ while (tailStart < tail.length && tail[tailStart] && tail[tailStart].role === 'tool') {
443
+ tailStart++;
444
+ }
445
+ const safeTail = tail.slice(tailStart);
446
+
447
+ const newMessages = [summaryMsg, ...safeTail];
448
+ const after = shouldCompactHistory(newMessages, { turnLimit, tokenLimit });
449
+
450
+ return {
451
+ messages: newMessages,
452
+ compacted: true,
453
+ reason: before.reason,
454
+ summary: summaryText,
455
+ archivedCount: archived.length,
456
+ beforeTurns: before.turnCount,
457
+ beforeTokens: before.tokenCount,
458
+ afterTurns: after.turnCount,
459
+ afterTokens: after.tokenCount,
460
+ };
461
+ }
@@ -44,6 +44,10 @@ import {
44
44
  import { openGroup, loadGroupMeta } from './groups/group-store.js';
45
45
  import { createCoordinator } from './groups/coordinator.js';
46
46
  import { seedDefaultGroup } from './groups/seed-default.js';
47
+ import {
48
+ shouldCompactHistory,
49
+ compactHistory,
50
+ } from './history-compact.js';
47
51
 
48
52
  /** @type {import('./session.js').Session | null} */
49
53
  let session = null;
@@ -681,6 +685,15 @@ export async function handleUnifyGroupChat(msg) {
681
685
  ? msg.groupId.trim()
682
686
  : 'grp_default';
683
687
 
688
+ // Entry gate: if a compact is in flight from the previous turn,
689
+ // wait for it to finish before reading conversationMessages. Compact
690
+ // runs at turn END (post-fanout) so it does not block the user's
691
+ // current message latency, but a fast double-send from the user must
692
+ // not race with the swap.
693
+ if (_compactInFlight) {
694
+ try { await _compactInFlight; } catch { /* first caller logs */ }
695
+ }
696
+
684
697
  // yeaftDir is a hard prerequisite for both session boot and group seeding;
685
698
  // validate BEFORE booting so a misconfigured agent doesn't leave a zombie
686
699
  // session lying around.
@@ -885,6 +898,14 @@ export async function handleUnifyGroupChat(msg) {
885
898
  } catch { /* never crash WS pipeline */ }
886
899
  }
887
900
  }));
901
+
902
+ // Post-turn compaction. Triggers when the JUST-APPENDED turn pushed
903
+ // history past 20 turns / 80K tokens. Runs in the background — does
904
+ // not block the response to this message. The next user message
905
+ // awaits `_compactInFlight` at the entry gate (handleUnifyGroupChat
906
+ // top), so the swap is guaranteed to be observed before the next
907
+ // baseSnapshot capture. Errors are swallowed; next turn retries.
908
+ scheduleCompactAfterTurn(groupId);
888
909
  }
889
910
 
890
911
  /**
@@ -1151,6 +1172,145 @@ function appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolRes
1151
1172
  }
1152
1173
  }
1153
1174
 
1175
+ /**
1176
+ * In-flight compact promise. Set by `scheduleCompactAfterTurn` when a
1177
+ * turn ends and triggers compaction; awaited by the next
1178
+ * `handleUnifyGroupChat` invocation at its entry gate so the next
1179
+ * baseSnapshot reflects the compacted history.
1180
+ *
1181
+ * Compact runs at turn END (not before fan-out), so it does not add
1182
+ * latency to the user's current message. The trade-off: the next user
1183
+ * message may have to wait briefly for the compact to finish — but
1184
+ * compact uses the fast model and typically completes in 1–3s.
1185
+ *
1186
+ * @type {Promise<void>|null}
1187
+ */
1188
+ let _compactInFlight = null;
1189
+
1190
+ /**
1191
+ * Re-trigger flag. If `scheduleCompactAfterTurn` is called while a
1192
+ * compact is already in flight, set this so the in-flight one chains
1193
+ * a follow-up immediately on completion. Without this, a sustained
1194
+ * burst of turns could starve compaction: turn N triggers compact,
1195
+ * turns N+1 / N+2 / … each find `_compactInFlight` set and skip,
1196
+ * leaving history above threshold until the burst ends.
1197
+ */
1198
+ let _compactPending = false;
1199
+
1200
+ /**
1201
+ * Fire-and-forget post-turn compaction. Called once at the end of each
1202
+ * `handleUnifyGroupChat` after `Promise.all(runVpTurn)` resolves. If a
1203
+ * compaction is still in flight from an earlier turn, we set
1204
+ * `_compactPending` so the running compact chains a follow-up on
1205
+ * completion (anti-starvation).
1206
+ *
1207
+ * The promise is stored in `_compactInFlight` so the next user message
1208
+ * can await it before reading `conversationMessages`.
1209
+ *
1210
+ * @param {string} groupId — for envelope tagging on the emitted event
1211
+ */
1212
+ function scheduleCompactAfterTurn(groupId) {
1213
+ if (_compactInFlight) {
1214
+ // A compact is already running. Mark a follow-up so when it
1215
+ // finishes, it re-evaluates and runs again if still triggered.
1216
+ _compactPending = true;
1217
+ return;
1218
+ }
1219
+ // Cheap O(n) precheck so we don't bother engaging the LLM at all
1220
+ // when the conversation is still small.
1221
+ const triage = shouldCompactHistory(conversationMessages);
1222
+ if (!triage.trigger) return;
1223
+ if (!session?.engine || typeof session.engine.summarizeForCompact !== 'function') {
1224
+ console.warn('[Unify] history compact: engine.summarizeForCompact unavailable — skipping');
1225
+ return;
1226
+ }
1227
+
1228
+ _compactInFlight = runCompactNow(groupId).finally(() => {
1229
+ _compactInFlight = null;
1230
+ // If turns piled up while we were running and compaction is still
1231
+ // needed, chain a follow-up. Use a microtask so the .finally chain
1232
+ // settles cleanly before the next promise is created.
1233
+ if (_compactPending) {
1234
+ _compactPending = false;
1235
+ queueMicrotask(() => scheduleCompactAfterTurn(groupId));
1236
+ }
1237
+ });
1238
+ }
1239
+
1240
+ /**
1241
+ * Run the in-memory history compactor. Replaces the older prefix of
1242
+ * `conversationMessages` with a single user-role summary message,
1243
+ * preserving the recent tail verbatim. Mutates the module-level
1244
+ * variable in place via reassignment.
1245
+ *
1246
+ * Behaviour:
1247
+ * - If summarization fails, leaves history untouched.
1248
+ * - On success, emits a `unify_history_compacted` event so dev tools
1249
+ * can show what happened (frontend currently ignores it).
1250
+ *
1251
+ * Race safety:
1252
+ * - Single-flight via `_compactInFlight` (only one runs at a time).
1253
+ * - Reads the array reference once into `snapshot`. If anything else
1254
+ * reassigns `conversationMessages` during the await (`consolidate`
1255
+ * event from the engine, `clearUnifyMessages`, `resetUnifySession`),
1256
+ * we detect the swap by reference comparison and bail without
1257
+ * overwriting their fresh state.
1258
+ *
1259
+ * @param {string} groupId
1260
+ * @returns {Promise<void>}
1261
+ */
1262
+ async function runCompactNow(groupId) {
1263
+ const summarize = ({ system, prompt }) =>
1264
+ session.engine.summarizeForCompact({ system, prompt, maxTokens: 1024 });
1265
+
1266
+ // Capture the current array reference. If anyone reassigns
1267
+ // `conversationMessages` while we're summarizing (engine consolidate
1268
+ // event, session reset, manual clear), the reference will differ
1269
+ // and we abandon the swap.
1270
+ const snapshot = conversationMessages;
1271
+
1272
+ try {
1273
+ const result = await compactHistory(snapshot, { summarize });
1274
+ if (!result.compacted) {
1275
+ if (result.error) {
1276
+ console.warn(
1277
+ `[Unify] history compact: summarizer failed (${result.error}); ` +
1278
+ `keeping ${result.beforeTurns} turns / ~${result.beforeTokens} tokens`
1279
+ );
1280
+ }
1281
+ return;
1282
+ }
1283
+ // Race guard: if `conversationMessages` was reassigned during the
1284
+ // await (e.g. consolidate / reset), do NOT overwrite the fresh
1285
+ // state with our stale compacted snapshot.
1286
+ if (conversationMessages !== snapshot) {
1287
+ console.log('[Unify] history compact: history was reset during compact — discarding stale summary');
1288
+ return;
1289
+ }
1290
+ conversationMessages = result.messages;
1291
+ console.log(
1292
+ `[Unify] history compacted (reason=${result.reason}): ` +
1293
+ `turns ${result.beforeTurns}→${result.afterTurns}, ` +
1294
+ `tokens ~${result.beforeTokens}→${result.afterTokens}, ` +
1295
+ `archived ${result.archivedCount} messages`
1296
+ );
1297
+ try {
1298
+ sendUnifyEvent({
1299
+ type: 'unify_history_compacted',
1300
+ reason: result.reason,
1301
+ beforeTurns: result.beforeTurns,
1302
+ afterTurns: result.afterTurns,
1303
+ beforeTokens: result.beforeTokens,
1304
+ afterTokens: result.afterTokens,
1305
+ archivedCount: result.archivedCount,
1306
+ ts: Date.now(),
1307
+ }, { groupId });
1308
+ } catch { /* WS pipeline failure must not crash compact */ }
1309
+ } catch (err) {
1310
+ console.warn('[Unify] history compact: unexpected failure', err?.message || err);
1311
+ }
1312
+ }
1313
+
1154
1314
  /**
1155
1315
  * H2.f.2: user-initiated abort. The pre-H2 multi-thread version took a
1156
1316
  * `threadId` parameter; the new version aborts the single in-flight