@yeaft/webchat-agent 0.1.688 → 0.1.689
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/cli.js +5 -2
- package/unify/conversation/persist.js +67 -18
- package/unify/history-compact.js +148 -118
- package/unify/pair-sanitize.js +143 -0
- package/unify/turn-utils.js +154 -0
- package/unify/web-bridge.js +32 -7
package/package.json
CHANGED
package/unify/cli.js
CHANGED
|
@@ -212,8 +212,11 @@ async function runREPL(config, args) {
|
|
|
212
212
|
|
|
213
213
|
const { engine, conversationStore, trace, skillManager, mcpManager, toolRegistry } = session;
|
|
214
214
|
|
|
215
|
-
// Load persisted conversation as initial messages
|
|
216
|
-
|
|
215
|
+
// Load persisted conversation as initial messages. `loadRecent` is now
|
|
216
|
+
// turn-based (one user round-trip = one turn; multi-VP fan-out collapses
|
|
217
|
+
// into one turn). 20 turns is the bootstrap window — the engine-level
|
|
218
|
+
// compactor in `history-compact.js` is the authoritative size limiter.
|
|
219
|
+
let conversationMessages = conversationStore.loadRecent().map(m => ({
|
|
217
220
|
role: m.role,
|
|
218
221
|
content: m.content,
|
|
219
222
|
...(m.toolCallId && { toolCallId: m.toolCallId }),
|
|
@@ -21,6 +21,25 @@
|
|
|
21
21
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync } from 'fs';
|
|
22
22
|
import { join, basename } from 'path';
|
|
23
23
|
import { isPermissionError } from '../init.js';
|
|
24
|
+
import { pairSanitize } from '../pair-sanitize.js';
|
|
25
|
+
import { sliceLastNTurns } from '../turn-utils.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default cold-start "recent window" size, expressed in TURNS (not raw
|
|
29
|
+
* messages). One turn = one user prompt round-trip; multi-VP fan-out
|
|
30
|
+
* collapses N `@vp-X` variants of the same canonical prompt into ONE turn.
|
|
31
|
+
*
|
|
32
|
+
* Why turns and not messages: message-count slicing can cut mid-arc and
|
|
33
|
+
* orphan a `[assistant(toolCalls), tool…]` pair, which 400s the Anthropic /
|
|
34
|
+
* Chat-Completions adapter. Turn-based slicing always cuts at a user-
|
|
35
|
+
* message boundary, which is pair-safe by construction.
|
|
36
|
+
*
|
|
37
|
+
* 20 turns is the bootstrap window the user signed off on (2026-05-01).
|
|
38
|
+
* The session-level compactor in `history-compact.js` is the authoritative
|
|
39
|
+
* size limiter once the engine is running; this is just the cold-start
|
|
40
|
+
* replay window after a fresh boot or reconnect.
|
|
41
|
+
*/
|
|
42
|
+
export const DEFAULT_RECENT_TURNS = 20;
|
|
24
43
|
|
|
25
44
|
// ─── Token estimation ────────────────────────────────────────
|
|
26
45
|
|
|
@@ -424,13 +443,29 @@ export class ConversationStore {
|
|
|
424
443
|
// ─── Read API ───────────────────────────────────────────
|
|
425
444
|
|
|
426
445
|
/**
|
|
427
|
-
* Load recent hot messages,
|
|
446
|
+
* Load recent hot messages, sliced to the last `turnsLimit` TURNS and
|
|
447
|
+
* sorted chronologically.
|
|
448
|
+
*
|
|
449
|
+
* Turn-based (not message-based) slicing is the contract here. A "turn"
|
|
450
|
+
* is one user-prompt round-trip — multi-VP fan-out emits N user
|
|
451
|
+
* messages for the same prompt, all of which collapse into ONE turn.
|
|
452
|
+
* `sliceLastNTurns` cuts at a user-message boundary, so an
|
|
453
|
+
* `[assistant(toolCalls), tool…]` arc is never split across the cut.
|
|
428
454
|
*
|
|
429
|
-
*
|
|
455
|
+
* `pairSanitize` runs as a defensive secondary pass — turn-boundary
|
|
456
|
+
* cuts are already pair-safe, but historical / hand-edited stores may
|
|
457
|
+
* contain orphans, and `pairSanitize` is idempotent.
|
|
458
|
+
*
|
|
459
|
+
* Back-compat: callers that pass `Infinity` (or a negative number) get
|
|
460
|
+
* the full hot history. `0` returns `[]`.
|
|
461
|
+
*
|
|
462
|
+
* @param {number} [turnsLimit=DEFAULT_RECENT_TURNS] — max turns to load
|
|
430
463
|
* @returns {object[]} — parsed message objects
|
|
431
464
|
*/
|
|
432
|
-
loadRecent(
|
|
433
|
-
|
|
465
|
+
loadRecent(turnsLimit = DEFAULT_RECENT_TURNS) {
|
|
466
|
+
const all = this.#loadFromDir(this.#msgDir, Infinity);
|
|
467
|
+
if (turnsLimit === Infinity || turnsLimit < 0) return pairSanitize(all);
|
|
468
|
+
return pairSanitize(sliceLastNTurns(all, turnsLimit));
|
|
434
469
|
}
|
|
435
470
|
|
|
436
471
|
/**
|
|
@@ -443,29 +478,43 @@ export class ConversationStore {
|
|
|
443
478
|
}
|
|
444
479
|
|
|
445
480
|
/**
|
|
446
|
-
* Load recent hot messages stamped with `groupId`,
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
481
|
+
* Load recent hot messages stamped with `groupId`, sliced to the last
|
|
482
|
+
* `turnsLimit` TURNS and sorted chronologically.
|
|
483
|
+
*
|
|
484
|
+
* Group-history-isolation (Bug 7): a message lives in exactly one
|
|
485
|
+
* group. Messages without a `groupId` frontmatter (legacy / pre-
|
|
486
|
+
* grouping) are NOT returned — they would otherwise leak into every
|
|
487
|
+
* group's stream.
|
|
488
|
+
*
|
|
489
|
+
* Turn-based slicing (2026-05-01): we used to take the last N
|
|
490
|
+
* messages, which can land mid-arc and orphan a tool_use/tool_result
|
|
491
|
+
* pair (the Anthropic / Chat-Completions adapter then 400s on the
|
|
492
|
+
* orphan). Switching to `sliceLastNTurns` always cuts at a user-
|
|
493
|
+
* message boundary — multi-VP `@vp-X` variants of the same canonical
|
|
494
|
+
* prompt collapse into ONE turn, so a fan-out turn is kept whole.
|
|
495
|
+
*
|
|
496
|
+
* `pairSanitize` runs as a belt-and-suspenders second pass: turn-
|
|
497
|
+
* boundary cuts are pair-safe by construction, but if a hand-edited
|
|
498
|
+
* store somehow contains pre-existing orphans we drop them anyway.
|
|
450
499
|
*
|
|
451
|
-
* Implementation note: filters AFTER reading the most recent
|
|
500
|
+
* Implementation note: filters AFTER reading the most recent files
|
|
452
501
|
* because the on-disk order is global by sequence id. We over-read by
|
|
453
|
-
* loading
|
|
454
|
-
* `
|
|
455
|
-
* recent messages on disk that happen to be in this group". For
|
|
456
|
-
* inboxes (≤ a few thousand hot messages) this is cheap; if
|
|
457
|
-
* becomes a hot path we add a per-group on-disk index.
|
|
502
|
+
* loading every hot file and slicing the tail of the FILTERED set so
|
|
503
|
+
* `turnsLimit` reflects "N most recent turns in this group", not "N
|
|
504
|
+
* most recent messages on disk that happen to be in this group". For
|
|
505
|
+
* typical inboxes (≤ a few thousand hot messages) this is cheap; if
|
|
506
|
+
* it ever becomes a hot path we add a per-group on-disk index.
|
|
458
507
|
*
|
|
459
508
|
* @param {string} groupId — required; null/empty returns []
|
|
460
|
-
* @param {number} [
|
|
509
|
+
* @param {number} [turnsLimit=DEFAULT_RECENT_TURNS]
|
|
461
510
|
* @returns {object[]}
|
|
462
511
|
*/
|
|
463
|
-
loadRecentByGroup(groupId,
|
|
512
|
+
loadRecentByGroup(groupId, turnsLimit = DEFAULT_RECENT_TURNS) {
|
|
464
513
|
if (!groupId) return [];
|
|
465
514
|
const all = this.#loadFromDir(this.#msgDir, Infinity);
|
|
466
515
|
const filtered = all.filter(m => m && m.groupId === groupId);
|
|
467
|
-
if (
|
|
468
|
-
return filtered
|
|
516
|
+
if (turnsLimit === Infinity || turnsLimit < 0) return pairSanitize(filtered);
|
|
517
|
+
return pairSanitize(sliceLastNTurns(filtered, turnsLimit));
|
|
469
518
|
}
|
|
470
519
|
|
|
471
520
|
/**
|
package/unify/history-compact.js
CHANGED
|
@@ -26,12 +26,19 @@
|
|
|
26
26
|
* 4. Keep the last `keepRecent` user→assistant turns intact so the model
|
|
27
27
|
* has fresh, untransformed context for whatever the user just said.
|
|
28
28
|
*
|
|
29
|
-
* Triggers (
|
|
30
|
-
* -
|
|
31
|
-
*
|
|
29
|
+
* Triggers (any fires, but only above a 30K token soft floor):
|
|
30
|
+
* - tokens < 30_000 → never compact (cheap chat, no point paying
|
|
31
|
+
* the summarizer)
|
|
32
|
+
* - tokens > 40 % of `maxContextTokens` (defaults to 200K → 80K)
|
|
33
|
+
* - tokens > 200,000 hard ceiling
|
|
32
34
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
+
* The "turn > 20" trigger that an earlier revision used was dropped:
|
|
36
|
+
* under a 30K token floor it's effectively dead code — the fractional
|
|
37
|
+
* threshold fires first in any conversation big enough to matter.
|
|
38
|
+
*
|
|
39
|
+
* Defaults are derived from `maxContextTokens` so the policy auto-adjusts
|
|
40
|
+
* when the user widens or narrows their context budget. All knobs are
|
|
41
|
+
* overridable via the options bag for tests / future config plumbing.
|
|
35
42
|
*
|
|
36
43
|
* Why role='user' for the summary message:
|
|
37
44
|
* The Anthropic Messages API rejects assistant prefill at the tail
|
|
@@ -44,13 +51,53 @@
|
|
|
44
51
|
*/
|
|
45
52
|
|
|
46
53
|
import { estimateTokens } from './conversation/persist.js';
|
|
54
|
+
import { pairSanitize } from './pair-sanitize.js';
|
|
55
|
+
import {
|
|
56
|
+
countTurns as countTurnsImpl,
|
|
57
|
+
indexOfNthTurnFromEnd,
|
|
58
|
+
} from './turn-utils.js';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Re-export `countTurns` so existing callers / tests that import it
|
|
62
|
+
* from this module continue to work. Implementation now lives in
|
|
63
|
+
* `turn-utils.js` and is shared with `ConversationStore`.
|
|
64
|
+
*/
|
|
65
|
+
export const countTurns = countTurnsImpl;
|
|
47
66
|
|
|
48
67
|
/**
|
|
49
|
-
* Default trigger thresholds
|
|
50
|
-
*
|
|
68
|
+
* Default trigger thresholds (2026-05-01 policy update):
|
|
69
|
+
* - never compact while total tokens < 30K (soft floor — most
|
|
70
|
+
* conversations under that aren't worth paying the summarizer
|
|
71
|
+
* cost; the LLM hasn't started feeling the context yet either),
|
|
72
|
+
* - otherwise compact if ANY of:
|
|
73
|
+
* tokens > 40 % of `maxContextTokens` (default 200K → 80K)
|
|
74
|
+
* tokens > 200K hard ceiling
|
|
75
|
+
*
|
|
76
|
+
* The earlier "turn count > 20" trigger was DROPPED because under
|
|
77
|
+
* the new floor it's effectively dead code — by the time you've sent
|
|
78
|
+
* 20 user prompts that exceed 30K tokens, the fractional threshold
|
|
79
|
+
* has already fired. `turnLimit` and the `turn_count` reason code
|
|
80
|
+
* are still accepted as overrides so tests / future config can re-
|
|
81
|
+
* enable a turn-based trigger if needed; with `turnLimit: Infinity`
|
|
82
|
+
* (the new default) the check is simply skipped.
|
|
83
|
+
*
|
|
84
|
+
* Token thresholds are derived from `maxContextTokens` at evaluation
|
|
85
|
+
* time so the policy auto-adjusts to the user's configured context.
|
|
86
|
+
*/
|
|
87
|
+
export const DEFAULT_TURN_LIMIT = Infinity;
|
|
88
|
+
export const DEFAULT_MIN_TOKEN_FLOOR = 30_000;
|
|
89
|
+
export const DEFAULT_MAX_CONTEXT_TOKENS = 200_000;
|
|
90
|
+
export const DEFAULT_TOKEN_FRACTION = 0.4;
|
|
91
|
+
export const DEFAULT_HARD_TOKEN_CEILING = 200_000;
|
|
92
|
+
/**
|
|
93
|
+
* Effective default token trigger when no `maxContextTokens` is provided:
|
|
94
|
+
* min(40% of 200K, 200K) = 80K. Preserved as `DEFAULT_TOKEN_LIMIT` for
|
|
95
|
+
* back-compat with existing tests that import this name.
|
|
51
96
|
*/
|
|
52
|
-
export const
|
|
53
|
-
|
|
97
|
+
export const DEFAULT_TOKEN_LIMIT = Math.min(
|
|
98
|
+
Math.floor(DEFAULT_MAX_CONTEXT_TOKENS * DEFAULT_TOKEN_FRACTION),
|
|
99
|
+
DEFAULT_HARD_TOKEN_CEILING
|
|
100
|
+
);
|
|
54
101
|
|
|
55
102
|
/**
|
|
56
103
|
* How many user→assistant pairs to leave intact at the tail. The summary
|
|
@@ -97,71 +144,67 @@ export function estimateMessagesTokens(messages) {
|
|
|
97
144
|
return total;
|
|
98
145
|
}
|
|
99
146
|
|
|
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
147
|
/**
|
|
148
148
|
* Pure trigger evaluator. Decides whether the in-memory history needs
|
|
149
149
|
* compaction. No I/O, no LLM call.
|
|
150
150
|
*
|
|
151
|
+
* Policy (2026-05-01):
|
|
152
|
+
* 1. tokens < `minTokenFloor` (default 30K) → trigger=false (always).
|
|
153
|
+
* 2. otherwise trigger if ANY of:
|
|
154
|
+
* turnCount > turnLimit (default Infinity → effectively off;
|
|
155
|
+
* callers can pin a number to re-enable a turn-count trigger)
|
|
156
|
+
* tokenCount > maxContextTokens*fraction (reason='token_threshold')
|
|
157
|
+
* tokenCount > hardTokenCeiling (reason='token_ceiling')
|
|
158
|
+
*
|
|
159
|
+
* `tokenLimit` is preserved as a back-compat override for callers /
|
|
160
|
+
* tests that pin a specific number; when set, it overrides the
|
|
161
|
+
* fraction-of-context calculation.
|
|
162
|
+
*
|
|
151
163
|
* @param {Array<object>} messages
|
|
152
|
-
* @param {{
|
|
153
|
-
*
|
|
164
|
+
* @param {{
|
|
165
|
+
* turnLimit?: number,
|
|
166
|
+
* tokenLimit?: number,
|
|
167
|
+
* minTokenFloor?: number,
|
|
168
|
+
* maxContextTokens?: number,
|
|
169
|
+
* tokenFraction?: number,
|
|
170
|
+
* hardTokenCeiling?: number,
|
|
171
|
+
* }} [opts]
|
|
172
|
+
* @returns {{trigger: boolean, reason: 'turn_count'|'token_threshold'|'token_ceiling'|null,
|
|
154
173
|
* turnCount: number, tokenCount: number,
|
|
155
|
-
* turnLimit: number, tokenLimit: number
|
|
174
|
+
* turnLimit: number, tokenLimit: number,
|
|
175
|
+
* minTokenFloor: number, hardTokenCeiling: number}}
|
|
156
176
|
*/
|
|
157
177
|
export function shouldCompactHistory(messages, opts = {}) {
|
|
158
178
|
const turnLimit = opts.turnLimit ?? DEFAULT_TURN_LIMIT;
|
|
159
|
-
const
|
|
179
|
+
const minTokenFloor = opts.minTokenFloor ?? DEFAULT_MIN_TOKEN_FLOOR;
|
|
180
|
+
const hardTokenCeiling = opts.hardTokenCeiling ?? DEFAULT_HARD_TOKEN_CEILING;
|
|
181
|
+
const maxContextTokens = opts.maxContextTokens ?? DEFAULT_MAX_CONTEXT_TOKENS;
|
|
182
|
+
const tokenFraction = opts.tokenFraction ?? DEFAULT_TOKEN_FRACTION;
|
|
183
|
+
// tokenLimit override wins; otherwise compute fractional threshold.
|
|
184
|
+
const tokenLimit =
|
|
185
|
+
opts.tokenLimit
|
|
186
|
+
?? Math.min(Math.floor(maxContextTokens * tokenFraction), hardTokenCeiling);
|
|
187
|
+
|
|
160
188
|
const turnCount = countTurns(messages);
|
|
161
189
|
const tokenCount = estimateMessagesTokens(messages);
|
|
162
190
|
|
|
163
191
|
let reason = null;
|
|
164
|
-
|
|
192
|
+
// (1) Soft floor: never compact small conversations.
|
|
193
|
+
if (tokenCount < minTokenFloor) {
|
|
194
|
+
return {
|
|
195
|
+
trigger: false,
|
|
196
|
+
reason: null,
|
|
197
|
+
turnCount,
|
|
198
|
+
tokenCount,
|
|
199
|
+
turnLimit,
|
|
200
|
+
tokenLimit,
|
|
201
|
+
minTokenFloor,
|
|
202
|
+
hardTokenCeiling,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
// (2) Trigger evaluation. Turn check is opt-in (Infinity by default).
|
|
206
|
+
if (Number.isFinite(turnLimit) && turnCount > turnLimit) reason = 'turn_count';
|
|
207
|
+
else if (tokenCount > hardTokenCeiling) reason = 'token_ceiling';
|
|
165
208
|
else if (tokenCount > tokenLimit) reason = 'token_threshold';
|
|
166
209
|
|
|
167
210
|
return {
|
|
@@ -171,6 +214,8 @@ export function shouldCompactHistory(messages, opts = {}) {
|
|
|
171
214
|
tokenCount,
|
|
172
215
|
turnLimit,
|
|
173
216
|
tokenLimit,
|
|
217
|
+
minTokenFloor,
|
|
218
|
+
hardTokenCeiling,
|
|
174
219
|
};
|
|
175
220
|
}
|
|
176
221
|
|
|
@@ -219,13 +264,14 @@ export function buildSummarizerInput(messages) {
|
|
|
219
264
|
* intact, fold everything before. Returns the index that the cut starts
|
|
220
265
|
* AT, i.e. messages[0..cutIdx) gets summarised, messages[cutIdx..] stays.
|
|
221
266
|
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
267
|
+
* Thin wrapper around `turn-utils.indexOfNthTurnFromEnd` with the
|
|
268
|
+
* historical contract preserved:
|
|
269
|
+
* - empty input returns -1,
|
|
270
|
+
* - `keepRecent <= 0` folds everything (returns messages.length),
|
|
271
|
+
* - "fewer turns than keepRecent" maps to -1 (caller treats as no-op).
|
|
272
|
+
*
|
|
273
|
+
* Multi-VP fan-out: `@vp-X` variants of the same underlying turn count
|
|
274
|
+
* as ONE turn and the boundary extends backwards through them all.
|
|
229
275
|
*
|
|
230
276
|
* @param {Array<object>} messages
|
|
231
277
|
* @param {number} keepRecent
|
|
@@ -234,42 +280,10 @@ export function buildSummarizerInput(messages) {
|
|
|
234
280
|
export function findCutIndex(messages, keepRecent) {
|
|
235
281
|
if (!Array.isArray(messages) || messages.length === 0) return -1;
|
|
236
282
|
if (keepRecent <= 0) return messages.length; // fold everything
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
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;
|
|
283
|
+
const idx = indexOfNthTurnFromEnd(messages, keepRecent);
|
|
284
|
+
// `indexOfNthTurnFromEnd` returns -1 when there are fewer turns than
|
|
285
|
+
// requested — historical contract is the same. Pass through.
|
|
286
|
+
return idx;
|
|
273
287
|
}
|
|
274
288
|
|
|
275
289
|
/**
|
|
@@ -331,6 +345,10 @@ export function buildSummaryPrompt(cleanedMessages) {
|
|
|
331
345
|
* keepRecent?: number,
|
|
332
346
|
* turnLimit?: number,
|
|
333
347
|
* tokenLimit?: number,
|
|
348
|
+
* minTokenFloor?: number,
|
|
349
|
+
* maxContextTokens?: number,
|
|
350
|
+
* tokenFraction?: number,
|
|
351
|
+
* hardTokenCeiling?: number,
|
|
334
352
|
* }} options
|
|
335
353
|
* @returns {Promise<{
|
|
336
354
|
* messages: Array<object>,
|
|
@@ -348,15 +366,29 @@ export async function compactHistory(messages, options) {
|
|
|
348
366
|
const {
|
|
349
367
|
summarize,
|
|
350
368
|
keepRecent = DEFAULT_KEEP_RECENT_TURNS,
|
|
351
|
-
turnLimit
|
|
352
|
-
tokenLimit
|
|
369
|
+
turnLimit,
|
|
370
|
+
tokenLimit,
|
|
371
|
+
minTokenFloor,
|
|
372
|
+
maxContextTokens,
|
|
373
|
+
tokenFraction,
|
|
374
|
+
hardTokenCeiling,
|
|
353
375
|
} = options || {};
|
|
354
376
|
|
|
355
377
|
if (typeof summarize !== 'function') {
|
|
356
378
|
throw new TypeError('compactHistory: options.summarize must be a function');
|
|
357
379
|
}
|
|
358
380
|
|
|
359
|
-
|
|
381
|
+
// Pass thresholds through to shouldCompactHistory so a single options
|
|
382
|
+
// bag controls the policy. Undefined keys fall back to module defaults.
|
|
383
|
+
const triggerOpts = {
|
|
384
|
+
turnLimit,
|
|
385
|
+
tokenLimit,
|
|
386
|
+
minTokenFloor,
|
|
387
|
+
maxContextTokens,
|
|
388
|
+
tokenFraction,
|
|
389
|
+
hardTokenCeiling,
|
|
390
|
+
};
|
|
391
|
+
const before = shouldCompactHistory(messages, triggerOpts);
|
|
360
392
|
if (!before.trigger) {
|
|
361
393
|
return {
|
|
362
394
|
messages,
|
|
@@ -433,19 +465,17 @@ export async function compactHistory(messages, options) {
|
|
|
433
465
|
|
|
434
466
|
const summaryMsg = wrapSummaryAsUserMessage(summaryText);
|
|
435
467
|
|
|
436
|
-
// Defensive:
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
}
|
|
445
|
-
const safeTail = tail.slice(tailStart);
|
|
468
|
+
// Defensive pair-sanitize: the cut at `cutIdx` lands at a user-message
|
|
469
|
+
// boundary so an `[assistant(toolCalls), tool…]` arc is not split, but
|
|
470
|
+
// we still run `pairSanitize` over the tail as belt-and-suspenders —
|
|
471
|
+
// it idempotently drops any orphan tool messages, and any assistant
|
|
472
|
+
// whose tool_use IDs aren't fully matched in the tail. This is what
|
|
473
|
+
// keeps the next adapter call from 400-ing on tool_use/tool_result
|
|
474
|
+
// mismatch when the storage / fan-out layer reorders messages.
|
|
475
|
+
const safeTail = pairSanitize(tail);
|
|
446
476
|
|
|
447
477
|
const newMessages = [summaryMsg, ...safeTail];
|
|
448
|
-
const after = shouldCompactHistory(newMessages,
|
|
478
|
+
const after = shouldCompactHistory(newMessages, triggerOpts);
|
|
449
479
|
|
|
450
480
|
return {
|
|
451
481
|
messages: newMessages,
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pair-sanitize.js — Drop tool_use / tool_result orphans from a message
|
|
3
|
+
* slice so it can be safely fed to the LLM adapter.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists:
|
|
6
|
+
* `agent/unify/conversation/persist.js#loadRecentByGroup` and
|
|
7
|
+
* `agent/unify/history-compact.js#compactHistory` both produce
|
|
8
|
+
* sub-slices of a longer message stream. Both paths can — depending on
|
|
9
|
+
* where the cut lands — produce one of two illegal shapes:
|
|
10
|
+
* 1. A `role: 'tool'` message whose owning assistant `tool_use` is
|
|
11
|
+
* no longer in the slice.
|
|
12
|
+
* 2. An `assistant` message whose `toolCalls[i].id` has no matching
|
|
13
|
+
* `role: 'tool'` follow-up inside the slice.
|
|
14
|
+
* The Anthropic Messages API and the Chat-Completions adapter both
|
|
15
|
+
* 400 on either shape ("`tool_use` blocks must be paired with
|
|
16
|
+
* `tool_result` blocks").
|
|
17
|
+
*
|
|
18
|
+
* Strategy (Strategy B from the design doc):
|
|
19
|
+
* Drop orphans rather than extending the slice backwards. Concretely:
|
|
20
|
+
*
|
|
21
|
+
* - Walk forward. For each `role: 'assistant'` message with
|
|
22
|
+
* `toolCalls`, look ahead at the contiguous run of `role: 'tool'`
|
|
23
|
+
* messages (or, in this codebase's flat array form, all `tool`
|
|
24
|
+
* messages between this assistant and the next assistant/user)
|
|
25
|
+
* and collect the set of `toolCallId`s that are present.
|
|
26
|
+
* - Filter the assistant's `toolCalls` to that set. If the result
|
|
27
|
+
* is empty AND the assistant has no text content, drop the
|
|
28
|
+
* assistant. Otherwise keep it with the surviving subset (which
|
|
29
|
+
* may be empty `toolCalls: []` if it had text).
|
|
30
|
+
* - Drop any `role: 'tool'` whose `toolCallId` is not in the
|
|
31
|
+
* surviving set of any preceding assistant in the slice.
|
|
32
|
+
*
|
|
33
|
+
* The transform is idempotent: running it twice produces the same
|
|
34
|
+
* result as running it once. It does not mutate the input.
|
|
35
|
+
*
|
|
36
|
+
* It's deliberately tolerant of "weird" inputs — null entries, missing
|
|
37
|
+
* fields, leading orphan tools (which become outright drops) — because
|
|
38
|
+
* the call sites already see all of those.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {Object} UnifiedMessage
|
|
43
|
+
* @property {string} role - 'user' | 'assistant' | 'tool' | 'system'
|
|
44
|
+
* @property {string} [content]
|
|
45
|
+
* @property {Array<{id: string, name?: string, input?: any}>} [toolCalls]
|
|
46
|
+
* @property {string} [toolCallId] - present on role:'tool'
|
|
47
|
+
* @property {boolean} [isError]
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sanitize a message slice so every `tool_use` is paired with its
|
|
52
|
+
* `tool_result` and vice versa. Returns a new array; never mutates input.
|
|
53
|
+
*
|
|
54
|
+
* @param {UnifiedMessage[]} messages
|
|
55
|
+
* @returns {UnifiedMessage[]}
|
|
56
|
+
*/
|
|
57
|
+
export function pairSanitize(messages) {
|
|
58
|
+
if (!Array.isArray(messages) || messages.length === 0) return [];
|
|
59
|
+
|
|
60
|
+
// Pass 1: collect every toolCallId that has a matching `role:'tool'`
|
|
61
|
+
// message somewhere in the slice. (Pairing is positional, but for
|
|
62
|
+
// the orphan-drop policy a global presence check is sufficient — and
|
|
63
|
+
// it tolerates reorderings that the storage layer occasionally does
|
|
64
|
+
// when sequence ids cross seconds-boundaries.)
|
|
65
|
+
const toolResultIds = new Set();
|
|
66
|
+
for (const m of messages) {
|
|
67
|
+
if (!m || typeof m !== 'object') continue;
|
|
68
|
+
if (m.role === 'tool' && typeof m.toolCallId === 'string' && m.toolCallId) {
|
|
69
|
+
toolResultIds.add(m.toolCallId);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Pass 2: walk messages, filter assistant.toolCalls down to those
|
|
74
|
+
// whose result is in the slice, and track which call-ids survived.
|
|
75
|
+
// A `role:'tool'` is kept iff its toolCallId is in survivingCallIds.
|
|
76
|
+
const survivingCallIds = new Set();
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const m of messages) {
|
|
79
|
+
if (!m || typeof m !== 'object') {
|
|
80
|
+
// Pass through non-object junk so callers that intentionally
|
|
81
|
+
// include sentinels don't lose them. (Practically never happens.)
|
|
82
|
+
out.push(m);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
|
|
86
|
+
const keptCalls = m.toolCalls.filter(tc =>
|
|
87
|
+
tc && typeof tc.id === 'string' && toolResultIds.has(tc.id)
|
|
88
|
+
);
|
|
89
|
+
const text = typeof m.content === 'string' ? m.content : '';
|
|
90
|
+
const hasText = text.trim().length > 0;
|
|
91
|
+
if (keptCalls.length === 0 && !hasText) {
|
|
92
|
+
// No text, all tool_uses orphaned → drop the message entirely.
|
|
93
|
+
// Anthropic / OpenAI both reject empty assistant turns anyway.
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// Record surviving call ids so we keep their tool_result counterparts.
|
|
97
|
+
for (const tc of keptCalls) survivingCallIds.add(tc.id);
|
|
98
|
+
// Replace toolCalls with the filtered subset. Preserve other fields.
|
|
99
|
+
out.push({ ...m, toolCalls: keptCalls });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (m.role === 'tool') {
|
|
103
|
+
if (typeof m.toolCallId !== 'string' || !m.toolCallId) {
|
|
104
|
+
// Tool message with no id — can't pair, drop it.
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!survivingCallIds.has(m.toolCallId)) {
|
|
108
|
+
// Orphan tool_result: the assistant that called it isn't in the
|
|
109
|
+
// slice (or its tool_use was filtered out above).
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
out.push(m);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// user / system / assistant-without-toolCalls / unknown — keep as-is.
|
|
116
|
+
out.push(m);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Quick predicate: does this slice contain at least one orphan?
|
|
124
|
+
* Useful for tests and diagnostics. Pure function over `pairSanitize`.
|
|
125
|
+
*
|
|
126
|
+
* @param {UnifiedMessage[]} messages
|
|
127
|
+
* @returns {boolean}
|
|
128
|
+
*/
|
|
129
|
+
export function hasOrphanPairs(messages) {
|
|
130
|
+
if (!Array.isArray(messages) || messages.length === 0) return false;
|
|
131
|
+
const sanitized = pairSanitize(messages);
|
|
132
|
+
if (sanitized.length !== messages.length) return true;
|
|
133
|
+
// Same length but maybe an assistant's toolCalls shrank.
|
|
134
|
+
for (let i = 0; i < messages.length; i++) {
|
|
135
|
+
const a = messages[i];
|
|
136
|
+
const b = sanitized[i];
|
|
137
|
+
if (!a || !b) continue;
|
|
138
|
+
const aCalls = Array.isArray(a.toolCalls) ? a.toolCalls.length : 0;
|
|
139
|
+
const bCalls = Array.isArray(b.toolCalls) ? b.toolCalls.length : 0;
|
|
140
|
+
if (aCalls !== bCalls) return true;
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turn-utils.js — Turn-based slicing primitives for the Unify history.
|
|
3
|
+
*
|
|
4
|
+
* "Turn" = one user-side round-trip, NOT one user-role message.
|
|
5
|
+
* Multi-VP fan-out emits N user messages (each `@vp-<id> <text>`) for
|
|
6
|
+
* the SAME underlying prompt. They collapse into ONE turn here.
|
|
7
|
+
*
|
|
8
|
+
* Two concerns this module unifies:
|
|
9
|
+
*
|
|
10
|
+
* 1. `compact/turn-group.js` already does atomicity grouping —
|
|
11
|
+
* `[user, assistant, tool…]` triples that must move as a unit
|
|
12
|
+
* so we don't split tool_use/tool_result pairs. That's storage-
|
|
13
|
+
* invariant work.
|
|
14
|
+
*
|
|
15
|
+
* 2. THIS module does turn IDENTITY — "is the next user-role message
|
|
16
|
+
* the start of a new conversational turn, or is it just another
|
|
17
|
+
* `@vp-X` variant of the previous one?" That's a higher-level
|
|
18
|
+
* semantic concern.
|
|
19
|
+
*
|
|
20
|
+
* Both are needed. `sliceLastNTurns` cuts at a turn boundary AND walks
|
|
21
|
+
* forward to include all `@vp-X` variants of that turn so the result
|
|
22
|
+
* is always a pair-safe, semantically-aligned slice.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Strip a leading `@vp-<id> ` mention prefix from a user prompt. The
|
|
27
|
+
* web bridge prefixes each VP's per-turn prompt with `@vp-<id> ` so
|
|
28
|
+
* the engine knows which VP is replying. When asking "are these the
|
|
29
|
+
* same turn?" we want the user-facing notion of a turn (one prompt
|
|
30
|
+
* fanned out to multiple VPs is one turn) — so we strip the prefix
|
|
31
|
+
* before comparing.
|
|
32
|
+
*
|
|
33
|
+
* Format mirrors `web-bridge.js#runVpTurn` (`@vp-${vpId} ${text}`)
|
|
34
|
+
* and the canonical vpId charset from `groups/ids.js#VP_ID_RE`
|
|
35
|
+
* (`[A-Za-z0-9_-]`). The regex here is intentionally constrained to
|
|
36
|
+
* that charset so a literal `@vp-` substring in a *user-typed* message
|
|
37
|
+
* (e.g. `"@vp-, fooled you"`, `"@vp-😀 hi"`) is NOT mistaken for a
|
|
38
|
+
* fan-out prefix and over-stripped.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} content
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function stripVpMentionPrefix(content) {
|
|
44
|
+
if (typeof content !== 'string') return '';
|
|
45
|
+
return content.replace(/^@vp-[A-Za-z0-9_-]+\s+/, '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Count "turns" — distinct user prompts after `@vp-X` collapsing.
|
|
50
|
+
*
|
|
51
|
+
* A turn is opened when we hit a user-role message whose canonical
|
|
52
|
+
* text differs from the previous user-role message. Two consecutive
|
|
53
|
+
* user-role messages with the same canonical text (i.e. `@vp-a foo`
|
|
54
|
+
* followed by `@vp-b foo`) count as ONE turn.
|
|
55
|
+
*
|
|
56
|
+
* @param {Array<object>} messages
|
|
57
|
+
* @returns {number}
|
|
58
|
+
*/
|
|
59
|
+
export function countTurns(messages) {
|
|
60
|
+
if (!Array.isArray(messages)) return 0;
|
|
61
|
+
let n = 0;
|
|
62
|
+
let prev = null;
|
|
63
|
+
for (const m of messages) {
|
|
64
|
+
if (!m || m.role !== 'user') continue;
|
|
65
|
+
const canonical = stripVpMentionPrefix(m.content || '');
|
|
66
|
+
if (canonical !== prev) {
|
|
67
|
+
n++;
|
|
68
|
+
prev = canonical;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return n;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Find the index of the message that opens the (n)-th-from-end turn.
|
|
76
|
+
* Returns -1 if there aren't n turns in the history.
|
|
77
|
+
*
|
|
78
|
+
* Algorithm (mirrors `history-compact.js#findCutIndex`):
|
|
79
|
+
* - Walk user-role messages from END backwards, counting DISTINCT
|
|
80
|
+
* turns by canonical text.
|
|
81
|
+
* - When `turnsFromEnd === n`, record the index. Keep walking — the
|
|
82
|
+
* same turn might extend further back through earlier `@vp-X`
|
|
83
|
+
* variants. Stop on the FIRST user-role message whose canonical
|
|
84
|
+
* text differs (i.e. the (n+1)-th-from-end turn).
|
|
85
|
+
*
|
|
86
|
+
* The returned index always points at a user-role message — the
|
|
87
|
+
* natural turn boundary — which means messages[idx..] is a clean
|
|
88
|
+
* `[user, ..., user, ...]` slice with no orphan tool_use / tool_result
|
|
89
|
+
* pairs (provided the input was clean).
|
|
90
|
+
*
|
|
91
|
+
* @param {Array<object>} messages
|
|
92
|
+
* @param {number} n — 1 = "open the most recent turn"
|
|
93
|
+
* @returns {number}
|
|
94
|
+
*/
|
|
95
|
+
export function indexOfNthTurnFromEnd(messages, n) {
|
|
96
|
+
if (!Array.isArray(messages) || messages.length === 0) return -1;
|
|
97
|
+
if (n <= 0) return messages.length; // "0 turns from end" = past everything
|
|
98
|
+
|
|
99
|
+
let turnsFromEnd = 0;
|
|
100
|
+
let openCanonical = null;
|
|
101
|
+
let candidate = -1;
|
|
102
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
103
|
+
if (!messages[i] || messages[i].role !== 'user') continue;
|
|
104
|
+
const canonical = stripVpMentionPrefix(messages[i].content || '');
|
|
105
|
+
if (canonical !== openCanonical) {
|
|
106
|
+
// Boundary: a new (older) turn starts here.
|
|
107
|
+
turnsFromEnd++;
|
|
108
|
+
openCanonical = canonical;
|
|
109
|
+
if (turnsFromEnd === n) {
|
|
110
|
+
candidate = i;
|
|
111
|
+
// Don't break — earlier `@vp-X` variants of THIS turn may
|
|
112
|
+
// extend the boundary further back. We'll catch them via the
|
|
113
|
+
// `else if` branch below until we hit a different canonical.
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (turnsFromEnd > n) {
|
|
117
|
+
// We've stepped one turn past the kept window — done.
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
} else if (turnsFromEnd === n) {
|
|
121
|
+
// Same canonical text as the kept boundary — this is an earlier
|
|
122
|
+
// `@vp-X` variant of the same turn. Pull `candidate` back to it.
|
|
123
|
+
candidate = i;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return candidate;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Return the suffix of `messages` containing the last `n` turns.
|
|
131
|
+
*
|
|
132
|
+
* Always cuts at a user-message boundary (the start of the (n)-th-from-
|
|
133
|
+
* end turn), so the returned slice is pair-safe with respect to
|
|
134
|
+
* `[assistant(toolCalls), tool…]` arcs that LIVE inside one of the
|
|
135
|
+
* kept turns. Anything before the boundary — including any leading
|
|
136
|
+
* non-user messages from a prior turn — is dropped.
|
|
137
|
+
*
|
|
138
|
+
* If the history has fewer than `n` turns, returns the whole array
|
|
139
|
+
* (caller can decide whether that's a no-op).
|
|
140
|
+
*
|
|
141
|
+
* @param {Array<object>} messages
|
|
142
|
+
* @param {number} n
|
|
143
|
+
* @returns {Array<object>}
|
|
144
|
+
*/
|
|
145
|
+
export function sliceLastNTurns(messages, n) {
|
|
146
|
+
if (!Array.isArray(messages) || messages.length === 0) return [];
|
|
147
|
+
if (n <= 0) return [];
|
|
148
|
+
const idx = indexOfNthTurnFromEnd(messages, n);
|
|
149
|
+
if (idx === -1) {
|
|
150
|
+
// Fewer than n turns — keep everything.
|
|
151
|
+
return messages.slice();
|
|
152
|
+
}
|
|
153
|
+
return messages.slice(idx);
|
|
154
|
+
}
|
package/unify/web-bridge.js
CHANGED
|
@@ -1007,7 +1007,7 @@ async function ensureSessionLoaded() {
|
|
|
1007
1007
|
|
|
1008
1008
|
unifyConversationId = `unify-${Date.now()}`;
|
|
1009
1009
|
|
|
1010
|
-
restoreHistoryFromRecent(session.conversationStore.loadRecent(
|
|
1010
|
+
restoreHistoryFromRecent(session.conversationStore.loadRecent());
|
|
1011
1011
|
|
|
1012
1012
|
sendUnifyEvent({
|
|
1013
1013
|
type: 'session_ready',
|
|
@@ -1217,8 +1217,16 @@ function scheduleCompactAfterTurn(groupId) {
|
|
|
1217
1217
|
return;
|
|
1218
1218
|
}
|
|
1219
1219
|
// Cheap O(n) precheck so we don't bother engaging the LLM at all
|
|
1220
|
-
// when the conversation is still small.
|
|
1221
|
-
|
|
1220
|
+
// when the conversation is still small. Mirrors the policy that
|
|
1221
|
+
// `runCompactNow` will apply: 30K soft floor / 40 % of configured
|
|
1222
|
+
// context / 200K hard ceiling. (The turn-count trigger is off by
|
|
1223
|
+
// default — DEFAULT_TURN_LIMIT=Infinity — pin `turnLimit` via opts
|
|
1224
|
+
// to re-enable it.)
|
|
1225
|
+
const maxContextTokens =
|
|
1226
|
+
typeof session?.config?.maxContextTokens === 'number'
|
|
1227
|
+
? session.config.maxContextTokens
|
|
1228
|
+
: undefined;
|
|
1229
|
+
const triage = shouldCompactHistory(conversationMessages, { maxContextTokens });
|
|
1222
1230
|
if (!triage.trigger) return;
|
|
1223
1231
|
if (!session?.engine || typeof session.engine.summarizeForCompact !== 'function') {
|
|
1224
1232
|
console.warn('[Unify] history compact: engine.summarizeForCompact unavailable — skipping');
|
|
@@ -1269,8 +1277,16 @@ async function runCompactNow(groupId) {
|
|
|
1269
1277
|
// and we abandon the swap.
|
|
1270
1278
|
const snapshot = conversationMessages;
|
|
1271
1279
|
|
|
1280
|
+
// Pull the user-configured context width so the 40 %-of-context
|
|
1281
|
+
// threshold auto-adjusts to whatever model they're on. Falls back to
|
|
1282
|
+
// the module default when missing.
|
|
1283
|
+
const maxContextTokens =
|
|
1284
|
+
typeof session?.config?.maxContextTokens === 'number'
|
|
1285
|
+
? session.config.maxContextTokens
|
|
1286
|
+
: undefined;
|
|
1287
|
+
|
|
1272
1288
|
try {
|
|
1273
|
-
const result = await compactHistory(snapshot, { summarize });
|
|
1289
|
+
const result = await compactHistory(snapshot, { summarize, maxContextTokens });
|
|
1274
1290
|
if (!result.compacted) {
|
|
1275
1291
|
if (result.error) {
|
|
1276
1292
|
console.warn(
|
|
@@ -1613,6 +1629,10 @@ export function handleUnifyModelSwitch(msg) {
|
|
|
1613
1629
|
*/
|
|
1614
1630
|
export async function handleUnifyLoadHistory(msg) {
|
|
1615
1631
|
const groupId = (msg && typeof msg.groupId === 'string' && msg.groupId) || null;
|
|
1632
|
+
// `lim` is now expressed in TURNS, not raw messages. `loadRecent` and
|
|
1633
|
+
// `loadRecentByGroup` use turn-based slicing so the cut never lands
|
|
1634
|
+
// mid-tool-arc. Pass `undefined` to use the persistence-layer default
|
|
1635
|
+
// (DEFAULT_RECENT_TURNS = 20 turns).
|
|
1616
1636
|
const pickRecent = (store, lim) =>
|
|
1617
1637
|
groupId ? store.loadRecentByGroup(groupId, lim) : store.loadRecent(lim);
|
|
1618
1638
|
|
|
@@ -1627,12 +1647,12 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
1627
1647
|
|
|
1628
1648
|
unifyConversationId = `unify-${Date.now()}`;
|
|
1629
1649
|
|
|
1630
|
-
restoreHistoryFromRecent(pickRecent(session.conversationStore,
|
|
1650
|
+
restoreHistoryFromRecent(pickRecent(session.conversationStore, undefined));
|
|
1631
1651
|
} else if (groupId) {
|
|
1632
1652
|
// Re-entering an existing session with a (possibly new) group filter:
|
|
1633
1653
|
// re-seed the engine's flat history so it doesn't carry messages from
|
|
1634
1654
|
// another group into the next turn's context.
|
|
1635
|
-
restoreHistoryFromRecent(pickRecent(session.conversationStore,
|
|
1655
|
+
restoreHistoryFromRecent(pickRecent(session.conversationStore, undefined));
|
|
1636
1656
|
}
|
|
1637
1657
|
|
|
1638
1658
|
// Always replay session_ready so refresh / reconnect rebuilds UI state.
|
|
@@ -1647,6 +1667,11 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
1647
1667
|
});
|
|
1648
1668
|
sendGroupSnapshotBroadcast();
|
|
1649
1669
|
|
|
1670
|
+
// `msg.limit` is the replay-scrollback request from the frontend (UI
|
|
1671
|
+
// history pane, not engine context). Semantics changed (2026-05-01):
|
|
1672
|
+
// now expressed in TURNS. The previous default (50 messages) maps to
|
|
1673
|
+
// ~20–25 turns; in the turn-count world 50 turns of UI scrollback is
|
|
1674
|
+
// still cheap and matches what the frontend already passes through.
|
|
1650
1675
|
const limit = (typeof msg.limit === 'number') ? msg.limit : 50;
|
|
1651
1676
|
const messages = limit > 0 ? pickRecent(session.conversationStore, limit) : [];
|
|
1652
1677
|
const compactSummary = session.conversationStore.readCompactSummary();
|
|
@@ -1704,7 +1729,7 @@ export async function resetUnifySession() {
|
|
|
1704
1729
|
|
|
1705
1730
|
unifyConversationId = `unify-${Date.now()}`;
|
|
1706
1731
|
|
|
1707
|
-
restoreHistoryFromRecent(session.conversationStore.loadRecent(
|
|
1732
|
+
restoreHistoryFromRecent(session.conversationStore.loadRecent());
|
|
1708
1733
|
|
|
1709
1734
|
sendUnifyEvent({
|
|
1710
1735
|
type: 'session_ready',
|