@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 +1 -1
- package/unify/engine.js +131 -13
- package/unify/history-compact.js +461 -0
- package/unify/web-bridge.js +223 -3
package/package.json
CHANGED
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
|
|
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: '
|
|
1202
|
-
|
|
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: {
|
|
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
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
1276
|
-
//
|
|
1277
|
-
//
|
|
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: '
|
|
1280
|
-
|
|
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: {
|
|
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: '
|
|
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
|
+
}
|
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;
|
|
@@ -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 '
|
|
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: '
|
|
551
|
-
|
|
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
|