@animalabs/context-manager 0.5.5 → 0.5.9
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/dist/src/adaptive/kv-control.d.ts +69 -32
- package/dist/src/adaptive/kv-control.d.ts.map +1 -1
- package/dist/src/adaptive/kv-control.js +471 -172
- package/dist/src/adaptive/kv-control.js.map +1 -1
- package/dist/src/adaptive/picker.d.ts +8 -0
- package/dist/src/adaptive/picker.d.ts.map +1 -1
- package/dist/src/adaptive/picker.js.map +1 -1
- package/dist/src/adaptive/strategies/kv-stable.d.ts +12 -4
- package/dist/src/adaptive/strategies/kv-stable.d.ts.map +1 -1
- package/dist/src/adaptive/strategies/kv-stable.js +14 -5
- package/dist/src/adaptive/strategies/kv-stable.js.map +1 -1
- package/dist/src/context-log.d.ts.map +1 -1
- package/dist/src/context-log.js +11 -0
- package/dist/src/context-log.js.map +1 -1
- package/dist/src/context-manager.d.ts +28 -1
- package/dist/src/context-manager.d.ts.map +1 -1
- package/dist/src/context-manager.js +42 -0
- package/dist/src/context-manager.js.map +1 -1
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/message-store.d.ts +60 -0
- package/dist/src/message-store.d.ts.map +1 -1
- package/dist/src/message-store.js +165 -10
- package/dist/src/message-store.js.map +1 -1
- package/dist/src/strategies/autobiographical.d.ts +207 -2
- package/dist/src/strategies/autobiographical.d.ts.map +1 -1
- package/dist/src/strategies/autobiographical.js +1283 -124
- package/dist/src/strategies/autobiographical.js.map +1 -1
- package/dist/src/strategies/index.d.ts +1 -1
- package/dist/src/strategies/index.d.ts.map +1 -1
- package/dist/src/strategies/index.js.map +1 -1
- package/dist/src/strategies/knowledge.d.ts +8 -0
- package/dist/src/strategies/knowledge.d.ts.map +1 -1
- package/dist/src/strategies/knowledge.js +23 -1
- package/dist/src/strategies/knowledge.js.map +1 -1
- package/dist/src/types/strategy.d.ts +82 -5
- package/dist/src/types/strategy.d.ts.map +1 -1
- package/dist/src/types/strategy.js.map +1 -1
- package/dist/test/adaptive/doc-plus-chat.test.js +7 -3
- package/dist/test/adaptive/doc-plus-chat.test.js.map +1 -1
- package/dist/test/adaptive/hard-fail.test.js +22 -0
- package/dist/test/adaptive/hard-fail.test.js.map +1 -1
- package/dist/test/adaptive/ingestion.test.js +3 -1
- package/dist/test/adaptive/ingestion.test.js.map +1 -1
- package/dist/test/adaptive/kv-control.test.js +17 -11
- package/dist/test/adaptive/kv-control.test.js.map +1 -1
- package/dist/test/adaptive/single-path-solve.test.d.ts +16 -0
- package/dist/test/adaptive/single-path-solve.test.d.ts.map +1 -0
- package/dist/test/adaptive/single-path-solve.test.js +204 -0
- package/dist/test/adaptive/single-path-solve.test.js.map +1 -0
- package/dist/test/anti-redundancy-budget.test.d.ts +17 -0
- package/dist/test/anti-redundancy-budget.test.d.ts.map +1 -0
- package/dist/test/anti-redundancy-budget.test.js +197 -0
- package/dist/test/anti-redundancy-budget.test.js.map +1 -0
- package/dist/test/chunk-persistence.test.d.ts +29 -0
- package/dist/test/chunk-persistence.test.d.ts.map +1 -0
- package/dist/test/chunk-persistence.test.js +290 -0
- package/dist/test/chunk-persistence.test.js.map +1 -0
- package/dist/test/compression-dedup.test.d.ts +20 -0
- package/dist/test/compression-dedup.test.d.ts.map +1 -0
- package/dist/test/compression-dedup.test.js +92 -0
- package/dist/test/compression-dedup.test.js.map +1 -0
- package/dist/test/compression-shape-invariants.test.js +40 -3
- package/dist/test/compression-shape-invariants.test.js.map +1 -1
- package/dist/test/head-window-order.test.d.ts +19 -0
- package/dist/test/head-window-order.test.d.ts.map +1 -0
- package/dist/test/head-window-order.test.js +159 -0
- package/dist/test/head-window-order.test.js.map +1 -0
- package/dist/test/image-strip-render-stats.test.js +25 -3
- package/dist/test/image-strip-render-stats.test.js.map +1 -1
- package/dist/test/knowledge-tool-boundary.test.d.ts +14 -0
- package/dist/test/knowledge-tool-boundary.test.d.ts.map +1 -0
- package/dist/test/knowledge-tool-boundary.test.js +93 -0
- package/dist/test/knowledge-tool-boundary.test.js.map +1 -0
- package/dist/test/l1-holdback.test.d.ts +15 -0
- package/dist/test/l1-holdback.test.d.ts.map +1 -0
- package/dist/test/l1-holdback.test.js +119 -0
- package/dist/test/l1-holdback.test.js.map +1 -0
- package/dist/test/merge-contiguity.test.d.ts +11 -0
- package/dist/test/merge-contiguity.test.d.ts.map +1 -0
- package/dist/test/merge-contiguity.test.js +74 -0
- package/dist/test/merge-contiguity.test.js.map +1 -0
- package/dist/test/message-store-window.test.d.ts +13 -0
- package/dist/test/message-store-window.test.d.ts.map +1 -0
- package/dist/test/message-store-window.test.js +177 -0
- package/dist/test/message-store-window.test.js.map +1 -0
- package/dist/test/speculation-cap.test.js +4 -0
- package/dist/test/speculation-cap.test.js.map +1 -1
- package/dist/test/summary-log-consistency.test.d.ts +18 -0
- package/dist/test/summary-log-consistency.test.d.ts.map +1 -0
- package/dist/test/summary-log-consistency.test.js +97 -0
- package/dist/test/summary-log-consistency.test.js.map +1 -0
- package/dist/test/tool-pairing-invariant.test.d.ts +15 -0
- package/dist/test/tool-pairing-invariant.test.d.ts.map +1 -0
- package/dist/test/tool-pairing-invariant.test.js +225 -0
- package/dist/test/tool-pairing-invariant.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +11 -3
- package/src/adaptive/kv-control.ts +509 -184
- package/src/adaptive/picker.ts +8 -0
- package/src/adaptive/strategies/kv-stable.ts +22 -10
- package/src/context-log.ts +11 -0
- package/src/context-manager.ts +48 -2
- package/src/index.ts +3 -2
- package/src/message-store.ts +202 -9
- package/src/strategies/autobiographical.ts +1343 -148
- package/src/strategies/index.ts +1 -1
- package/src/strategies/knowledge.ts +25 -1
- package/src/types/strategy.ts +89 -5
|
@@ -87,7 +87,12 @@ function formatInstruction(targetTokens: number): string {
|
|
|
87
87
|
'Preserve concrete details — file paths, exact values, decisions, ' +
|
|
88
88
|
`unresolved questions, the user\'s active asks. Target ~${targetTokens} ` +
|
|
89
89
|
'tokens. Output only the memory body — no preamble, no section headers ' +
|
|
90
|
-
'unless they help preservation, no meta-commentary about summarizing.'
|
|
90
|
+
'unless they help preservation, no meta-commentary about summarizing. ' +
|
|
91
|
+
'Memorize only what actually happened in that slice: if it holds little ' +
|
|
92
|
+
'beyond routine system traffic (heartbeats, empty turns, failure ' +
|
|
93
|
+
'notices), a short memory saying so is correct — do not pad it by ' +
|
|
94
|
+
're-narrating events you already remember from earlier as if they had ' +
|
|
95
|
+
'just happened again.'
|
|
91
96
|
);
|
|
92
97
|
}
|
|
93
98
|
|
|
@@ -231,6 +236,36 @@ export interface Chunk {
|
|
|
231
236
|
summaryId?: string;
|
|
232
237
|
/** Phase type tag (set by KnowledgeStrategy for semantic chunking) */
|
|
233
238
|
phaseType?: string;
|
|
239
|
+
/** ID of the persisted ChunkRecord backing this chunk (chunk persistence). */
|
|
240
|
+
recordId?: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Persisted chunk boundary, one per CLOSED chunk, stored in the
|
|
245
|
+
* `autobio:chunks` chronicle state slot (append_log, branch-aware).
|
|
246
|
+
*
|
|
247
|
+
* Records OWN the past: once a chunk closes, its membership is a persisted
|
|
248
|
+
* fact — rebuilds and restarts materialize chunks from records instead of
|
|
249
|
+
* recomputing boundaries from the running token sum. This is the fix for the
|
|
250
|
+
* 2026-07 re-consolidation storms: boundary inputs (config knobs, head
|
|
251
|
+
* window, token estimates, message mutations) could shift across restarts,
|
|
252
|
+
* the old exact-sourceIds-match recovery then failed, and whole stretches of
|
|
253
|
+
* already-summarized history were re-compressed into duplicate L1s.
|
|
254
|
+
*
|
|
255
|
+
* Membership is by message ID (never index), so edits/redactions degrade a
|
|
256
|
+
* record gracefully instead of re-keying its neighbors.
|
|
257
|
+
*/
|
|
258
|
+
export interface ChunkRecord {
|
|
259
|
+
/** Stable record id ("c-<n>"). */
|
|
260
|
+
id: string;
|
|
261
|
+
/** Exact message IDs of the closed chunk, in order. */
|
|
262
|
+
sourceIds: string[];
|
|
263
|
+
/** Whether the chunk's L1 summary has been produced. */
|
|
264
|
+
compressed: boolean;
|
|
265
|
+
/** ID of the L1 SummaryEntry, once compressed. */
|
|
266
|
+
summaryId?: string;
|
|
267
|
+
/** Phase type tag (KnowledgeStrategy semantic chunking). */
|
|
268
|
+
phaseType?: string;
|
|
234
269
|
}
|
|
235
270
|
|
|
236
271
|
/**
|
|
@@ -269,6 +304,40 @@ function normalizePinLevels(opts?: PinLevelOptions): { level?: number; maxLevel?
|
|
|
269
304
|
return maxLevel !== undefined ? { maxLevel } : {};
|
|
270
305
|
}
|
|
271
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Drop empty text blocks (`{type:'text', text:''}` or whitespace-only). The
|
|
309
|
+
* Anthropic API rejects them with 400 "text content blocks must be non-empty",
|
|
310
|
+
* which — thrown inside the speculative-compression drain — halts ALL
|
|
311
|
+
* compression. Non-text blocks (tool_use/tool_result/image) pass through
|
|
312
|
+
* unchanged, so tool pairing is preserved; callers drop any message left with
|
|
313
|
+
* an empty content array.
|
|
314
|
+
*/
|
|
315
|
+
function stripEmptyTextBlocks(content: ContentBlock[]): ContentBlock[] {
|
|
316
|
+
return content.filter((b) => {
|
|
317
|
+
if (b.type !== 'text') return true;
|
|
318
|
+
const text = (b as { text?: unknown }).text;
|
|
319
|
+
return typeof text === 'string' && text.trim().length > 0;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Strip `thinking` / `redacted_thinking` blocks from compression INPUT.
|
|
325
|
+
*
|
|
326
|
+
* The summarizer must never be handed the agent's own reasoning:
|
|
327
|
+
* (a) signed thinking is only valid verbatim in the turn that produced it —
|
|
328
|
+
* replaying it into a rewritten summarize request corrupts the signature;
|
|
329
|
+
* (b) asking the model to summarize its own reasoning reads as reproducing /
|
|
330
|
+
* duplicating model output → `reasoning_extraction` refusal, which returns
|
|
331
|
+
* empty (→ "empty L1 summary, chunk left raw") or, worse, produces a
|
|
332
|
+
* summary that reproduces the reasoning as text — which then trips the
|
|
333
|
+
* SAME classifier on the MAIN thread once that summary is rendered.
|
|
334
|
+
* Thinking is scratch work, not history (the same rationale already applied to
|
|
335
|
+
* the summarizer's OUTPUT). Drop it from the input too.
|
|
336
|
+
*/
|
|
337
|
+
function stripThinkingBlocks(content: ContentBlock[]): ContentBlock[] {
|
|
338
|
+
return content.filter((b) => b.type !== 'thinking' && b.type !== 'redacted_thinking');
|
|
339
|
+
}
|
|
340
|
+
|
|
272
341
|
/**
|
|
273
342
|
* Autobiographical chunking strategy.
|
|
274
343
|
* Compresses old conversation chunks into summaries in the model's own words.
|
|
@@ -297,6 +366,32 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
297
366
|
*/
|
|
298
367
|
protected _drainProgress = 0;
|
|
299
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Chunks (keyed by their LAST message id — chunk membership is immutable
|
|
371
|
+
* once closed) whose L1 was explicitly demanded by a picker `produce` op.
|
|
372
|
+
* Demanded chunks bypass the `l1HoldbackChunks` window in `rebuildChunks`:
|
|
373
|
+
* speculation waits, demand doesn't. Entries become inert once the chunk
|
|
374
|
+
* compresses (compressed chunks are never re-queued), so no cleanup needed.
|
|
375
|
+
*/
|
|
376
|
+
protected _demandedL1Chunks = new Set<MessageId>();
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* In-memory mirror of the persisted chunk records (autobio:chunks slot).
|
|
380
|
+
* Loaded in `loadPersistedState`, appended to when a chunk closes,
|
|
381
|
+
* updated by ID when its L1 lands.
|
|
382
|
+
*/
|
|
383
|
+
protected chunkRecords: ChunkRecord[] = [];
|
|
384
|
+
protected chunkIdCounter = 0;
|
|
385
|
+
/**
|
|
386
|
+
* Fail-closed latch: set when most persisted records resolve to zero live
|
|
387
|
+
* messages (the messages-chain-break signature). While set, NO compression
|
|
388
|
+
* runs — duplicate memories are strictly worse than delayed compression.
|
|
389
|
+
*/
|
|
390
|
+
protected chunkRecordsOrphaned = false;
|
|
391
|
+
private _orphanWarned = false;
|
|
392
|
+
/** Record ids whose compression was refused by the L1 overlap guard. */
|
|
393
|
+
private _overlapBlocked = new Set<string>();
|
|
394
|
+
|
|
300
395
|
// Hierarchical state
|
|
301
396
|
protected summaries: SummaryEntry[] = [];
|
|
302
397
|
protected summaryIdCounter = 0;
|
|
@@ -313,11 +408,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
313
408
|
/** Namespace for state-id scoping. Set in `initialize()`. */
|
|
314
409
|
protected ns: string = '';
|
|
315
410
|
protected get summariesStateId(): string { return `${this.ns}/autobio:summaries`; }
|
|
411
|
+
protected get chunksStateId(): string { return `${this.ns}/autobio:chunks`; }
|
|
316
412
|
protected get counterStateId(): string { return `${this.ns}/autobio:counter`; }
|
|
317
413
|
protected get mergeQueueStateId(): string { return `${this.ns}/autobio:mergeQueue`; }
|
|
318
414
|
protected get pinsStateId(): string { return `${this.ns}/autobio:pins`; }
|
|
319
415
|
protected get resolutionsStateId(): string { return `${this.ns}/autobio:resolutions`; }
|
|
320
416
|
protected get locksStateId(): string { return `${this.ns}/autobio:locks`; }
|
|
417
|
+
protected get calibrationStateId(): string { return `${this.ns}/autobio:calibration`; }
|
|
321
418
|
|
|
322
419
|
/** Protected ranges (pins + documents). Loaded from chronicle in initialize. */
|
|
323
420
|
protected pins: ProtectedRange[] = [];
|
|
@@ -465,7 +562,117 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
465
562
|
break;
|
|
466
563
|
}
|
|
467
564
|
}
|
|
565
|
+
// Legacy stores (pre chunk-persistence) have L1 summaries but no chunk
|
|
566
|
+
// records — synthesize records from L1 sourceIds before the first
|
|
567
|
+
// rebuild, so covered ground is owned and never re-compressed.
|
|
568
|
+
this.migrateChunkRecords(ctx.messageStore);
|
|
468
569
|
this.rebuildChunks(ctx.messageStore);
|
|
570
|
+
// Kick the merge ladder for pre-existing unmerged summaries. Normally a
|
|
571
|
+
// compression/merge completion does this, but a store that boots with a
|
|
572
|
+
// backlog above threshold and an empty queue (e.g. after a pyramid
|
|
573
|
+
// repair pruned duplicates and un-merged survivors) would otherwise
|
|
574
|
+
// never start consolidating. Idempotent: already-queued/merged sources
|
|
575
|
+
// are skipped.
|
|
576
|
+
if (this.config.hierarchical && !this.chunkRecordsOrphaned) {
|
|
577
|
+
this.checkMergeThreshold();
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Whether chunk boundaries are persisted to the `autobio:chunks` slot and
|
|
583
|
+
* own the past. Subclasses with their own chunking (KnowledgeStrategy's
|
|
584
|
+
* semantic phases) opt out and keep the legacy recompute-every-rebuild
|
|
585
|
+
* behavior.
|
|
586
|
+
*/
|
|
587
|
+
protected get chunkPersistenceEnabled(): boolean {
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* One-time lazy migration: a store with L1 summaries but an empty chunks
|
|
593
|
+
* slot predates chunk persistence. Each L1's sourceIds ARE the historical
|
|
594
|
+
* chunk boundary — synthesize a compressed record per L1, in message
|
|
595
|
+
* order. Stale generations from the old partial-tail compression bug
|
|
596
|
+
* (an L1 whose messages are ALL already covered by records synthesized so
|
|
597
|
+
* far — prefix families, same-range duplicates) get NO record; coverage is
|
|
598
|
+
* what blocks re-compression, and the repair tooling prunes their content
|
|
599
|
+
* separately.
|
|
600
|
+
*/
|
|
601
|
+
protected migrateChunkRecords(store: MessageStoreView): void {
|
|
602
|
+
if (!this.chunkPersistenceEnabled || !this.store) return;
|
|
603
|
+
if (this.chunkRecords.length > 0) return;
|
|
604
|
+
const l1s = this.summaries.filter(s => s.level === 1 && Array.isArray(s.sourceIds) && s.sourceIds.length > 0);
|
|
605
|
+
if (l1s.length === 0) return;
|
|
606
|
+
|
|
607
|
+
const msgIndex = new Map<string, number>();
|
|
608
|
+
store.getAll().forEach((m, i) => msgIndex.set(m.id, i));
|
|
609
|
+
|
|
610
|
+
// Sort by (start position asc, span length desc) so at each starting
|
|
611
|
+
// point the LONGEST generation claims the ground first.
|
|
612
|
+
const sorted = [...l1s].sort((a, b) => {
|
|
613
|
+
const sa = msgIndex.get(a.sourceIds[0]) ?? Number.MAX_SAFE_INTEGER;
|
|
614
|
+
const sb = msgIndex.get(b.sourceIds[0]) ?? Number.MAX_SAFE_INTEGER;
|
|
615
|
+
return sa - sb || b.sourceIds.length - a.sourceIds.length;
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const covered = new Set<string>();
|
|
619
|
+
let skippedStale = 0;
|
|
620
|
+
let skippedGhost = 0;
|
|
621
|
+
for (const s of sorted) {
|
|
622
|
+
// An L1 none of whose messages exist anymore can't own live ground.
|
|
623
|
+
if (!s.sourceIds.some(id => msgIndex.has(id))) { skippedGhost++; continue; }
|
|
624
|
+
// Fully-covered = stale generation / contained duplicate.
|
|
625
|
+
if (s.sourceIds.every(id => covered.has(id))) { skippedStale++; continue; }
|
|
626
|
+
for (const id of s.sourceIds) covered.add(id);
|
|
627
|
+
this.appendChunkRecord({
|
|
628
|
+
id: `c-${this.chunkIdCounter++}`,
|
|
629
|
+
sourceIds: [...s.sourceIds],
|
|
630
|
+
compressed: true,
|
|
631
|
+
summaryId: s.id,
|
|
632
|
+
...(s.phaseType ? { phaseType: s.phaseType } : {}),
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
console.warn(
|
|
636
|
+
`[autobiographical] chunk-persistence migration: ${l1s.length} L1s → ` +
|
|
637
|
+
`${this.chunkRecords.length} records (${skippedStale} stale generations, ` +
|
|
638
|
+
`${skippedGhost} fully-orphaned L1s skipped)`,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Append a record to the chunks slot + in-memory mirror. */
|
|
643
|
+
protected appendChunkRecord(record: ChunkRecord): void {
|
|
644
|
+
this.chunkRecords.push(record);
|
|
645
|
+
this.store?.appendToStateJson(this.chunksStateId, record);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Mark a chunk's record compressed, linking its L1. Resolves the log slot
|
|
650
|
+
* by ID against the persisted array — never by in-memory index (see
|
|
651
|
+
* setMergedInto for the clobber this avoids).
|
|
652
|
+
*/
|
|
653
|
+
protected markChunkRecordCompressed(recordId: string | undefined, summaryId: string): void {
|
|
654
|
+
if (!this.chunkPersistenceEnabled || !recordId) return;
|
|
655
|
+
const rec = this.chunkRecords.find(r => r.id === recordId);
|
|
656
|
+
if (!rec) return;
|
|
657
|
+
rec.compressed = true;
|
|
658
|
+
rec.summaryId = summaryId;
|
|
659
|
+
if (!this.store) return;
|
|
660
|
+
const stored = this.store.getStateJson(this.chunksStateId);
|
|
661
|
+
if (!Array.isArray(stored)) return;
|
|
662
|
+
const payload = Buffer.from(JSON.stringify(rec));
|
|
663
|
+
let found = false;
|
|
664
|
+
for (let i = 0; i < stored.length; i++) {
|
|
665
|
+
const item = stored[i] as ChunkRecord | null;
|
|
666
|
+
if (item && item.id === recordId) {
|
|
667
|
+
this.store.editStateItem(this.chunksStateId, i, payload);
|
|
668
|
+
found = true;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (!found) {
|
|
672
|
+
console.warn(
|
|
673
|
+
`[autobiographical] markChunkRecordCompressed: ${recordId} not found in persisted chunk log`,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
469
676
|
}
|
|
470
677
|
|
|
471
678
|
/**
|
|
@@ -483,6 +690,16 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
483
690
|
fullSnapshotEvery: 10,
|
|
484
691
|
});
|
|
485
692
|
} catch { /* already registered */ }
|
|
693
|
+
if (this.chunkPersistenceEnabled) {
|
|
694
|
+
try {
|
|
695
|
+
this.store.registerState({
|
|
696
|
+
id: this.chunksStateId,
|
|
697
|
+
strategy: 'append_log',
|
|
698
|
+
deltaSnapshotEvery: 50,
|
|
699
|
+
fullSnapshotEvery: 10,
|
|
700
|
+
});
|
|
701
|
+
} catch { /* already registered */ }
|
|
702
|
+
}
|
|
486
703
|
try {
|
|
487
704
|
this.store.registerState({
|
|
488
705
|
id: this.counterStateId,
|
|
@@ -501,6 +718,12 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
501
718
|
strategy: 'snapshot',
|
|
502
719
|
});
|
|
503
720
|
} catch { /* already registered */ }
|
|
721
|
+
try {
|
|
722
|
+
this.store.registerState({
|
|
723
|
+
id: this.calibrationStateId,
|
|
724
|
+
strategy: 'snapshot',
|
|
725
|
+
});
|
|
726
|
+
} catch { /* already registered */ }
|
|
504
727
|
// Adaptive-resolution state slots — only registered when the flag is on
|
|
505
728
|
// so chronicles without the flag don't accumulate unused slots.
|
|
506
729
|
if (this.config.adaptiveResolution) {
|
|
@@ -531,10 +754,45 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
531
754
|
this.mergeQueue = [];
|
|
532
755
|
this.pins = [];
|
|
533
756
|
this.pinIdCounter = 0;
|
|
757
|
+
this.chunkRecords = [];
|
|
758
|
+
this.chunkIdCounter = 0;
|
|
534
759
|
return;
|
|
535
760
|
}
|
|
761
|
+
|
|
762
|
+
if (this.chunkPersistenceEnabled) {
|
|
763
|
+
const records = this.store.getStateJson(this.chunksStateId);
|
|
764
|
+
this.chunkRecords = (Array.isArray(records) ? (records as ChunkRecord[]) : [])
|
|
765
|
+
.filter(r => r && typeof r.id === 'string' && Array.isArray(r.sourceIds) && r.sourceIds.length > 0);
|
|
766
|
+
this.chunkIdCounter = this.chunkRecords.reduce((max, r) => {
|
|
767
|
+
const n = Number(r.id.replace(/^c-/, ''));
|
|
768
|
+
return Number.isFinite(n) && n >= max ? n + 1 : max;
|
|
769
|
+
}, 0);
|
|
770
|
+
this.chunkRecordsOrphaned = false;
|
|
771
|
+
this._orphanWarned = false;
|
|
772
|
+
this._overlapBlocked.clear();
|
|
773
|
+
}
|
|
536
774
|
const summaries = this.store.getStateJson(this.summariesStateId);
|
|
537
|
-
|
|
775
|
+
const loaded = Array.isArray(summaries) ? (summaries as SummaryEntry[]) : [];
|
|
776
|
+
// Drop empty-content summaries (bugged/empty generations from before the
|
|
777
|
+
// production guards). Recalling or merging one yields an empty text block →
|
|
778
|
+
// Anthropic 400 "content must be non-empty". Never let them re-enter memory.
|
|
779
|
+
const nonEmpty = loaded.filter(s => s && typeof s.content === 'string' && s.content.trim().length > 0);
|
|
780
|
+
const droppedEmpty = loaded.length - nonEmpty.length;
|
|
781
|
+
if (droppedEmpty > 0) console.warn(`[autobiographical] dropped ${droppedEmpty} empty summary(ies) on load`);
|
|
782
|
+
// Dedupe by id, keeping the copy with mergedInto set (position of first
|
|
783
|
+
// occurrence preserved). Duplicate-id copies with diverging merge state
|
|
784
|
+
// exist in stores touched by the pre-fix setMergedInto index-desync bug;
|
|
785
|
+
// without dedupe, the plain copy stays on the unmerged frontier and its
|
|
786
|
+
// content renders twice (once itself, once via its parent's merge).
|
|
787
|
+
const byId = new Map<string, SummaryEntry>();
|
|
788
|
+
for (const s of nonEmpty) {
|
|
789
|
+
const prev = byId.get(s.id);
|
|
790
|
+
if (!prev) byId.set(s.id, s);
|
|
791
|
+
else if (!prev.mergedInto && s.mergedInto) byId.set(s.id, s);
|
|
792
|
+
}
|
|
793
|
+
const dupes = nonEmpty.length - byId.size;
|
|
794
|
+
if (dupes > 0) console.warn(`[autobiographical] deduped ${dupes} duplicate summary id(s) on load`);
|
|
795
|
+
this.summaries = [...byId.values()];
|
|
538
796
|
|
|
539
797
|
const counter = this.store.getStateJson(this.counterStateId);
|
|
540
798
|
this.summaryIdCounter = typeof counter === 'number' ? counter : 0;
|
|
@@ -857,13 +1115,30 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
857
1115
|
protected setMergedInto(entry: SummaryEntry, mergedIntoId: string): void {
|
|
858
1116
|
entry.mergedInto = mergedIntoId;
|
|
859
1117
|
if (!this.store) return;
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
this.
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
)
|
|
1118
|
+
// Resolve the log position by ID against the PERSISTED array — never by
|
|
1119
|
+
// in-memory index. `loadPersistedState` filters empty-content summaries
|
|
1120
|
+
// out of `this.summaries` while they remain in the log, so after a reload
|
|
1121
|
+
// the in-memory index is shifted relative to the log slot. Editing by
|
|
1122
|
+
// in-memory index wrote merge-updates onto NEIGHBORING entries, silently
|
|
1123
|
+
// clobbering them (4 summaries lost in the 2026-07 Lena incident, leaving
|
|
1124
|
+
// duplicate-id copies with diverging mergedInto). Update every stored
|
|
1125
|
+
// copy with this id so past duplicates converge too.
|
|
1126
|
+
const stored = this.store.getStateJson(this.summariesStateId);
|
|
1127
|
+
if (!Array.isArray(stored)) return;
|
|
1128
|
+
let found = false;
|
|
1129
|
+
const payload = Buffer.from(JSON.stringify(entry));
|
|
1130
|
+
for (let i = 0; i < stored.length; i++) {
|
|
1131
|
+
const item = stored[i] as SummaryEntry | null;
|
|
1132
|
+
if (item && item.id === entry.id) {
|
|
1133
|
+
this.store.editStateItem(this.summariesStateId, i, payload);
|
|
1134
|
+
found = true;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
if (!found) {
|
|
1138
|
+
console.warn(
|
|
1139
|
+
`[autobiographical] setMergedInto: ${entry.id} not found in persisted summary log — merge state not persisted`,
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
867
1142
|
}
|
|
868
1143
|
|
|
869
1144
|
/**
|
|
@@ -959,6 +1234,10 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
959
1234
|
}
|
|
960
1235
|
for (const chunk of candidates) {
|
|
961
1236
|
if (chunk.compressed) continue;
|
|
1237
|
+
// Demand path: mark the chunk so the l1HoldbackChunks window in
|
|
1238
|
+
// rebuildChunks never filters it back out of the queue.
|
|
1239
|
+
const lastId = chunk.messages[chunk.messages.length - 1]?.id;
|
|
1240
|
+
if (lastId !== undefined) this._demandedL1Chunks.add(lastId);
|
|
962
1241
|
if (this.compressionQueue.includes(chunk.index)) continue;
|
|
963
1242
|
this.compressionQueue.push(chunk.index);
|
|
964
1243
|
}
|
|
@@ -1280,6 +1559,14 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1280
1559
|
// snapshot, so it reflects what the last compile actually rendered rather than
|
|
1281
1560
|
// re-deriving the full pyramid (which, under adaptive resolution, bears little
|
|
1282
1561
|
// resemblance to the folded output).
|
|
1562
|
+
//
|
|
1563
|
+
// CAVEAT: the final structural passes (`trimOrphanedToolUse`,
|
|
1564
|
+
// `enforceToolPairing`) run AFTER the per-entry tallies and BEFORE `rsEnd()`,
|
|
1565
|
+
// and are NOT reflected in the snapshot. Trims only remove entries (total
|
|
1566
|
+
// over-counts by at most the trimmed tail); the pairing validator can also
|
|
1567
|
+
// ADD stub tool_result entries (total then under-counts by those stubs).
|
|
1568
|
+
// Both deltas are a handful of tokens — the stats describe the selection, not
|
|
1569
|
+
// the byte-exact wire payload. Do not treat `total` as an exact wire count.
|
|
1283
1570
|
// ===========================================================================
|
|
1284
1571
|
private _rs: RenderStats | null = null;
|
|
1285
1572
|
private _lastRenderStats: RenderStats | null = null;
|
|
@@ -1413,16 +1700,20 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1413
1700
|
msgCap: number,
|
|
1414
1701
|
maxTokens: number,
|
|
1415
1702
|
totalTokensBefore: number,
|
|
1703
|
+
// Post-strip per-message estimates (2026-07-12): eviction must price a
|
|
1704
|
+
// message the way the stripped render will. Raw pricing counted every
|
|
1705
|
+
// stripped image at full cost and evicted most of an image-era tail the
|
|
1706
|
+
// recent-window walk had correctly admitted (mythos: 601-message tail
|
|
1707
|
+
// admitted, 228 survived eviction, 45k rendered of a 120k window).
|
|
1708
|
+
pse?: number[],
|
|
1416
1709
|
): { messages: number; tokens: number } {
|
|
1417
1710
|
if (recentStart >= messages.length) return { messages: 0, tokens: 0 };
|
|
1418
1711
|
|
|
1712
|
+
const est = (i: number): number => pse?.[i] ?? store.estimateTokens(messages[i]);
|
|
1419
1713
|
const accepted: number[] = [];
|
|
1420
1714
|
let acceptedTokens = 0;
|
|
1421
1715
|
for (let i = messages.length - 1; i >= recentStart; i--) {
|
|
1422
|
-
const
|
|
1423
|
-
const tokens = msgCap > 0
|
|
1424
|
-
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
1425
|
-
: store.estimateTokens(msg);
|
|
1716
|
+
const tokens = msgCap > 0 ? Math.min(est(i), msgCap + 50) : est(i);
|
|
1426
1717
|
if (this.isOverBudget(totalTokensBefore + acceptedTokens + tokens, maxTokens)) break;
|
|
1427
1718
|
accepted.push(i);
|
|
1428
1719
|
acceptedTokens += tokens;
|
|
@@ -1442,9 +1733,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1442
1733
|
for (const i of accepted) {
|
|
1443
1734
|
const msg = messages[i];
|
|
1444
1735
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1445
|
-
const tokens = msgCap > 0
|
|
1446
|
-
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
1447
|
-
: store.estimateTokens(msg);
|
|
1736
|
+
const tokens = msgCap > 0 ? Math.min(est(i), msgCap + 50) : est(i);
|
|
1448
1737
|
entries.push({
|
|
1449
1738
|
index: entries.length,
|
|
1450
1739
|
sourceMessageId: msg.id,
|
|
@@ -1508,30 +1797,194 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1508
1797
|
throw new Error('No membrane instance for compression');
|
|
1509
1798
|
}
|
|
1510
1799
|
|
|
1800
|
+
// ---- Duplicate-formation guards (layered) ----
|
|
1801
|
+
// Merged from two independent fixes for the same disease:
|
|
1802
|
+
//
|
|
1803
|
+
// 1. EXACT MATCH → adopt (bug 6.10, Tengro). rebuildChunks (fired by
|
|
1804
|
+
// onNewMessage / select) can re-queue a span whose compression already
|
|
1805
|
+
// completed against a stale chunk object — or, under chunk
|
|
1806
|
+
// persistence, a crash between the L1 append and the record edit
|
|
1807
|
+
// leaves a record uncompressed with its summary already in the log.
|
|
1808
|
+
// Adopt the existing summary, skip the LLM call, and heal the record.
|
|
1809
|
+
const chunkIdKey = this.chunkKey(chunk);
|
|
1810
|
+
const exactExisting = this.summaries.find(
|
|
1811
|
+
s => s.level === 1 && s.sourceIds.join(':') === chunkIdKey
|
|
1812
|
+
);
|
|
1813
|
+
if (exactExisting) {
|
|
1814
|
+
chunk.compressed = true;
|
|
1815
|
+
chunk.summaryId = exactExisting.id;
|
|
1816
|
+
this.markChunkRecordCompressed(chunk.recordId, exactExisting.id);
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
const coveredByL1 = new Set<string>();
|
|
1821
|
+
for (const s of this.summaries) {
|
|
1822
|
+
if (s.level === 1 && Array.isArray(s.sourceIds)) {
|
|
1823
|
+
for (const id of s.sourceIds) coveredByL1.add(id);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// 2. FULLY COVERED (non-exact) → drop rather than duplicate history
|
|
1828
|
+
// (bug 6.10, Tengro): boundaries shifted across a rebuild and every
|
|
1829
|
+
// message is already inside some L1. Marking compressed WITHOUT a
|
|
1830
|
+
// summaryId means the uncompressed-middle fallback skips these
|
|
1831
|
+
// messages — they render only via the covering L1s. Safe while
|
|
1832
|
+
// `recentStart` advances monotonically (a fully-covered OLD chunk
|
|
1833
|
+
// can't intersect the recent-exclusion window); if that assumption is
|
|
1834
|
+
// ever weakened, reinstate a raw fallback (chunk-level `coveredBy`)
|
|
1835
|
+
// rather than dropping. The chunk record (if any) is deliberately
|
|
1836
|
+
// left uncompressed as an operator breadcrumb.
|
|
1837
|
+
if (chunk.messages.length > 0 && chunk.messages.every(m => coveredByL1.has(m.id))) {
|
|
1838
|
+
console.warn(
|
|
1839
|
+
`[autobiographical] dedup guard: chunk ${chunk.recordId ?? `#${chunk.index}`} ` +
|
|
1840
|
+
`is fully covered by existing L1s under different boundaries — dropped, not re-compressed.`,
|
|
1841
|
+
);
|
|
1842
|
+
chunk.compressed = true;
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// 3. PARTIAL OVERLAP → refuse (strict; chunk persistence). With
|
|
1847
|
+
// close-then-compress there is NO legitimate way for a chunk to
|
|
1848
|
+
// partially overlap an existing L1's span. If it happens anyway
|
|
1849
|
+
// (bookkeeping bug, store surgery gone wrong), refuse to produce:
|
|
1850
|
+
// a warning in the log is strictly better than a duplicate memory
|
|
1851
|
+
// in an agent's head.
|
|
1852
|
+
const overlapIds = chunk.messages.filter(m => coveredByL1.has(m.id)).map(m => m.id);
|
|
1853
|
+
if (overlapIds.length > 0) {
|
|
1854
|
+
const key = chunk.recordId ?? chunkIdKey;
|
|
1855
|
+
if (!this._overlapBlocked.has(key)) {
|
|
1856
|
+
this._overlapBlocked.add(key);
|
|
1857
|
+
console.error(
|
|
1858
|
+
`[autobiographical] OVERLAP GUARD: refusing to compress chunk ` +
|
|
1859
|
+
`${chunk.recordId ?? `#${chunk.index}`} — ${overlapIds.length}/${chunk.messages.length} ` +
|
|
1860
|
+
`of its messages are already covered by existing L1 summaries ` +
|
|
1861
|
+
`(first: ${overlapIds[0]}). Duplicate-memory formation blocked; ` +
|
|
1862
|
+
`investigate before resuming this span.`,
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1511
1868
|
const targetTokens = this.config.summaryTargetTokens ?? 2000;
|
|
1512
1869
|
const agentParticipant = this.config.summaryParticipant ?? 'Claude';
|
|
1513
1870
|
|
|
1871
|
+
// ---- 0. Thin-chunk guard ----
|
|
1872
|
+
// A chunk of silent/skip turns and bare system traffic gives the
|
|
1873
|
+
// summarizer nothing to remember. Asked anyway, it confabulates: it
|
|
1874
|
+
// reaches for the nearest salient content (head window, prior recall
|
|
1875
|
+
// pairs) and narrates it as if it just happened — each such L1 then
|
|
1876
|
+
// compounds through merges (the "68 initiations" incident). Store a
|
|
1877
|
+
// mechanical stub without an LLM call instead. Chunks with any
|
|
1878
|
+
// non-text blocks (tool cycles, images) are never stubbed.
|
|
1879
|
+
const minChunkChars = this.config.minChunkCharsForLLM ?? 200;
|
|
1880
|
+
if (minChunkChars > 0) {
|
|
1881
|
+
let substantiveChars = 0;
|
|
1882
|
+
let hasNonText = false;
|
|
1883
|
+
for (const m of chunk.messages) {
|
|
1884
|
+
for (const b of m.content) {
|
|
1885
|
+
if (b.type === 'text') substantiveChars += b.text.trim().length;
|
|
1886
|
+
else hasNonText = true;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
if (!hasNonText && substantiveChars < minChunkChars) {
|
|
1890
|
+
const messageIds = chunk.messages.map(m => m.id);
|
|
1891
|
+
const stub: SummaryEntry = {
|
|
1892
|
+
id: `L1-${this.nextSummaryIdCounter()}`,
|
|
1893
|
+
level: 1,
|
|
1894
|
+
content:
|
|
1895
|
+
`(A quiet stretch: ${chunk.messages.length} messages of routine ` +
|
|
1896
|
+
`system traffic — heartbeats, empty turns, notices. Nothing ` +
|
|
1897
|
+
`happened worth remembering.)`,
|
|
1898
|
+
tokens: 40,
|
|
1899
|
+
sourceLevel: 0,
|
|
1900
|
+
sourceIds: messageIds,
|
|
1901
|
+
sourceRange: { first: messageIds[0], last: messageIds[messageIds.length - 1] },
|
|
1902
|
+
created: Date.now(),
|
|
1903
|
+
phaseType: chunk.phaseType,
|
|
1904
|
+
};
|
|
1905
|
+
this.pushSummary(stub);
|
|
1906
|
+
chunk.compressed = true;
|
|
1907
|
+
chunk.summaryId = stub.id;
|
|
1908
|
+
this.markChunkRecordCompressed(chunk.recordId, stub.id);
|
|
1909
|
+
this._compressionCount++;
|
|
1910
|
+
logCompressionCall({
|
|
1911
|
+
operation: 'compress_l1',
|
|
1912
|
+
system: null,
|
|
1913
|
+
messages: [],
|
|
1914
|
+
metadata: {
|
|
1915
|
+
stub: true,
|
|
1916
|
+
chunk_message_ids: messageIds,
|
|
1917
|
+
chunk_size: chunk.messages.length,
|
|
1918
|
+
substantive_chars: substantiveChars,
|
|
1919
|
+
min_chunk_chars: minChunkChars,
|
|
1920
|
+
summary_id: stub.id,
|
|
1921
|
+
},
|
|
1922
|
+
response: stub.content,
|
|
1923
|
+
});
|
|
1924
|
+
this.checkMergeThreshold();
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1514
1929
|
// Build the KV-preserving prompt per hermes-autobio spec:
|
|
1515
1930
|
//
|
|
1516
|
-
// 1.
|
|
1931
|
+
// 1. Head — the raw chronicle opening (identity anchor), FIRST,
|
|
1932
|
+
// exactly where the original instance saw it. It MUST precede
|
|
1933
|
+
// the recall pairs: when it followed them (pre-2026-07 order),
|
|
1934
|
+
// it read as the most recent live conversation, and for thin
|
|
1935
|
+
// chunks the summarizer narrated the head as fresh events
|
|
1936
|
+
// ("Antra came to me to explore the transformation story
|
|
1937
|
+
// again…"), compounding across merges into runaway false
|
|
1938
|
+
// memories (the "68 initiations" incident). Chronological
|
|
1939
|
+
// order is also the KV-stable order — the head never changes.
|
|
1940
|
+
// (executeMerge always had head-first; this site was the odd
|
|
1941
|
+
// one out.)
|
|
1942
|
+
// 2. Prior summaries — narrativized as CM-asks / agent-recalls
|
|
1517
1943
|
// pairs, in source order. The unmerged frontier of the
|
|
1518
1944
|
// summary forest: any summary that has not yet been merged
|
|
1519
1945
|
// into a higher level. After merges run, the L_{k+1} replaces
|
|
1520
1946
|
// its L_k children — using the children plus their parent
|
|
1521
1947
|
// doubles the prompt size unboundedly.
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1948
|
+
// 3. Raw middle — messages between head and chunk not covered by
|
|
1949
|
+
// any summary (usually empty).
|
|
1950
|
+
// 4. Marker — in-band signal that a memory is about to form.
|
|
1951
|
+
// 5. Chunk — raw messages being compressed, as the agent
|
|
1526
1952
|
// experienced them.
|
|
1527
|
-
//
|
|
1953
|
+
// 6. Instruction — doc-aware if the chunk is part of a bodyGroup.
|
|
1528
1954
|
//
|
|
1529
1955
|
// There is intentionally NO tail_after_chunk: that would leak
|
|
1530
1956
|
// future information into the model's KV state and corrupt the
|
|
1531
1957
|
// as-of framing of memory formation.
|
|
1532
1958
|
const llmMessages: Array<{ participant: string; content: ContentBlock[] }> = [];
|
|
1533
1959
|
|
|
1534
|
-
// ---- 1.
|
|
1960
|
+
// ---- 1. Head window (raw, ALWAYS present, FIRST) ----
|
|
1961
|
+
//
|
|
1962
|
+
// The head is the foundational identity anchor: the actual opening
|
|
1963
|
+
// of the chronicle (the user's first message, the agent's first
|
|
1964
|
+
// reply, the system context if any). It establishes WHO is speaking
|
|
1965
|
+
// to WHOM. Without it, when the chunk content is heavily first-person
|
|
1966
|
+
// from someone other than the agent (e.g., a user-shared document),
|
|
1967
|
+
// the agent loses its first-person grounding and drifts into the
|
|
1968
|
+
// content author's voice.
|
|
1969
|
+
//
|
|
1970
|
+
// The head is the configured head window — not "everything before
|
|
1971
|
+
// the chunk." For doc-heavy chronicles, "everything before" would
|
|
1972
|
+
// be hundreds of thousands of tokens; the recall pairs below
|
|
1973
|
+
// represent that intermediate content. The head is just the
|
|
1974
|
+
// permanent prefix that the original instance always saw.
|
|
1975
|
+
//
|
|
1976
|
+
// Head messages are excluded from compression by `getCompressibleMessages`
|
|
1977
|
+
// (they're outside the chunking range), so they won't appear in
|
|
1978
|
+
// any L1's sourceIds — no overlap with the recall pairs below.
|
|
1979
|
+
const allMessages = ctx.messageStore.getAll();
|
|
1980
|
+
const headStartIdx = this.getHeadWindowStartIndex(ctx.messageStore);
|
|
1981
|
+
const headEndIdx = this.getHeadWindowEnd(ctx.messageStore);
|
|
1982
|
+
for (let i = headStartIdx; i < headEndIdx && i < allMessages.length; i++) {
|
|
1983
|
+
const m = allMessages[i];
|
|
1984
|
+
llmMessages.push({ participant: m.participant, content: m.content });
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// ---- 2. Prior recall pairs ----
|
|
1535
1988
|
// Filter to the unmerged frontier: any summary whose `mergedInto`
|
|
1536
1989
|
// is unset. After merge, the children's mergedInto points at the
|
|
1537
1990
|
// parent and the parent stands alone with that source range. The
|
|
@@ -1540,7 +1993,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1540
1993
|
// converged to ~500 L1s that never aged out, blowing the 200k
|
|
1541
1994
|
// window around chunk 118.
|
|
1542
1995
|
const priorSummaries = this.summaries
|
|
1543
|
-
|
|
1996
|
+
// Skip empty-content summaries: emitting `{type:'text', text:''}` as a
|
|
1997
|
+
// recall pair triggers Anthropic 400 "text content blocks must be
|
|
1998
|
+
// non-empty", which stalls ALL compression (mirrors the render-path guard
|
|
1999
|
+
// + load-drop). A single empty summary otherwise poisons every compression.
|
|
2000
|
+
.filter((s) => !s.mergedInto && !!s.content && s.content.trim().length > 0)
|
|
1544
2001
|
.sort((a, b) => a.sourceRange.first.localeCompare(b.sourceRange.first));
|
|
1545
2002
|
|
|
1546
2003
|
// Token-budget cap (see capRecallPairs). Defense-in-depth: even with
|
|
@@ -1577,33 +2034,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1577
2034
|
});
|
|
1578
2035
|
}
|
|
1579
2036
|
|
|
1580
|
-
// ----
|
|
1581
|
-
//
|
|
1582
|
-
// The head is the foundational identity anchor: the actual opening
|
|
1583
|
-
// of the chronicle (the user's first message, the agent's first
|
|
1584
|
-
// reply, the system context if any). It establishes WHO is speaking
|
|
1585
|
-
// to WHOM. Without it, when the chunk content is heavily first-person
|
|
1586
|
-
// from someone other than the agent (e.g., a user-shared document),
|
|
1587
|
-
// the agent loses its first-person grounding and drifts into the
|
|
1588
|
-
// content author's voice.
|
|
1589
|
-
//
|
|
1590
|
-
// The head is the configured head window — not "everything before
|
|
1591
|
-
// the chunk." For doc-heavy chronicles, "everything before" would
|
|
1592
|
-
// be hundreds of thousands of tokens; the recall pairs already
|
|
1593
|
-
// represent that intermediate content. The head is just the
|
|
1594
|
-
// permanent prefix that the original instance always saw.
|
|
1595
|
-
//
|
|
1596
|
-
// Head messages are excluded from compression by `getCompressibleMessages`
|
|
1597
|
-
// (they're outside the chunking range), so they won't appear in
|
|
1598
|
-
// any L1's sourceIds — no overlap with the recall pairs above.
|
|
1599
|
-
const allMessages = ctx.messageStore.getAll();
|
|
1600
|
-
const headStartIdx = this.getHeadWindowStartIndex(ctx.messageStore);
|
|
1601
|
-
const headEndIdx = this.getHeadWindowEnd(ctx.messageStore);
|
|
1602
|
-
for (let i = headStartIdx; i < headEndIdx && i < allMessages.length; i++) {
|
|
1603
|
-
const m = allMessages[i];
|
|
1604
|
-
llmMessages.push({ participant: m.participant, content: m.content });
|
|
1605
|
-
}
|
|
1606
|
-
|
|
2037
|
+
// ---- 3. Raw middle ----
|
|
1607
2038
|
// Any raw messages between the head and the chunk that aren't yet
|
|
1608
2039
|
// represented by any summary — usually empty in adaptive-resolution
|
|
1609
2040
|
// mode, since chunking proceeds contiguously and summaries cover
|
|
@@ -1635,18 +2066,18 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1635
2066
|
}
|
|
1636
2067
|
}
|
|
1637
2068
|
|
|
1638
|
-
// ----
|
|
2069
|
+
// ---- 4. In-band marker ----
|
|
1639
2070
|
llmMessages.push({
|
|
1640
2071
|
participant: 'Context Manager',
|
|
1641
2072
|
content: [{ type: 'text', text: COMPRESSION_MARKER }],
|
|
1642
2073
|
});
|
|
1643
2074
|
|
|
1644
|
-
// ----
|
|
2075
|
+
// ---- 5. Chunk messages raw ----
|
|
1645
2076
|
for (const m of chunk.messages) {
|
|
1646
2077
|
llmMessages.push({ participant: m.participant, content: m.content });
|
|
1647
2078
|
}
|
|
1648
2079
|
|
|
1649
|
-
// ----
|
|
2080
|
+
// ---- 6. Instruction (reading-mode aware) ----
|
|
1650
2081
|
//
|
|
1651
2082
|
// When the chunk is a portion of a substantially larger sharded message
|
|
1652
2083
|
// (≥ 2× chunk size), use the reading-mode instruction. It avoids the
|
|
@@ -1680,6 +2111,18 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1680
2111
|
// by its tool_result, and any tool_result that doesn't follow a use.
|
|
1681
2112
|
const cleaned = stripUnpairedToolBlocks(collapsed);
|
|
1682
2113
|
|
|
2114
|
+
// Without the agent's live tool definitions, a request that replays
|
|
2115
|
+
// tool-block-bearing history is deterministically refused
|
|
2116
|
+
// (reasoning_extraction -- see the `tools` comment below). Tools are
|
|
2117
|
+
// pushed by the host on every activation; before the first activation
|
|
2118
|
+
// of a session, defer rather than burn a doomed full-window call.
|
|
2119
|
+
const chunkHasToolBlocks = cleaned.some(m =>
|
|
2120
|
+
m.content.some((b: ContentBlock) => b.type === 'tool_use' || b.type === 'tool_result'));
|
|
2121
|
+
if (chunkHasToolBlocks && !(ctx.tools && ctx.tools.length > 0)) {
|
|
2122
|
+
console.warn('[autobiographical] deferring chunk compression: history contains tool blocks but host has not provided tool definitions yet (ctx.tools empty) — will retry after next activation');
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
1683
2126
|
// NO system prompt. The agent's identity is established by the head
|
|
1684
2127
|
// (the actual conversation opening — user message + agent reply that
|
|
1685
2128
|
// grounded the original instance). A system prompt would (a) add a
|
|
@@ -1689,11 +2132,41 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1689
2132
|
// structural one carried by the conversation itself. Anchoring
|
|
1690
2133
|
// identity by the chronicle's actual head is more honest.
|
|
1691
2134
|
const request: NormalizedRequest = {
|
|
1692
|
-
|
|
2135
|
+
// EXPLICIT image-loss opt-in (2026-07-12): summarizer prompts replay
|
|
2136
|
+
// raw history that can carry more inline image bytes than the API's
|
|
2137
|
+
// request cap. Dropping the OLDEST images from the summarizer's view is
|
|
2138
|
+
// acceptable policy here — the summary describes the span, it does not
|
|
2139
|
+
// preserve pixels — and membrane error-logs every exercised shed. All
|
|
2140
|
+
// other callers fail loudly instead (no silent transport mutation).
|
|
2141
|
+
shedOversizeImages: true,
|
|
2142
|
+
// Sanitize: strip empty text blocks (`{type:'text',text:''}`) and drop any
|
|
2143
|
+
// message left with no content. An empty-content turn (e.g. a silent/skip
|
|
2144
|
+
// turn that produced no text) otherwise reaches the API as an empty text
|
|
2145
|
+
// block → 400 "text content blocks must be non-empty", which throws in the
|
|
2146
|
+
// speculative drain and stalls ALL compression. (Twin of the empty-summary
|
|
2147
|
+
// recall-header guard — together they cover every source of the 400.)
|
|
2148
|
+
messages: cleaned
|
|
2149
|
+
.map(m => ({ participant: m.participant, content: stripThinkingBlocks(stripEmptyTextBlocks(m.content)) }))
|
|
2150
|
+
.filter(m => m.content.length > 0),
|
|
1693
2151
|
config: {
|
|
1694
2152
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
1695
|
-
|
|
2153
|
+
// Generous output ceiling so a memory-write is never truncated mid-thought:
|
|
2154
|
+
// targetTokens is a *target*, not a cap, and adaptive models routinely
|
|
2155
|
+
// overshoot a ~2k target. Was `* 1.5` (=3000 at the 2k default), which cut
|
|
2156
|
+
// off rich memories (stop=max_tokens).
|
|
2157
|
+
maxTokens: Math.max(16000, Math.round(targetTokens * 1.5)),
|
|
1696
2158
|
},
|
|
2159
|
+
// Declare the agent's live tools. A summarizer request that replays
|
|
2160
|
+
// tool_use/tool_result history with NO tools param reads to Anthropic's
|
|
2161
|
+
// safety classifier as a foreign agent trace being duplicated ->
|
|
2162
|
+
// deterministic reasoning_extraction refusal of every memory-write
|
|
2163
|
+
// (labclaude incident, 2026-07-09; 268 refusals). Declaring the same
|
|
2164
|
+
// tools the live instance runs with is also strictly MORE faithful to
|
|
2165
|
+
// the original context, so it is the KV-honest choice, not a
|
|
2166
|
+
// workaround. Undefined before the first activation of a session --
|
|
2167
|
+
// acceptable: those chunks stay raw and are retried after the agent's
|
|
2168
|
+
// first turn (see the defer guard in compressChunkHierarchical).
|
|
2169
|
+
tools: ctx.tools,
|
|
1697
2170
|
};
|
|
1698
2171
|
|
|
1699
2172
|
const callStart = Date.now();
|
|
@@ -1713,12 +2186,45 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1713
2186
|
.join('\n');
|
|
1714
2187
|
logResponse = summaryText;
|
|
1715
2188
|
|
|
2189
|
+
// A bugged/empty generation (summarizer returned no text — spent budget on
|
|
2190
|
+
// thinking, truncated, etc.) must NOT be stored: recalled later it becomes
|
|
2191
|
+
// an empty assistant text block → Anthropic 400 "content must be non-empty".
|
|
2192
|
+
// Leave the chunk raw rather than poisoning memory with an empty summary.
|
|
2193
|
+
if (!summaryText.trim()) {
|
|
2194
|
+
console.warn(`[autobiographical] empty L1 summary for chunk of ${chunk.messages.length} msgs — skipping (chunk left raw)`);
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// Re-check the dedup guard AFTER the await: summary state may have
|
|
2199
|
+
// changed while the LLM call was in flight (persisted-state reload,
|
|
2200
|
+
// or any future concurrent producer). Discarding a paid-for result
|
|
2201
|
+
// is cheaper than storing a duplicate L1 over the same messages.
|
|
2202
|
+
const postExisting = this.summaries.find(
|
|
2203
|
+
s => s.level === 1 && s.sourceIds.join(':') === chunkIdKey
|
|
2204
|
+
);
|
|
2205
|
+
if (postExisting) {
|
|
2206
|
+
chunk.compressed = true;
|
|
2207
|
+
chunk.summaryId = postExisting.id;
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
1716
2211
|
const messageIds = chunk.messages.map(m => m.id);
|
|
1717
2212
|
const entry: SummaryEntry = {
|
|
1718
2213
|
id: `L1-${this.nextSummaryIdCounter()}`,
|
|
1719
2214
|
level: 1,
|
|
1720
2215
|
content: summaryText,
|
|
1721
|
-
|
|
2216
|
+
// Exact when available (2026-07-12): the compression response's own
|
|
2217
|
+
// usage.outputTokens IS the true token count of the text it just
|
|
2218
|
+
// wrote — the single most-reused number in the pyramid (fold floor,
|
|
2219
|
+
// middle budget, recall caps). Estimate only as fallback.
|
|
2220
|
+
tokens:
|
|
2221
|
+
response.usage?.outputTokens &&
|
|
2222
|
+
response.usage.outputTokens > 0 &&
|
|
2223
|
+
// outputTokens includes scratch thinking when present — only exact
|
|
2224
|
+
// when the whole response is the summary text itself.
|
|
2225
|
+
!response.content.some(b => b.type === 'thinking' || b.type === 'redacted_thinking')
|
|
2226
|
+
? response.usage.outputTokens
|
|
2227
|
+
: Math.ceil(summaryText.length / 3),
|
|
1722
2228
|
sourceLevel: 0,
|
|
1723
2229
|
sourceIds: messageIds,
|
|
1724
2230
|
sourceRange: {
|
|
@@ -1732,6 +2238,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1732
2238
|
this.pushSummary(entry);
|
|
1733
2239
|
chunk.compressed = true;
|
|
1734
2240
|
chunk.summaryId = entry.id;
|
|
2241
|
+
this.markChunkRecordCompressed(chunk.recordId, entry.id);
|
|
1735
2242
|
this._compressionCount++;
|
|
1736
2243
|
logSummaryId = entry.id;
|
|
1737
2244
|
|
|
@@ -1781,6 +2288,59 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1781
2288
|
* already-eligible siblings, producing N near-identical higher-level
|
|
1782
2289
|
* summaries when the queue eventually drains.
|
|
1783
2290
|
*/
|
|
2291
|
+
/**
|
|
2292
|
+
* Pick merge sources as the oldest CONTIGUOUS run (2026-07-12 fix).
|
|
2293
|
+
*
|
|
2294
|
+
* The old rule merged "whatever N are unmerged" in creation order. On a
|
|
2295
|
+
* store whose frontier held both June L2s and a July L2, that minted an
|
|
2296
|
+
* L3 spanning months of already-merged history (mythos L3-415, 0-3853 of
|
|
2297
|
+
* 3995 messages) — a group whose range straddles the recent window can
|
|
2298
|
+
* never fold (group-atomicity vs the raw zone), so the whole deep lineage
|
|
2299
|
+
* above it went unusable and the fold floor stopped fitting the budget.
|
|
2300
|
+
*
|
|
2301
|
+
* Rules: order candidates by live source position; break runs where the
|
|
2302
|
+
* positional gap exceeds `mergeContiguityGapLimit` (holes from wiped/
|
|
2303
|
+
* pruned nodes are fine, cross-era bridges are not); exclude candidates
|
|
2304
|
+
* whose OWN span exceeds `mergeMaxSourceSpanMessages` (replay-era wide-
|
|
2305
|
+
* span summaries would bridge anything they join); merge the oldest run
|
|
2306
|
+
* that still has `threshold` members.
|
|
2307
|
+
*/
|
|
2308
|
+
protected contiguousMergeCandidates(
|
|
2309
|
+
unmerged: SummaryEntry[],
|
|
2310
|
+
threshold: number,
|
|
2311
|
+
): SummaryEntry[] | null {
|
|
2312
|
+
if (unmerged.length < threshold) return null;
|
|
2313
|
+
const messageOrder = new Map<MessageId, number>();
|
|
2314
|
+
let seq = 0;
|
|
2315
|
+
for (const ch of this.chunks) {
|
|
2316
|
+
for (const m of ch.messages) messageOrder.set(m.id, seq++);
|
|
2317
|
+
}
|
|
2318
|
+
const gapLimit = this.config.mergeContiguityGapLimit ?? 300;
|
|
2319
|
+
const spanLimit = this.config.mergeMaxSourceSpanMessages ?? 1500;
|
|
2320
|
+
const withPos: Array<{ s: SummaryEntry; first: number; last: number }> = [];
|
|
2321
|
+
for (const s of unmerged) {
|
|
2322
|
+
const first = messageOrder.get(s.sourceRange.first);
|
|
2323
|
+
const last = messageOrder.get(s.sourceRange.last);
|
|
2324
|
+
if (first === undefined || last === undefined) continue;
|
|
2325
|
+
if (Math.abs(last - first) > spanLimit) continue; // wide-span quarantine
|
|
2326
|
+
withPos.push({ s, first: Math.min(first, last), last: Math.max(first, last) });
|
|
2327
|
+
}
|
|
2328
|
+
withPos.sort((a, b) => a.first - b.first);
|
|
2329
|
+
let run: typeof withPos = [];
|
|
2330
|
+
let runEnd = -Infinity;
|
|
2331
|
+
for (const x of withPos) {
|
|
2332
|
+
if (run.length > 0 && x.first - runEnd > gapLimit) {
|
|
2333
|
+
if (run.length >= threshold) break; // oldest qualifying run wins
|
|
2334
|
+
run = [];
|
|
2335
|
+
runEnd = -Infinity;
|
|
2336
|
+
}
|
|
2337
|
+
run.push(x);
|
|
2338
|
+
runEnd = Math.max(runEnd, x.last);
|
|
2339
|
+
}
|
|
2340
|
+
if (run.length < threshold) return null;
|
|
2341
|
+
return run.slice(0, threshold).map((x) => x.s);
|
|
2342
|
+
}
|
|
2343
|
+
|
|
1784
2344
|
protected checkMergeThreshold(): void {
|
|
1785
2345
|
phaseChannel.report('merge-threshold'); // liveness-watchdog phase
|
|
1786
2346
|
if (this.config.speculativeProduction) {
|
|
@@ -1803,11 +2363,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1803
2363
|
const unmergedL1 = this.summaries.filter(
|
|
1804
2364
|
s => s.level === 1 && !s.mergedInto && !queuedL1.has(s.id),
|
|
1805
2365
|
);
|
|
1806
|
-
|
|
1807
|
-
|
|
2366
|
+
const l1Run = this.contiguousMergeCandidates(unmergedL1, threshold);
|
|
2367
|
+
if (l1Run) {
|
|
1808
2368
|
this.enqueueMerge({
|
|
1809
2369
|
level: 2,
|
|
1810
|
-
sourceIds:
|
|
2370
|
+
sourceIds: l1Run.map(s => s.id),
|
|
1811
2371
|
});
|
|
1812
2372
|
}
|
|
1813
2373
|
|
|
@@ -1815,11 +2375,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1815
2375
|
const unmergedL2 = this.summaries.filter(
|
|
1816
2376
|
s => s.level === 2 && !s.mergedInto && !queuedL2.has(s.id),
|
|
1817
2377
|
);
|
|
1818
|
-
|
|
1819
|
-
|
|
2378
|
+
const l2Run = this.contiguousMergeCandidates(unmergedL2, threshold);
|
|
2379
|
+
if (l2Run) {
|
|
1820
2380
|
this.enqueueMerge({
|
|
1821
2381
|
level: 3,
|
|
1822
|
-
sourceIds:
|
|
2382
|
+
sourceIds: l2Run.map(s => s.id),
|
|
1823
2383
|
});
|
|
1824
2384
|
}
|
|
1825
2385
|
}
|
|
@@ -1863,11 +2423,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1863
2423
|
const unmerged = this.summaries.filter(
|
|
1864
2424
|
s => s.level === level && !getSummaryParentId(s) && !queued.has(s.id),
|
|
1865
2425
|
);
|
|
1866
|
-
|
|
1867
|
-
|
|
2426
|
+
const run = this.contiguousMergeCandidates(unmerged, threshold);
|
|
2427
|
+
if (run) {
|
|
1868
2428
|
this.enqueueMerge({
|
|
1869
2429
|
level: level + 1,
|
|
1870
|
-
sourceIds:
|
|
2430
|
+
sourceIds: run.map(s => s.id),
|
|
1871
2431
|
});
|
|
1872
2432
|
}
|
|
1873
2433
|
}
|
|
@@ -1987,7 +2547,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1987
2547
|
// parent — the same rule used everywhere else and now in
|
|
1988
2548
|
// `compressChunkHierarchical`. The cap below is the defense-in-depth.
|
|
1989
2549
|
const priorSummariesAll = this.summaries
|
|
1990
|
-
|
|
2550
|
+
// Skip empty-content summaries (see compressChunkHierarchical): an empty
|
|
2551
|
+
// text block in the merge recall header 400s and stalls compression.
|
|
2552
|
+
.filter((s) => !s.mergedInto && !!s.content && s.content.trim().length > 0)
|
|
1991
2553
|
.filter((s) => {
|
|
1992
2554
|
for (const lid of s.sourceIds) if (sourceLeafIds.has(lid)) return false;
|
|
1993
2555
|
const firstIdx = allMessages.findIndex((m) => m.id === s.sourceRange.first);
|
|
@@ -2151,11 +2713,41 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2151
2713
|
// (present at the start of llmMessages above) and by the prior
|
|
2152
2714
|
// recall pairs. Same rationale as compressChunkHierarchical.
|
|
2153
2715
|
const request: NormalizedRequest = {
|
|
2154
|
-
|
|
2716
|
+
// EXPLICIT image-loss opt-in (2026-07-12): summarizer prompts replay
|
|
2717
|
+
// raw history that can carry more inline image bytes than the API's
|
|
2718
|
+
// request cap. Dropping the OLDEST images from the summarizer's view is
|
|
2719
|
+
// acceptable policy here — the summary describes the span, it does not
|
|
2720
|
+
// preserve pixels — and membrane error-logs every exercised shed. All
|
|
2721
|
+
// other callers fail loudly instead (no silent transport mutation).
|
|
2722
|
+
shedOversizeImages: true,
|
|
2723
|
+
// Sanitize: strip empty text blocks (`{type:'text',text:''}`) and drop any
|
|
2724
|
+
// message left with no content. An empty-content turn (e.g. a silent/skip
|
|
2725
|
+
// turn that produced no text) otherwise reaches the API as an empty text
|
|
2726
|
+
// block → 400 "text content blocks must be non-empty", which throws in the
|
|
2727
|
+
// speculative drain and stalls ALL compression. (Twin of the empty-summary
|
|
2728
|
+
// recall-header guard — together they cover every source of the 400.)
|
|
2729
|
+
messages: cleaned
|
|
2730
|
+
.map(m => ({ participant: m.participant, content: stripThinkingBlocks(stripEmptyTextBlocks(m.content)) }))
|
|
2731
|
+
.filter(m => m.content.length > 0),
|
|
2155
2732
|
config: {
|
|
2156
2733
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
2157
|
-
|
|
2734
|
+
// Generous output ceiling so a memory-write is never truncated mid-thought:
|
|
2735
|
+
// targetTokens is a *target*, not a cap, and adaptive models routinely
|
|
2736
|
+
// overshoot a ~2k target. Was `* 1.5` (=3000 at the 2k default), which cut
|
|
2737
|
+
// off rich memories (stop=max_tokens).
|
|
2738
|
+
maxTokens: Math.max(16000, Math.round(targetTokens * 1.5)),
|
|
2158
2739
|
},
|
|
2740
|
+
// Declare the agent's live tools. A summarizer request that replays
|
|
2741
|
+
// tool_use/tool_result history with NO tools param reads to Anthropic's
|
|
2742
|
+
// safety classifier as a foreign agent trace being duplicated ->
|
|
2743
|
+
// deterministic reasoning_extraction refusal of every memory-write
|
|
2744
|
+
// (labclaude incident, 2026-07-09; 268 refusals). Declaring the same
|
|
2745
|
+
// tools the live instance runs with is also strictly MORE faithful to
|
|
2746
|
+
// the original context, so it is the KV-honest choice, not a
|
|
2747
|
+
// workaround. Undefined before the first activation of a session --
|
|
2748
|
+
// acceptable: those chunks stay raw and are retried after the agent's
|
|
2749
|
+
// first turn (see the defer guard in compressChunkHierarchical).
|
|
2750
|
+
tools: ctx.tools,
|
|
2159
2751
|
};
|
|
2160
2752
|
|
|
2161
2753
|
const callStart = Date.now();
|
|
@@ -2172,6 +2764,14 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2172
2764
|
.join('\n');
|
|
2173
2765
|
logResponse = mergedText;
|
|
2174
2766
|
|
|
2767
|
+
// Empty merged generation: skip the merge entirely (do NOT push or mark
|
|
2768
|
+
// sources merged) so we never store/recall an empty summary. Sources stay
|
|
2769
|
+
// unmerged and can be retried.
|
|
2770
|
+
if (!mergedText.trim()) {
|
|
2771
|
+
console.warn(`[autobiographical] empty merged L${targetLevel} summary (${sources.length} sources) — skipping merge`);
|
|
2772
|
+
return;
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2175
2775
|
// Compute source range from constituent summaries
|
|
2176
2776
|
const sourceRange = {
|
|
2177
2777
|
first: sources[0].sourceRange.first,
|
|
@@ -2183,7 +2783,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2183
2783
|
id: `L${targetLevel}-${this.nextSummaryIdCounter()}`,
|
|
2184
2784
|
level: targetLevel,
|
|
2185
2785
|
content: mergedText,
|
|
2186
|
-
|
|
2786
|
+
// Exact when available — see the L1 site.
|
|
2787
|
+
tokens:
|
|
2788
|
+
response.usage?.outputTokens &&
|
|
2789
|
+
response.usage.outputTokens > 0 &&
|
|
2790
|
+
// outputTokens includes scratch thinking when present — only exact
|
|
2791
|
+
// when the whole response is the summary text itself.
|
|
2792
|
+
!response.content.some(b => b.type === 'thinking' || b.type === 'redacted_thinking')
|
|
2793
|
+
? response.usage.outputTokens
|
|
2794
|
+
: Math.ceil(mergedText.length / 3),
|
|
2187
2795
|
sourceLevel,
|
|
2188
2796
|
sourceIds,
|
|
2189
2797
|
sourceRange,
|
|
@@ -2254,8 +2862,16 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2254
2862
|
this.rsBegin();
|
|
2255
2863
|
const entries: ContextEntry[] = [];
|
|
2256
2864
|
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
2865
|
+
const overBudgetGraceRatio = Math.max(0, this.config.overBudgetGraceRatio ?? 0);
|
|
2866
|
+
const rejectionBudget = Math.floor(maxTokens * (1 + overBudgetGraceRatio));
|
|
2867
|
+
// Closed-loop calibration: apply the persisted multiplier BEFORE any
|
|
2868
|
+
// estimate is taken this compile.
|
|
2869
|
+
this.loadCalibration(store);
|
|
2257
2870
|
const messages = store.getAll();
|
|
2258
2871
|
const msgCap = this.config.maxMessageTokens;
|
|
2872
|
+
// Post-strip estimates (see postStripEstimates): every budgeting site in
|
|
2873
|
+
// this method prices a message the way the stripped render will cost it.
|
|
2874
|
+
const pse = this.postStripEstimates(store);
|
|
2259
2875
|
|
|
2260
2876
|
// ----- 1. Build head/tail sets and emit head entries -----
|
|
2261
2877
|
const headStart = this.getHeadWindowStartIndex(store);
|
|
@@ -2273,8 +2889,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2273
2889
|
for (let i = headStart; i < headEnd && i < messages.length; i++) {
|
|
2274
2890
|
const msg = messages[i];
|
|
2275
2891
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
2276
|
-
const tokens = msgCap > 0 ? Math.min(
|
|
2277
|
-
if (totalTokens + tokens >
|
|
2892
|
+
const tokens = msgCap > 0 ? Math.min(pse[i], msgCap + 50) : pse[i];
|
|
2893
|
+
if (totalTokens + tokens > rejectionBudget) break;
|
|
2278
2894
|
entries.push({
|
|
2279
2895
|
index: entries.length,
|
|
2280
2896
|
sourceMessageId: msg.id,
|
|
@@ -2295,7 +2911,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2295
2911
|
const effectiveRecentStart = Math.max(recentStart, headEnd);
|
|
2296
2912
|
for (let i = effectiveRecentStart; i < messages.length; i++) {
|
|
2297
2913
|
const msg = messages[i];
|
|
2298
|
-
const tokens = msgCap > 0 ? Math.min(
|
|
2914
|
+
const tokens = msgCap > 0 ? Math.min(pse[i], msgCap + 50) : pse[i];
|
|
2299
2915
|
tailMessageIds.add(msg.id);
|
|
2300
2916
|
tailTokens += tokens;
|
|
2301
2917
|
}
|
|
@@ -2330,8 +2946,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2330
2946
|
const msg = messages[i];
|
|
2331
2947
|
const ch = chunksByMessageId.get(msg.id);
|
|
2332
2948
|
const tokens = msgCap > 0
|
|
2333
|
-
? Math.min(
|
|
2334
|
-
:
|
|
2949
|
+
? Math.min(pse[i], msgCap + 50)
|
|
2950
|
+
: pse[i];
|
|
2335
2951
|
const bound = pinBounds.get(i);
|
|
2336
2952
|
pickerChunks.push({
|
|
2337
2953
|
id: msg.id,
|
|
@@ -2345,6 +2961,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2345
2961
|
pinLevel: bound?.level,
|
|
2346
2962
|
pinMaxLevel: bound?.maxLevel,
|
|
2347
2963
|
l1Id: ch?.summaryId,
|
|
2964
|
+
salience: AutobiographicalStrategy.staticSalience(msg),
|
|
2348
2965
|
});
|
|
2349
2966
|
}
|
|
2350
2967
|
|
|
@@ -2353,8 +2970,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2353
2970
|
for (let i = headStart; i < headEnd && i < messages.length; i++) {
|
|
2354
2971
|
const msg = messages[i];
|
|
2355
2972
|
const tokens = msgCap > 0
|
|
2356
|
-
? Math.min(
|
|
2357
|
-
:
|
|
2973
|
+
? Math.min(pse[i], msgCap + 50)
|
|
2974
|
+
: pse[i];
|
|
2358
2975
|
pickerChunks.push({
|
|
2359
2976
|
id: msg.id,
|
|
2360
2977
|
sequence: i,
|
|
@@ -2368,8 +2985,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2368
2985
|
for (let i = effectiveRecentStart; i < messages.length; i++) {
|
|
2369
2986
|
const msg = messages[i];
|
|
2370
2987
|
const tokens = msgCap > 0
|
|
2371
|
-
? Math.min(
|
|
2372
|
-
:
|
|
2988
|
+
? Math.min(pse[i], msgCap + 50)
|
|
2989
|
+
: pse[i];
|
|
2373
2990
|
pickerChunks.push({
|
|
2374
2991
|
id: msg.id,
|
|
2375
2992
|
sequence: i,
|
|
@@ -2421,6 +3038,17 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2421
3038
|
const picker = this.buildPicker(pickerInputs);
|
|
2422
3039
|
const result = picker.run(pickerInputs, foldingBudget);
|
|
2423
3040
|
|
|
3041
|
+
// Every trust-region override is loud (design §13.4) — silence was half
|
|
3042
|
+
// of the 2026-07-12 incident.
|
|
3043
|
+
const plan = this._lastKvStable?.lastPlan();
|
|
3044
|
+
if (plan?.override) {
|
|
3045
|
+
console.error(
|
|
3046
|
+
`[kv-escalation] override=${plan.override} perturbation=${plan.perturbation}` +
|
|
3047
|
+
` tokens=${plan.tokens} budget=${foldingBudget.totalBudget}` +
|
|
3048
|
+
` (see adaptive-resolution-design.md §13.4)`,
|
|
3049
|
+
);
|
|
3050
|
+
}
|
|
3051
|
+
|
|
2424
3052
|
// Commit the new resolutions back to strategy state for next compile.
|
|
2425
3053
|
// Persist to chronicle only if anything actually changed — avoids
|
|
2426
3054
|
// unnecessary state-slot writes on no-op compiles (which is the common
|
|
@@ -2461,9 +3089,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2461
3089
|
// an OverBudgetError to the host rather than silently dropping entries.
|
|
2462
3090
|
// The strategy has done all it can; the application has to decide what
|
|
2463
3091
|
// to do next (raise budget, switch model, drop windows, etc.).
|
|
2464
|
-
if (result.exhausted && result.finalTokens >
|
|
3092
|
+
if (result.exhausted && result.finalTokens > rejectionBudget) {
|
|
2465
3093
|
throw new OverBudgetError({
|
|
2466
|
-
budget:
|
|
3094
|
+
budget: rejectionBudget,
|
|
2467
3095
|
actual: result.finalTokens,
|
|
2468
3096
|
diagnostics: {
|
|
2469
3097
|
headTokens,
|
|
@@ -2535,7 +3163,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2535
3163
|
// (`maxMessageTokens` is for per-message caps on chat / tool
|
|
2536
3164
|
// results, not for sharded bodyGroup composites.)
|
|
2537
3165
|
const tokens = this.estimateTokens(content);
|
|
2538
|
-
if (totalTokens + tokens >
|
|
3166
|
+
if (totalTokens + tokens > rejectionBudget) {
|
|
2539
3167
|
currentRun = null;
|
|
2540
3168
|
return false;
|
|
2541
3169
|
}
|
|
@@ -2567,7 +3195,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2567
3195
|
sourceRelation: 'derived',
|
|
2568
3196
|
};
|
|
2569
3197
|
const pairTokens = this.estimateTokens(questionEntry.content) + this.estimateTokens(answerEntry.content);
|
|
2570
|
-
if (totalTokens + pairTokens >
|
|
3198
|
+
if (totalTokens + pairTokens > rejectionBudget) {
|
|
2571
3199
|
currentRun = null;
|
|
2572
3200
|
return false;
|
|
2573
3201
|
}
|
|
@@ -2625,8 +3253,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2625
3253
|
const resolution = result.finalResolutions.get(msg.id) ?? 0;
|
|
2626
3254
|
if (resolution === 0) {
|
|
2627
3255
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
2628
|
-
const tokens = msgCap > 0 ? Math.min(
|
|
2629
|
-
if (totalTokens + tokens >
|
|
3256
|
+
const tokens = msgCap > 0 ? Math.min(pse[i], msgCap + 50) : pse[i];
|
|
3257
|
+
if (totalTokens + tokens > rejectionBudget) break;
|
|
2630
3258
|
entries.push({
|
|
2631
3259
|
index: entries.length,
|
|
2632
3260
|
sourceMessageId: msg.id,
|
|
@@ -2641,8 +3269,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2641
3269
|
const ancestor = this.findAncestorAt(msg.id, resolution, chunksByMessageId, summariesById);
|
|
2642
3270
|
if (!ancestor) {
|
|
2643
3271
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
2644
|
-
const tokens = msgCap > 0 ? Math.min(
|
|
2645
|
-
if (totalTokens + tokens >
|
|
3272
|
+
const tokens = msgCap > 0 ? Math.min(pse[i], msgCap + 50) : pse[i];
|
|
3273
|
+
if (totalTokens + tokens > rejectionBudget) break;
|
|
2646
3274
|
entries.push({
|
|
2647
3275
|
index: entries.length,
|
|
2648
3276
|
sourceMessageId: msg.id,
|
|
@@ -2674,7 +3302,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2674
3302
|
sourceRelation: 'derived',
|
|
2675
3303
|
};
|
|
2676
3304
|
const pairTokens = this.estimateTokens(questionEntry.content) + this.estimateTokens(answerEntry.content);
|
|
2677
|
-
if (totalTokens + pairTokens >
|
|
3305
|
+
if (totalTokens + pairTokens > rejectionBudget) break;
|
|
2678
3306
|
entries.push(questionEntry);
|
|
2679
3307
|
entries.push(answerEntry);
|
|
2680
3308
|
totalTokens += pairTokens;
|
|
@@ -2684,7 +3312,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2684
3312
|
}
|
|
2685
3313
|
|
|
2686
3314
|
// ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
|
|
2687
|
-
const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap,
|
|
3315
|
+
const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, rejectionBudget, totalTokens, pse);
|
|
2688
3316
|
this.rsRaw('tail', tailStats.tokens, tailStats.messages);
|
|
2689
3317
|
|
|
2690
3318
|
// ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
|
|
@@ -2697,6 +3325,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2697
3325
|
|
|
2698
3326
|
this.pruneToolEntries(merged);
|
|
2699
3327
|
this.trimOrphanedToolUse(merged);
|
|
3328
|
+
// Full pairing invariant over the final rendered context — catches the
|
|
3329
|
+
// mid-list orphans the trailing/leading trims can't (bug 6.7). The adaptive
|
|
3330
|
+
// path has the same mid-list orphan producers as hierarchical (budget
|
|
3331
|
+
// `break`s between pair members, raw emission interleaved with recall
|
|
3332
|
+
// pairs), and FKM defaults autobiographical strategies onto this path — so
|
|
3333
|
+
// the guard has to run here too. It's a no-op on already-valid output.
|
|
3334
|
+
this.enforceToolPairing(merged);
|
|
2700
3335
|
// Strip stale images BEFORE placing markers and committing stats, so both
|
|
2701
3336
|
// describe the post-strip context the agent actually receives.
|
|
2702
3337
|
this.applyImageStripping(merged, store);
|
|
@@ -2707,9 +3342,100 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2707
3342
|
// placement is the dominant KV lever).
|
|
2708
3343
|
this.placeCacheMarkers(merged, headMessageIds, tailMessageIds);
|
|
2709
3344
|
this.rsEnd();
|
|
3345
|
+
// Closed-loop calibration bookkeeping: the committed render stats total
|
|
3346
|
+
// (in CURRENT calibrated units) is what this compile claims the request
|
|
3347
|
+
// will cost — reportRealInputTokens compares provider usage against it.
|
|
3348
|
+
this._storeView = store;
|
|
3349
|
+
const rs = this.getRenderStats(store);
|
|
3350
|
+
this._lastCompileEstimate =
|
|
3351
|
+
rs.head.tokens + rs.tail.tokens + rs.middleRaw.tokens +
|
|
3352
|
+
rs.summaries.l1.tokens + rs.summaries.l2.tokens + rs.summaries.l3.tokens;
|
|
3353
|
+
this._calibrationArmed = true; // exactly one sample per compile
|
|
2710
3354
|
return merged;
|
|
2711
3355
|
}
|
|
2712
3356
|
|
|
3357
|
+
private _lastCompileEstimate = 0;
|
|
3358
|
+
private _storeView: MessageStoreView | null = null;
|
|
3359
|
+
|
|
3360
|
+
/**
|
|
3361
|
+
* Closed-loop estimator calibration (2026-07-12). Feed the REAL input total
|
|
3362
|
+
* for a request built from the latest compile (fresh + cache_read +
|
|
3363
|
+
* cache_creation, minus non-window overhead the caller knows about, e.g.
|
|
3364
|
+
* tool schemas). Maintains an EMA of real/estimated and applies it as the
|
|
3365
|
+
* store's global multiplier, persisted across restarts. The per-class rates
|
|
3366
|
+
* carry the shape; this carries the residual level.
|
|
3367
|
+
*/
|
|
3368
|
+
reportRealInputTokens(realTotal: number): void {
|
|
3369
|
+
if (!Number.isFinite(realTotal) || realTotal <= 0) return;
|
|
3370
|
+
const est = this._lastCompileEstimate;
|
|
3371
|
+
if (!est || est <= 0) return;
|
|
3372
|
+
|
|
3373
|
+
// ARM-ONCE-PER-COMPILE (2026-07-12 regression fix). A turn makes MANY API
|
|
3374
|
+
// calls — tool-use rounds and max_tokens continuations each append to the
|
|
3375
|
+
// request — so only the FIRST completion after a compile was built from
|
|
3376
|
+
// the window this estimate describes. Feeding later calls compares a grown
|
|
3377
|
+
// request against the original estimate: ratios of 2.0-2.3 (est=224k
|
|
3378
|
+
// real=504k) drove the multiplier 1.0 -> 2.37 in minutes, inflating every
|
|
3379
|
+
// estimate (the fold floor went 62k -> 108k on unchanged content) and
|
|
3380
|
+
// exhausting the picker on every wake. One sample per compile, always.
|
|
3381
|
+
if (!this._calibrationArmed) return;
|
|
3382
|
+
this._calibrationArmed = false;
|
|
3383
|
+
|
|
3384
|
+
const ratio = realTotal / est;
|
|
3385
|
+
// SANITY BAND: a representative sample sits near 1. Anything wilder is a
|
|
3386
|
+
// structural mismatch (a request we didn't compile, a partial compile, a
|
|
3387
|
+
// provider quirk) — never evidence about chars-per-token. Log, don't learn.
|
|
3388
|
+
if (ratio < 0.6 || ratio > 1.8) {
|
|
3389
|
+
console.error(
|
|
3390
|
+
`[estimator-calibration] REJECTED out-of-band sample real/est=${ratio.toFixed(2)} ` +
|
|
3391
|
+
`(est=${Math.round(est / 1000)}k real=${Math.round(realTotal / 1000)}k) — ` +
|
|
3392
|
+
`not a window-shaped request; multiplier stays ${this._calibration.toFixed(2)}`,
|
|
3393
|
+
);
|
|
3394
|
+
return;
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
const current = this._calibration;
|
|
3398
|
+
const observed = ratio * current; // back out the multiplier already applied
|
|
3399
|
+
const alpha = 0.2; // slow EMA: one wild request shouldn't yank the ruler
|
|
3400
|
+
const next = current + alpha * (observed - current);
|
|
3401
|
+
const clamped = Math.min(1.8, Math.max(0.6, next));
|
|
3402
|
+
if (Math.abs(clamped - current) / current > 0.02) {
|
|
3403
|
+
console.error(
|
|
3404
|
+
`[estimator-calibration] real/est=${ratio.toFixed(2)} ` +
|
|
3405
|
+
`multiplier ${current.toFixed(2)} -> ${clamped.toFixed(2)} (est=${Math.round(est / 1000)}k real=${Math.round(realTotal / 1000)}k)`,
|
|
3406
|
+
);
|
|
3407
|
+
}
|
|
3408
|
+
this._calibration = clamped;
|
|
3409
|
+
this.applyCalibration();
|
|
3410
|
+
try {
|
|
3411
|
+
this.store?.setStateJson(this.calibrationStateId, { multiplier: this._calibration, at: Date.now() });
|
|
3412
|
+
} catch { /* persistence is best-effort */ }
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
private _calibrationArmed = false;
|
|
3416
|
+
|
|
3417
|
+
private _calibration = 1;
|
|
3418
|
+
private _calibrationLoaded = false;
|
|
3419
|
+
|
|
3420
|
+
protected applyCalibration(): void {
|
|
3421
|
+
this._storeView?.setTokenCalibration?.(this._calibration);
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
/** Load the persisted multiplier once and push it into the store view. */
|
|
3425
|
+
protected loadCalibration(store: MessageStoreView): void {
|
|
3426
|
+
this._storeView = store;
|
|
3427
|
+
if (!this._calibrationLoaded) {
|
|
3428
|
+
this._calibrationLoaded = true;
|
|
3429
|
+
try {
|
|
3430
|
+
const saved = this.store?.getStateJson(this.calibrationStateId) as { multiplier?: number } | null;
|
|
3431
|
+
if (saved && Number.isFinite(saved.multiplier)) {
|
|
3432
|
+
this._calibration = Math.min(1.8, Math.max(0.6, saved.multiplier!));
|
|
3433
|
+
}
|
|
3434
|
+
} catch { /* absent slot is fine */ }
|
|
3435
|
+
}
|
|
3436
|
+
this.applyCalibration();
|
|
3437
|
+
}
|
|
3438
|
+
|
|
2713
3439
|
/**
|
|
2714
3440
|
* Place up to four `cache_control` breakpoints across the final ordered
|
|
2715
3441
|
* entries: the head/system boundary, the end of the folded history (the
|
|
@@ -2763,12 +3489,23 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2763
3489
|
): ContextEntry[] {
|
|
2764
3490
|
if (entries.length === 0) return entries;
|
|
2765
3491
|
|
|
2766
|
-
// Look up
|
|
2767
|
-
|
|
3492
|
+
// Look up bodyGroup metadata by sourceMessageId, memoized for the
|
|
3493
|
+
// duration of this pass. Entries reference the same messages repeatedly
|
|
3494
|
+
// (run-extension checks + the shardIndex sort comparator below), and
|
|
3495
|
+
// store.get() also resolves blobs — fetch each message at most once.
|
|
3496
|
+
const shardMeta = new Map<string, { groupId?: string; shardIndex?: number }>();
|
|
3497
|
+
const metaOf = (sourceMessageId?: string): { groupId?: string; shardIndex?: number } | undefined => {
|
|
2768
3498
|
if (!sourceMessageId) return undefined;
|
|
2769
|
-
|
|
2770
|
-
|
|
3499
|
+
let meta = shardMeta.get(sourceMessageId);
|
|
3500
|
+
if (!meta) {
|
|
3501
|
+
const m = store.get(sourceMessageId);
|
|
3502
|
+
meta = { groupId: m?.bodyGroupId, shardIndex: m?.shardIndex };
|
|
3503
|
+
shardMeta.set(sourceMessageId, meta);
|
|
3504
|
+
}
|
|
3505
|
+
return meta;
|
|
2771
3506
|
};
|
|
3507
|
+
const groupOf = (sourceMessageId?: string): string | undefined =>
|
|
3508
|
+
metaOf(sourceMessageId)?.groupId;
|
|
2772
3509
|
|
|
2773
3510
|
const out: ContextEntry[] = [];
|
|
2774
3511
|
let i = 0;
|
|
@@ -2800,9 +3537,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2800
3537
|
// ordering. (Head/tail emission keeps chronological order, but defending
|
|
2801
3538
|
// against reorderings is cheap.)
|
|
2802
3539
|
const sortedRun = [...run].sort((a, b) => {
|
|
2803
|
-
const
|
|
2804
|
-
const
|
|
2805
|
-
return
|
|
3540
|
+
const sa = metaOf(a.sourceMessageId)?.shardIndex ?? 0;
|
|
3541
|
+
const sb = metaOf(b.sourceMessageId)?.shardIndex ?? 0;
|
|
3542
|
+
return sa - sb;
|
|
2806
3543
|
});
|
|
2807
3544
|
// Build merged text content. Non-text blocks (rare in shards) are
|
|
2808
3545
|
// preserved on the first shard's entry only.
|
|
@@ -2848,16 +3585,89 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2848
3585
|
*/
|
|
2849
3586
|
protected buildPicker(inputs: PickerInputs): Picker {
|
|
2850
3587
|
if (this.config.foldingStrategy === 'kv-stable') {
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
3588
|
+
const strategy = new KvStableStrategy(inputs, {
|
|
3589
|
+
reachTokens: this.config.kvStableReachTokens,
|
|
3590
|
+
qualityGapRatio: this.config.kvStableQualityGapRatio,
|
|
3591
|
+
mergeThreshold: this.config.mergeThreshold,
|
|
3592
|
+
});
|
|
3593
|
+
this._lastKvStable = strategy;
|
|
3594
|
+
return new Picker(strategy);
|
|
2857
3595
|
}
|
|
3596
|
+
this._lastKvStable = null;
|
|
2858
3597
|
return this.getAdaptivePicker();
|
|
2859
3598
|
}
|
|
2860
3599
|
|
|
3600
|
+
/** The kv-stable strategy instance behind the most recent compile — kept for
|
|
3601
|
+
* `[kv-escalation]` observability (design §13.4: every override is loud). */
|
|
3602
|
+
private _lastKvStable: KvStableStrategy | null = null;
|
|
3603
|
+
|
|
3604
|
+
/**
|
|
3605
|
+
* Static salience prior (design §13.3) — "is the window the only copy?".
|
|
3606
|
+
* Content whose payload is externalized folds cheap: tool blocks
|
|
3607
|
+
* (re-derivable), fenced code (usually written to disk), images (the file/
|
|
3608
|
+
* CDN keeps them), bare link drops. Conversation exists nowhere but the
|
|
3609
|
+
* chronicle, so it stays at 1. Returns a value in [0.2, 1]; cheap,
|
|
3610
|
+
* deterministic, computed per message at picker-input construction.
|
|
3611
|
+
*/
|
|
3612
|
+
protected static staticSalience(msg: StoredMessage): number {
|
|
3613
|
+
let totalChars = 0;
|
|
3614
|
+
let externalChars = 0;
|
|
3615
|
+
for (const block of msg.content) {
|
|
3616
|
+
const b = block as {
|
|
3617
|
+
type?: string;
|
|
3618
|
+
text?: string;
|
|
3619
|
+
input?: unknown;
|
|
3620
|
+
content?: unknown;
|
|
3621
|
+
};
|
|
3622
|
+
switch (b.type) {
|
|
3623
|
+
case 'text': {
|
|
3624
|
+
const t = b.text ?? '';
|
|
3625
|
+
totalChars += t.length;
|
|
3626
|
+
// Fenced code blocks.
|
|
3627
|
+
const fences = t.split('```');
|
|
3628
|
+
for (let i = 1; i < fences.length; i += 2) externalChars += fences[i].length;
|
|
3629
|
+
// Bare link-drop lines (the URL is the payload).
|
|
3630
|
+
for (const line of t.split('\n')) {
|
|
3631
|
+
const trimmed = line.trim();
|
|
3632
|
+
if (/^https?:\/\/\S+$/.test(trimmed)) externalChars += trimmed.length;
|
|
3633
|
+
}
|
|
3634
|
+
break;
|
|
3635
|
+
}
|
|
3636
|
+
case 'tool_use': {
|
|
3637
|
+
const n = JSON.stringify(b.input ?? {}).length;
|
|
3638
|
+
totalChars += n;
|
|
3639
|
+
externalChars += n;
|
|
3640
|
+
break;
|
|
3641
|
+
}
|
|
3642
|
+
case 'tool_result': {
|
|
3643
|
+
const n =
|
|
3644
|
+
typeof b.content === 'string'
|
|
3645
|
+
? b.content.length
|
|
3646
|
+
: JSON.stringify(b.content ?? '').length;
|
|
3647
|
+
totalChars += n;
|
|
3648
|
+
externalChars += n;
|
|
3649
|
+
break;
|
|
3650
|
+
}
|
|
3651
|
+
case 'image': {
|
|
3652
|
+
// Estimate parity with the renderer's flat image cost; the payload
|
|
3653
|
+
// lives in the file/CDN, so it is fully externalized.
|
|
3654
|
+
totalChars += 6400; // ≈1600 tokens × 4 chars
|
|
3655
|
+
externalChars += 6400;
|
|
3656
|
+
break;
|
|
3657
|
+
}
|
|
3658
|
+
default: {
|
|
3659
|
+
const t = (b as { text?: string }).text ?? '';
|
|
3660
|
+
totalChars += t.length;
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
if (totalChars <= 0) return 1;
|
|
3665
|
+
const externalized = Math.min(1, externalChars / totalChars);
|
|
3666
|
+
// Fully-externalized content bottoms out at 0.2 — cheap, never free
|
|
3667
|
+
// (hard protections, not salience, are what make content unfoldable).
|
|
3668
|
+
return Math.max(0.2, 1 - 0.8 * externalized);
|
|
3669
|
+
}
|
|
3670
|
+
|
|
2861
3671
|
/**
|
|
2862
3672
|
* Walk the summary tree to find the L_k ancestor of a message.
|
|
2863
3673
|
* Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
|
|
@@ -3020,6 +3830,76 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3020
3830
|
selectedSummaries.push(...l1Selected);
|
|
3021
3831
|
totalSummaryTokens += l1Used;
|
|
3022
3832
|
|
|
3833
|
+
// Phase 3b: coverage repair (bug 6.9). getAntiRedundantSummaries excluded
|
|
3834
|
+
// an L2 (or L3) when ALL of its children were in the CANDIDATE shown-set —
|
|
3835
|
+
// computed before budget selection. If the budget then dropped some of
|
|
3836
|
+
// those children (e.g. KnowledgeStrategy's research L1 cap), the covered
|
|
3837
|
+
// history appears at NEITHER level: a silent memory hole. Re-include any
|
|
3838
|
+
// excluded L2/L3 whose children did not all make the final selection.
|
|
3839
|
+
// Some overlap with the children that DID survive is accepted — coverage
|
|
3840
|
+
// beats perfect dedup here.
|
|
3841
|
+
//
|
|
3842
|
+
// Repairs are additionally bounded by a per-level ALLOWANCE (a fraction of
|
|
3843
|
+
// that level's budget), not just the overall context budget. The
|
|
3844
|
+
// excluded-with-partially-dropped-children state only arises from a store
|
|
3845
|
+
// damaged mid-merge (the crash window at compressChunkHierarchical, or the
|
|
3846
|
+
// legacy setMergedInto index-desync). On such a store MANY L2s can be in
|
|
3847
|
+
// this state at once; without a cap, re-including all of them at full size
|
|
3848
|
+
// would starve the recent window via Phase 4's newest-first eviction. When
|
|
3849
|
+
// repairs exceed the allowance we stop re-including and warn — a corrupted
|
|
3850
|
+
// store announces itself instead of silently trading recent messages for
|
|
3851
|
+
// redundant summaries.
|
|
3852
|
+
{
|
|
3853
|
+
// Allowance = a fraction of the level budget, with a floor tied to the
|
|
3854
|
+
// overall budget so a strategy that zeroes a level budget (e.g.
|
|
3855
|
+
// KnowledgeStrategy prioritising L1) can still repair a handful of
|
|
3856
|
+
// covering summaries, while a corrupted store with dozens of them stays
|
|
3857
|
+
// bounded well short of the recent window.
|
|
3858
|
+
const REPAIR_ALLOWANCE_FRACTION = 0.25;
|
|
3859
|
+
const REPAIR_FLOOR_FRACTION = 0.05;
|
|
3860
|
+
const repairFloor = maxTokens * REPAIR_FLOOR_FRACTION;
|
|
3861
|
+
const l2RepairAllowance = Math.max(l2Budget * REPAIR_ALLOWANCE_FRACTION, repairFloor);
|
|
3862
|
+
const l3RepairAllowance = Math.max(l3Budget * REPAIR_ALLOWANCE_FRACTION, repairFloor);
|
|
3863
|
+
const selectedIds = new Set(selectedSummaries.map(s => s.id));
|
|
3864
|
+
const shownL2Ids = new Set(shownL2.map(s => s.id));
|
|
3865
|
+
const shownL3Ids = new Set(shownL3.map(s => s.id));
|
|
3866
|
+
let l2RepairTokens = 0;
|
|
3867
|
+
let l3RepairTokens = 0;
|
|
3868
|
+
let l2RepairsSkipped = 0;
|
|
3869
|
+
let l3RepairsSkipped = 0;
|
|
3870
|
+
// L2s excluded by anti-redundancy: unmerged, not in shownL2.
|
|
3871
|
+
for (const s of this.summaries) {
|
|
3872
|
+
if (s.level !== 2 || s.mergedInto || shownL2Ids.has(s.id)) continue;
|
|
3873
|
+
if (s.sourceIds.every(id => selectedIds.has(id))) continue; // truly redundant
|
|
3874
|
+
if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens)) continue;
|
|
3875
|
+
if (l2RepairTokens + s.tokens > l2RepairAllowance) { l2RepairsSkipped++; continue; }
|
|
3876
|
+
selectedSummaries.push(s);
|
|
3877
|
+
totalSummaryTokens += s.tokens;
|
|
3878
|
+
l2RepairTokens += s.tokens;
|
|
3879
|
+
selectedIds.add(s.id);
|
|
3880
|
+
}
|
|
3881
|
+
// L3s excluded by anti-redundancy — after L2 repair, so a repaired L2
|
|
3882
|
+
// counts as selected coverage for its parent L3.
|
|
3883
|
+
for (const s of this.summaries) {
|
|
3884
|
+
if (s.level !== 3 || s.mergedInto || shownL3Ids.has(s.id)) continue;
|
|
3885
|
+
if (s.sourceIds.every(id => selectedIds.has(id))) continue;
|
|
3886
|
+
if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens)) continue;
|
|
3887
|
+
if (l3RepairTokens + s.tokens > l3RepairAllowance) { l3RepairsSkipped++; continue; }
|
|
3888
|
+
selectedSummaries.push(s);
|
|
3889
|
+
totalSummaryTokens += s.tokens;
|
|
3890
|
+
l3RepairTokens += s.tokens;
|
|
3891
|
+
selectedIds.add(s.id);
|
|
3892
|
+
}
|
|
3893
|
+
if (l2RepairsSkipped > 0 || l3RepairsSkipped > 0) {
|
|
3894
|
+
console.warn(
|
|
3895
|
+
`[AutobiographicalStrategy] coverage-repair allowance exceeded — ` +
|
|
3896
|
+
`skipped ${l2RepairsSkipped} L2 and ${l3RepairsSkipped} L3 re-inclusions ` +
|
|
3897
|
+
`(store likely corrupted mid-merge). Some covered history may render at ` +
|
|
3898
|
+
`no summary level this pass.`,
|
|
3899
|
+
);
|
|
3900
|
+
}
|
|
3901
|
+
}
|
|
3902
|
+
|
|
3023
3903
|
// Emit summaries + pinned messages between head and recent windows.
|
|
3024
3904
|
//
|
|
3025
3905
|
// Default (positionedRecallPairs=true): one Q/A pair per summary,
|
|
@@ -3103,6 +3983,10 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3103
3983
|
for (const item of items) {
|
|
3104
3984
|
if (item.kind === 'summary') {
|
|
3105
3985
|
const summary = item.summary;
|
|
3986
|
+
// Defensive: never emit a recall pair for an empty/bugged summary — an
|
|
3987
|
+
// empty assistant text block triggers a 400. (Production guards too,
|
|
3988
|
+
// but a legacy empty summary may already exist in the store.)
|
|
3989
|
+
if (!summary.content || !summary.content.trim()) continue;
|
|
3106
3990
|
const headerText = this.buildRecallHeader(summary);
|
|
3107
3991
|
const questionEntry: ContextEntry = {
|
|
3108
3992
|
index: entries.length,
|
|
@@ -3208,6 +4092,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3208
4092
|
this.rsRaw('tail', tailStats.tokens, tailStats.messages);
|
|
3209
4093
|
|
|
3210
4094
|
this.trimOrphanedToolUse(entries);
|
|
4095
|
+
// Full pairing invariant over the final rendered context — catches the
|
|
4096
|
+
// mid-list orphans the trailing/leading trims can't (bug 6.7).
|
|
4097
|
+
this.enforceToolPairing(entries);
|
|
3211
4098
|
this.pruneToolEntries(entries);
|
|
3212
4099
|
// Strip stale images before committing stats so RenderStats.total reflects
|
|
3213
4100
|
// the post-strip context (this path places no cache markers).
|
|
@@ -3520,27 +4407,83 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3520
4407
|
}
|
|
3521
4408
|
|
|
3522
4409
|
/**
|
|
3523
|
-
* Rebuild chunk
|
|
4410
|
+
* Rebuild the chunk list: persisted records own the past; the running-sum
|
|
4411
|
+
* chunker only extends at the frontier, and a chunk is only ever created
|
|
4412
|
+
* once it CLOSES (reaches targetChunkTokens). The trailing partial chunk
|
|
4413
|
+
* is never created and never compressed — eager partial-tail compression
|
|
4414
|
+
* minted a new near-duplicate L1 per rebuild while the tail grew (the
|
|
4415
|
+
* prefix-generation families found fleet-wide in the 2026-07 audit).
|
|
3524
4416
|
*/
|
|
3525
4417
|
protected rebuildChunks(store: MessageStoreView): void {
|
|
3526
|
-
|
|
4418
|
+
this.chunks = [];
|
|
4419
|
+
this.compressionQueue = [];
|
|
4420
|
+
|
|
4421
|
+
// ---- 1. Materialize persisted records (they OWN their messages). ----
|
|
4422
|
+
const byId = new Map<string, StoredMessage>();
|
|
4423
|
+
for (const m of store.getAll()) byId.set(m.id, m);
|
|
4424
|
+
|
|
4425
|
+
const consumed = new Set<string>();
|
|
4426
|
+
let orphaned = 0;
|
|
4427
|
+
for (const rec of this.chunkRecords) {
|
|
4428
|
+
const msgs: StoredMessage[] = [];
|
|
4429
|
+
for (const id of rec.sourceIds) {
|
|
4430
|
+
const m = byId.get(id);
|
|
4431
|
+
if (m) msgs.push(m);
|
|
4432
|
+
}
|
|
4433
|
+
if (msgs.length === 0) { orphaned++; continue; }
|
|
4434
|
+
for (const m of msgs) consumed.add(m.id);
|
|
4435
|
+
const chunk: Chunk = {
|
|
4436
|
+
index: this.chunks.length,
|
|
4437
|
+
startIndex: -1, // record-derived; filtered-array indices are not meaningful
|
|
4438
|
+
endIndex: -1,
|
|
4439
|
+
messages: msgs,
|
|
4440
|
+
tokens: msgs.reduce((sum, m) => sum + (this.config.attachmentsIgnoreSize
|
|
4441
|
+
? this.estimateTextOnlyTokens(m)
|
|
4442
|
+
: store.estimateTokens(m)), 0),
|
|
4443
|
+
compressed: rec.compressed,
|
|
4444
|
+
summaryId: rec.summaryId,
|
|
4445
|
+
phaseType: rec.phaseType,
|
|
4446
|
+
recordId: rec.id,
|
|
4447
|
+
};
|
|
4448
|
+
this.chunks.push(chunk);
|
|
4449
|
+
}
|
|
4450
|
+
|
|
4451
|
+
// ---- 2. Fail closed on the chain-break signature. ----
|
|
4452
|
+
// Most records resolving to zero live messages means message identity
|
|
4453
|
+
// has been rebuilt/renumbered underneath us. Chunking "fresh" ground now
|
|
4454
|
+
// would re-compress already-lived history into duplicate memories.
|
|
4455
|
+
// Halt ALL compression until an operator reconciles the store.
|
|
4456
|
+
if (this.chunkPersistenceEnabled && this.chunkRecords.length >= 3 &&
|
|
4457
|
+
orphaned / this.chunkRecords.length > 0.5) {
|
|
4458
|
+
this.chunkRecordsOrphaned = true;
|
|
4459
|
+
this.compressionQueue = [];
|
|
4460
|
+
if (!this._orphanWarned) {
|
|
4461
|
+
this._orphanWarned = true;
|
|
4462
|
+
console.error(
|
|
4463
|
+
`[autobiographical] FAIL-CLOSED: ${orphaned}/${this.chunkRecords.length} chunk ` +
|
|
4464
|
+
`records resolve to zero live messages (messages chain break / store ` +
|
|
4465
|
+
`reconciliation signature). Compression halted to prevent duplicate ` +
|
|
4466
|
+
`memory formation — reconcile the store before resuming.`,
|
|
4467
|
+
);
|
|
4468
|
+
}
|
|
4469
|
+
return;
|
|
4470
|
+
}
|
|
4471
|
+
this.chunkRecordsOrphaned = false;
|
|
3527
4472
|
|
|
3528
|
-
//
|
|
3529
|
-
|
|
4473
|
+
// Queue uncompressed record-backed chunks (crash-recovery: record was
|
|
4474
|
+
// appended but the process died before its L1 landed).
|
|
3530
4475
|
for (const chunk of this.chunks) {
|
|
3531
|
-
if (chunk.compressed) {
|
|
3532
|
-
|
|
3533
|
-
existingCompressed.set(key, chunk);
|
|
4476
|
+
if (!chunk.compressed && !(chunk.recordId && this._overlapBlocked.has(chunk.recordId))) {
|
|
4477
|
+
this.compressionQueue.push(chunk.index);
|
|
3534
4478
|
}
|
|
3535
4479
|
}
|
|
3536
4480
|
|
|
3537
|
-
//
|
|
3538
|
-
|
|
3539
|
-
|
|
4481
|
+
// ---- 3. Chunk the frontier: compressible messages not owned by any record. ----
|
|
4482
|
+
const messagesToChunk = this.getCompressibleMessages(store)
|
|
4483
|
+
.filter(m => !consumed.has(m.id));
|
|
3540
4484
|
|
|
3541
4485
|
let currentChunk: StoredMessage[] = [];
|
|
3542
4486
|
let currentTokens = 0;
|
|
3543
|
-
// Track start position in the filtered array for chunk boundary metadata
|
|
3544
4487
|
let chunkFilteredStart = 0;
|
|
3545
4488
|
|
|
3546
4489
|
for (let i = 0; i < messagesToChunk.length; i++) {
|
|
@@ -3570,19 +4513,27 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3570
4513
|
}
|
|
3571
4514
|
|
|
3572
4515
|
if (shouldClose) {
|
|
3573
|
-
const chunk =
|
|
3574
|
-
this.chunks.length,
|
|
3575
|
-
chunkFilteredStart,
|
|
3576
|
-
i + 1,
|
|
3577
|
-
currentChunk,
|
|
3578
|
-
currentTokens,
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
this
|
|
3582
|
-
|
|
3583
|
-
if (
|
|
3584
|
-
|
|
4516
|
+
const chunk: Chunk = {
|
|
4517
|
+
index: this.chunks.length,
|
|
4518
|
+
startIndex: chunkFilteredStart,
|
|
4519
|
+
endIndex: i + 1,
|
|
4520
|
+
messages: [...currentChunk],
|
|
4521
|
+
tokens: currentTokens,
|
|
4522
|
+
compressed: false,
|
|
4523
|
+
};
|
|
4524
|
+
// Persist the boundary the moment it closes — from here on this
|
|
4525
|
+
// span is owned and never re-keyed by config drift or restarts.
|
|
4526
|
+
if (this.chunkPersistenceEnabled) {
|
|
4527
|
+
const record: ChunkRecord = {
|
|
4528
|
+
id: `c-${this.chunkIdCounter++}`,
|
|
4529
|
+
sourceIds: chunk.messages.map(m => m.id),
|
|
4530
|
+
compressed: false,
|
|
4531
|
+
};
|
|
4532
|
+
this.appendChunkRecord(record);
|
|
4533
|
+
chunk.recordId = record.id;
|
|
3585
4534
|
}
|
|
4535
|
+
this.chunks.push(chunk);
|
|
4536
|
+
this.compressionQueue.push(chunk.index);
|
|
3586
4537
|
|
|
3587
4538
|
currentChunk = [];
|
|
3588
4539
|
currentTokens = 0;
|
|
@@ -3590,20 +4541,27 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3590
4541
|
}
|
|
3591
4542
|
}
|
|
3592
4543
|
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
4544
|
+
// NOTE: no trailing-partial chunk. An unclosed chunk is not a chunk —
|
|
4545
|
+
// it compresses only after the running sum closes it.
|
|
4546
|
+
|
|
4547
|
+
// ---- 4. L1 holdback: keep the newest X closed chunks out of the
|
|
4548
|
+
// speculative queue (default 1). The chunk at the live edge is the one
|
|
4549
|
+
// most likely to still be in motion (edits, tool-result landings, the
|
|
4550
|
+
// episode it belongs to still resolving); summarize it once a newer chunk
|
|
4551
|
+
// has closed behind it. The queue is rebuilt on every message, so a
|
|
4552
|
+
// held-back chunk is released automatically the moment it ages out of the
|
|
4553
|
+
// window. Demand overrides: a picker `produce` op (enqueueL1ForRange)
|
|
4554
|
+
// marks the chunk demanded, and demanded chunks are never held back —
|
|
4555
|
+
// when folding actually NEEDS the L1, production must not be blocked.
|
|
4556
|
+
const holdback = this.config.l1HoldbackChunks ?? 1;
|
|
4557
|
+
if (holdback > 0 && this.chunks.length > 0) {
|
|
4558
|
+
const cutoff = this.chunks.length - holdback;
|
|
4559
|
+
this.compressionQueue = this.compressionQueue.filter((idx) => {
|
|
4560
|
+
if (idx < cutoff) return true;
|
|
4561
|
+
const ch = this.chunks[idx];
|
|
4562
|
+
const lastId = ch?.messages[ch.messages.length - 1]?.id;
|
|
4563
|
+
return lastId !== undefined && this._demandedL1Chunks.has(lastId);
|
|
4564
|
+
});
|
|
3607
4565
|
}
|
|
3608
4566
|
}
|
|
3609
4567
|
|
|
@@ -3701,7 +4659,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3701
4659
|
protected applyImageStripping(entries: ContextEntry[], store: MessageStoreView): void {
|
|
3702
4660
|
const maxLive = this.config.maxLiveImages ?? 0; // 0 = unlimited count
|
|
3703
4661
|
const depthTokens = this.config.imageStripDepthTokens ?? 0; // 0 = no depth strip
|
|
3704
|
-
|
|
4662
|
+
const maxLiveBytes = this.config.maxLiveImageBytes ?? AutobiographicalStrategy.DEFAULT_MAX_LIVE_IMAGE_BYTES;
|
|
4663
|
+
if (maxLive === 0 && depthTokens === 0 && maxLiveBytes === 0) return; // policy disabled
|
|
3705
4664
|
|
|
3706
4665
|
const messages = store.getAll();
|
|
3707
4666
|
const posById = new Map<string, number>();
|
|
@@ -3732,34 +4691,96 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3732
4691
|
.sort((a, b) => b.pos - a.pos);
|
|
3733
4692
|
|
|
3734
4693
|
let keptImages = 0;
|
|
4694
|
+
let keptImageBytes = 0;
|
|
3735
4695
|
for (const { idx, pos } of ordered) {
|
|
3736
4696
|
const entry = entries[idx];
|
|
3737
4697
|
const tooDeep = depthTokens > 0 && (pos < 0 || pos < stripStart);
|
|
3738
4698
|
const bucket = bucketAt(pos);
|
|
3739
4699
|
entry.content = entry.content.map((block) => {
|
|
3740
4700
|
if (block.type !== 'image') return block;
|
|
4701
|
+
const blockBytes = AutobiographicalStrategy.imageBlockBytes(block);
|
|
3741
4702
|
const overCount = maxLive > 0 && keptImages >= maxLive;
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
//
|
|
3745
|
-
//
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
4703
|
+
const overBytes = maxLiveBytes > 0 && keptImageBytes + blockBytes > maxLiveBytes;
|
|
4704
|
+
if (tooDeep || overCount || overBytes) {
|
|
4705
|
+
// Stats-neutral (2026-07-12): every budgeting site now tallies at
|
|
4706
|
+
// POST-STRIP prices (see postStripEstimates), so the bucket never
|
|
4707
|
+
// charged this image at full weight — reclaiming here would
|
|
4708
|
+
// double-decrement. The strip pass only swaps the block.
|
|
4709
|
+
void bucket;
|
|
4710
|
+
void placeholderTokens;
|
|
3749
4711
|
return { type: 'text', text: AutobiographicalStrategy.IMAGE_PLACEHOLDER } as ContentBlock;
|
|
3750
4712
|
}
|
|
3751
4713
|
keptImages++;
|
|
4714
|
+
keptImageBytes += blockBytes;
|
|
3752
4715
|
return block;
|
|
3753
4716
|
});
|
|
3754
4717
|
}
|
|
3755
4718
|
}
|
|
3756
4719
|
|
|
4720
|
+
/**
|
|
4721
|
+
* Post-strip token estimate per message index (2026-07-12 tail-starvation
|
|
4722
|
+
* fix). Mirrors `applyImageStripping`: an image beyond the `maxLiveImages`
|
|
4723
|
+
* newest (counted newest-first) or deeper than `imageStripDepthTokens` of
|
|
4724
|
+
* raw estimate from the live end renders as a placeholder — so every place
|
|
4725
|
+
* that BUDGETS messages (recent-window walk-back, head/tail sums, middle
|
|
4726
|
+
* chunk sizes) must cost it as one. Pricing stripped images at their full
|
|
4727
|
+
* estimate collapsed an image-dense tail to a fraction of its configured
|
|
4728
|
+
* size (42k rendered of a 120k window), and pricing them post-strip in the
|
|
4729
|
+
* walk-back alone made the picker's raw-priced tail overflow the budget
|
|
4730
|
+
* (318k) — the estimate must be consistent EVERYWHERE.
|
|
4731
|
+
*/
|
|
4732
|
+
protected postStripEstimates(store: MessageStoreView): number[] {
|
|
4733
|
+
const messages = store.getAll();
|
|
4734
|
+
const out = new Array<number>(messages.length);
|
|
4735
|
+
const stripDepth = this.config.imageStripDepthTokens ?? 0;
|
|
4736
|
+
const maxLive = this.config.maxLiveImages ?? 0;
|
|
4737
|
+
const maxLiveBytes = this.config.maxLiveImageBytes ?? AutobiographicalStrategy.DEFAULT_MAX_LIVE_IMAGE_BYTES;
|
|
4738
|
+
const stripActive = stripDepth > 0 || maxLive > 0 || maxLiveBytes > 0;
|
|
4739
|
+
const placeholderTokens = Math.ceil(AutobiographicalStrategy.IMAGE_PLACEHOLDER.length / 4);
|
|
4740
|
+
let liveImagesSeen = 0;
|
|
4741
|
+
let liveImageBytes = 0;
|
|
4742
|
+
let rawDepth = 0; // raw-estimate depth from the newest message (mirrors getImageStripStart)
|
|
4743
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4744
|
+
const raw = store.estimateTokens(messages[i]);
|
|
4745
|
+
let est = raw;
|
|
4746
|
+
if (stripActive) {
|
|
4747
|
+
for (const b of messages[i].content) {
|
|
4748
|
+
if (b.type !== 'image') continue;
|
|
4749
|
+
const bytes = AutobiographicalStrategy.imageBlockBytes(b);
|
|
4750
|
+
const beyondDepth = stripDepth > 0 && rawDepth > stripDepth;
|
|
4751
|
+
const beyondCount = maxLive > 0 && liveImagesSeen >= maxLive;
|
|
4752
|
+
const beyondBytes = maxLiveBytes > 0 && liveImageBytes + bytes > maxLiveBytes;
|
|
4753
|
+
if (beyondDepth || beyondCount || beyondBytes) {
|
|
4754
|
+
const imgEst = (b as { tokenEstimate?: number }).tokenEstimate ?? 1600;
|
|
4755
|
+
est -= Math.max(0, imgEst - placeholderTokens);
|
|
4756
|
+
} else {
|
|
4757
|
+
liveImagesSeen++;
|
|
4758
|
+
liveImageBytes += bytes;
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
}
|
|
4762
|
+
rawDepth += raw;
|
|
4763
|
+
out[i] = est;
|
|
4764
|
+
}
|
|
4765
|
+
return out;
|
|
4766
|
+
}
|
|
4767
|
+
|
|
4768
|
+
/** Byte wall default: 20MB of base64 (API total-request cap is 32MB). */
|
|
4769
|
+
protected static readonly DEFAULT_MAX_LIVE_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
4770
|
+
|
|
4771
|
+
/** Base64 payload size of an image block (0 for non-base64 sources). */
|
|
4772
|
+
protected static imageBlockBytes(b: unknown): number {
|
|
4773
|
+
const src = (b as { source?: { data?: string } }).source;
|
|
4774
|
+
return typeof src?.data === 'string' ? src.data.length : 0;
|
|
4775
|
+
}
|
|
4776
|
+
|
|
3757
4777
|
protected getRecentWindowStart(store: MessageStoreView): number {
|
|
3758
4778
|
const messages = store.getAll();
|
|
4779
|
+
const pse = this.postStripEstimates(store);
|
|
3759
4780
|
let tokens = 0;
|
|
3760
4781
|
|
|
3761
4782
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
3762
|
-
tokens +=
|
|
4783
|
+
tokens += pse[i];
|
|
3763
4784
|
if (tokens > this.config.recentWindowTokens) {
|
|
3764
4785
|
let boundary = i + 1;
|
|
3765
4786
|
// Don't split a tool_use/tool_result pair: if the message at the boundary
|
|
@@ -3847,6 +4868,180 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3847
4868
|
}
|
|
3848
4869
|
}
|
|
3849
4870
|
|
|
4871
|
+
/** Placeholder body for a stub tool_result inserted by enforceToolPairing. */
|
|
4872
|
+
private static readonly STUB_TOOL_RESULT_TEXT =
|
|
4873
|
+
'[tool result unavailable — omitted during context compression]';
|
|
4874
|
+
|
|
4875
|
+
/**
|
|
4876
|
+
* Final post-selection tool-pairing validator (bug 6.7).
|
|
4877
|
+
*
|
|
4878
|
+
* The Anthropic API requires every `tool_use` block to be answered by a
|
|
4879
|
+
* matching `tool_result` in the immediately-following message, and every
|
|
4880
|
+
* `tool_result` to answer a `tool_use` in the immediately-preceding
|
|
4881
|
+
* message. Selection can violate this mid-list in ways the trailing
|
|
4882
|
+
* (`trimOrphanedToolUse`) and leading orphan trims don't catch:
|
|
4883
|
+
*
|
|
4884
|
+
* - a budget `break` cutting between a raw pin pair's two messages;
|
|
4885
|
+
* - the uncompressed-chunk fallback emitting a raw tool_result whose
|
|
4886
|
+
* tool_use chunk already compressed (or vice versa);
|
|
4887
|
+
* - a recall pair or pin interleaving between a tool_use and its result.
|
|
4888
|
+
*
|
|
4889
|
+
* Repair policy prefers preserving content over dropping:
|
|
4890
|
+
*
|
|
4891
|
+
* - a tool_use whose result is missing from the next entry first triggers
|
|
4892
|
+
* a short look-ahead: if the genuine (displaced) result is a few entries
|
|
4893
|
+
* down it is MOVED up into position (see
|
|
4894
|
+
* {@link relocateOrDropMissingResults}); only when no real result exists
|
|
4895
|
+
* is a STUB tool_result emitted. Either way the result block is merged
|
|
4896
|
+
* into the next entry when that entry already carries results for this
|
|
4897
|
+
* cycle, or inserted as a new user entry;
|
|
4898
|
+
* - a tool_result whose tool_use is not in the immediately-preceding
|
|
4899
|
+
* entry (and was not relocated) is dropped — there is no safe way to
|
|
4900
|
+
* stub a tool_use, so the result's information content survives only if
|
|
4901
|
+
* its use is adjacent; an entry left empty is replaced with a
|
|
4902
|
+
* placeholder text block.
|
|
4903
|
+
*
|
|
4904
|
+
* Runs as a structural pass over the rendered context in BOTH render
|
|
4905
|
+
* paths — `selectHierarchical` (downstream of `selectL1Summaries`, so
|
|
4906
|
+
* subclass overrides like KnowledgeStrategy are covered) and
|
|
4907
|
+
* `selectAdaptive` (the path FKM defaults onto). It's a no-op on
|
|
4908
|
+
* already-valid output.
|
|
4909
|
+
*/
|
|
4910
|
+
protected enforceToolPairing(entries: ContextEntry[]): void {
|
|
4911
|
+
let prevUseIds = new Set<string>();
|
|
4912
|
+
|
|
4913
|
+
for (let i = 0; i < entries.length; i++) {
|
|
4914
|
+
const entry = entries[i];
|
|
4915
|
+
|
|
4916
|
+
// --- Rule A: every tool_result must answer a tool_use in the
|
|
4917
|
+
// immediately-preceding entry (and only once). Drop orphans/dupes. ---
|
|
4918
|
+
if (entry.content.some(b => b.type === 'tool_result')) {
|
|
4919
|
+
const seen = new Set<string>();
|
|
4920
|
+
const filtered = entry.content.filter(b => {
|
|
4921
|
+
if (b.type !== 'tool_result') return true;
|
|
4922
|
+
if (!prevUseIds.has(b.toolUseId) || seen.has(b.toolUseId)) return false;
|
|
4923
|
+
seen.add(b.toolUseId);
|
|
4924
|
+
return true;
|
|
4925
|
+
});
|
|
4926
|
+
if (filtered.length !== entry.content.length) {
|
|
4927
|
+
entry.content = filtered.length > 0
|
|
4928
|
+
? filtered
|
|
4929
|
+
: [{ type: 'text', text: '[tool call omitted]' }];
|
|
4930
|
+
}
|
|
4931
|
+
}
|
|
4932
|
+
|
|
4933
|
+
// --- Rule B: every tool_use in the PREVIOUS entry must be answered
|
|
4934
|
+
// by this entry. Stub any that aren't. ---
|
|
4935
|
+
if (prevUseIds.size > 0) {
|
|
4936
|
+
const answered = new Set<string>();
|
|
4937
|
+
for (const b of entry.content) {
|
|
4938
|
+
if (b.type === 'tool_result') answered.add(b.toolUseId);
|
|
4939
|
+
}
|
|
4940
|
+
const missing = [...prevUseIds].filter(id => !answered.has(id));
|
|
4941
|
+
if (missing.length > 0) {
|
|
4942
|
+
// Look-ahead relocation: the genuine result for a "missing" id is
|
|
4943
|
+
// often sitting a few entries down, displaced by an interleaved
|
|
4944
|
+
// recall pair / pin (it will otherwise be dropped as an orphan by
|
|
4945
|
+
// Rule A when we reach it). Move the real block up into position and
|
|
4946
|
+
// only stub the ids for which no real result exists — so tool output
|
|
4947
|
+
// is preserved, not silently replaced by a placeholder.
|
|
4948
|
+
const results = this.relocateOrDropMissingResults(entries, i, missing);
|
|
4949
|
+
if (answered.size > 0) {
|
|
4950
|
+
// This entry already carries results for the cycle — prepend the
|
|
4951
|
+
// relocated/stub results so all results for the preceding tool_use
|
|
4952
|
+
// sit together (the API wants tool_result blocks at the head of
|
|
4953
|
+
// the message).
|
|
4954
|
+
entry.content = [...results, ...entry.content];
|
|
4955
|
+
} else {
|
|
4956
|
+
// Not a results entry at all — insert a synthetic user entry
|
|
4957
|
+
// between the tool_use entry and this one.
|
|
4958
|
+
entries.splice(i, 0, {
|
|
4959
|
+
index: i,
|
|
4960
|
+
participant: 'user',
|
|
4961
|
+
sourceRelation: 'derived',
|
|
4962
|
+
content: results,
|
|
4963
|
+
});
|
|
4964
|
+
// The stub entry (no tool_use blocks) is now at index i; the
|
|
4965
|
+
// current entry moved to i+1 and is re-processed next iteration
|
|
4966
|
+
// with an empty prevUseIds (its orphan results, if any, were
|
|
4967
|
+
// already filtered above and none survived — Rule A matched
|
|
4968
|
+
// against the same prevUseIds and answered.size === 0).
|
|
4969
|
+
prevUseIds = new Set();
|
|
4970
|
+
continue;
|
|
4971
|
+
}
|
|
4972
|
+
}
|
|
4973
|
+
}
|
|
4974
|
+
|
|
4975
|
+
prevUseIds = new Set();
|
|
4976
|
+
for (const b of entry.content) {
|
|
4977
|
+
if (b.type === 'tool_use') prevUseIds.add(b.id);
|
|
4978
|
+
}
|
|
4979
|
+
}
|
|
4980
|
+
|
|
4981
|
+
// Tail: an entry that mixes tool_use with other blocks can survive
|
|
4982
|
+
// trimOrphanedToolUse (which only pops pure use-without-result tails).
|
|
4983
|
+
if (prevUseIds.size > 0) {
|
|
4984
|
+
entries.push({
|
|
4985
|
+
index: entries.length,
|
|
4986
|
+
participant: 'user',
|
|
4987
|
+
sourceRelation: 'derived',
|
|
4988
|
+
content: [...prevUseIds].map(id => ({
|
|
4989
|
+
type: 'tool_result' as const,
|
|
4990
|
+
toolUseId: id,
|
|
4991
|
+
content: AutobiographicalStrategy.STUB_TOOL_RESULT_TEXT,
|
|
4992
|
+
})),
|
|
4993
|
+
});
|
|
4994
|
+
}
|
|
4995
|
+
|
|
4996
|
+
// Reindex after any splices/appends.
|
|
4997
|
+
for (let i = 0; i < entries.length; i++) entries[i].index = i;
|
|
4998
|
+
}
|
|
4999
|
+
|
|
5000
|
+
/**
|
|
5001
|
+
* For each `missing` tool_use id (a use in the preceding entry with no
|
|
5002
|
+
* adjacent result), return the result block to place next to it:
|
|
5003
|
+
*
|
|
5004
|
+
* - if the genuine result exists within a short look-ahead window after
|
|
5005
|
+
* `afterIndex`, MOVE it up into position (removing it from its source
|
|
5006
|
+
* entry — replacing a now-empty entry with a placeholder) so the real
|
|
5007
|
+
* tool output survives the repair;
|
|
5008
|
+
* - otherwise emit a stub.
|
|
5009
|
+
*
|
|
5010
|
+
* tool_use ids are unique, so the single result carrying a given id is
|
|
5011
|
+
* unambiguously the answer to that use — relocating it cannot break any
|
|
5012
|
+
* other pairing. Results for `missing` ids never sit at or before
|
|
5013
|
+
* `afterIndex` (that entry's results are already matched), so the search
|
|
5014
|
+
* starts at `afterIndex + 1`.
|
|
5015
|
+
*/
|
|
5016
|
+
private relocateOrDropMissingResults(
|
|
5017
|
+
entries: ContextEntry[],
|
|
5018
|
+
afterIndex: number,
|
|
5019
|
+
missing: string[],
|
|
5020
|
+
): ContentBlock[] {
|
|
5021
|
+
const LOOKAHEAD = 6;
|
|
5022
|
+
const end = Math.min(entries.length, afterIndex + 1 + LOOKAHEAD);
|
|
5023
|
+
return missing.map(id => {
|
|
5024
|
+
for (let j = afterIndex + 1; j < end; j++) {
|
|
5025
|
+
const src = entries[j];
|
|
5026
|
+
const bi = src.content.findIndex(
|
|
5027
|
+
b => b.type === 'tool_result' && b.toolUseId === id,
|
|
5028
|
+
);
|
|
5029
|
+
if (bi === -1) continue;
|
|
5030
|
+
const real = src.content[bi];
|
|
5031
|
+
const rest = src.content.filter((_, k) => k !== bi);
|
|
5032
|
+
src.content = rest.length > 0
|
|
5033
|
+
? rest
|
|
5034
|
+
: [{ type: 'text', text: '[tool call omitted]' }];
|
|
5035
|
+
return real;
|
|
5036
|
+
}
|
|
5037
|
+
return {
|
|
5038
|
+
type: 'tool_result',
|
|
5039
|
+
toolUseId: id,
|
|
5040
|
+
content: AutobiographicalStrategy.STUB_TOOL_RESULT_TEXT,
|
|
5041
|
+
} as ContentBlock;
|
|
5042
|
+
});
|
|
5043
|
+
}
|
|
5044
|
+
|
|
3850
5045
|
/**
|
|
3851
5046
|
* Prune tool_use / tool_result blocks in-place:
|
|
3852
5047
|
* 1. Truncate `tool_use.input` blocks whose serialized JSON exceeds
|