@yeaft/webchat-agent 0.1.688 → 0.1.690
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/connection/message-router.js +4 -1
- package/package.json +1 -1
- package/unify/cli.js +5 -2
- package/unify/conversation/persist.js +67 -18
- package/unify/engine.js +6 -5
- package/unify/groups/group-crud.js +20 -0
- package/unify/groups/group-store.js +7 -0
- package/unify/history-compact.js +148 -118
- package/unify/pair-sanitize.js +143 -0
- package/unify/prompts.js +10 -0
- package/unify/turn-utils.js +154 -0
- package/unify/web-bridge.js +94 -15
|
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
|
|
|
36
36
|
import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
|
|
37
37
|
import { loadMcpServers, updateMcpConfig } from '../mcp.js';
|
|
38
38
|
import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../unify/config-api.js';
|
|
39
|
-
import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFeatureMessage, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger } from '../unify/web-bridge.js';
|
|
39
|
+
import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFeatureMessage, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger } from '../unify/web-bridge.js';
|
|
40
40
|
|
|
41
41
|
export async function handleMessage(msg) {
|
|
42
42
|
switch (msg.type) {
|
|
@@ -468,6 +468,9 @@ export async function handleMessage(msg) {
|
|
|
468
468
|
case 'unify_rename_group':
|
|
469
469
|
handleUnifyRenameGroup(msg);
|
|
470
470
|
break;
|
|
471
|
+
case 'unify_update_group':
|
|
472
|
+
handleUnifyUpdateGroup(msg);
|
|
473
|
+
break;
|
|
471
474
|
case 'unify_archive_group':
|
|
472
475
|
handleUnifyArchiveGroup(msg);
|
|
473
476
|
break;
|
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/engine.js
CHANGED
|
@@ -563,7 +563,7 @@ export class Engine {
|
|
|
563
563
|
* @param {{user?:string, group?:string, vp?:string}} [summaries]
|
|
564
564
|
* @returns {string}
|
|
565
565
|
*/
|
|
566
|
-
#buildSystemPrompt(memory, compactSummary, prompt, memoryInjection, userProfile, vpPersona, summaries) {
|
|
566
|
+
#buildSystemPrompt(memory, compactSummary, prompt, memoryInjection, userProfile, vpPersona, summaries, groupAnnouncement) {
|
|
567
567
|
// Get relevant skill content if SkillManager is wired
|
|
568
568
|
let skillContent = '';
|
|
569
569
|
if (this.#skillManager && prompt) {
|
|
@@ -585,6 +585,7 @@ export class Engine {
|
|
|
585
585
|
userProfile,
|
|
586
586
|
vpPersona,
|
|
587
587
|
summaries,
|
|
588
|
+
groupAnnouncement,
|
|
588
589
|
// Worker-shape harness is descriptive metadata for human inspection;
|
|
589
590
|
// production prompts skip it to save tokens. Re-enable via env when
|
|
590
591
|
// diagnosing prompt structure issues.
|
|
@@ -859,7 +860,7 @@ export class Engine {
|
|
|
859
860
|
* SCENARIO_EFFORT. Unknown values fall through to 'high'.
|
|
860
861
|
* @yields {EngineEvent}
|
|
861
862
|
*/
|
|
862
|
-
async *query({ prompt, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan } = {}) {
|
|
863
|
+
async *query({ prompt, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement } = {}) {
|
|
863
864
|
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
864
865
|
yield {
|
|
865
866
|
type: 'error',
|
|
@@ -910,7 +911,7 @@ export class Engine {
|
|
|
910
911
|
const runSignal = abortCtrl.signal;
|
|
911
912
|
|
|
912
913
|
try {
|
|
913
|
-
yield* this.#runQuery({ prompt: effectivePrompt, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan });
|
|
914
|
+
yield* this.#runQuery({ prompt: effectivePrompt, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement });
|
|
914
915
|
} finally {
|
|
915
916
|
if (signal) {
|
|
916
917
|
try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
|
|
@@ -928,7 +929,7 @@ export class Engine {
|
|
|
928
929
|
* in a try/finally without indenting the whole loop.
|
|
929
930
|
* @private
|
|
930
931
|
*/
|
|
931
|
-
async *#runQuery({ prompt, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan }) {
|
|
932
|
+
async *#runQuery({ prompt, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement }) {
|
|
932
933
|
|
|
933
934
|
// ─── Pre-query: FTS5 Memory Recall + Compact Summary ──
|
|
934
935
|
// Memory feed comes from two places:
|
|
@@ -1004,7 +1005,7 @@ export class Engine {
|
|
|
1004
1005
|
: amsContext.snapshotBlock;
|
|
1005
1006
|
}
|
|
1006
1007
|
|
|
1007
|
-
const systemPrompt = this.#buildSystemPrompt(undefined, compactSummary, prompt, memoryInjection, userProfile, vpPersona, summaries);
|
|
1008
|
+
const systemPrompt = this.#buildSystemPrompt(undefined, compactSummary, prompt, memoryInjection, userProfile, vpPersona, summaries, groupAnnouncement);
|
|
1008
1009
|
|
|
1009
1010
|
// Build conversation: existing messages + new user message
|
|
1010
1011
|
const conversationMessages = [
|
|
@@ -162,6 +162,26 @@ export function renameGroup(yeaftDir, groupId, newName) {
|
|
|
162
162
|
return next;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* (A.2.b) Update announcement — group-wide system-prompt prefix shared by
|
|
167
|
+
* every VP in the group (CLAUDE.md-style). Empty/whitespace clears it.
|
|
168
|
+
*
|
|
169
|
+
* `text` must be a string. Trimmed before persist so leading/trailing
|
|
170
|
+
* whitespace doesn't pollute the prompt.
|
|
171
|
+
*/
|
|
172
|
+
export function updateGroupAnnouncement(yeaftDir, groupId, text) {
|
|
173
|
+
if (typeof text !== 'string') {
|
|
174
|
+
throw new GroupCrudError('invalid_announcement', groupId);
|
|
175
|
+
}
|
|
176
|
+
const announcement = text.trim();
|
|
177
|
+
const handle = requireGroup(yeaftDir, groupId);
|
|
178
|
+
const meta = handle.getMeta();
|
|
179
|
+
handle.saveMeta({ ...meta, announcement });
|
|
180
|
+
const next = handle.getMeta();
|
|
181
|
+
handle.close();
|
|
182
|
+
return next;
|
|
183
|
+
}
|
|
184
|
+
|
|
165
185
|
/**
|
|
166
186
|
* (A.3) Archive — renames the dir to `.archived-<ts>-<id>`. Directory
|
|
167
187
|
* prefix `.` keeps `listGroups` from picking it up (readdirSync filter in
|
|
@@ -126,6 +126,7 @@ export function createGroup(groupsRoot, spec) {
|
|
|
126
126
|
name: spec.name || spec.id,
|
|
127
127
|
roster,
|
|
128
128
|
defaultVpId: spec.defaultVpId || null,
|
|
129
|
+
announcement: typeof spec.announcement === 'string' ? spec.announcement : '',
|
|
129
130
|
createdAt: spec.createdAt || new Date().toISOString(),
|
|
130
131
|
};
|
|
131
132
|
h.saveMeta(meta);
|
|
@@ -140,6 +141,9 @@ export function loadGroupMeta(dir) {
|
|
|
140
141
|
const raw = readFileSync(path, 'utf8');
|
|
141
142
|
const parsed = JSON.parse(raw);
|
|
142
143
|
validateMeta(parsed);
|
|
144
|
+
// Legacy groups created before the announcement field was added are
|
|
145
|
+
// forward-compat: missing field reads back as empty string.
|
|
146
|
+
if (typeof parsed.announcement !== 'string') parsed.announcement = '';
|
|
143
147
|
return parsed;
|
|
144
148
|
} catch {
|
|
145
149
|
return null;
|
|
@@ -173,6 +177,9 @@ function validateMeta(meta) {
|
|
|
173
177
|
if (meta.defaultVpId != null && typeof meta.defaultVpId !== 'string') {
|
|
174
178
|
throw new Error('group.defaultVpId must be string|null');
|
|
175
179
|
}
|
|
180
|
+
if (meta.announcement != null && typeof meta.announcement !== 'string') {
|
|
181
|
+
throw new Error('group.announcement must be string');
|
|
182
|
+
}
|
|
176
183
|
}
|
|
177
184
|
|
|
178
185
|
/**
|
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
|
+
}
|
package/unify/prompts.js
CHANGED
|
@@ -283,6 +283,7 @@ export function buildSystemPrompt({
|
|
|
283
283
|
coreMemory,
|
|
284
284
|
memoryTraceAvailable = false,
|
|
285
285
|
vpPersona,
|
|
286
|
+
groupAnnouncement = '',
|
|
286
287
|
} = {}) {
|
|
287
288
|
// Fallback to English for unknown languages
|
|
288
289
|
const lang = PROMPTS[language] || PROMPTS.en;
|
|
@@ -310,6 +311,15 @@ export function buildSystemPrompt({
|
|
|
310
311
|
}
|
|
311
312
|
}
|
|
312
313
|
|
|
314
|
+
// ─── 1.5 Group Announcement (CLAUDE.md-style shared prefix) ───
|
|
315
|
+
// When a group has set an announcement, every VP in the group sees it
|
|
316
|
+
// near the top of the system prompt — before tools, memory, mode-specific
|
|
317
|
+
// instructions. Empty/whitespace = no block emitted.
|
|
318
|
+
const annText = (typeof groupAnnouncement === 'string') ? groupAnnouncement.trim() : '';
|
|
319
|
+
if (annText) {
|
|
320
|
+
parts.push(`[Group Announcement]\n${annText}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
313
323
|
// ─── 2. Date Metadata ──────────────────────────────────
|
|
314
324
|
parts.push(lang.date(new Date().toISOString().split('T')[0]));
|
|
315
325
|
|
|
@@ -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
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
GroupCrudError,
|
|
34
34
|
createGroupFromSpec,
|
|
35
35
|
renameGroup,
|
|
36
|
+
updateGroupAnnouncement,
|
|
36
37
|
archiveGroup,
|
|
37
38
|
deleteGroup,
|
|
38
39
|
purgeArchivedGroups,
|
|
@@ -318,6 +319,45 @@ export function handleUnifyRenameGroup(msg) {
|
|
|
318
319
|
}
|
|
319
320
|
}
|
|
320
321
|
|
|
322
|
+
/**
|
|
323
|
+
* `unify_update_group` — generalised group meta patch. Currently accepts
|
|
324
|
+
* `name` and `announcement` keys. Empty patch is rejected; an empty/
|
|
325
|
+
* whitespace-only `name` is also rejected up front rather than letting
|
|
326
|
+
* `renameGroup` raise a less-specific error deeper in the call stack.
|
|
327
|
+
*
|
|
328
|
+
* Partial-success contract: when a single patch contains BOTH `name` and
|
|
329
|
+
* `announcement`, the rename is committed first; if the announcement
|
|
330
|
+
* write throws, the rename has already persisted on disk and the client
|
|
331
|
+
* receives `ok:false` for the announcement error — i.e. the WS op is not
|
|
332
|
+
* atomic. Today's UI binds Save buttons per pane in `GroupSettingsModal`
|
|
333
|
+
* so this is theoretical; readers extending the patch shape should know
|
|
334
|
+
* the contract permits half-commits.
|
|
335
|
+
*/
|
|
336
|
+
export function handleUnifyUpdateGroup(msg) {
|
|
337
|
+
const requestId = msg && msg.requestId;
|
|
338
|
+
const groupId = msg && msg.groupId;
|
|
339
|
+
const patch = (msg && msg.patch && typeof msg.patch === 'object') ? msg.patch : null;
|
|
340
|
+
try {
|
|
341
|
+
const hasName = patch && typeof patch.name === 'string' && patch.name.trim().length > 0;
|
|
342
|
+
const hasAnnouncement = patch && typeof patch.announcement === 'string';
|
|
343
|
+
if (!patch || (!hasName && !hasAnnouncement)) {
|
|
344
|
+
throw new GroupCrudError('invalid_patch', groupId);
|
|
345
|
+
}
|
|
346
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
347
|
+
let group = null;
|
|
348
|
+
if (hasName) {
|
|
349
|
+
group = renameGroup(yeaftDir, groupId, patch.name);
|
|
350
|
+
}
|
|
351
|
+
if (hasAnnouncement) {
|
|
352
|
+
group = updateGroupAnnouncement(yeaftDir, groupId, patch.announcement);
|
|
353
|
+
}
|
|
354
|
+
sendGroupCrudResult({ op: 'update', requestId, ok: true, group });
|
|
355
|
+
sendGroupSnapshotBroadcast();
|
|
356
|
+
} catch (err) {
|
|
357
|
+
sendGroupCrudResult({ op: 'update', requestId, ok: false, error: groupErrorPayload(err) });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
321
361
|
export function handleUnifyArchiveGroup(msg) {
|
|
322
362
|
const requestId = msg && msg.requestId;
|
|
323
363
|
const groupId = msg && msg.groupId;
|
|
@@ -912,16 +952,24 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
912
952
|
* Build the per-query VP context for the Engine.
|
|
913
953
|
*/
|
|
914
954
|
export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
|
|
955
|
+
// Read the group meta once and reuse for both defaultVpId fallback and
|
|
956
|
+
// announcement injection. Each .getMeta() reload reads + parses the
|
|
957
|
+
// group.json file, so calling it twice per turn is wasteful — and
|
|
958
|
+
// (more importantly) opens a window where a concurrent group edit
|
|
959
|
+
// could land between the two reads, giving the engine a defaultVpId
|
|
960
|
+
// from one snapshot and an announcement from a newer one.
|
|
961
|
+
let groupMeta = null;
|
|
962
|
+
try {
|
|
963
|
+
groupMeta = groupCoordinator && groupCoordinator.group
|
|
964
|
+
&& typeof groupCoordinator.group.getMeta === 'function'
|
|
965
|
+
? groupCoordinator.group.getMeta() : null;
|
|
966
|
+
} catch { /* coordinator inspection is best-effort */ }
|
|
967
|
+
|
|
915
968
|
let resolvedVpId = vpId;
|
|
916
969
|
if (!resolvedVpId) {
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
? groupCoordinator.group.getMeta() : null;
|
|
921
|
-
if (meta && typeof meta.defaultVpId === 'string' && meta.defaultVpId) {
|
|
922
|
-
resolvedVpId = meta.defaultVpId;
|
|
923
|
-
}
|
|
924
|
-
} catch { /* coordinator inspection is best-effort */ }
|
|
970
|
+
if (groupMeta && typeof groupMeta.defaultVpId === 'string' && groupMeta.defaultVpId) {
|
|
971
|
+
resolvedVpId = groupMeta.defaultVpId;
|
|
972
|
+
}
|
|
925
973
|
}
|
|
926
974
|
if (!resolvedVpId) {
|
|
927
975
|
const cfgDefault = session?.config?.defaultVpId;
|
|
@@ -943,6 +991,12 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
|
|
|
943
991
|
if (typeof groupId === 'string' && groupId.trim()) {
|
|
944
992
|
out.groupId = groupId.trim();
|
|
945
993
|
}
|
|
994
|
+
// task-334-group-editor: surface the group announcement to the engine so
|
|
995
|
+
// buildWorkerPrompt can inject it as a CLAUDE.md-style shared prefix.
|
|
996
|
+
// Empty/missing reads as '' and prompts.js skips the section.
|
|
997
|
+
if (groupMeta && typeof groupMeta.announcement === 'string') {
|
|
998
|
+
out.groupAnnouncement = groupMeta.announcement;
|
|
999
|
+
}
|
|
946
1000
|
try {
|
|
947
1001
|
const vp = readVp(resolvedVpId);
|
|
948
1002
|
if (vp) {
|
|
@@ -1007,7 +1061,7 @@ async function ensureSessionLoaded() {
|
|
|
1007
1061
|
|
|
1008
1062
|
unifyConversationId = `unify-${Date.now()}`;
|
|
1009
1063
|
|
|
1010
|
-
restoreHistoryFromRecent(session.conversationStore.loadRecent(
|
|
1064
|
+
restoreHistoryFromRecent(session.conversationStore.loadRecent());
|
|
1011
1065
|
|
|
1012
1066
|
sendUnifyEvent({
|
|
1013
1067
|
type: 'session_ready',
|
|
@@ -1217,8 +1271,16 @@ function scheduleCompactAfterTurn(groupId) {
|
|
|
1217
1271
|
return;
|
|
1218
1272
|
}
|
|
1219
1273
|
// Cheap O(n) precheck so we don't bother engaging the LLM at all
|
|
1220
|
-
// when the conversation is still small.
|
|
1221
|
-
|
|
1274
|
+
// when the conversation is still small. Mirrors the policy that
|
|
1275
|
+
// `runCompactNow` will apply: 30K soft floor / 40 % of configured
|
|
1276
|
+
// context / 200K hard ceiling. (The turn-count trigger is off by
|
|
1277
|
+
// default — DEFAULT_TURN_LIMIT=Infinity — pin `turnLimit` via opts
|
|
1278
|
+
// to re-enable it.)
|
|
1279
|
+
const maxContextTokens =
|
|
1280
|
+
typeof session?.config?.maxContextTokens === 'number'
|
|
1281
|
+
? session.config.maxContextTokens
|
|
1282
|
+
: undefined;
|
|
1283
|
+
const triage = shouldCompactHistory(conversationMessages, { maxContextTokens });
|
|
1222
1284
|
if (!triage.trigger) return;
|
|
1223
1285
|
if (!session?.engine || typeof session.engine.summarizeForCompact !== 'function') {
|
|
1224
1286
|
console.warn('[Unify] history compact: engine.summarizeForCompact unavailable — skipping');
|
|
@@ -1269,8 +1331,16 @@ async function runCompactNow(groupId) {
|
|
|
1269
1331
|
// and we abandon the swap.
|
|
1270
1332
|
const snapshot = conversationMessages;
|
|
1271
1333
|
|
|
1334
|
+
// Pull the user-configured context width so the 40 %-of-context
|
|
1335
|
+
// threshold auto-adjusts to whatever model they're on. Falls back to
|
|
1336
|
+
// the module default when missing.
|
|
1337
|
+
const maxContextTokens =
|
|
1338
|
+
typeof session?.config?.maxContextTokens === 'number'
|
|
1339
|
+
? session.config.maxContextTokens
|
|
1340
|
+
: undefined;
|
|
1341
|
+
|
|
1272
1342
|
try {
|
|
1273
|
-
const result = await compactHistory(snapshot, { summarize });
|
|
1343
|
+
const result = await compactHistory(snapshot, { summarize, maxContextTokens });
|
|
1274
1344
|
if (!result.compacted) {
|
|
1275
1345
|
if (result.error) {
|
|
1276
1346
|
console.warn(
|
|
@@ -1613,6 +1683,10 @@ export function handleUnifyModelSwitch(msg) {
|
|
|
1613
1683
|
*/
|
|
1614
1684
|
export async function handleUnifyLoadHistory(msg) {
|
|
1615
1685
|
const groupId = (msg && typeof msg.groupId === 'string' && msg.groupId) || null;
|
|
1686
|
+
// `lim` is now expressed in TURNS, not raw messages. `loadRecent` and
|
|
1687
|
+
// `loadRecentByGroup` use turn-based slicing so the cut never lands
|
|
1688
|
+
// mid-tool-arc. Pass `undefined` to use the persistence-layer default
|
|
1689
|
+
// (DEFAULT_RECENT_TURNS = 20 turns).
|
|
1616
1690
|
const pickRecent = (store, lim) =>
|
|
1617
1691
|
groupId ? store.loadRecentByGroup(groupId, lim) : store.loadRecent(lim);
|
|
1618
1692
|
|
|
@@ -1627,12 +1701,12 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
1627
1701
|
|
|
1628
1702
|
unifyConversationId = `unify-${Date.now()}`;
|
|
1629
1703
|
|
|
1630
|
-
restoreHistoryFromRecent(pickRecent(session.conversationStore,
|
|
1704
|
+
restoreHistoryFromRecent(pickRecent(session.conversationStore, undefined));
|
|
1631
1705
|
} else if (groupId) {
|
|
1632
1706
|
// Re-entering an existing session with a (possibly new) group filter:
|
|
1633
1707
|
// re-seed the engine's flat history so it doesn't carry messages from
|
|
1634
1708
|
// another group into the next turn's context.
|
|
1635
|
-
restoreHistoryFromRecent(pickRecent(session.conversationStore,
|
|
1709
|
+
restoreHistoryFromRecent(pickRecent(session.conversationStore, undefined));
|
|
1636
1710
|
}
|
|
1637
1711
|
|
|
1638
1712
|
// Always replay session_ready so refresh / reconnect rebuilds UI state.
|
|
@@ -1647,6 +1721,11 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
1647
1721
|
});
|
|
1648
1722
|
sendGroupSnapshotBroadcast();
|
|
1649
1723
|
|
|
1724
|
+
// `msg.limit` is the replay-scrollback request from the frontend (UI
|
|
1725
|
+
// history pane, not engine context). Semantics changed (2026-05-01):
|
|
1726
|
+
// now expressed in TURNS. The previous default (50 messages) maps to
|
|
1727
|
+
// ~20–25 turns; in the turn-count world 50 turns of UI scrollback is
|
|
1728
|
+
// still cheap and matches what the frontend already passes through.
|
|
1650
1729
|
const limit = (typeof msg.limit === 'number') ? msg.limit : 50;
|
|
1651
1730
|
const messages = limit > 0 ? pickRecent(session.conversationStore, limit) : [];
|
|
1652
1731
|
const compactSummary = session.conversationStore.readCompactSummary();
|
|
@@ -1704,7 +1783,7 @@ export async function resetUnifySession() {
|
|
|
1704
1783
|
|
|
1705
1784
|
unifyConversationId = `unify-${Date.now()}`;
|
|
1706
1785
|
|
|
1707
|
-
restoreHistoryFromRecent(session.conversationStore.loadRecent(
|
|
1786
|
+
restoreHistoryFromRecent(session.conversationStore.loadRecent());
|
|
1708
1787
|
|
|
1709
1788
|
sendUnifyEvent({
|
|
1710
1789
|
type: 'session_ready',
|