@yeaft/webchat-agent 0.1.684 → 0.1.686

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.684",
3
+ "version": "0.1.686",
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
@@ -1028,6 +1028,43 @@ export class Engine {
1028
1028
  let t1Fired = false;
1029
1029
  const queryNumber = (this.#__queryCounter = (this.#__queryCounter || 0) + 1);
1030
1030
 
1031
+ // feat-6af5f9f1 PR B: a Turn = one user prompt + all AI responses.
1032
+ // `queryTurnId` is the wire-level turn identifier; every event emitted
1033
+ // during this query() carries it as `turnId`. Each LLM call inside
1034
+ // the loop is a `loopNumber` (was wire field `turnNumber`).
1035
+ const queryTurnId = randomUUID();
1036
+ const queryStartedAt = Date.now();
1037
+ const userQuestionPreview = String(prompt || '').slice(0, 200);
1038
+ const queryVpId = vpPersona && typeof vpPersona === 'object'
1039
+ && typeof vpPersona.vpId === 'string'
1040
+ ? vpPersona.vpId
1041
+ : (typeof senderVpId === 'string' ? senderVpId : null);
1042
+
1043
+ yield {
1044
+ type: 'turn_open',
1045
+ turnId: queryTurnId,
1046
+ userPrompt: userQuestionPreview,
1047
+ vpId: queryVpId,
1048
+ groupId: groupId || null,
1049
+ at: queryStartedAt,
1050
+ };
1051
+
1052
+ // Surface memory recall to the debug panel right after turn_open.
1053
+ // recallResult was loaded above; emit a structured `memory_used`
1054
+ // event so the UI can show "loaded N segments" without parsing
1055
+ // the legacy `recall` event (which only carried entryCount).
1056
+ if (recallResult && Array.isArray(recallResult.entries) && recallResult.entries.length > 0) {
1057
+ yield {
1058
+ type: 'memory_used',
1059
+ turnId: queryTurnId,
1060
+ loaded: recallResult.entries.map(e => ({
1061
+ id: e && e.id || null,
1062
+ score: e && typeof e.score === 'number' ? e.score : null,
1063
+ kind: e && e.kind || null,
1064
+ })),
1065
+ };
1066
+ }
1067
+
1031
1068
  const toolDefs = this.#getToolDefs();
1032
1069
  let turnNumber = 0;
1033
1070
  let continueTurns = 0; // auto-continue counter
@@ -1196,16 +1233,23 @@ export class Engine {
1196
1233
  responseText,
1197
1234
  });
1198
1235
 
1199
- // Emit debug_turn for error path too
1236
+ // Emit `loop` event for error path too (was `debug_turn`).
1237
+ const errLoopInputTokens = totalUsage.inputTokens || 0;
1238
+ const errLoopOutputTokens = totalUsage.outputTokens || 0;
1200
1239
  yield {
1201
- type: 'debug_turn',
1202
- turnNumber,
1240
+ type: 'loop',
1241
+ turnId: queryTurnId,
1242
+ loopNumber: turnNumber,
1203
1243
  model: currentModel,
1204
1244
  systemPrompt,
1205
1245
  messages: conversationMessages.map(mapDebugMessage),
1206
1246
  response: responseText || `Error: ${err.message}`,
1207
1247
  toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
1208
- usage: { inputTokens: totalUsage.inputTokens, outputTokens: totalUsage.outputTokens },
1248
+ usage: {
1249
+ inputTokens: errLoopInputTokens,
1250
+ outputTokens: errLoopOutputTokens,
1251
+ totalTokens: errLoopInputTokens + errLoopOutputTokens,
1252
+ },
1209
1253
  latencyMs,
1210
1254
  ttfbMs,
1211
1255
  stopReason: 'error',
@@ -1270,20 +1314,31 @@ export class Engine {
1270
1314
  responseText,
1271
1315
  });
1272
1316
 
1273
- // Emit debug_turn event for web UI debug panel
1274
- // (conversationMessages does NOT yet include the assistant response at this point)
1275
- // task-331: preserve toolCalls / toolCallId / isError on each message so
1276
- // the Debug panel can render function_call requests and their paired
1277
- // tool_result responses across turns.
1317
+ // Emit `loop` event for the debug panel.
1318
+ // feat-6af5f9f1 PR B: a Loop is one LLM call inside a Turn. The wire
1319
+ // event was historically named `debug_turn` and carried `turnNumber`,
1320
+ // which is misleading it's per-LLM-call, not per-user-prompt.
1321
+ // We emit the new shape (turnId + loopNumber) and keep totalTokens
1322
+ // pre-computed so the UI doesn't have to.
1323
+ // task-331: preserve toolCalls / toolCallId / isError on each message
1324
+ // so the panel can render function_call requests and their paired
1325
+ // tool_result responses across loops.
1326
+ const loopInputTokens = totalUsage.inputTokens || 0;
1327
+ const loopOutputTokens = totalUsage.outputTokens || 0;
1278
1328
  yield {
1279
- type: 'debug_turn',
1280
- turnNumber,
1329
+ type: 'loop',
1330
+ turnId: queryTurnId,
1331
+ loopNumber: turnNumber,
1281
1332
  model: currentModel,
1282
1333
  systemPrompt,
1283
1334
  messages: conversationMessages.map(mapDebugMessage),
1284
1335
  response: responseText,
1285
1336
  toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
1286
- usage: { inputTokens: totalUsage.inputTokens, outputTokens: totalUsage.outputTokens },
1337
+ usage: {
1338
+ inputTokens: loopInputTokens,
1339
+ outputTokens: loopOutputTokens,
1340
+ totalTokens: loopInputTokens + loopOutputTokens,
1341
+ },
1287
1342
  latencyMs,
1288
1343
  ttfbMs,
1289
1344
  stopReason,
@@ -1390,10 +1445,12 @@ export class Engine {
1390
1445
  });
1391
1446
  if (adjustResult && adjustResult.ran) {
1392
1447
  yield {
1393
- type: 'ams_adjust',
1448
+ type: 'memory_adjust',
1449
+ turnId: queryTurnId,
1394
1450
  groupKey: amsContext.groupKey,
1395
1451
  added: adjustResult.added,
1396
1452
  evicted: adjustResult.evicted,
1453
+ skipped: adjustResult.skipped || 0,
1397
1454
  reason: adjustResult.reason,
1398
1455
  };
1399
1456
  }
@@ -1413,6 +1470,8 @@ export class Engine {
1413
1470
  );
1414
1471
  yield {
1415
1472
  type: 'reflection',
1473
+ turnId: queryTurnId,
1474
+ loopNumber: turnNumber,
1416
1475
  trigger: 't2',
1417
1476
  status: 'pending',
1418
1477
  loopRange: [arcStart, arcEnd],
@@ -1437,6 +1496,7 @@ export class Engine {
1437
1496
  loopRange: [arcStart, arcEnd],
1438
1497
  count: pairs.length,
1439
1498
  originalUserMsg: prompt,
1499
+ originatingTurnId: queryTurnId,
1440
1500
  ready: false,
1441
1501
  result: null,
1442
1502
  error: null,
@@ -1534,6 +1594,20 @@ export class Engine {
1534
1594
 
1535
1595
  const toolDurationMs = Date.now() - toolStartTime;
1536
1596
 
1597
+ // feat-6af5f9f1 PR B: emit a structured `tool_exec` event for the
1598
+ // debug panel. Args/output are already in `conversationMessages`
1599
+ // and will be visible in the next loop's snapshot, so we don't
1600
+ // duplicate them here — only the per-tool timing + status.
1601
+ yield {
1602
+ type: 'tool_exec',
1603
+ turnId: queryTurnId,
1604
+ loopNumber: turnNumber,
1605
+ callId: tc.id,
1606
+ name: tc.name,
1607
+ durationMs: toolDurationMs,
1608
+ isError,
1609
+ };
1610
+
1537
1611
  // Log tool to debug trace
1538
1612
  this.#trace.logTool(turnId, {
1539
1613
  toolName: tc.name,
@@ -1596,6 +1670,8 @@ export class Engine {
1596
1670
  );
1597
1671
  yield {
1598
1672
  type: 'reflection',
1673
+ turnId: queryTurnId,
1674
+ loopNumber: turnNumber,
1599
1675
  trigger: 't1',
1600
1676
  status: 'pending',
1601
1677
  loopRange: [arcStart, arcEnd],
@@ -1616,6 +1692,8 @@ export class Engine {
1616
1692
  for (const m of next) conversationMessages.push(m);
1617
1693
  yield {
1618
1694
  type: 'reflection',
1695
+ turnId: queryTurnId,
1696
+ loopNumber: turnNumber,
1619
1697
  trigger: 't1',
1620
1698
  // PR-L bug fix: keep the same loopRange as the `pending` event
1621
1699
  // so the frontend key stays stable across pending → ready and
@@ -1631,6 +1709,8 @@ export class Engine {
1631
1709
  // continues normally — never block the turn.
1632
1710
  yield {
1633
1711
  type: 'reflection',
1712
+ turnId: queryTurnId,
1713
+ loopNumber: turnNumber,
1634
1714
  trigger: 't1',
1635
1715
  status: 'error',
1636
1716
  error: err && err.message || String(err),
@@ -1656,6 +1736,18 @@ export class Engine {
1656
1736
 
1657
1737
  // Loop back to call adapter again with tool results
1658
1738
  }
1739
+
1740
+ // feat-6af5f9f1 PR B: turn closed. Emits final totals so the debug
1741
+ // panel can show "Turn done · 4 loops · 12.4s · 5.0k tok" without
1742
+ // having to reduce the loops itself. Always fires (every break path
1743
+ // above falls through here).
1744
+ yield {
1745
+ type: 'turn_close',
1746
+ turnId: queryTurnId,
1747
+ totalMs: Date.now() - queryStartedAt,
1748
+ totalTokens: cumulativeInputTokens + cumulativeOutputTokens,
1749
+ loopCount: turnNumber,
1750
+ };
1659
1751
  }
1660
1752
 
1661
1753
  /**
@@ -1753,6 +1845,7 @@ export class Engine {
1753
1845
 
1754
1846
  yield {
1755
1847
  type: 'reflection',
1848
+ turnId: info.originatingTurnId || null,
1756
1849
  trigger,
1757
1850
  status: 'ready',
1758
1851
  loopRange: [startIdx, endIdx],
@@ -1782,4 +1875,29 @@ export class Engine {
1782
1875
 
1783
1876
  /** @returns {object} — Config with fastModel as model (for internal tasks) */
1784
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
+ }
1785
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;
@@ -535,6 +539,10 @@ function handleEngineEvent(event, hctx) {
535
539
  case 'reflection':
536
540
  sendUnifyEvent({
537
541
  type: 'reflection',
542
+ // feat-6af5f9f1 PR B: stamp turnId/loopNumber so the debug panel
543
+ // can attach reflection cards to the matching loop.
544
+ turnId: event.turnId || null,
545
+ loopNumber: event.loopNumber || null,
538
546
  trigger: event.trigger,
539
547
  status: event.status,
540
548
  loopRange: event.loopRange,
@@ -545,10 +553,66 @@ function handleEngineEvent(event, hctx) {
545
553
  }, envelope);
546
554
  break;
547
555
 
548
- case 'debug_turn':
556
+ case 'turn_open':
557
+ sendUnifyEvent({
558
+ type: 'turn_open',
559
+ turnId: event.turnId,
560
+ userPrompt: event.userPrompt,
561
+ vpId: event.vpId,
562
+ groupId: event.groupId,
563
+ at: event.at,
564
+ }, envelope);
565
+ break;
566
+
567
+ case 'turn_close':
568
+ sendUnifyEvent({
569
+ type: 'turn_close',
570
+ turnId: event.turnId,
571
+ totalMs: event.totalMs,
572
+ totalTokens: event.totalTokens,
573
+ loopCount: event.loopCount,
574
+ }, envelope);
575
+ break;
576
+
577
+ case 'memory_used':
578
+ sendUnifyEvent({
579
+ type: 'memory_used',
580
+ turnId: event.turnId,
581
+ loaded: event.loaded || [],
582
+ }, envelope);
583
+ break;
584
+
585
+ case 'memory_adjust':
586
+ sendUnifyEvent({
587
+ type: 'memory_adjust',
588
+ turnId: event.turnId,
589
+ groupKey: event.groupKey,
590
+ added: event.added,
591
+ evicted: event.evicted,
592
+ skipped: event.skipped,
593
+ reason: event.reason,
594
+ }, envelope);
595
+ break;
596
+
597
+ case 'tool_exec':
549
598
  sendUnifyEvent({
550
- type: 'debug_turn',
551
- turnNumber: event.turnNumber,
599
+ type: 'tool_exec',
600
+ turnId: event.turnId,
601
+ loopNumber: event.loopNumber,
602
+ callId: event.callId,
603
+ name: event.name,
604
+ durationMs: event.durationMs,
605
+ isError: event.isError,
606
+ }, envelope);
607
+ break;
608
+
609
+ case 'loop':
610
+ // feat-6af5f9f1 PR B: replaces the old `debug_turn` event. Same
611
+ // payload shape plus turnId + loopNumber + usage.totalTokens.
612
+ sendUnifyEvent({
613
+ type: 'loop',
614
+ turnId: event.turnId,
615
+ loopNumber: event.loopNumber,
552
616
  model: event.model,
553
617
  systemPrompt: event.systemPrompt,
554
618
  messages: event.messages,
@@ -621,6 +685,15 @@ export async function handleUnifyGroupChat(msg) {
621
685
  ? msg.groupId.trim()
622
686
  : 'grp_default';
623
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
+
624
697
  // yeaftDir is a hard prerequisite for both session boot and group seeding;
625
698
  // validate BEFORE booting so a misconfigured agent doesn't leave a zombie
626
699
  // session lying around.
@@ -825,6 +898,14 @@ export async function handleUnifyGroupChat(msg) {
825
898
  } catch { /* never crash WS pipeline */ }
826
899
  }
827
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);
828
909
  }
829
910
 
830
911
  /**
@@ -1091,6 +1172,145 @@ function appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolRes
1091
1172
  }
1092
1173
  }
1093
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
+
1094
1314
  /**
1095
1315
  * H2.f.2: user-initiated abort. The pre-H2 multi-thread version took a
1096
1316
  * `threadId` parameter; the new version aborts the single in-flight