@animalabs/context-manager 0.5.3 → 0.5.5
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/index.d.ts +6 -0
- package/dist/src/adaptive/index.d.ts.map +1 -1
- package/dist/src/adaptive/index.js +8 -0
- package/dist/src/adaptive/index.js.map +1 -1
- package/dist/src/adaptive/kv-cache-sim.d.ts +128 -0
- package/dist/src/adaptive/kv-cache-sim.d.ts.map +1 -0
- package/dist/src/adaptive/kv-cache-sim.js +0 -0
- package/dist/src/adaptive/kv-cache-sim.js.map +1 -0
- package/dist/src/adaptive/kv-control.d.ts +166 -0
- package/dist/src/adaptive/kv-control.d.ts.map +1 -0
- package/dist/src/adaptive/kv-control.js +434 -0
- package/dist/src/adaptive/kv-control.js.map +1 -0
- package/dist/src/adaptive/kv-replay.d.ts +76 -0
- package/dist/src/adaptive/kv-replay.d.ts.map +1 -0
- package/dist/src/adaptive/kv-replay.js +175 -0
- package/dist/src/adaptive/kv-replay.js.map +1 -0
- package/dist/src/adaptive/picker.d.ts +13 -0
- package/dist/src/adaptive/picker.d.ts.map +1 -1
- package/dist/src/adaptive/picker.js.map +1 -1
- package/dist/src/adaptive/render-offsets.d.ts +61 -0
- package/dist/src/adaptive/render-offsets.d.ts.map +1 -0
- package/dist/src/adaptive/render-offsets.js +88 -0
- package/dist/src/adaptive/render-offsets.js.map +1 -0
- package/dist/src/adaptive/strategies/kv-stable.d.ts +48 -0
- package/dist/src/adaptive/strategies/kv-stable.d.ts.map +1 -0
- package/dist/src/adaptive/strategies/kv-stable.js +153 -0
- package/dist/src/adaptive/strategies/kv-stable.js.map +1 -0
- package/dist/src/adaptive/summary-tree.d.ts +98 -0
- package/dist/src/adaptive/summary-tree.d.ts.map +1 -0
- package/dist/src/adaptive/summary-tree.js +214 -0
- package/dist/src/adaptive/summary-tree.js.map +1 -0
- package/dist/src/context-log.d.ts.map +1 -1
- package/dist/src/context-log.js +7 -2
- package/dist/src/context-log.js.map +1 -1
- package/dist/src/context-manager.d.ts +10 -5
- package/dist/src/context-manager.d.ts.map +1 -1
- package/dist/src/context-manager.js +9 -0
- package/dist/src/context-manager.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/message-store.js +1 -1
- package/dist/src/message-store.js.map +1 -1
- package/dist/src/phase-channel.d.ts +13 -0
- package/dist/src/phase-channel.d.ts.map +1 -0
- package/dist/src/phase-channel.js +55 -0
- package/dist/src/phase-channel.js.map +1 -0
- package/dist/src/strategies/autobiographical.d.ts +107 -44
- package/dist/src/strategies/autobiographical.d.ts.map +1 -1
- package/dist/src/strategies/autobiographical.js +437 -230
- package/dist/src/strategies/autobiographical.js.map +1 -1
- package/dist/src/types/index.d.ts +1 -1
- package/dist/src/types/index.d.ts.map +1 -1
- package/dist/src/types/index.js.map +1 -1
- package/dist/src/types/strategy.d.ts +70 -7
- package/dist/src/types/strategy.d.ts.map +1 -1
- package/dist/src/types/strategy.js +2 -0
- package/dist/src/types/strategy.js.map +1 -1
- package/dist/test/adaptive/kv-cache-sim.test.d.ts +16 -0
- package/dist/test/adaptive/kv-cache-sim.test.d.ts.map +1 -0
- package/dist/test/adaptive/kv-cache-sim.test.js +173 -0
- package/dist/test/adaptive/kv-cache-sim.test.js.map +1 -0
- package/dist/test/adaptive/kv-control.test.d.ts +14 -0
- package/dist/test/adaptive/kv-control.test.d.ts.map +1 -0
- package/dist/test/adaptive/kv-control.test.js +130 -0
- package/dist/test/adaptive/kv-control.test.js.map +1 -0
- package/dist/test/adaptive/kv-replay.test.d.ts +15 -0
- package/dist/test/adaptive/kv-replay.test.d.ts.map +1 -0
- package/dist/test/adaptive/kv-replay.test.js +112 -0
- package/dist/test/adaptive/kv-replay.test.js.map +1 -0
- package/dist/test/adaptive/kv-stable-strategy.test.d.ts +10 -0
- package/dist/test/adaptive/kv-stable-strategy.test.d.ts.map +1 -0
- package/dist/test/adaptive/kv-stable-strategy.test.js +89 -0
- package/dist/test/adaptive/kv-stable-strategy.test.js.map +1 -0
- package/dist/test/adaptive/pin-at-level.test.d.ts +16 -0
- package/dist/test/adaptive/pin-at-level.test.d.ts.map +1 -0
- package/dist/test/adaptive/pin-at-level.test.js +133 -0
- package/dist/test/adaptive/pin-at-level.test.js.map +1 -0
- package/dist/test/adaptive/render-offsets.test.d.ts +11 -0
- package/dist/test/adaptive/render-offsets.test.d.ts.map +1 -0
- package/dist/test/adaptive/render-offsets.test.js +85 -0
- package/dist/test/adaptive/render-offsets.test.js.map +1 -0
- package/dist/test/adaptive/summary-tree.test.d.ts +5 -0
- package/dist/test/adaptive/summary-tree.test.d.ts.map +1 -0
- package/dist/test/adaptive/summary-tree.test.js +72 -0
- package/dist/test/adaptive/summary-tree.test.js.map +1 -0
- package/dist/test/image-strip-render-stats.test.d.ts +2 -0
- package/dist/test/image-strip-render-stats.test.d.ts.map +1 -0
- package/dist/test/image-strip-render-stats.test.js +130 -0
- package/dist/test/image-strip-render-stats.test.js.map +1 -0
- package/dist/test/image-stripping.test.d.ts +13 -0
- package/dist/test/image-stripping.test.d.ts.map +1 -0
- package/dist/test/image-stripping.test.js +104 -0
- package/dist/test/image-stripping.test.js.map +1 -0
- package/dist/test/pins.test.js +40 -0
- package/dist/test/pins.test.js.map +1 -1
- package/dist/test/render-stats.test.d.ts +2 -0
- package/dist/test/render-stats.test.d.ts.map +1 -0
- package/dist/test/render-stats.test.js +91 -0
- package/dist/test/render-stats.test.js.map +1 -0
- package/dist/test/speculation-cap.test.d.ts +9 -3
- package/dist/test/speculation-cap.test.d.ts.map +1 -1
- package/dist/test/speculation-cap.test.js +175 -3
- package/dist/test/speculation-cap.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/adaptive/index.ts +48 -0
- package/src/adaptive/kv-cache-sim.ts +0 -0
- package/src/adaptive/kv-control.ts +574 -0
- package/src/adaptive/kv-replay.ts +238 -0
- package/src/adaptive/picker.ts +13 -0
- package/src/adaptive/render-offsets.ts +116 -0
- package/src/adaptive/strategies/kv-stable.ts +161 -0
- package/src/adaptive/summary-tree.ts +252 -0
- package/src/context-log.ts +7 -2
- package/src/context-manager.ts +13 -2
- package/src/index.ts +3 -0
- package/src/message-store.ts +1 -1
- package/src/phase-channel.ts +57 -0
- package/src/strategies/autobiographical.ts +453 -282
- package/src/types/index.ts +1 -0
- package/src/types/strategy.ts +71 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { JsStore } from '@animalabs/chronicle';
|
|
2
2
|
import type { Membrane, NormalizedRequest, ContentBlock, CompleteOptions } from '@animalabs/membrane';
|
|
3
3
|
import { NativeFormatter } from '@animalabs/membrane';
|
|
4
|
+
import { phaseChannel } from '../phase-channel.js';
|
|
4
5
|
import type {
|
|
5
6
|
ContextStrategy,
|
|
6
7
|
ResettableStrategy,
|
|
@@ -15,16 +16,19 @@ import type {
|
|
|
15
16
|
SummaryLevel,
|
|
16
17
|
SummaryEntry,
|
|
17
18
|
ProtectedRange,
|
|
19
|
+
PinLevelOptions,
|
|
18
20
|
SearchQuery,
|
|
19
21
|
SearchResult,
|
|
22
|
+
RenderStats,
|
|
20
23
|
} from '../types/index.js';
|
|
21
24
|
import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
|
|
22
25
|
import { getSummaryParentId } from '../types/strategy.js';
|
|
23
26
|
import { splitMixedToolMessages, stripUnpairedToolBlocks } from '../normalize-tool-messages.js';
|
|
24
27
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
25
28
|
import { dirname } from 'node:path';
|
|
26
|
-
import { Picker, OverBudgetError, type PickerChunk } from '../adaptive/picker.js';
|
|
29
|
+
import { Picker, OverBudgetError, type PickerChunk, type PickerInputs } from '../adaptive/picker.js';
|
|
27
30
|
import { FlatProfileStrategy } from '../adaptive/strategies/flat-profile.js';
|
|
31
|
+
import { KvStableStrategy } from '../adaptive/strategies/kv-stable.js';
|
|
28
32
|
import { OldestFirstStrategy } from '../adaptive/strategies/oldest-first.js';
|
|
29
33
|
import type {
|
|
30
34
|
FoldingStrategy,
|
|
@@ -223,8 +227,6 @@ export interface Chunk {
|
|
|
223
227
|
tokens: number;
|
|
224
228
|
/** Whether this chunk has been compressed */
|
|
225
229
|
compressed: boolean;
|
|
226
|
-
/** The diary entry if compressed (legacy mode) */
|
|
227
|
-
diary?: string;
|
|
228
230
|
/** ID of the L1 SummaryEntry (hierarchical mode) */
|
|
229
231
|
summaryId?: string;
|
|
230
232
|
/** Phase type tag (set by KnowledgeStrategy for semantic chunking) */
|
|
@@ -252,6 +254,21 @@ export interface AutobiographicalProgressSnapshot {
|
|
|
252
254
|
pending: boolean;
|
|
253
255
|
}
|
|
254
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Validate + normalize the optional V2 pin fold-depth bounds. Returns only the
|
|
259
|
+
* fields that are present and valid (non-negative integers), so a classic pin
|
|
260
|
+
* with no bounds persists exactly as before. `level` takes precedence over
|
|
261
|
+
* `maxLevel` (pin-at-k is stronger than a cap), so they're never both emitted.
|
|
262
|
+
*/
|
|
263
|
+
function normalizePinLevels(opts?: PinLevelOptions): { level?: number; maxLevel?: number } {
|
|
264
|
+
const clean = (v: number | undefined): number | undefined =>
|
|
265
|
+
typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : undefined;
|
|
266
|
+
const level = clean(opts?.level);
|
|
267
|
+
if (level !== undefined) return { level };
|
|
268
|
+
const maxLevel = clean(opts?.maxLevel);
|
|
269
|
+
return maxLevel !== undefined ? { maxLevel } : {};
|
|
270
|
+
}
|
|
271
|
+
|
|
255
272
|
/**
|
|
256
273
|
* Autobiographical chunking strategy.
|
|
257
274
|
* Compresses old conversation chunks into summaries in the model's own words.
|
|
@@ -271,6 +288,14 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
271
288
|
protected pendingCompression: Promise<void> | null = null;
|
|
272
289
|
protected compressionQueue: number[] = [];
|
|
273
290
|
protected _compressionCount = 0;
|
|
291
|
+
/**
|
|
292
|
+
* Monotonic counter of tick() operations that actually processed a queue item
|
|
293
|
+
* (compressed a chunk or executed a merge). `driveSpeculativeDrain` recurses
|
|
294
|
+
* while this advances — a length-delta check would falsely read "no progress"
|
|
295
|
+
* when a productive tick also enqueues a follow-on item (net queue length
|
|
296
|
+
* unchanged), halting the drain with work still queued.
|
|
297
|
+
*/
|
|
298
|
+
protected _drainProgress = 0;
|
|
274
299
|
|
|
275
300
|
// Hierarchical state
|
|
276
301
|
protected summaries: SummaryEntry[] = [];
|
|
@@ -583,7 +608,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
583
608
|
* Pin a range of messages so they aren't compressed and render raw at
|
|
584
609
|
* their original position. Returns the pin id.
|
|
585
610
|
*/
|
|
586
|
-
pinRange(firstMessageId: string, lastMessageId: string, opts?:
|
|
611
|
+
pinRange(firstMessageId: string, lastMessageId: string, opts?: PinLevelOptions): string {
|
|
587
612
|
const id = `pin-${this.pinIdCounter++}`;
|
|
588
613
|
this.pins.push({
|
|
589
614
|
id,
|
|
@@ -592,6 +617,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
592
617
|
kind: 'pin',
|
|
593
618
|
name: opts?.name,
|
|
594
619
|
created: Date.now(),
|
|
620
|
+
...normalizePinLevels(opts),
|
|
595
621
|
});
|
|
596
622
|
this.persistPins();
|
|
597
623
|
return id;
|
|
@@ -602,7 +628,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
602
628
|
* information the agent wants to retain in full. Functionally a
|
|
603
629
|
* single-message pin with `kind: 'document'`.
|
|
604
630
|
*/
|
|
605
|
-
markDocument(messageId: string, opts?:
|
|
631
|
+
markDocument(messageId: string, opts?: PinLevelOptions): string {
|
|
606
632
|
const id = `pin-${this.pinIdCounter++}`;
|
|
607
633
|
this.pins.push({
|
|
608
634
|
id,
|
|
@@ -611,11 +637,22 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
611
637
|
kind: 'document',
|
|
612
638
|
name: opts?.name,
|
|
613
639
|
created: Date.now(),
|
|
640
|
+
...normalizePinLevels(opts),
|
|
614
641
|
});
|
|
615
642
|
this.persistPins();
|
|
616
643
|
return id;
|
|
617
644
|
}
|
|
618
645
|
|
|
646
|
+
/**
|
|
647
|
+
* V2 dynamic pin-at-level-k convenience: fix a range to render at EXACTLY
|
|
648
|
+
* fold level `level` (0 = raw). Honored only by `foldingStrategy: 'kv-stable'`;
|
|
649
|
+
* other strategies fall back to treating the range as raw. Equivalent to
|
|
650
|
+
* `pinRange(first, last, { level })`.
|
|
651
|
+
*/
|
|
652
|
+
pinAtLevel(firstMessageId: string, lastMessageId: string, level: number, opts?: { name?: string }): string {
|
|
653
|
+
return this.pinRange(firstMessageId, lastMessageId, { name: opts?.name, level });
|
|
654
|
+
}
|
|
655
|
+
|
|
619
656
|
/** Remove a pin or document mark by id. Returns true if removed. */
|
|
620
657
|
unpin(pinId: string): boolean {
|
|
621
658
|
const before = this.pins.length;
|
|
@@ -735,6 +772,42 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
735
772
|
return out;
|
|
736
773
|
}
|
|
737
774
|
|
|
775
|
+
/**
|
|
776
|
+
* Resolve the V2 dynamic-pin fold-depth bounds (`ProtectedRange.level` /
|
|
777
|
+
* `maxLevel`) to message positions. Only pins that carry a bound appear; a
|
|
778
|
+
* classic raw pin (no bound) is absent here and handled by `pinnedPositions`.
|
|
779
|
+
* When ranges overlap, the FINEST requirement wins (lowest effective level):
|
|
780
|
+
* a fixed `level` clamps both ends; a `maxLevel` only caps depth. Honored
|
|
781
|
+
* solely by the KV-stable controller — see `ProtectedRange`.
|
|
782
|
+
*/
|
|
783
|
+
protected pinLevelBounds(messages: StoredMessage[]): Map<number, { level?: number; maxLevel?: number }> {
|
|
784
|
+
const out = new Map<number, { level?: number; maxLevel?: number }>();
|
|
785
|
+
if (this.pins.length === 0) return out;
|
|
786
|
+
const positionOf = new Map<string, number>();
|
|
787
|
+
for (let i = 0; i < messages.length; i++) positionOf.set(messages[i].id, i);
|
|
788
|
+
|
|
789
|
+
for (const pin of this.pins) {
|
|
790
|
+
if (pin.level === undefined && pin.maxLevel === undefined) continue;
|
|
791
|
+
const first = positionOf.get(pin.firstMessageId);
|
|
792
|
+
const last = positionOf.get(pin.lastMessageId);
|
|
793
|
+
if (first === undefined || last === undefined) continue;
|
|
794
|
+
const lo = Math.min(first, last);
|
|
795
|
+
const hi = Math.max(first, last);
|
|
796
|
+
for (let i = lo; i <= hi; i++) {
|
|
797
|
+
const prev = out.get(i) ?? {};
|
|
798
|
+
// A fixed level is the strongest constraint; when two pins fix the same
|
|
799
|
+
// position, the shallower (lower) level wins (finest requirement).
|
|
800
|
+
if (pin.level !== undefined) {
|
|
801
|
+
prev.level = prev.level === undefined ? pin.level : Math.min(prev.level, pin.level);
|
|
802
|
+
} else if (pin.maxLevel !== undefined) {
|
|
803
|
+
prev.maxLevel = prev.maxLevel === undefined ? pin.maxLevel : Math.min(prev.maxLevel, pin.maxLevel);
|
|
804
|
+
}
|
|
805
|
+
out.set(i, prev);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return out;
|
|
809
|
+
}
|
|
810
|
+
|
|
738
811
|
/**
|
|
739
812
|
* Append a summary to the in-memory list and to the chronicle AppendLog.
|
|
740
813
|
* Single point so subclasses inherit persistence.
|
|
@@ -1004,20 +1077,31 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1004
1077
|
*/
|
|
1005
1078
|
protected driveSpeculativeDrain(ctx: StrategyContext): void {
|
|
1006
1079
|
if (this.pendingCompression) return;
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1080
|
+
// Merges consolidate existing L_k summaries into L_{k+1} and REDUCE the
|
|
1081
|
+
// unmerged-L1 count; L1 compression PRODUCES new unmerged L1s. The
|
|
1082
|
+
// speculation cap / preflight throttle *production* only — they must never
|
|
1083
|
+
// gate merges, otherwise exceeding the cap (e.g. after a manual backfill)
|
|
1084
|
+
// permanently deadlocks the drain: too many unmerged L1s trips the cap,
|
|
1085
|
+
// which blocks the very merges that would bring the count back down.
|
|
1086
|
+
const hasMerges = this.config.hierarchical === true && this.mergeQueue.length > 0;
|
|
1087
|
+
const hasCompression = this.compressionQueue.length > 0;
|
|
1088
|
+
if (!hasCompression && !hasMerges) return;
|
|
1089
|
+
// Only bail when the *sole* available work is L1 compression that the cap
|
|
1090
|
+
// or preflight currently forbids. Merge work always proceeds.
|
|
1091
|
+
if (!hasMerges && (this.isAtSpeculativeCap() || !this.shouldCompressPreflight())) return;
|
|
1092
|
+
|
|
1093
|
+
const progressBefore = this._drainProgress;
|
|
1013
1094
|
|
|
1014
1095
|
this.tick(ctx)
|
|
1015
1096
|
.then(() => {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
//
|
|
1019
|
-
|
|
1020
|
-
|
|
1097
|
+
// Progress = the tick actually processed a queue item (compress or
|
|
1098
|
+
// merge), tracked by `_drainProgress`. A queue-length delta is the
|
|
1099
|
+
// wrong signal: a productive merge tick can also enqueue a follow-on
|
|
1100
|
+
// merge, leaving the length unchanged — which the old check misread as
|
|
1101
|
+
// "no progress" and halted the drain mid-backlog. A genuine no-op tick
|
|
1102
|
+
// (empty queues, at-cap with no merges, no membrane) doesn't advance
|
|
1103
|
+
// the counter, so this still stops cleanly (no runaway recursion).
|
|
1104
|
+
if (this._drainProgress === progressBefore) return;
|
|
1021
1105
|
// Recurse to drain more. queueMicrotask defers until the current
|
|
1022
1106
|
// task is done, letting other code (the agent's stream consumer)
|
|
1023
1107
|
// interleave.
|
|
@@ -1029,14 +1113,23 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1029
1113
|
}
|
|
1030
1114
|
|
|
1031
1115
|
/**
|
|
1032
|
-
* Whether the
|
|
1116
|
+
* Whether the count of *produced, unmerged* L1 summaries has reached the cap
|
|
1033
1117
|
* configured by `maxSpeculativeL1s`. If no cap is set, always false.
|
|
1118
|
+
*
|
|
1119
|
+
* The cap bounds how many L1 summaries may sit un-consolidated before the
|
|
1120
|
+
* strategy must merge them into L_{k+1} (bounding prefix churn / merge debt).
|
|
1121
|
+
* It deliberately does NOT count the pending `compressionQueue`: that queue is
|
|
1122
|
+
* the backlog of work to be *drained*, not produced summaries. Counting it
|
|
1123
|
+
* here would let a large backlog permanently trip the cap and block the very
|
|
1124
|
+
* compression that would clear it — a deadlock (merges relieve the cap, but
|
|
1125
|
+
* compression of the backlog never resumes). The throttle is on produced L1s;
|
|
1126
|
+
* the queue drains freely, with merges keeping the unmerged count under the cap.
|
|
1034
1127
|
*/
|
|
1035
1128
|
protected isAtSpeculativeCap(): boolean {
|
|
1036
1129
|
const cap = this.config.maxSpeculativeL1s;
|
|
1037
1130
|
if (cap === undefined || cap < 0) return false;
|
|
1038
1131
|
const unmergedL1s = this.summaries.filter(s => s.level === 1 && !s.mergedInto).length;
|
|
1039
|
-
return unmergedL1s
|
|
1132
|
+
return unmergedL1s > cap;
|
|
1040
1133
|
}
|
|
1041
1134
|
|
|
1042
1135
|
/**
|
|
@@ -1050,6 +1143,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1050
1143
|
}
|
|
1051
1144
|
|
|
1052
1145
|
async tick(ctx: StrategyContext): Promise<void> {
|
|
1146
|
+
phaseChannel.report('compress-tick'); // liveness-watchdog phase
|
|
1053
1147
|
if (this.pendingCompression) return;
|
|
1054
1148
|
|
|
1055
1149
|
if (!ctx.membrane) {
|
|
@@ -1057,16 +1151,18 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1057
1151
|
return;
|
|
1058
1152
|
}
|
|
1059
1153
|
|
|
1060
|
-
// Priority 1: Compress raw chunks → L1
|
|
1061
|
-
|
|
1154
|
+
// Priority 1: Compress raw chunks → L1. Skipped while at the speculative
|
|
1155
|
+
// cap (maxSpeculativeL1s) so we don't pile up more unmerged L1s; the merge
|
|
1156
|
+
// priority below still runs to consolidate existing L1s and relieve the cap.
|
|
1157
|
+
// No cap configured → isAtSpeculativeCap() is always false → unchanged.
|
|
1158
|
+
if (this.compressionQueue.length > 0 && !this.isAtSpeculativeCap()) {
|
|
1062
1159
|
const chunkIndex = this.compressionQueue.shift()!;
|
|
1160
|
+
this._drainProgress++; // consumed a queue item (real work or stale-cleanup)
|
|
1063
1161
|
const chunk = this.chunks[chunkIndex];
|
|
1064
1162
|
|
|
1065
1163
|
if (!chunk || chunk.compressed) return;
|
|
1066
1164
|
|
|
1067
|
-
this.pendingCompression = this.
|
|
1068
|
-
? this.compressChunkHierarchical(chunk, ctx)
|
|
1069
|
-
: this.compressChunkLegacy(chunk, ctx);
|
|
1165
|
+
this.pendingCompression = this.compressChunkHierarchical(chunk, ctx);
|
|
1070
1166
|
|
|
1071
1167
|
try {
|
|
1072
1168
|
await this.pendingCompression;
|
|
@@ -1087,6 +1183,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1087
1183
|
// and the next tick() retries it.
|
|
1088
1184
|
if (this.config.hierarchical && this.mergeQueue.length > 0) {
|
|
1089
1185
|
const merge = this.mergeQueue[0]!;
|
|
1186
|
+
this._drainProgress++; // executing a merge is real work, even if a
|
|
1187
|
+
// follow-on merge gets enqueued and the queue length nets out unchanged
|
|
1090
1188
|
this.pendingCompression = this.executeMerge(merge.level, merge.sourceIds, ctx);
|
|
1091
1189
|
|
|
1092
1190
|
try {
|
|
@@ -1136,12 +1234,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1136
1234
|
): ContextEntry[] {
|
|
1137
1235
|
this.rebuildChunks(store);
|
|
1138
1236
|
|
|
1237
|
+
// Image stripping runs inside each select path (before stats commit / cache
|
|
1238
|
+
// markers), so the returned entries are already bounded — see
|
|
1239
|
+
// applyImageStripping.
|
|
1139
1240
|
if (this.config.adaptiveResolution) {
|
|
1140
1241
|
return this.selectAdaptive(store, budget);
|
|
1141
1242
|
}
|
|
1142
|
-
return this.
|
|
1143
|
-
? this.selectHierarchical(store, budget)
|
|
1144
|
-
: this.selectLegacy(store, log, budget);
|
|
1243
|
+
return this.selectHierarchical(store, budget);
|
|
1145
1244
|
}
|
|
1146
1245
|
|
|
1147
1246
|
/**
|
|
@@ -1171,16 +1270,89 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1171
1270
|
* Useful for TUI / dashboards. The token sums use the strategy's own
|
|
1172
1271
|
* token estimates (which match what `select()` uses for budget math).
|
|
1173
1272
|
*/
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1273
|
+
// ===========================================================================
|
|
1274
|
+
// Render-stats instrumentation — inspect, don't reconstruct.
|
|
1275
|
+
//
|
|
1276
|
+
// selectAdaptive/selectHierarchical tally each entry into `_rs` AS THEY EMIT
|
|
1277
|
+
// it (raw head/tail, raw middle the picker kept verbatim, and recall pairs
|
|
1278
|
+
// bucketed by the ancestor summary's level), using the same token numbers the
|
|
1279
|
+
// renderer uses for budget math. `getRenderStats()` returns that committed
|
|
1280
|
+
// snapshot, so it reflects what the last compile actually rendered rather than
|
|
1281
|
+
// re-deriving the full pyramid (which, under adaptive resolution, bears little
|
|
1282
|
+
// resemblance to the folded output).
|
|
1283
|
+
// ===========================================================================
|
|
1284
|
+
private _rs: RenderStats | null = null;
|
|
1285
|
+
private _lastRenderStats: RenderStats | null = null;
|
|
1286
|
+
|
|
1287
|
+
/** Begin a render-stats accumulation for one select() pass. */
|
|
1288
|
+
protected rsBegin(): void {
|
|
1289
|
+
this._rs = {
|
|
1290
|
+
head: { messages: 0, tokens: 0 },
|
|
1291
|
+
tail: { messages: 0, tokens: 0 },
|
|
1292
|
+
middleRaw: { messages: 0, tokens: 0 },
|
|
1293
|
+
summaries: {
|
|
1294
|
+
l1: { count: 0, tokens: 0 },
|
|
1295
|
+
l2: { count: 0, tokens: 0 },
|
|
1296
|
+
l3: { count: 0, tokens: 0 },
|
|
1297
|
+
},
|
|
1298
|
+
pending: { chunks: 0, merges: 0 },
|
|
1299
|
+
total: { messages: 0, tokens: 0 },
|
|
1181
1300
|
};
|
|
1182
|
-
|
|
1183
|
-
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/** Tally one (or `count`) raw message(s) into a raw bucket. */
|
|
1304
|
+
protected rsRaw(bucket: 'head' | 'tail' | 'middleRaw', tokens: number, count = 1): void {
|
|
1305
|
+
const r = this._rs;
|
|
1306
|
+
if (!r) return;
|
|
1307
|
+
r[bucket].messages += count;
|
|
1308
|
+
r[bucket].tokens += tokens;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/** Tally one emitted recall pair under its ancestor's level (>=3 folds into l3). */
|
|
1312
|
+
protected rsSummary(level: number, tokens: number): void {
|
|
1313
|
+
const r = this._rs;
|
|
1314
|
+
if (!r) return;
|
|
1315
|
+
const k: 'l1' | 'l2' | 'l3' = level <= 1 ? 'l1' : level === 2 ? 'l2' : 'l3';
|
|
1316
|
+
r.summaries[k].count += 1;
|
|
1317
|
+
r.summaries[k].tokens += tokens;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/** Commit the accumulated stats as the last-render snapshot. */
|
|
1321
|
+
protected rsEnd(): void {
|
|
1322
|
+
const r = this._rs;
|
|
1323
|
+
if (!r) return;
|
|
1324
|
+
r.pending = {
|
|
1325
|
+
chunks: this.chunks.filter(c => !c.compressed).length,
|
|
1326
|
+
merges: this.mergeQueue.length,
|
|
1327
|
+
};
|
|
1328
|
+
const s = r.summaries;
|
|
1329
|
+
const summaryMsgs = (s.l1.count + s.l2.count + s.l3.count) * 2; // Q/A pair each
|
|
1330
|
+
r.total = {
|
|
1331
|
+
messages: r.head.messages + r.tail.messages + r.middleRaw.messages + summaryMsgs,
|
|
1332
|
+
tokens:
|
|
1333
|
+
r.head.tokens + r.tail.tokens + r.middleRaw.tokens +
|
|
1334
|
+
s.l1.tokens + s.l2.tokens + s.l3.tokens,
|
|
1335
|
+
};
|
|
1336
|
+
this._lastRenderStats = r;
|
|
1337
|
+
this._rs = null;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Stats describing the LAST rendered context. Returns the inspected snapshot
|
|
1342
|
+
* captured during the most recent `select()`. Before any compile has run (no
|
|
1343
|
+
* snapshot yet), falls back to a reconstructed pyramid view so callers still
|
|
1344
|
+
* get a non-null shape.
|
|
1345
|
+
*/
|
|
1346
|
+
getRenderStats(store: MessageStoreView): RenderStats {
|
|
1347
|
+
return this._lastRenderStats ?? this.reconstructRenderStats(store);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Pre-render fallback: re-derive head/tail windows + the full live pyramid.
|
|
1352
|
+
* NOTE: this is the old "reconstruct" behavior and does NOT reflect adaptive
|
|
1353
|
+
* folding — used only until the first compile populates the inspected stats.
|
|
1354
|
+
*/
|
|
1355
|
+
protected reconstructRenderStats(store: MessageStoreView): RenderStats {
|
|
1184
1356
|
const messages = store.getAll();
|
|
1185
1357
|
const headStart = this.getHeadWindowStartIndex(store);
|
|
1186
1358
|
const headEnd = this.getHeadWindowEnd(store);
|
|
@@ -1197,145 +1369,31 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1197
1369
|
const sumLevelTokens = (level: SummaryLevel): number =>
|
|
1198
1370
|
live(level).reduce((acc, s) => acc + s.tokens, 0);
|
|
1199
1371
|
|
|
1372
|
+
const head = { messages: headMsgs.length, tokens: sumTokens(headMsgs) };
|
|
1373
|
+
const tail = { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) };
|
|
1374
|
+
const summaries = {
|
|
1375
|
+
l1: { count: live(1).length, tokens: sumLevelTokens(1) },
|
|
1376
|
+
l2: { count: live(2).length, tokens: sumLevelTokens(2) },
|
|
1377
|
+
l3: { count: live(3).length, tokens: sumLevelTokens(3) },
|
|
1378
|
+
};
|
|
1200
1379
|
return {
|
|
1201
|
-
head
|
|
1202
|
-
tail
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
l2: { count: live(2).length, tokens: sumLevelTokens(2) },
|
|
1206
|
-
l3: { count: live(3).length, tokens: sumLevelTokens(3) },
|
|
1207
|
-
},
|
|
1380
|
+
head,
|
|
1381
|
+
tail,
|
|
1382
|
+
middleRaw: { messages: 0, tokens: 0 },
|
|
1383
|
+
summaries,
|
|
1208
1384
|
pending: {
|
|
1209
1385
|
chunks: this.chunks.filter(c => !c.compressed).length,
|
|
1210
1386
|
merges: this.mergeQueue.length,
|
|
1211
1387
|
},
|
|
1388
|
+
total: {
|
|
1389
|
+
messages: head.messages + tail.messages
|
|
1390
|
+
+ (summaries.l1.count + summaries.l2.count + summaries.l3.count) * 2,
|
|
1391
|
+
tokens: head.tokens + tail.tokens
|
|
1392
|
+
+ summaries.l1.tokens + summaries.l2.tokens + summaries.l3.tokens,
|
|
1393
|
+
},
|
|
1212
1394
|
};
|
|
1213
1395
|
}
|
|
1214
1396
|
|
|
1215
|
-
// ============================================================================
|
|
1216
|
-
// Legacy (single-level) path
|
|
1217
|
-
// ============================================================================
|
|
1218
|
-
|
|
1219
|
-
protected selectLegacy(
|
|
1220
|
-
store: MessageStoreView,
|
|
1221
|
-
_log: ContextLogView,
|
|
1222
|
-
budget: TokenBudget
|
|
1223
|
-
): ContextEntry[] {
|
|
1224
|
-
const entries: ContextEntry[] = [];
|
|
1225
|
-
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
1226
|
-
let totalTokens = 0;
|
|
1227
|
-
const messages = store.getAll();
|
|
1228
|
-
const msgCap = this.config.maxMessageTokens;
|
|
1229
|
-
|
|
1230
|
-
// 1. Head window: preserved verbatim as raw copies
|
|
1231
|
-
const headStart = this.getHeadWindowStartIndex(store);
|
|
1232
|
-
const headEnd = this.getHeadWindowEnd(store);
|
|
1233
|
-
for (let i = headStart; i < headEnd && i < messages.length; i++) {
|
|
1234
|
-
const msg = messages[i];
|
|
1235
|
-
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1236
|
-
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
1237
|
-
if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
|
|
1238
|
-
|
|
1239
|
-
entries.push({
|
|
1240
|
-
index: entries.length,
|
|
1241
|
-
sourceMessageId: msg.id,
|
|
1242
|
-
sourceRelation: 'copy',
|
|
1243
|
-
participant: msg.participant,
|
|
1244
|
-
content,
|
|
1245
|
-
});
|
|
1246
|
-
totalTokens += tokens;
|
|
1247
|
-
}
|
|
1248
|
-
// Mark the last head entry as a cache boundary (even if budget truncated the window)
|
|
1249
|
-
if (entries.length > 0) {
|
|
1250
|
-
entries[entries.length - 1].cacheMarker = true;
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
// 2. Middle zone: compressed chunks as diary pairs, uncompressed as raw messages.
|
|
1254
|
-
const rawRecentStart = this.getRecentWindowStart(store);
|
|
1255
|
-
// Track which message IDs are covered by chunks
|
|
1256
|
-
const coveredByChunks = new Set<string>();
|
|
1257
|
-
|
|
1258
|
-
for (const chunk of this.chunks) {
|
|
1259
|
-
for (const m of chunk.messages) coveredByChunks.add(m.id);
|
|
1260
|
-
|
|
1261
|
-
if (chunk.compressed && chunk.diary) {
|
|
1262
|
-
const contextLabel = this.config.summaryContextLabel ?? 'Here is a summary of earlier conversation context:';
|
|
1263
|
-
const summaryParticipant = this.config.summaryParticipant ?? 'Summary';
|
|
1264
|
-
|
|
1265
|
-
const questionEntry: ContextEntry = {
|
|
1266
|
-
index: entries.length,
|
|
1267
|
-
participant: 'Context Manager',
|
|
1268
|
-
content: [{ type: 'text', text: contextLabel }],
|
|
1269
|
-
sourceRelation: 'derived',
|
|
1270
|
-
};
|
|
1271
|
-
|
|
1272
|
-
// Synthesised summary turns must respect maxMessageTokens just like raw
|
|
1273
|
-
// copies do — otherwise a runaway diary can starve recent messages.
|
|
1274
|
-
const answerContent: ContentBlock[] = [{ type: 'text', text: chunk.diary }];
|
|
1275
|
-
const answerEntry: ContextEntry = {
|
|
1276
|
-
index: entries.length + 1,
|
|
1277
|
-
participant: summaryParticipant,
|
|
1278
|
-
content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
|
|
1279
|
-
sourceRelation: 'derived',
|
|
1280
|
-
};
|
|
1281
|
-
|
|
1282
|
-
const pairTokens = this.estimateTokens(questionEntry.content) +
|
|
1283
|
-
this.estimateTokens(answerEntry.content);
|
|
1284
|
-
|
|
1285
|
-
if (this.isOverBudget(totalTokens + pairTokens, maxTokens)) break;
|
|
1286
|
-
|
|
1287
|
-
entries.push(questionEntry);
|
|
1288
|
-
entries.push(answerEntry);
|
|
1289
|
-
totalTokens += pairTokens;
|
|
1290
|
-
} else {
|
|
1291
|
-
// Uncompressed: emit raw messages so they aren't lost
|
|
1292
|
-
for (const msg of chunk.messages) {
|
|
1293
|
-
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1294
|
-
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
1295
|
-
if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
|
|
1296
|
-
|
|
1297
|
-
entries.push({
|
|
1298
|
-
index: entries.length,
|
|
1299
|
-
sourceMessageId: msg.id,
|
|
1300
|
-
sourceRelation: 'copy',
|
|
1301
|
-
participant: msg.participant,
|
|
1302
|
-
content,
|
|
1303
|
-
});
|
|
1304
|
-
totalTokens += tokens;
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
// Emit gap messages in the compressible zone not covered by any chunk.
|
|
1310
|
-
// Compressible zone: [0, headStart) ∪ [headEnd, rawRecentStart)
|
|
1311
|
-
for (let i = 0; i < rawRecentStart && i < messages.length; i++) {
|
|
1312
|
-
// Skip head window messages (already emitted verbatim above)
|
|
1313
|
-
if (i >= headStart && i < headEnd) continue;
|
|
1314
|
-
if (coveredByChunks.has(messages[i].id)) continue;
|
|
1315
|
-
const msg = messages[i];
|
|
1316
|
-
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1317
|
-
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
1318
|
-
if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
|
|
1319
|
-
|
|
1320
|
-
entries.push({
|
|
1321
|
-
index: entries.length,
|
|
1322
|
-
sourceMessageId: msg.id,
|
|
1323
|
-
sourceRelation: 'copy',
|
|
1324
|
-
participant: msg.participant,
|
|
1325
|
-
content,
|
|
1326
|
-
});
|
|
1327
|
-
totalTokens += tokens;
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
// 3. Recent uncompressed messages (skip those already in head window)
|
|
1331
|
-
const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
|
|
1332
|
-
this.emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokens);
|
|
1333
|
-
|
|
1334
|
-
this.trimOrphanedToolUse(entries);
|
|
1335
|
-
this.pruneToolEntries(entries);
|
|
1336
|
-
return entries;
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
1397
|
/**
|
|
1340
1398
|
* Emit recent-window messages, evicting OLDEST-first when the budget is tight.
|
|
1341
1399
|
*
|
|
@@ -1355,8 +1413,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1355
1413
|
msgCap: number,
|
|
1356
1414
|
maxTokens: number,
|
|
1357
1415
|
totalTokensBefore: number,
|
|
1358
|
-
):
|
|
1359
|
-
if (recentStart >= messages.length) return;
|
|
1416
|
+
): { messages: number; tokens: number } {
|
|
1417
|
+
if (recentStart >= messages.length) return { messages: 0, tokens: 0 };
|
|
1360
1418
|
|
|
1361
1419
|
const accepted: number[] = [];
|
|
1362
1420
|
let acceptedTokens = 0;
|
|
@@ -1380,9 +1438,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1380
1438
|
accepted.shift();
|
|
1381
1439
|
}
|
|
1382
1440
|
|
|
1441
|
+
let emittedTokens = 0;
|
|
1383
1442
|
for (const i of accepted) {
|
|
1384
1443
|
const msg = messages[i];
|
|
1385
1444
|
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);
|
|
1386
1448
|
entries.push({
|
|
1387
1449
|
index: entries.length,
|
|
1388
1450
|
sourceMessageId: msg.id,
|
|
@@ -1390,100 +1452,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1390
1452
|
participant: msg.participant,
|
|
1391
1453
|
content,
|
|
1392
1454
|
});
|
|
1455
|
+
emittedTokens += tokens;
|
|
1393
1456
|
}
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
protected async compressChunkLegacy(chunk: Chunk, ctx: StrategyContext): Promise<void> {
|
|
1397
|
-
if (!ctx.membrane) {
|
|
1398
|
-
throw new Error('No membrane instance for compression');
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
const priorContext = this.buildPriorContextLegacy(chunk, ctx);
|
|
1402
|
-
const chunkContent = this.formatChunkForCompression(chunk);
|
|
1403
|
-
|
|
1404
|
-
const prompt = this.config.diaryUserPrompt ?? this.config.summaryUserPrompt!;
|
|
1405
|
-
const systemPrompt = this.config.diarySystemPrompt ?? this.config.summarySystemPrompt!;
|
|
1406
|
-
|
|
1407
|
-
const messages = [
|
|
1408
|
-
...priorContext,
|
|
1409
|
-
{
|
|
1410
|
-
participant: 'Context Manager',
|
|
1411
|
-
content: [{ type: 'text' as const, text: prompt.replace('{content}', chunkContent) }],
|
|
1412
|
-
},
|
|
1413
|
-
];
|
|
1414
|
-
|
|
1415
|
-
const request: NormalizedRequest = {
|
|
1416
|
-
messages: messages.map((m) => ({
|
|
1417
|
-
participant: m.participant,
|
|
1418
|
-
content: m.content,
|
|
1419
|
-
})),
|
|
1420
|
-
system: systemPrompt,
|
|
1421
|
-
config: {
|
|
1422
|
-
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
1423
|
-
maxTokens: 2000,
|
|
1424
|
-
},
|
|
1425
|
-
};
|
|
1426
|
-
|
|
1427
|
-
try {
|
|
1428
|
-
const response = await ctx.membrane.complete(request);
|
|
1429
|
-
const diaryText = response.content
|
|
1430
|
-
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
|
1431
|
-
.map((b) => b.text)
|
|
1432
|
-
.join('\n');
|
|
1433
|
-
|
|
1434
|
-
chunk.compressed = true;
|
|
1435
|
-
chunk.diary = diaryText;
|
|
1436
|
-
this._compressionCount++;
|
|
1437
|
-
} catch (error) {
|
|
1438
|
-
console.error('Failed to compress chunk:', error);
|
|
1439
|
-
throw error;
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
protected buildPriorContextLegacy(chunk: Chunk, ctx: StrategyContext): Array<{
|
|
1444
|
-
participant: string;
|
|
1445
|
-
content: ContentBlock[];
|
|
1446
|
-
}> {
|
|
1447
|
-
const context: Array<{ participant: string; content: ContentBlock[] }> = [];
|
|
1448
|
-
|
|
1449
|
-
for (const prevChunk of this.chunks) {
|
|
1450
|
-
if (prevChunk.index >= chunk.index) break;
|
|
1451
|
-
if (!prevChunk.compressed || !prevChunk.diary) continue;
|
|
1452
|
-
|
|
1453
|
-
context.push({
|
|
1454
|
-
participant: 'Context Manager',
|
|
1455
|
-
content: [{ type: 'text', text: this.config.summaryContextLabel ?? 'Summary of earlier context:' }],
|
|
1456
|
-
});
|
|
1457
|
-
context.push({
|
|
1458
|
-
participant: this.config.summaryParticipant ?? 'Summary',
|
|
1459
|
-
content: [{ type: 'text', text: prevChunk.diary }],
|
|
1460
|
-
});
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
// Find the actual position of this chunk's first message in the full array
|
|
1464
|
-
const messages = ctx.messageStore.getAll();
|
|
1465
|
-
const firstMsgId = chunk.messages[0]?.id;
|
|
1466
|
-
const chunkAbsStart = firstMsgId
|
|
1467
|
-
? messages.findIndex(m => m.id === firstMsgId)
|
|
1468
|
-
: -1;
|
|
1469
|
-
|
|
1470
|
-
if (chunkAbsStart > 0) {
|
|
1471
|
-
const precedingStart = Math.max(0, chunkAbsStart - 50);
|
|
1472
|
-
let tokens = 0;
|
|
1473
|
-
|
|
1474
|
-
for (let i = chunkAbsStart - 1; i >= precedingStart && tokens < 15000; i--) {
|
|
1475
|
-
const msg = messages[i];
|
|
1476
|
-
if (!msg) break;
|
|
1477
|
-
|
|
1478
|
-
tokens += ctx.messageStore.estimateTokens(msg);
|
|
1479
|
-
context.unshift({
|
|
1480
|
-
participant: msg.participant,
|
|
1481
|
-
content: msg.content,
|
|
1482
|
-
});
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
return context;
|
|
1457
|
+
return { messages: accepted.length, tokens: emittedTokens };
|
|
1487
1458
|
}
|
|
1488
1459
|
|
|
1489
1460
|
// ============================================================================
|
|
@@ -1532,6 +1503,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1532
1503
|
* No system prompt — framing via message structure only.
|
|
1533
1504
|
*/
|
|
1534
1505
|
protected async compressChunkHierarchical(chunk: Chunk, ctx: StrategyContext): Promise<void> {
|
|
1506
|
+
phaseChannel.report('compress-chunk'); // liveness-watchdog phase
|
|
1535
1507
|
if (!ctx.membrane) {
|
|
1536
1508
|
throw new Error('No membrane instance for compression');
|
|
1537
1509
|
}
|
|
@@ -1731,6 +1703,10 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1731
1703
|
|
|
1732
1704
|
try {
|
|
1733
1705
|
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
1706
|
+
// Text-only on purpose: this is the SUMMARIZER's one-shot response —
|
|
1707
|
+
// its thinking/redacted_thinking blocks are scratch work, not part of
|
|
1708
|
+
// the agent's history, and signed thinking is never valid inside a
|
|
1709
|
+
// rewritten summary anyway.
|
|
1734
1710
|
const summaryText = response.content
|
|
1735
1711
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
|
1736
1712
|
.map(b => b.text)
|
|
@@ -1806,6 +1782,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1806
1782
|
* summaries when the queue eventually drains.
|
|
1807
1783
|
*/
|
|
1808
1784
|
protected checkMergeThreshold(): void {
|
|
1785
|
+
phaseChannel.report('merge-threshold'); // liveness-watchdog phase
|
|
1809
1786
|
if (this.config.speculativeProduction) {
|
|
1810
1787
|
this.checkMergeThresholdRecursive();
|
|
1811
1788
|
return;
|
|
@@ -2188,6 +2165,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2188
2165
|
|
|
2189
2166
|
try {
|
|
2190
2167
|
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
2168
|
+
// Text-only on purpose: summarizer scratch thinking is not agent history
|
|
2191
2169
|
const mergedText = response.content
|
|
2192
2170
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
|
2193
2171
|
.map(b => b.text)
|
|
@@ -2272,6 +2250,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2272
2250
|
* See `docs/adaptive-resolution-design.md` §3, §5.
|
|
2273
2251
|
*/
|
|
2274
2252
|
protected selectAdaptive(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
|
|
2253
|
+
phaseChannel.report('context-build'); // liveness-watchdog phase
|
|
2254
|
+
this.rsBegin();
|
|
2275
2255
|
const entries: ContextEntry[] = [];
|
|
2276
2256
|
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
2277
2257
|
const messages = store.getAll();
|
|
@@ -2305,10 +2285,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2305
2285
|
totalTokens += tokens;
|
|
2306
2286
|
headMessageIds.add(msg.id);
|
|
2307
2287
|
headTokens += tokens;
|
|
2288
|
+
this.rsRaw('head', tokens);
|
|
2308
2289
|
}
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2290
|
+
// (Cache breakpoints are placed in one pass over the FINAL ordered entries
|
|
2291
|
+
// below — see placeCacheMarkers — capturing the stable folded prefix, not
|
|
2292
|
+
// just the head boundary.)
|
|
2312
2293
|
|
|
2313
2294
|
// Compute tail message IDs (will be emitted at end)
|
|
2314
2295
|
const effectiveRecentStart = Math.max(recentStart, headEnd);
|
|
@@ -2333,6 +2314,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2333
2314
|
// Pinned-position set so the picker doesn't fold messages the user
|
|
2334
2315
|
// explicitly marked as keep-raw. Built once and reused.
|
|
2335
2316
|
const pinnedSet = this.pinnedPositions(messages);
|
|
2317
|
+
// V2 dynamic-pin fold-depth bounds (level / maxLevel). A position with a
|
|
2318
|
+
// bound is NOT a classic force-raw pin — the KV-stable controller must be
|
|
2319
|
+
// able to move it to/within its bound — so it renders as `pinned: false`
|
|
2320
|
+
// carrying `pinLevel` / `pinMaxLevel` instead.
|
|
2321
|
+
const pinBounds = this.pinLevelBounds(messages);
|
|
2336
2322
|
|
|
2337
2323
|
// O(1) summary lookup for findAncestorAt — avoids O(summaries) find()
|
|
2338
2324
|
// calls during emission.
|
|
@@ -2346,13 +2332,18 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2346
2332
|
const tokens = msgCap > 0
|
|
2347
2333
|
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
2348
2334
|
: store.estimateTokens(msg);
|
|
2335
|
+
const bound = pinBounds.get(i);
|
|
2349
2336
|
pickerChunks.push({
|
|
2350
2337
|
id: msg.id,
|
|
2351
2338
|
sequence: i,
|
|
2352
2339
|
rawTokens: tokens,
|
|
2353
2340
|
currentResolution: this.resolutions.get(msg.id) ?? 0,
|
|
2354
2341
|
lockedByAgent: this.locked.has(msg.id),
|
|
2355
|
-
|
|
2342
|
+
// A classic pin (in pinnedSet with no level bound) stays force-raw. A
|
|
2343
|
+
// leveled pin is not force-raw; it carries its bound instead.
|
|
2344
|
+
pinned: pinnedSet.has(i) && bound === undefined,
|
|
2345
|
+
pinLevel: bound?.level,
|
|
2346
|
+
pinMaxLevel: bound?.maxLevel,
|
|
2356
2347
|
l1Id: ch?.summaryId,
|
|
2357
2348
|
});
|
|
2358
2349
|
}
|
|
@@ -2401,7 +2392,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2401
2392
|
}
|
|
2402
2393
|
|
|
2403
2394
|
// ----- 4. Run the picker -----
|
|
2404
|
-
|
|
2395
|
+
// The picker's token count ALREADY includes the pinned head+tail (it gets
|
|
2396
|
+
// headTokens/tailTokens in pickerInputs and result.finalTokens covers them).
|
|
2397
|
+
// So the budget it folds against is the full maxTokens — NOT maxTokens-head,
|
|
2398
|
+
// which double-counts the head (reserves it twice: once here, once because
|
|
2399
|
+
// finalTokens already includes it). The old form threw ~head-tokens early at
|
|
2400
|
+
// tight budgets and quietly under-used the budget by ~head everywhere.
|
|
2401
|
+
const totalBudget = maxTokens;
|
|
2405
2402
|
const slack = this.config.compressionSlackRatio ?? 0.1;
|
|
2406
2403
|
const foldingBudget: FoldingBudget = {
|
|
2407
2404
|
totalBudget,
|
|
@@ -2412,19 +2409,17 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2412
2409
|
const headSetForPicker = new Set<ChunkId>(headMessageIds);
|
|
2413
2410
|
const tailSetForPicker = new Set<ChunkId>(tailMessageIds);
|
|
2414
2411
|
|
|
2415
|
-
const
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
foldingBudget
|
|
2427
|
-
);
|
|
2412
|
+
const pickerInputs: PickerInputs = {
|
|
2413
|
+
chunks: pickerChunks,
|
|
2414
|
+
summaries: summariesMap,
|
|
2415
|
+
recallPairTokens,
|
|
2416
|
+
headChunkIds: headSetForPicker,
|
|
2417
|
+
tailChunkIds: tailSetForPicker,
|
|
2418
|
+
headTokens,
|
|
2419
|
+
tailTokens,
|
|
2420
|
+
};
|
|
2421
|
+
const picker = this.buildPicker(pickerInputs);
|
|
2422
|
+
const result = picker.run(pickerInputs, foldingBudget);
|
|
2428
2423
|
|
|
2429
2424
|
// Commit the new resolutions back to strategy state for next compile.
|
|
2430
2425
|
// Persist to chronicle only if anything actually changed — avoids
|
|
@@ -2552,6 +2547,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2552
2547
|
content,
|
|
2553
2548
|
});
|
|
2554
2549
|
totalTokens += tokens;
|
|
2550
|
+
this.rsRaw('middleRaw', tokens);
|
|
2555
2551
|
} else {
|
|
2556
2552
|
// summary run — emit Q+A pair, dedup at the strategy level
|
|
2557
2553
|
const ancestor = currentRun.ancestor;
|
|
@@ -2578,6 +2574,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2578
2574
|
entries.push(questionEntry);
|
|
2579
2575
|
entries.push(answerEntry);
|
|
2580
2576
|
totalTokens += pairTokens;
|
|
2577
|
+
this.rsSummary(ancestor.level, pairTokens);
|
|
2581
2578
|
}
|
|
2582
2579
|
}
|
|
2583
2580
|
currentRun = null;
|
|
@@ -2638,6 +2635,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2638
2635
|
content,
|
|
2639
2636
|
});
|
|
2640
2637
|
totalTokens += tokens;
|
|
2638
|
+
this.rsRaw('middleRaw', tokens);
|
|
2641
2639
|
i++;
|
|
2642
2640
|
} else {
|
|
2643
2641
|
const ancestor = this.findAncestorAt(msg.id, resolution, chunksByMessageId, summariesById);
|
|
@@ -2653,6 +2651,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2653
2651
|
content,
|
|
2654
2652
|
});
|
|
2655
2653
|
totalTokens += tokens;
|
|
2654
|
+
this.rsRaw('middleRaw', tokens);
|
|
2656
2655
|
i++;
|
|
2657
2656
|
continue;
|
|
2658
2657
|
}
|
|
@@ -2679,12 +2678,14 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2679
2678
|
entries.push(questionEntry);
|
|
2680
2679
|
entries.push(answerEntry);
|
|
2681
2680
|
totalTokens += pairTokens;
|
|
2681
|
+
this.rsSummary(ancestor.level, pairTokens);
|
|
2682
2682
|
i++;
|
|
2683
2683
|
}
|
|
2684
2684
|
}
|
|
2685
2685
|
|
|
2686
2686
|
// ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
|
|
2687
|
-
this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
2687
|
+
const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
2688
|
+
this.rsRaw('tail', tailStats.tokens, tailStats.messages);
|
|
2688
2689
|
|
|
2689
2690
|
// ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
|
|
2690
2691
|
// Both head and tail emission paths emit shards as separate ContextEntries.
|
|
@@ -2694,10 +2695,59 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2694
2695
|
// it falls into (preserves KV cache through region transitions).
|
|
2695
2696
|
const merged = this.mergeAdjacentBodyGroupRaw(entries, store);
|
|
2696
2697
|
|
|
2698
|
+
this.pruneToolEntries(merged);
|
|
2697
2699
|
this.trimOrphanedToolUse(merged);
|
|
2700
|
+
// Strip stale images BEFORE placing markers and committing stats, so both
|
|
2701
|
+
// describe the post-strip context the agent actually receives.
|
|
2702
|
+
this.applyImageStripping(merged, store);
|
|
2703
|
+
// Place ≤4 cache breakpoints across the FINAL ordered entries so the
|
|
2704
|
+
// provider can reuse the stable folded prefix — not just the head. With a
|
|
2705
|
+
// single head marker the cache hit is ~2%; well-placed breakpoints take the
|
|
2706
|
+
// real strategy to ~50% (docs/kv-stable-context-control.md — marker
|
|
2707
|
+
// placement is the dominant KV lever).
|
|
2708
|
+
this.placeCacheMarkers(merged, headMessageIds, tailMessageIds);
|
|
2709
|
+
this.rsEnd();
|
|
2698
2710
|
return merged;
|
|
2699
2711
|
}
|
|
2700
2712
|
|
|
2713
|
+
/**
|
|
2714
|
+
* Place up to four `cache_control` breakpoints across the final ordered
|
|
2715
|
+
* entries: the head/system boundary, the end of the folded history (the
|
|
2716
|
+
* stable prefix that persists turn-to-turn — the most valuable), a mid-history
|
|
2717
|
+
* seam, and the very end (for pure-append reuse). Mirrors `placeMarkers` in
|
|
2718
|
+
* the adaptive layer but operates on emitted entries. Idempotent; clears any
|
|
2719
|
+
* pre-existing markers first.
|
|
2720
|
+
*/
|
|
2721
|
+
protected placeCacheMarkers(
|
|
2722
|
+
entries: ContextEntry[],
|
|
2723
|
+
headMessageIds: ReadonlySet<MessageId>,
|
|
2724
|
+
tailMessageIds: ReadonlySet<MessageId>,
|
|
2725
|
+
): void {
|
|
2726
|
+
for (const e of entries) if (e.cacheMarker) e.cacheMarker = false;
|
|
2727
|
+
const n = entries.length;
|
|
2728
|
+
if (n === 0) return;
|
|
2729
|
+
|
|
2730
|
+
let lastHead = -1;
|
|
2731
|
+
let firstTail = n;
|
|
2732
|
+
for (let i = 0; i < n; i++) {
|
|
2733
|
+
const sid = entries[i].sourceMessageId;
|
|
2734
|
+
if (sid && headMessageIds.has(sid)) lastHead = i;
|
|
2735
|
+
}
|
|
2736
|
+
for (let i = 0; i < n; i++) {
|
|
2737
|
+
const sid = entries[i].sourceMessageId;
|
|
2738
|
+
if (sid && tailMessageIds.has(sid)) { firstTail = i; break; }
|
|
2739
|
+
}
|
|
2740
|
+
const historyEnd = firstTail - 1; // last middle (folded-history) entry
|
|
2741
|
+
|
|
2742
|
+
const marks = new Set<number>();
|
|
2743
|
+
if (lastHead >= 0) marks.add(lastHead); // system / head block
|
|
2744
|
+
if (historyEnd > lastHead) marks.add(historyEnd); // stable folded prefix (the big one)
|
|
2745
|
+
if (historyEnd - lastHead > 2) marks.add(lastHead + Math.floor((historyEnd - lastHead) / 2)); // mid-history
|
|
2746
|
+
marks.add(n - 1); // end → pure-append reuse
|
|
2747
|
+
|
|
2748
|
+
for (const idx of marks) if (idx >= 0 && idx < n) entries[idx].cacheMarker = true;
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2701
2751
|
/**
|
|
2702
2752
|
* Walk an entries array; for every run of consecutive entries that
|
|
2703
2753
|
* (a) have sourceRelation: 'copy' (raw, not a synthesized recall pair)
|
|
@@ -2791,6 +2841,23 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2791
2841
|
return this._adaptivePicker;
|
|
2792
2842
|
}
|
|
2793
2843
|
|
|
2844
|
+
/**
|
|
2845
|
+
* Build the picker for this compile. Instance folding strategies (kv-stable)
|
|
2846
|
+
* need the per-compile `PickerInputs` at construction, so they're built fresh
|
|
2847
|
+
* here; stateless ones (flat-profile / oldest-first) reuse the memoized picker.
|
|
2848
|
+
*/
|
|
2849
|
+
protected buildPicker(inputs: PickerInputs): Picker {
|
|
2850
|
+
if (this.config.foldingStrategy === 'kv-stable') {
|
|
2851
|
+
return new Picker(
|
|
2852
|
+
new KvStableStrategy(inputs, {
|
|
2853
|
+
reachTokens: this.config.kvStableReachTokens,
|
|
2854
|
+
mergeThreshold: this.config.mergeThreshold,
|
|
2855
|
+
}),
|
|
2856
|
+
);
|
|
2857
|
+
}
|
|
2858
|
+
return this.getAdaptivePicker();
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2794
2861
|
/**
|
|
2795
2862
|
* Walk the summary tree to find the L_k ancestor of a message.
|
|
2796
2863
|
* Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
|
|
@@ -2871,6 +2938,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2871
2938
|
* Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
|
|
2872
2939
|
*/
|
|
2873
2940
|
protected selectHierarchical(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
|
|
2941
|
+
phaseChannel.report('context-build'); // liveness-watchdog phase
|
|
2942
|
+
this.rsBegin();
|
|
2874
2943
|
const entries: ContextEntry[] = [];
|
|
2875
2944
|
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
2876
2945
|
const messages = store.getAll();
|
|
@@ -2895,6 +2964,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2895
2964
|
content,
|
|
2896
2965
|
});
|
|
2897
2966
|
totalTokens += tokens;
|
|
2967
|
+
this.rsRaw('head', tokens);
|
|
2898
2968
|
}
|
|
2899
2969
|
// Mark the last head entry as a cache boundary (even if budget truncated the window)
|
|
2900
2970
|
if (entries.length > 0) {
|
|
@@ -3054,6 +3124,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3054
3124
|
entries.push(questionEntry);
|
|
3055
3125
|
entries.push(answerEntry);
|
|
3056
3126
|
totalTokens += pairTokens;
|
|
3127
|
+
this.rsSummary(summary.level, pairTokens);
|
|
3057
3128
|
} else {
|
|
3058
3129
|
const msg = item.msg;
|
|
3059
3130
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
@@ -3069,6 +3140,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3069
3140
|
content,
|
|
3070
3141
|
});
|
|
3071
3142
|
totalTokens += tokens;
|
|
3143
|
+
this.rsRaw('middleRaw', tokens);
|
|
3072
3144
|
}
|
|
3073
3145
|
}
|
|
3074
3146
|
} else {
|
|
@@ -3102,6 +3174,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3102
3174
|
entries.push(questionEntry);
|
|
3103
3175
|
entries.push(answerEntry);
|
|
3104
3176
|
totalTokens += pairTokens;
|
|
3177
|
+
for (const s of selectedSummaries) this.rsSummary(s.level, s.tokens);
|
|
3105
3178
|
}
|
|
3106
3179
|
|
|
3107
3180
|
// Sort by position so uncompressed-middle messages and pins both
|
|
@@ -3121,6 +3194,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3121
3194
|
content,
|
|
3122
3195
|
});
|
|
3123
3196
|
totalTokens += tokens;
|
|
3197
|
+
this.rsRaw('middleRaw', tokens);
|
|
3124
3198
|
}
|
|
3125
3199
|
}
|
|
3126
3200
|
}
|
|
@@ -3130,10 +3204,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3130
3204
|
// budget, the latest messages (the ones the agent actually needs to act
|
|
3131
3205
|
// on) are preserved and the oldest recent-window messages are dropped.
|
|
3132
3206
|
const effectiveRecentStart = Math.max(recentStart, headEnd);
|
|
3133
|
-
this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
3207
|
+
const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
3208
|
+
this.rsRaw('tail', tailStats.tokens, tailStats.messages);
|
|
3134
3209
|
|
|
3135
3210
|
this.trimOrphanedToolUse(entries);
|
|
3136
3211
|
this.pruneToolEntries(entries);
|
|
3212
|
+
// Strip stale images before committing stats so RenderStats.total reflects
|
|
3213
|
+
// the post-strip context (this path places no cache markers).
|
|
3214
|
+
this.applyImageStripping(entries, store);
|
|
3215
|
+
this.rsEnd();
|
|
3137
3216
|
return entries;
|
|
3138
3217
|
}
|
|
3139
3218
|
|
|
@@ -3388,6 +3467,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3388
3467
|
};
|
|
3389
3468
|
|
|
3390
3469
|
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
3470
|
+
// Text-only on purpose: summarizer scratch thinking is not agent history
|
|
3391
3471
|
return response.content
|
|
3392
3472
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
|
3393
3473
|
.map(b => b.text)
|
|
@@ -3561,7 +3641,6 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3561
3641
|
const existing = existingCompressed.get(key);
|
|
3562
3642
|
if (existing) {
|
|
3563
3643
|
chunk.compressed = true;
|
|
3564
|
-
chunk.diary = existing.diary;
|
|
3565
3644
|
chunk.summaryId = existing.summaryId;
|
|
3566
3645
|
}
|
|
3567
3646
|
|
|
@@ -3583,6 +3662,98 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3583
3662
|
return chunk.messages.map((m) => m.id).join(':');
|
|
3584
3663
|
}
|
|
3585
3664
|
|
|
3665
|
+
/** True if any content block is a live image. */
|
|
3666
|
+
protected hasImageBlock(content: ContentBlock[]): boolean {
|
|
3667
|
+
return content.some((b) => b.type === 'image');
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
/** Message index marking the image-strip depth boundary: walks newest→oldest
|
|
3671
|
+
* summing the same per-message estimate as getRecentWindowStart, and returns
|
|
3672
|
+
* the index of the first message still within `depthTokens`. Messages before
|
|
3673
|
+
* this index have their images stripped to placeholders. */
|
|
3674
|
+
protected getImageStripStart(store: MessageStoreView, depthTokens: number): number {
|
|
3675
|
+
const messages = store.getAll();
|
|
3676
|
+
let tokens = 0;
|
|
3677
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
3678
|
+
tokens += store.estimateTokens(messages[i]);
|
|
3679
|
+
if (tokens > depthTokens) return i + 1;
|
|
3680
|
+
}
|
|
3681
|
+
return 0;
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
/** Text substituted for an image block once it leaves the live-image window. */
|
|
3685
|
+
private static readonly IMAGE_PLACEHOLDER = '[image dropped from live context]';
|
|
3686
|
+
|
|
3687
|
+
/** Post-pass over compiled entries: replace image blocks with a text
|
|
3688
|
+
* placeholder once they fall outside the live-image window — either deeper
|
|
3689
|
+
* than `imageStripDepthTokens` from the newest message, or beyond the
|
|
3690
|
+
* `maxLiveImages` most-recent images (counted newest-first). Summaries are
|
|
3691
|
+
* already text, so they're naturally unaffected. The adjacent
|
|
3692
|
+
* "[image attachment: <name>]" text added at ingest preserves the filename,
|
|
3693
|
+
* so the placeholder itself stays terse. Reduces tokens, so it never pushes
|
|
3694
|
+
* a compiled context back over budget.
|
|
3695
|
+
*
|
|
3696
|
+
* Runs INSIDE each select path, *before* `rsEnd()` and `placeCacheMarkers`,
|
|
3697
|
+
* so the committed render stats (and the cache breakpoints) describe the
|
|
3698
|
+
* post-strip context. As it strips, it decrements the matching raw bucket of
|
|
3699
|
+
* the in-progress render stats by the reclaimed tokens, keeping
|
|
3700
|
+
* `RenderStats.total` equal to the real rendered size. */
|
|
3701
|
+
protected applyImageStripping(entries: ContextEntry[], store: MessageStoreView): void {
|
|
3702
|
+
const maxLive = this.config.maxLiveImages ?? 0; // 0 = unlimited count
|
|
3703
|
+
const depthTokens = this.config.imageStripDepthTokens ?? 0; // 0 = no depth strip
|
|
3704
|
+
if (maxLive === 0 && depthTokens === 0) return; // policy disabled
|
|
3705
|
+
|
|
3706
|
+
const messages = store.getAll();
|
|
3707
|
+
const posById = new Map<string, number>();
|
|
3708
|
+
for (let i = 0; i < messages.length; i++) posById.set(messages[i].id, i);
|
|
3709
|
+
const stripStart = depthTokens > 0 ? this.getImageStripStart(store, depthTokens) : 0;
|
|
3710
|
+
|
|
3711
|
+
// Same region windows select() bucketed by, so a stripped image's reclaimed
|
|
3712
|
+
// tokens come back out of the bucket it was originally tallied into.
|
|
3713
|
+
const headStart = this.getHeadWindowStartIndex(store);
|
|
3714
|
+
const headEnd = this.getHeadWindowEnd(store);
|
|
3715
|
+
const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
|
|
3716
|
+
const bucketAt = (pos: number): 'head' | 'tail' | 'middleRaw' => {
|
|
3717
|
+
if (pos < 0) return 'middleRaw'; // no resolvable region — keep total == Σbuckets
|
|
3718
|
+
if (pos >= headStart && pos < headEnd) return 'head';
|
|
3719
|
+
if (pos >= recentStart) return 'tail';
|
|
3720
|
+
return 'middleRaw';
|
|
3721
|
+
};
|
|
3722
|
+
const placeholderTokens = Math.ceil(AutobiographicalStrategy.IMAGE_PLACEHOLDER.length / 4);
|
|
3723
|
+
|
|
3724
|
+
// Image-bearing entries, newest-first by source position. Entries with no
|
|
3725
|
+
// resolvable source position sort last (pos -1) and never count as "live".
|
|
3726
|
+
const ordered = entries
|
|
3727
|
+
.map((entry, idx) => ({
|
|
3728
|
+
idx,
|
|
3729
|
+
pos: entry.sourceMessageId !== undefined ? posById.get(entry.sourceMessageId) ?? -1 : -1,
|
|
3730
|
+
}))
|
|
3731
|
+
.filter(({ idx }) => this.hasImageBlock(entries[idx].content))
|
|
3732
|
+
.sort((a, b) => b.pos - a.pos);
|
|
3733
|
+
|
|
3734
|
+
let keptImages = 0;
|
|
3735
|
+
for (const { idx, pos } of ordered) {
|
|
3736
|
+
const entry = entries[idx];
|
|
3737
|
+
const tooDeep = depthTokens > 0 && (pos < 0 || pos < stripStart);
|
|
3738
|
+
const bucket = bucketAt(pos);
|
|
3739
|
+
entry.content = entry.content.map((block) => {
|
|
3740
|
+
if (block.type !== 'image') return block;
|
|
3741
|
+
const overCount = maxLive > 0 && keptImages >= maxLive;
|
|
3742
|
+
if (tooDeep || overCount) {
|
|
3743
|
+
// The renderer estimates an image at `tokenEstimate ?? 1600` (see
|
|
3744
|
+
// message-store/context-log). Hand that back to the bucket, less the
|
|
3745
|
+
// placeholder text that replaces it, so the stats match the output.
|
|
3746
|
+
const reclaimed =
|
|
3747
|
+
((block as { tokenEstimate?: number }).tokenEstimate ?? 1600) - placeholderTokens;
|
|
3748
|
+
if (this._rs && reclaimed > 0) this._rs[bucket].tokens -= reclaimed;
|
|
3749
|
+
return { type: 'text', text: AutobiographicalStrategy.IMAGE_PLACEHOLDER } as ContentBlock;
|
|
3750
|
+
}
|
|
3751
|
+
keptImages++;
|
|
3752
|
+
return block;
|
|
3753
|
+
});
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3756
|
+
|
|
3586
3757
|
protected getRecentWindowStart(store: MessageStoreView): number {
|
|
3587
3758
|
const messages = store.getAll();
|
|
3588
3759
|
let tokens = 0;
|