@yeaft/webchat-agent 0.1.849 → 0.1.851
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/conversation/persist.js +176 -1
- package/unify/engine.js +70 -8
- package/unify/history-compact.js +66 -14
- package/unify/web-bridge.js +18 -1
package/package.json
CHANGED
|
@@ -329,6 +329,7 @@ export class ConversationStore {
|
|
|
329
329
|
#coldDir; // ~/.yeaft/conversation/cold
|
|
330
330
|
#indexPath; // ~/.yeaft/conversation/index.md
|
|
331
331
|
#compactPath; // ~/.yeaft/conversation/compact.md
|
|
332
|
+
#compactScopedDir; // ~/.yeaft/conversation/compact/ (per-(group,vp))
|
|
332
333
|
#nextSeq; // next message sequence number (global, legacy)
|
|
333
334
|
#nextSeqByThread; // Map<threadId, number> — per-thread counters (task-314)
|
|
334
335
|
|
|
@@ -342,11 +343,15 @@ export class ConversationStore {
|
|
|
342
343
|
this.#coldDir = join(dir, 'conversation', 'cold');
|
|
343
344
|
this.#indexPath = join(dir, 'conversation', 'index.md');
|
|
344
345
|
this.#compactPath = join(dir, 'conversation', 'compact.md');
|
|
346
|
+
// Per-(groupId, vpId) compact summary files live here. The legacy
|
|
347
|
+
// single-file `compact.md` above is kept for backward compatibility
|
|
348
|
+
// and the "no groupId/vpId" fallback (sub-agents, legacy callers).
|
|
349
|
+
this.#compactScopedDir = join(dir, 'conversation', 'compact');
|
|
345
350
|
this.#nextSeq = null;
|
|
346
351
|
this.#nextSeqByThread = new Map();
|
|
347
352
|
|
|
348
353
|
// Ensure directories exist (graceful on permission errors)
|
|
349
|
-
for (const d of [this.#convDir, this.#msgDir, this.#coldDir]) {
|
|
354
|
+
for (const d of [this.#convDir, this.#msgDir, this.#coldDir, this.#compactScopedDir]) {
|
|
350
355
|
try {
|
|
351
356
|
if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
|
|
352
357
|
} catch (err) {
|
|
@@ -481,6 +486,111 @@ export class ConversationStore {
|
|
|
481
486
|
return readFileSync(this.#compactPath, 'utf8');
|
|
482
487
|
}
|
|
483
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Sanitize one id (groupId or vpId) into a safe filename component.
|
|
491
|
+
* Anything outside `[A-Za-z0-9._-]` collapses to `_`; max 120 chars.
|
|
492
|
+
* The result is only ever used as a basename joined to `compactScopedDir`
|
|
493
|
+
* — path traversal is blocked by the basename-only `join`, not by the
|
|
494
|
+
* regex (a literal `..` stays as `..` here and becomes part of a
|
|
495
|
+
* regular filename via the `__` separator + `.md` suffix).
|
|
496
|
+
*
|
|
497
|
+
* @param {string} s
|
|
498
|
+
* @returns {string}
|
|
499
|
+
*/
|
|
500
|
+
#safeIdComponent(s) {
|
|
501
|
+
return String(s).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Sanitize a (groupId, vpId) pair into a safe filename. We accept
|
|
506
|
+
* arbitrary user strings here (groupIds and vpIds are user-set), so
|
|
507
|
+
* the result is purely a basename — never parsed back.
|
|
508
|
+
*
|
|
509
|
+
* @param {string} groupId
|
|
510
|
+
* @param {string} vpId
|
|
511
|
+
* @returns {string|null} — full path, or null if either id missing
|
|
512
|
+
*/
|
|
513
|
+
#scopedCompactPath(groupId, vpId) {
|
|
514
|
+
if (!groupId || !vpId) return null;
|
|
515
|
+
return join(this.#compactScopedDir, `${this.#safeIdComponent(groupId)}__${this.#safeIdComponent(vpId)}.md`);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Read a per-(groupId, vpId) compact summary. Returns '' if no summary
|
|
520
|
+
* has been written yet. Falls back to nothing — callers that need the
|
|
521
|
+
* legacy global file should call `readCompactSummary()` explicitly.
|
|
522
|
+
*
|
|
523
|
+
* The (group, vp) scoping was introduced after we noticed the legacy
|
|
524
|
+
* single-file `compact.md` was shared across every group AND every VP
|
|
525
|
+
* in a session — so each new compact would clobber/append on top of
|
|
526
|
+
* unrelated content and every VP read the same merged blob. See
|
|
527
|
+
* `engine.#runOrchestratorCompact`.
|
|
528
|
+
*
|
|
529
|
+
* @param {string} groupId
|
|
530
|
+
* @param {string} vpId
|
|
531
|
+
* @returns {string}
|
|
532
|
+
*/
|
|
533
|
+
readCompactSummaryFor(groupId, vpId) {
|
|
534
|
+
const path = this.#scopedCompactPath(groupId, vpId);
|
|
535
|
+
if (!path) return '';
|
|
536
|
+
if (!existsSync(path)) return '';
|
|
537
|
+
try { return readFileSync(path, 'utf8'); }
|
|
538
|
+
catch { return ''; }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Append a per-(groupId, vpId) compact summary entry. Same append-only
|
|
543
|
+
* "## YYYY-MM-DD ..." structure as the legacy `updateCompactSummary`,
|
|
544
|
+
* but isolated to one file per (group, vp).
|
|
545
|
+
*
|
|
546
|
+
* @param {string} groupId
|
|
547
|
+
* @param {string} vpId
|
|
548
|
+
* @param {string} summary
|
|
549
|
+
*/
|
|
550
|
+
updateCompactSummaryFor(groupId, vpId, summary) {
|
|
551
|
+
const path = this.#scopedCompactPath(groupId, vpId);
|
|
552
|
+
if (!path) return;
|
|
553
|
+
let existing = '';
|
|
554
|
+
if (existsSync(path)) {
|
|
555
|
+
try { existing = readFileSync(path, 'utf8'); }
|
|
556
|
+
catch { existing = ''; }
|
|
557
|
+
}
|
|
558
|
+
const date = new Date().toISOString().split('T')[0];
|
|
559
|
+
const entry = `\n## ${date}\n\n${summary}\n`;
|
|
560
|
+
try {
|
|
561
|
+
writeFileSync(path, existing + entry, { encoding: 'utf8', mode: 0o644 });
|
|
562
|
+
} catch (err) {
|
|
563
|
+
if (isPermissionError(err)) {
|
|
564
|
+
if (!_permissionWarned) {
|
|
565
|
+
console.warn(`[Yeaft] Cannot write scoped compact summary: ${err.code}`);
|
|
566
|
+
_permissionWarned = true;
|
|
567
|
+
}
|
|
568
|
+
} else {
|
|
569
|
+
throw err;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Check whether ANY per-(group, vp) compact summary exists for `groupId`.
|
|
576
|
+
* Used by the history-replay path to decide whether to flag
|
|
577
|
+
* `hasCompactSummary` for the UI without committing to one VP's view.
|
|
578
|
+
*
|
|
579
|
+
* @param {string} groupId
|
|
580
|
+
* @returns {boolean}
|
|
581
|
+
*/
|
|
582
|
+
hasAnyCompactSummaryForGroup(groupId) {
|
|
583
|
+
if (!groupId) return false;
|
|
584
|
+
if (!existsSync(this.#compactScopedDir)) return false;
|
|
585
|
+
const prefix = `${this.#safeIdComponent(groupId)}__`;
|
|
586
|
+
try {
|
|
587
|
+
for (const f of readdirSync(this.#compactScopedDir)) {
|
|
588
|
+
if (f.startsWith(prefix) && f.endsWith('.md')) return true;
|
|
589
|
+
}
|
|
590
|
+
} catch { /* best-effort */ }
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
|
|
484
594
|
/**
|
|
485
595
|
* Update the conversation index.md with current state.
|
|
486
596
|
*
|
|
@@ -635,6 +745,71 @@ export class ConversationStore {
|
|
|
635
745
|
return this.loadRecentByGroup(groupId, Infinity);
|
|
636
746
|
}
|
|
637
747
|
|
|
748
|
+
/**
|
|
749
|
+
* VP-scoped view of group history, used by per-VP post-turn compact.
|
|
750
|
+
*
|
|
751
|
+
* Compact must operate on what the VP actually *saw* in its context,
|
|
752
|
+
* not the union of every VP's tool calls/results — otherwise compact
|
|
753
|
+
* tries to summarize tool transcripts that were never in this VP's
|
|
754
|
+
* prompt window. The rule we settled on (with the user, 2026-06-01):
|
|
755
|
+
*
|
|
756
|
+
* - User rows (no speakerVpId): KEEP — every VP sees the prompt.
|
|
757
|
+
* - This VP's own assistant rows + their paired tool rows: KEEP.
|
|
758
|
+
* - OTHER VPs' assistant rows: KEEP TEXT ONLY (strip toolCalls AND
|
|
759
|
+
* thinkingBlocks — thinking is VP-private per Anthropic's signed-
|
|
760
|
+
* block contract and would never appear in another VP's context).
|
|
761
|
+
* - OTHER VPs' tool result rows (role:'tool'): DROP — they pair with
|
|
762
|
+
* stripped tool_use ids and would orphan on replay.
|
|
763
|
+
* - Rows with `_reflection` / `internal` / `systemOnly`: DROP — they
|
|
764
|
+
* are engine-private and never enter another VP's context.
|
|
765
|
+
*
|
|
766
|
+
* The output is pair-safe by construction for THIS VP's tool arcs and
|
|
767
|
+
* carries only summary-relevant text for the other VPs.
|
|
768
|
+
*
|
|
769
|
+
* @param {string} groupId
|
|
770
|
+
* @param {string} vpId
|
|
771
|
+
* @returns {object[]}
|
|
772
|
+
*/
|
|
773
|
+
loadGroupHistoryForVp(groupId, vpId) {
|
|
774
|
+
if (!groupId || !vpId) return [];
|
|
775
|
+
const all = this.#loadFromDir(this.#msgDir, Infinity);
|
|
776
|
+
const out = [];
|
|
777
|
+
for (const m of all) {
|
|
778
|
+
if (!m || m.groupId !== groupId) continue;
|
|
779
|
+
if (m._reflection || m.internal || m.systemOnly || m.systemOnlyMessage) continue;
|
|
780
|
+
if (m.role === 'user') {
|
|
781
|
+
out.push(m);
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
if (m.role === 'assistant') {
|
|
785
|
+
if (m.speakerVpId === vpId) {
|
|
786
|
+
out.push(m);
|
|
787
|
+
} else {
|
|
788
|
+
// Other VP's assistant text only — drop their toolCalls so the
|
|
789
|
+
// following role:'tool' rows (which we also drop) don't leave
|
|
790
|
+
// orphan tool_use ids in the compact input.
|
|
791
|
+
const copy = { ...m };
|
|
792
|
+
delete copy.toolCalls;
|
|
793
|
+
delete copy.thinkingBlocks;
|
|
794
|
+
out.push(copy);
|
|
795
|
+
}
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
if (m.role === 'tool') {
|
|
799
|
+
// Tool results belong to the assistant turn that emitted the
|
|
800
|
+
// tool_use. Only keep ours; other VPs' results were dropped via
|
|
801
|
+
// their assistant's stripped toolCalls.
|
|
802
|
+
if (m.speakerVpId === vpId) out.push(m);
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// Note: we don't run `sliceLastNTurns` here. The caller
|
|
807
|
+
// (#runOrchestratorCompact) decides what's "cooling" via
|
|
808
|
+
// `partitionMessages`, and we don't want to pre-truncate before that
|
|
809
|
+
// budget calc sees the full picture.
|
|
810
|
+
return pairSanitize(out);
|
|
811
|
+
}
|
|
812
|
+
|
|
638
813
|
/**
|
|
639
814
|
* Pagination-cursor read: load the page of `turnsLimit` TURNS that ends
|
|
640
815
|
* just before `beforeSeq` (exclusive) for the given `groupId`. Used by
|
package/unify/engine.js
CHANGED
|
@@ -302,6 +302,10 @@ export class Engine {
|
|
|
302
302
|
|
|
303
303
|
/** @type {string|null} */
|
|
304
304
|
#yeaftDir;
|
|
305
|
+
/** @type {string|null} — set when this engine is bound to a specific group (per-VP fan-out path). */
|
|
306
|
+
#groupId = null;
|
|
307
|
+
/** @type {string|null} — set when this engine is bound to a specific VP (per-VP fan-out path). */
|
|
308
|
+
#vpId = null;
|
|
305
309
|
|
|
306
310
|
/** @type {import('./stats/tool-usage.js').ToolUsageStats|null} — per-tool call/latency counters */
|
|
307
311
|
#toolStats = null;
|
|
@@ -402,7 +406,7 @@ export class Engine {
|
|
|
402
406
|
* toolStats?: import('./stats/tool-usage.js').ToolUsageStats,
|
|
403
407
|
* }} params
|
|
404
408
|
*/
|
|
405
|
-
constructor({ adapter, trace, config, conversationStore, memoryIndex, amsRegistry, toolRegistry, skillManager, mcpManager, yeaftDir, toolStats = null }) {
|
|
409
|
+
constructor({ adapter, trace, config, conversationStore, memoryIndex, amsRegistry, toolRegistry, skillManager, mcpManager, yeaftDir, toolStats = null, groupId = null, vpId = null }) {
|
|
406
410
|
this.#adapter = adapter;
|
|
407
411
|
this.#trace = trace;
|
|
408
412
|
this.#config = config;
|
|
@@ -416,6 +420,14 @@ export class Engine {
|
|
|
416
420
|
this.#mcpManager = mcpManager || null;
|
|
417
421
|
this.#yeaftDir = yeaftDir || null;
|
|
418
422
|
this.#toolStats = toolStats || null;
|
|
423
|
+
// Per-VP fan-out (2026-06-01): engine instances in the group path are
|
|
424
|
+
// keyed by ${groupId}::${vpId}::${threadId}, so binding the engine to
|
|
425
|
+
// its (groupId, vpId) pair at construction lets post-turn compact
|
|
426
|
+
// scope its read/write to THIS VP's view of the conversation instead
|
|
427
|
+
// of clobbering a session-global compact.md. Legacy / sub-agent
|
|
428
|
+
// callers leave both null → fall back to the global file.
|
|
429
|
+
this.#groupId = (typeof groupId === 'string' && groupId) ? groupId : null;
|
|
430
|
+
this.#vpId = (typeof vpId === 'string' && vpId) ? vpId : null;
|
|
419
431
|
|
|
420
432
|
// PR-L: tool history reflection log. Keyed by traceId so distinct
|
|
421
433
|
// engine instances don't stomp on each other's jsonl files. When
|
|
@@ -978,6 +990,16 @@ export class Engine {
|
|
|
978
990
|
*/
|
|
979
991
|
#getCompactSummary() {
|
|
980
992
|
if (!this.#conversationStore) return '';
|
|
993
|
+
// Per-(group, vp) scoping: when this engine is bound to a fan-out VP,
|
|
994
|
+
// read its own summary file. On a miss (empty scoped file) we fall
|
|
995
|
+
// through to the legacy global file so pre-PR sessions whose only
|
|
996
|
+
// summary lives in compact.md still surface their context — matches
|
|
997
|
+
// the OR-fallback in web-bridge's `hasCompactSummary` flag.
|
|
998
|
+
if (this.#groupId && this.#vpId
|
|
999
|
+
&& typeof this.#conversationStore.readCompactSummaryFor === 'function') {
|
|
1000
|
+
const scoped = this.#conversationStore.readCompactSummaryFor(this.#groupId, this.#vpId);
|
|
1001
|
+
if (scoped) return scoped;
|
|
1002
|
+
}
|
|
981
1003
|
return this.#conversationStore.readCompactSummary();
|
|
982
1004
|
}
|
|
983
1005
|
|
|
@@ -1063,14 +1085,30 @@ export class Engine {
|
|
|
1063
1085
|
const adapter = this.#adapter;
|
|
1064
1086
|
const fastConfig = this.#fastConfig;
|
|
1065
1087
|
|
|
1088
|
+
// Per-(group, vp) scoping: when this engine is bound to a fan-out VP
|
|
1089
|
+
// (the common case in group mode), load only the rows THIS VP saw in
|
|
1090
|
+
// its context — user prompts + every VP's assistant text, with other
|
|
1091
|
+
// VPs' tool calls/results stripped (see persist.loadGroupHistoryForVp).
|
|
1092
|
+
//
|
|
1093
|
+
// Legacy / sub-agent callers (no groupId/vpId pair) keep the global
|
|
1094
|
+
// loadAll() behaviour so we don't break those flows.
|
|
1066
1095
|
let messages;
|
|
1096
|
+
const scoped = !!(this.#groupId && this.#vpId
|
|
1097
|
+
&& typeof conversationStore.loadGroupHistoryForVp === 'function');
|
|
1067
1098
|
try {
|
|
1068
|
-
messages =
|
|
1099
|
+
messages = scoped
|
|
1100
|
+
? conversationStore.loadGroupHistoryForVp(this.#groupId, this.#vpId)
|
|
1101
|
+
: conversationStore.loadAll();
|
|
1069
1102
|
} catch { return null; }
|
|
1070
1103
|
if (!Array.isArray(messages) || messages.length === 0) return null;
|
|
1071
1104
|
|
|
1072
1105
|
const tokenCount = conversationStore.hotTokens();
|
|
1073
|
-
|
|
1106
|
+
// In the scoped path, groupId is the engine's binding (authoritative).
|
|
1107
|
+
// In the legacy path, fall back to scanning the messages (best-effort,
|
|
1108
|
+
// used only for the group context-window gate).
|
|
1109
|
+
const groupId = this.#groupId
|
|
1110
|
+
|| messages.find(m => m && typeof m.groupId === 'string' && m.groupId)?.groupId
|
|
1111
|
+
|| null;
|
|
1074
1112
|
const groupContextGate = shouldAllowGroupReflection({
|
|
1075
1113
|
system: '',
|
|
1076
1114
|
messages,
|
|
@@ -1130,7 +1168,13 @@ export class Engine {
|
|
|
1130
1168
|
}
|
|
1131
1169
|
},
|
|
1132
1170
|
archive: async (_groupIdx, groupMsgs) => {
|
|
1133
|
-
|
|
1171
|
+
// Only collect archive ids when we'll actually use them. In the
|
|
1172
|
+
// scoped (per-VP) path we never call moveToColdBatch — those
|
|
1173
|
+
// rows are shared with sibling VPs in this group — so leaving
|
|
1174
|
+
// the push in would be dead state a future reader has to chase.
|
|
1175
|
+
if (!scoped) {
|
|
1176
|
+
for (const m of groupMsgs) if (m.id) archiveIds.push(m.id);
|
|
1177
|
+
}
|
|
1134
1178
|
const turnId = groupMsgs[0]?.id || `g_${Date.now()}`;
|
|
1135
1179
|
if (this.#yeaftDir) {
|
|
1136
1180
|
try {
|
|
@@ -1150,10 +1194,28 @@ export class Engine {
|
|
|
1150
1194
|
const out = await runCompactOrchestrator({
|
|
1151
1195
|
messages, keepHot: 10, hooks,
|
|
1152
1196
|
});
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1197
|
+
// Scoped path (per-(group, vp)): do NOT moveToColdBatch — those
|
|
1198
|
+
// archive ids include user rows and other VPs' assistant rows that
|
|
1199
|
+
// sibling VPs in this group still need in their hot context. The
|
|
1200
|
+
// per-VP summary written below is the durable win; physical
|
|
1201
|
+
// cold-archival across shared rows is the dream-level orchestrator's
|
|
1202
|
+
// job, not post-turn compact's.
|
|
1203
|
+
if (!scoped && archiveIds.length > 0) {
|
|
1204
|
+
conversationStore.moveToColdBatch(archiveIds);
|
|
1205
|
+
}
|
|
1206
|
+
if (out.compactSummary) {
|
|
1207
|
+
if (scoped && typeof conversationStore.updateCompactSummaryFor === 'function') {
|
|
1208
|
+
conversationStore.updateCompactSummaryFor(this.#groupId, this.#vpId, out.compactSummary);
|
|
1209
|
+
} else {
|
|
1210
|
+
conversationStore.updateCompactSummary(out.compactSummary);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
// Index update only makes sense for the legacy path that actually
|
|
1214
|
+
// moved rows to cold. In the scoped path, nothing on disk changed.
|
|
1215
|
+
if (!scoped) {
|
|
1216
|
+
const lastKept = messages[messages.length - 1];
|
|
1217
|
+
conversationStore.updateIndex({ lastMessageId: lastKept?.id || null });
|
|
1218
|
+
}
|
|
1157
1219
|
|
|
1158
1220
|
return {
|
|
1159
1221
|
archivedCount: out.archivedMessages,
|
package/unify/history-compact.js
CHANGED
|
@@ -94,13 +94,14 @@ export const countTurns = countTurnsImpl;
|
|
|
94
94
|
* Token thresholds are derived from `maxContextTokens` at evaluation
|
|
95
95
|
* time so the policy auto-adjusts to the user's configured context.
|
|
96
96
|
*/
|
|
97
|
-
export const DEFAULT_TURN_LIMIT =
|
|
98
|
-
export const DEFAULT_MIN_TOKEN_FLOOR =
|
|
97
|
+
export const DEFAULT_TURN_LIMIT = Infinity;
|
|
98
|
+
export const DEFAULT_MIN_TOKEN_FLOOR = 0;
|
|
99
99
|
export const DEFAULT_MAX_CONTEXT_TOKENS = 200_000;
|
|
100
|
-
export const DEFAULT_TOKEN_FRACTION = 0.
|
|
101
|
-
export const DEFAULT_HARD_TOKEN_CEILING =
|
|
102
|
-
export const DEFAULT_MIN_TURNS_FOR_COMPACT =
|
|
100
|
+
export const DEFAULT_TOKEN_FRACTION = 0.5;
|
|
101
|
+
export const DEFAULT_HARD_TOKEN_CEILING = Infinity;
|
|
102
|
+
export const DEFAULT_MIN_TURNS_FOR_COMPACT = 0;
|
|
103
103
|
export const DEFAULT_KEEP_TOOL_TURNS = 3;
|
|
104
|
+
export const DEFAULT_TOOL_CALL_COMPACT_THRESHOLD = 30;
|
|
104
105
|
/**
|
|
105
106
|
* Effective default token trigger when no `maxContextTokens` is provided:
|
|
106
107
|
* min(80% of 200K, 200K) = 160K. Preserved as `DEFAULT_TOKEN_LIMIT` for
|
|
@@ -116,7 +117,7 @@ export const DEFAULT_TOKEN_LIMIT = Math.min(
|
|
|
116
117
|
* replaces everything before this window. 2 keeps "what we were just
|
|
117
118
|
* talking about" lossless.
|
|
118
119
|
*/
|
|
119
|
-
export const DEFAULT_KEEP_RECENT_TURNS =
|
|
120
|
+
export const DEFAULT_KEEP_RECENT_TURNS = 3;
|
|
120
121
|
|
|
121
122
|
/**
|
|
122
123
|
* Default cap on the number of turns kept in the per-call snapshot fed
|
|
@@ -233,9 +234,10 @@ export function shouldCompactHistory(messages, opts = {}) {
|
|
|
233
234
|
const tokenCount = estimateMessagesTokens(messages);
|
|
234
235
|
|
|
235
236
|
let reason = null;
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
237
|
+
// Product rule: async group compact is allowed only when the current
|
|
238
|
+
// conversation exceeds the model context window threshold. Turn count is
|
|
239
|
+
// preserved as an explicit test/future-config override, but defaults to
|
|
240
|
+
// Infinity so it cannot compact a small context by itself.
|
|
239
241
|
if (tokenCount < minTokenFloor || (turnCount < minTurnsForCompact && tokenCount < tokenLimit)) {
|
|
240
242
|
return {
|
|
241
243
|
trigger: false,
|
|
@@ -249,10 +251,8 @@ export function shouldCompactHistory(messages, opts = {}) {
|
|
|
249
251
|
hardTokenCeiling,
|
|
250
252
|
};
|
|
251
253
|
}
|
|
252
|
-
|
|
253
|
-
if (
|
|
254
|
-
else if (tokenCount > hardTokenCeiling) reason = 'token_ceiling';
|
|
255
|
-
else if (tokenCount >= tokenLimit) reason = 'token_threshold';
|
|
254
|
+
if (tokenCount > hardTokenCeiling) reason = 'token_ceiling';
|
|
255
|
+
else if (tokenCount > tokenLimit) reason = 'token_threshold';
|
|
256
256
|
|
|
257
257
|
return {
|
|
258
258
|
trigger: reason !== null,
|
|
@@ -273,6 +273,27 @@ function hasContentAfterToolStrip(content) {
|
|
|
273
273
|
return content != null;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
function countToolCallsInContent(content) {
|
|
277
|
+
if (!Array.isArray(content)) return 0;
|
|
278
|
+
let n = 0;
|
|
279
|
+
for (const part of content) {
|
|
280
|
+
if (!part || typeof part !== 'object') continue;
|
|
281
|
+
if (part.type === 'tool_use' || part.type === 'function_call') n++;
|
|
282
|
+
}
|
|
283
|
+
return n;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function countToolCallsInMessages(messages) {
|
|
287
|
+
if (!Array.isArray(messages)) return 0;
|
|
288
|
+
let n = 0;
|
|
289
|
+
for (const m of messages) {
|
|
290
|
+
if (!m || typeof m !== 'object') continue;
|
|
291
|
+
if (Array.isArray(m.toolCalls)) n += m.toolCalls.length;
|
|
292
|
+
n += countToolCallsInContent(m.content);
|
|
293
|
+
}
|
|
294
|
+
return n;
|
|
295
|
+
}
|
|
296
|
+
|
|
276
297
|
function stripToolContentParts(content) {
|
|
277
298
|
if (!Array.isArray(content)) return content;
|
|
278
299
|
return content.filter(part => {
|
|
@@ -326,6 +347,31 @@ export function stripToolNoiseFromOlderTurns(messages, opts = {}) {
|
|
|
326
347
|
return [...cleanedOlder, ...recent.map(m => ({ ...m }))];
|
|
327
348
|
}
|
|
328
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Apply the async compact retained-tail tool policy. Small retained tails keep
|
|
352
|
+
* every tool pair intact. Once the retained tail exceeds the threshold, keep
|
|
353
|
+
* full tool history only for the latest turn and strip tool noise from the
|
|
354
|
+
* earlier retained turns while preserving their normal text.
|
|
355
|
+
*
|
|
356
|
+
* @param {Array<object>} tail
|
|
357
|
+
* @param {{ keepToolTurns?: number, toolCallCompactThreshold?: number }} [opts]
|
|
358
|
+
* @returns {Array<object>}
|
|
359
|
+
*/
|
|
360
|
+
export function compactRetainedTailToolCalls(tail, opts = {}) {
|
|
361
|
+
if (!Array.isArray(tail) || tail.length === 0) return [];
|
|
362
|
+
|
|
363
|
+
const threshold = Number.isFinite(opts.toolCallCompactThreshold) && opts.toolCallCompactThreshold >= 0
|
|
364
|
+
? opts.toolCallCompactThreshold
|
|
365
|
+
: DEFAULT_TOOL_CALL_COMPACT_THRESHOLD;
|
|
366
|
+
const toolCallCount = countToolCallsInMessages(tail);
|
|
367
|
+
if (toolCallCount <= threshold) return tail.map(m => ({ ...m }));
|
|
368
|
+
|
|
369
|
+
const keepToolTurns = Number.isFinite(opts.keepToolTurns) && opts.keepToolTurns >= 0
|
|
370
|
+
? opts.keepToolTurns
|
|
371
|
+
: 1;
|
|
372
|
+
return stripToolNoiseFromOlderTurns(tail, { keepToolTurns });
|
|
373
|
+
}
|
|
374
|
+
|
|
329
375
|
/**
|
|
330
376
|
* Strip noise from a message list before sending it to the summarizer:
|
|
331
377
|
* - drop `role: 'tool'` (raw tool results — too verbose, mostly redundant)
|
|
@@ -499,6 +545,8 @@ export async function compactHistory(messages, options) {
|
|
|
499
545
|
tokenFraction,
|
|
500
546
|
hardTokenCeiling,
|
|
501
547
|
language,
|
|
548
|
+
keepToolTurns,
|
|
549
|
+
toolCallCompactThreshold,
|
|
502
550
|
} = options || {};
|
|
503
551
|
|
|
504
552
|
if (typeof summarize !== 'function') {
|
|
@@ -600,7 +648,11 @@ export async function compactHistory(messages, options) {
|
|
|
600
648
|
// whose tool_use IDs aren't fully matched in the tail. This is what
|
|
601
649
|
// keeps the next adapter call from 400-ing on tool_use/tool_result
|
|
602
650
|
// mismatch when the storage / fan-out layer reorders messages.
|
|
603
|
-
const
|
|
651
|
+
const compactedTail = compactRetainedTailToolCalls(tail, {
|
|
652
|
+
keepToolTurns,
|
|
653
|
+
toolCallCompactThreshold,
|
|
654
|
+
});
|
|
655
|
+
const safeTail = pairSanitize(compactedTail);
|
|
604
656
|
|
|
605
657
|
const newMessages = [summaryMsg, ...safeTail];
|
|
606
658
|
const after = shouldCompactHistory(newMessages, triggerOpts);
|
package/unify/web-bridge.js
CHANGED
|
@@ -851,6 +851,12 @@ function getOrCreateVpEngine(groupId, vpId, threadId = 'main') {
|
|
|
851
851
|
// (`if (this.#toolStats && ...)`) is false and group VP tool calls
|
|
852
852
|
// are silently dropped.
|
|
853
853
|
toolStats: session.toolStats || null,
|
|
854
|
+
// Per-VP fan-out: bind the engine to its (groupId, vpId) so post-turn
|
|
855
|
+
// compact reads/writes a scoped summary instead of the legacy global
|
|
856
|
+
// compact.md (which every VP would otherwise share, producing
|
|
857
|
+
// identical, ever-growing summaries across groups).
|
|
858
|
+
groupId,
|
|
859
|
+
vpId,
|
|
854
860
|
});
|
|
855
861
|
vpEngines.set(key, eng);
|
|
856
862
|
return eng;
|
|
@@ -3634,10 +3640,21 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
3634
3640
|
oldestSeq = visiblePage.oldestSeq;
|
|
3635
3641
|
}
|
|
3636
3642
|
|
|
3643
|
+
// hasCompactSummary used to read a single session-global file, so it
|
|
3644
|
+
// was always true once ANY group/VP in the session had compacted.
|
|
3645
|
+
// Now we check (a) the scoped dir for any per-VP summary file in this
|
|
3646
|
+
// group, falling back to (b) the legacy global file for sessions that
|
|
3647
|
+
// pre-date the per-(group, vp) split.
|
|
3648
|
+
let hasCompactSummaryFlag = !!compactSummary;
|
|
3649
|
+
if (groupId && typeof session.conversationStore.hasAnyCompactSummaryForGroup === 'function') {
|
|
3650
|
+
hasCompactSummaryFlag = session.conversationStore.hasAnyCompactSummaryForGroup(groupId)
|
|
3651
|
+
|| hasCompactSummaryFlag;
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3637
3654
|
sendUnifyEvent({
|
|
3638
3655
|
type: 'history_loaded',
|
|
3639
3656
|
count: replayEntries.length,
|
|
3640
|
-
hasCompactSummary:
|
|
3657
|
+
hasCompactSummary: hasCompactSummaryFlag,
|
|
3641
3658
|
totalHot: session.conversationStore.countHot(),
|
|
3642
3659
|
totalCold: session.conversationStore.countCold(),
|
|
3643
3660
|
groupId,
|