@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 +1 -1
- package/unify/engine.js +25 -0
- package/unify/history-compact.js +461 -0
- package/unify/web-bridge.js +160 -0
package/package.json
CHANGED
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
|
+
}
|
package/unify/web-bridge.js
CHANGED
|
@@ -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
|