@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,4 +1,5 @@
|
|
|
1
1
|
import { NativeFormatter } from '@animalabs/membrane';
|
|
2
|
+
import { phaseChannel } from '../phase-channel.js';
|
|
2
3
|
import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
|
|
3
4
|
import { getSummaryParentId } from '../types/strategy.js';
|
|
4
5
|
import { splitMixedToolMessages, stripUnpairedToolBlocks } from '../normalize-tool-messages.js';
|
|
@@ -6,6 +7,7 @@ import { appendFileSync, mkdirSync } from 'node:fs';
|
|
|
6
7
|
import { dirname } from 'node:path';
|
|
7
8
|
import { Picker, OverBudgetError } from '../adaptive/picker.js';
|
|
8
9
|
import { FlatProfileStrategy } from '../adaptive/strategies/flat-profile.js';
|
|
10
|
+
import { KvStableStrategy } from '../adaptive/strategies/kv-stable.js';
|
|
9
11
|
import { OldestFirstStrategy } from '../adaptive/strategies/oldest-first.js';
|
|
10
12
|
import { chunkMessage, DEFAULT_CHUNKER_OPTIONS } from '../adaptive/chunker.js';
|
|
11
13
|
/**
|
|
@@ -149,6 +151,20 @@ function safeSlice(str, start, end) {
|
|
|
149
151
|
}
|
|
150
152
|
return str.slice(start, end);
|
|
151
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Validate + normalize the optional V2 pin fold-depth bounds. Returns only the
|
|
156
|
+
* fields that are present and valid (non-negative integers), so a classic pin
|
|
157
|
+
* with no bounds persists exactly as before. `level` takes precedence over
|
|
158
|
+
* `maxLevel` (pin-at-k is stronger than a cap), so they're never both emitted.
|
|
159
|
+
*/
|
|
160
|
+
function normalizePinLevels(opts) {
|
|
161
|
+
const clean = (v) => typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : undefined;
|
|
162
|
+
const level = clean(opts?.level);
|
|
163
|
+
if (level !== undefined)
|
|
164
|
+
return { level };
|
|
165
|
+
const maxLevel = clean(opts?.maxLevel);
|
|
166
|
+
return maxLevel !== undefined ? { maxLevel } : {};
|
|
167
|
+
}
|
|
152
168
|
/**
|
|
153
169
|
* Autobiographical chunking strategy.
|
|
154
170
|
* Compresses old conversation chunks into summaries in the model's own words.
|
|
@@ -166,6 +182,14 @@ export class AutobiographicalStrategy {
|
|
|
166
182
|
pendingCompression = null;
|
|
167
183
|
compressionQueue = [];
|
|
168
184
|
_compressionCount = 0;
|
|
185
|
+
/**
|
|
186
|
+
* Monotonic counter of tick() operations that actually processed a queue item
|
|
187
|
+
* (compressed a chunk or executed a merge). `driveSpeculativeDrain` recurses
|
|
188
|
+
* while this advances — a length-delta check would falsely read "no progress"
|
|
189
|
+
* when a productive tick also enqueues a follow-on item (net queue length
|
|
190
|
+
* unchanged), halting the drain with work still queued.
|
|
191
|
+
*/
|
|
192
|
+
_drainProgress = 0;
|
|
169
193
|
// Hierarchical state
|
|
170
194
|
summaries = [];
|
|
171
195
|
summaryIdCounter = 0;
|
|
@@ -474,6 +498,7 @@ export class AutobiographicalStrategy {
|
|
|
474
498
|
kind: 'pin',
|
|
475
499
|
name: opts?.name,
|
|
476
500
|
created: Date.now(),
|
|
501
|
+
...normalizePinLevels(opts),
|
|
477
502
|
});
|
|
478
503
|
this.persistPins();
|
|
479
504
|
return id;
|
|
@@ -492,10 +517,20 @@ export class AutobiographicalStrategy {
|
|
|
492
517
|
kind: 'document',
|
|
493
518
|
name: opts?.name,
|
|
494
519
|
created: Date.now(),
|
|
520
|
+
...normalizePinLevels(opts),
|
|
495
521
|
});
|
|
496
522
|
this.persistPins();
|
|
497
523
|
return id;
|
|
498
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* V2 dynamic pin-at-level-k convenience: fix a range to render at EXACTLY
|
|
527
|
+
* fold level `level` (0 = raw). Honored only by `foldingStrategy: 'kv-stable'`;
|
|
528
|
+
* other strategies fall back to treating the range as raw. Equivalent to
|
|
529
|
+
* `pinRange(first, last, { level })`.
|
|
530
|
+
*/
|
|
531
|
+
pinAtLevel(firstMessageId, lastMessageId, level, opts) {
|
|
532
|
+
return this.pinRange(firstMessageId, lastMessageId, { name: opts?.name, level });
|
|
533
|
+
}
|
|
499
534
|
/** Remove a pin or document mark by id. Returns true if removed. */
|
|
500
535
|
unpin(pinId) {
|
|
501
536
|
const before = this.pins.length;
|
|
@@ -611,6 +646,45 @@ export class AutobiographicalStrategy {
|
|
|
611
646
|
}
|
|
612
647
|
return out;
|
|
613
648
|
}
|
|
649
|
+
/**
|
|
650
|
+
* Resolve the V2 dynamic-pin fold-depth bounds (`ProtectedRange.level` /
|
|
651
|
+
* `maxLevel`) to message positions. Only pins that carry a bound appear; a
|
|
652
|
+
* classic raw pin (no bound) is absent here and handled by `pinnedPositions`.
|
|
653
|
+
* When ranges overlap, the FINEST requirement wins (lowest effective level):
|
|
654
|
+
* a fixed `level` clamps both ends; a `maxLevel` only caps depth. Honored
|
|
655
|
+
* solely by the KV-stable controller — see `ProtectedRange`.
|
|
656
|
+
*/
|
|
657
|
+
pinLevelBounds(messages) {
|
|
658
|
+
const out = new Map();
|
|
659
|
+
if (this.pins.length === 0)
|
|
660
|
+
return out;
|
|
661
|
+
const positionOf = new Map();
|
|
662
|
+
for (let i = 0; i < messages.length; i++)
|
|
663
|
+
positionOf.set(messages[i].id, i);
|
|
664
|
+
for (const pin of this.pins) {
|
|
665
|
+
if (pin.level === undefined && pin.maxLevel === undefined)
|
|
666
|
+
continue;
|
|
667
|
+
const first = positionOf.get(pin.firstMessageId);
|
|
668
|
+
const last = positionOf.get(pin.lastMessageId);
|
|
669
|
+
if (first === undefined || last === undefined)
|
|
670
|
+
continue;
|
|
671
|
+
const lo = Math.min(first, last);
|
|
672
|
+
const hi = Math.max(first, last);
|
|
673
|
+
for (let i = lo; i <= hi; i++) {
|
|
674
|
+
const prev = out.get(i) ?? {};
|
|
675
|
+
// A fixed level is the strongest constraint; when two pins fix the same
|
|
676
|
+
// position, the shallower (lower) level wins (finest requirement).
|
|
677
|
+
if (pin.level !== undefined) {
|
|
678
|
+
prev.level = prev.level === undefined ? pin.level : Math.min(prev.level, pin.level);
|
|
679
|
+
}
|
|
680
|
+
else if (pin.maxLevel !== undefined) {
|
|
681
|
+
prev.maxLevel = prev.maxLevel === undefined ? pin.maxLevel : Math.min(prev.maxLevel, pin.maxLevel);
|
|
682
|
+
}
|
|
683
|
+
out.set(i, prev);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return out;
|
|
687
|
+
}
|
|
614
688
|
/**
|
|
615
689
|
* Append a summary to the in-memory list and to the chronicle AppendLog.
|
|
616
690
|
* Single point so subclasses inherit persistence.
|
|
@@ -865,21 +939,31 @@ export class AutobiographicalStrategy {
|
|
|
865
939
|
driveSpeculativeDrain(ctx) {
|
|
866
940
|
if (this.pendingCompression)
|
|
867
941
|
return;
|
|
868
|
-
|
|
942
|
+
// Merges consolidate existing L_k summaries into L_{k+1} and REDUCE the
|
|
943
|
+
// unmerged-L1 count; L1 compression PRODUCES new unmerged L1s. The
|
|
944
|
+
// speculation cap / preflight throttle *production* only — they must never
|
|
945
|
+
// gate merges, otherwise exceeding the cap (e.g. after a manual backfill)
|
|
946
|
+
// permanently deadlocks the drain: too many unmerged L1s trips the cap,
|
|
947
|
+
// which blocks the very merges that would bring the count back down.
|
|
948
|
+
const hasMerges = this.config.hierarchical === true && this.mergeQueue.length > 0;
|
|
949
|
+
const hasCompression = this.compressionQueue.length > 0;
|
|
950
|
+
if (!hasCompression && !hasMerges)
|
|
869
951
|
return;
|
|
870
|
-
|
|
952
|
+
// Only bail when the *sole* available work is L1 compression that the cap
|
|
953
|
+
// or preflight currently forbids. Merge work always proceeds.
|
|
954
|
+
if (!hasMerges && (this.isAtSpeculativeCap() || !this.shouldCompressPreflight()))
|
|
871
955
|
return;
|
|
872
|
-
|
|
873
|
-
return;
|
|
874
|
-
const beforeChunks = this.compressionQueue.length;
|
|
875
|
-
const beforeMerges = this.mergeQueue.length;
|
|
956
|
+
const progressBefore = this._drainProgress;
|
|
876
957
|
this.tick(ctx)
|
|
877
958
|
.then(() => {
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
//
|
|
881
|
-
|
|
882
|
-
|
|
959
|
+
// Progress = the tick actually processed a queue item (compress or
|
|
960
|
+
// merge), tracked by `_drainProgress`. A queue-length delta is the
|
|
961
|
+
// wrong signal: a productive merge tick can also enqueue a follow-on
|
|
962
|
+
// merge, leaving the length unchanged — which the old check misread as
|
|
963
|
+
// "no progress" and halted the drain mid-backlog. A genuine no-op tick
|
|
964
|
+
// (empty queues, at-cap with no merges, no membrane) doesn't advance
|
|
965
|
+
// the counter, so this still stops cleanly (no runaway recursion).
|
|
966
|
+
if (this._drainProgress === progressBefore)
|
|
883
967
|
return;
|
|
884
968
|
// Recurse to drain more. queueMicrotask defers until the current
|
|
885
969
|
// task is done, letting other code (the agent's stream consumer)
|
|
@@ -891,15 +975,24 @@ export class AutobiographicalStrategy {
|
|
|
891
975
|
});
|
|
892
976
|
}
|
|
893
977
|
/**
|
|
894
|
-
* Whether the
|
|
978
|
+
* Whether the count of *produced, unmerged* L1 summaries has reached the cap
|
|
895
979
|
* configured by `maxSpeculativeL1s`. If no cap is set, always false.
|
|
980
|
+
*
|
|
981
|
+
* The cap bounds how many L1 summaries may sit un-consolidated before the
|
|
982
|
+
* strategy must merge them into L_{k+1} (bounding prefix churn / merge debt).
|
|
983
|
+
* It deliberately does NOT count the pending `compressionQueue`: that queue is
|
|
984
|
+
* the backlog of work to be *drained*, not produced summaries. Counting it
|
|
985
|
+
* here would let a large backlog permanently trip the cap and block the very
|
|
986
|
+
* compression that would clear it — a deadlock (merges relieve the cap, but
|
|
987
|
+
* compression of the backlog never resumes). The throttle is on produced L1s;
|
|
988
|
+
* the queue drains freely, with merges keeping the unmerged count under the cap.
|
|
896
989
|
*/
|
|
897
990
|
isAtSpeculativeCap() {
|
|
898
991
|
const cap = this.config.maxSpeculativeL1s;
|
|
899
992
|
if (cap === undefined || cap < 0)
|
|
900
993
|
return false;
|
|
901
994
|
const unmergedL1s = this.summaries.filter(s => s.level === 1 && !s.mergedInto).length;
|
|
902
|
-
return unmergedL1s
|
|
995
|
+
return unmergedL1s > cap;
|
|
903
996
|
}
|
|
904
997
|
/**
|
|
905
998
|
* Preflight hook for whether speculative compression should fire on
|
|
@@ -911,21 +1004,24 @@ export class AutobiographicalStrategy {
|
|
|
911
1004
|
return true;
|
|
912
1005
|
}
|
|
913
1006
|
async tick(ctx) {
|
|
1007
|
+
phaseChannel.report('compress-tick'); // liveness-watchdog phase
|
|
914
1008
|
if (this.pendingCompression)
|
|
915
1009
|
return;
|
|
916
1010
|
if (!ctx.membrane) {
|
|
917
1011
|
console.warn('AutobiographicalStrategy: No membrane instance for compression');
|
|
918
1012
|
return;
|
|
919
1013
|
}
|
|
920
|
-
// Priority 1: Compress raw chunks → L1
|
|
921
|
-
|
|
1014
|
+
// Priority 1: Compress raw chunks → L1. Skipped while at the speculative
|
|
1015
|
+
// cap (maxSpeculativeL1s) so we don't pile up more unmerged L1s; the merge
|
|
1016
|
+
// priority below still runs to consolidate existing L1s and relieve the cap.
|
|
1017
|
+
// No cap configured → isAtSpeculativeCap() is always false → unchanged.
|
|
1018
|
+
if (this.compressionQueue.length > 0 && !this.isAtSpeculativeCap()) {
|
|
922
1019
|
const chunkIndex = this.compressionQueue.shift();
|
|
1020
|
+
this._drainProgress++; // consumed a queue item (real work or stale-cleanup)
|
|
923
1021
|
const chunk = this.chunks[chunkIndex];
|
|
924
1022
|
if (!chunk || chunk.compressed)
|
|
925
1023
|
return;
|
|
926
|
-
this.pendingCompression = this.
|
|
927
|
-
? this.compressChunkHierarchical(chunk, ctx)
|
|
928
|
-
: this.compressChunkLegacy(chunk, ctx);
|
|
1024
|
+
this.pendingCompression = this.compressChunkHierarchical(chunk, ctx);
|
|
929
1025
|
try {
|
|
930
1026
|
await this.pendingCompression;
|
|
931
1027
|
}
|
|
@@ -945,6 +1041,8 @@ export class AutobiographicalStrategy {
|
|
|
945
1041
|
// and the next tick() retries it.
|
|
946
1042
|
if (this.config.hierarchical && this.mergeQueue.length > 0) {
|
|
947
1043
|
const merge = this.mergeQueue[0];
|
|
1044
|
+
this._drainProgress++; // executing a merge is real work, even if a
|
|
1045
|
+
// follow-on merge gets enqueued and the queue length nets out unchanged
|
|
948
1046
|
this.pendingCompression = this.executeMerge(merge.level, merge.sourceIds, ctx);
|
|
949
1047
|
try {
|
|
950
1048
|
await this.pendingCompression;
|
|
@@ -992,12 +1090,13 @@ export class AutobiographicalStrategy {
|
|
|
992
1090
|
}
|
|
993
1091
|
select(store, log, budget) {
|
|
994
1092
|
this.rebuildChunks(store);
|
|
1093
|
+
// Image stripping runs inside each select path (before stats commit / cache
|
|
1094
|
+
// markers), so the returned entries are already bounded — see
|
|
1095
|
+
// applyImageStripping.
|
|
995
1096
|
if (this.config.adaptiveResolution) {
|
|
996
1097
|
return this.selectAdaptive(store, budget);
|
|
997
1098
|
}
|
|
998
|
-
return this.
|
|
999
|
-
? this.selectHierarchical(store, budget)
|
|
1000
|
-
: this.selectLegacy(store, log, budget);
|
|
1099
|
+
return this.selectHierarchical(store, budget);
|
|
1001
1100
|
}
|
|
1002
1101
|
/**
|
|
1003
1102
|
* Get summary statistics for observability.
|
|
@@ -1022,7 +1121,85 @@ export class AutobiographicalStrategy {
|
|
|
1022
1121
|
* Useful for TUI / dashboards. The token sums use the strategy's own
|
|
1023
1122
|
* token estimates (which match what `select()` uses for budget math).
|
|
1024
1123
|
*/
|
|
1124
|
+
// ===========================================================================
|
|
1125
|
+
// Render-stats instrumentation — inspect, don't reconstruct.
|
|
1126
|
+
//
|
|
1127
|
+
// selectAdaptive/selectHierarchical tally each entry into `_rs` AS THEY EMIT
|
|
1128
|
+
// it (raw head/tail, raw middle the picker kept verbatim, and recall pairs
|
|
1129
|
+
// bucketed by the ancestor summary's level), using the same token numbers the
|
|
1130
|
+
// renderer uses for budget math. `getRenderStats()` returns that committed
|
|
1131
|
+
// snapshot, so it reflects what the last compile actually rendered rather than
|
|
1132
|
+
// re-deriving the full pyramid (which, under adaptive resolution, bears little
|
|
1133
|
+
// resemblance to the folded output).
|
|
1134
|
+
// ===========================================================================
|
|
1135
|
+
_rs = null;
|
|
1136
|
+
_lastRenderStats = null;
|
|
1137
|
+
/** Begin a render-stats accumulation for one select() pass. */
|
|
1138
|
+
rsBegin() {
|
|
1139
|
+
this._rs = {
|
|
1140
|
+
head: { messages: 0, tokens: 0 },
|
|
1141
|
+
tail: { messages: 0, tokens: 0 },
|
|
1142
|
+
middleRaw: { messages: 0, tokens: 0 },
|
|
1143
|
+
summaries: {
|
|
1144
|
+
l1: { count: 0, tokens: 0 },
|
|
1145
|
+
l2: { count: 0, tokens: 0 },
|
|
1146
|
+
l3: { count: 0, tokens: 0 },
|
|
1147
|
+
},
|
|
1148
|
+
pending: { chunks: 0, merges: 0 },
|
|
1149
|
+
total: { messages: 0, tokens: 0 },
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
/** Tally one (or `count`) raw message(s) into a raw bucket. */
|
|
1153
|
+
rsRaw(bucket, tokens, count = 1) {
|
|
1154
|
+
const r = this._rs;
|
|
1155
|
+
if (!r)
|
|
1156
|
+
return;
|
|
1157
|
+
r[bucket].messages += count;
|
|
1158
|
+
r[bucket].tokens += tokens;
|
|
1159
|
+
}
|
|
1160
|
+
/** Tally one emitted recall pair under its ancestor's level (>=3 folds into l3). */
|
|
1161
|
+
rsSummary(level, tokens) {
|
|
1162
|
+
const r = this._rs;
|
|
1163
|
+
if (!r)
|
|
1164
|
+
return;
|
|
1165
|
+
const k = level <= 1 ? 'l1' : level === 2 ? 'l2' : 'l3';
|
|
1166
|
+
r.summaries[k].count += 1;
|
|
1167
|
+
r.summaries[k].tokens += tokens;
|
|
1168
|
+
}
|
|
1169
|
+
/** Commit the accumulated stats as the last-render snapshot. */
|
|
1170
|
+
rsEnd() {
|
|
1171
|
+
const r = this._rs;
|
|
1172
|
+
if (!r)
|
|
1173
|
+
return;
|
|
1174
|
+
r.pending = {
|
|
1175
|
+
chunks: this.chunks.filter(c => !c.compressed).length,
|
|
1176
|
+
merges: this.mergeQueue.length,
|
|
1177
|
+
};
|
|
1178
|
+
const s = r.summaries;
|
|
1179
|
+
const summaryMsgs = (s.l1.count + s.l2.count + s.l3.count) * 2; // Q/A pair each
|
|
1180
|
+
r.total = {
|
|
1181
|
+
messages: r.head.messages + r.tail.messages + r.middleRaw.messages + summaryMsgs,
|
|
1182
|
+
tokens: r.head.tokens + r.tail.tokens + r.middleRaw.tokens +
|
|
1183
|
+
s.l1.tokens + s.l2.tokens + s.l3.tokens,
|
|
1184
|
+
};
|
|
1185
|
+
this._lastRenderStats = r;
|
|
1186
|
+
this._rs = null;
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Stats describing the LAST rendered context. Returns the inspected snapshot
|
|
1190
|
+
* captured during the most recent `select()`. Before any compile has run (no
|
|
1191
|
+
* snapshot yet), falls back to a reconstructed pyramid view so callers still
|
|
1192
|
+
* get a non-null shape.
|
|
1193
|
+
*/
|
|
1025
1194
|
getRenderStats(store) {
|
|
1195
|
+
return this._lastRenderStats ?? this.reconstructRenderStats(store);
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Pre-render fallback: re-derive head/tail windows + the full live pyramid.
|
|
1199
|
+
* NOTE: this is the old "reconstruct" behavior and does NOT reflect adaptive
|
|
1200
|
+
* folding — used only until the first compile populates the inspected stats.
|
|
1201
|
+
*/
|
|
1202
|
+
reconstructRenderStats(store) {
|
|
1026
1203
|
const messages = store.getAll();
|
|
1027
1204
|
const headStart = this.getHeadWindowStartIndex(store);
|
|
1028
1205
|
const headEnd = this.getHeadWindowEnd(store);
|
|
@@ -1032,131 +1209,30 @@ export class AutobiographicalStrategy {
|
|
|
1032
1209
|
const tailMsgs = messages.slice(recentStart);
|
|
1033
1210
|
const live = (level) => this.summaries.filter(s => s.level === level && !s.mergedInto);
|
|
1034
1211
|
const sumLevelTokens = (level) => live(level).reduce((acc, s) => acc + s.tokens, 0);
|
|
1212
|
+
const head = { messages: headMsgs.length, tokens: sumTokens(headMsgs) };
|
|
1213
|
+
const tail = { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) };
|
|
1214
|
+
const summaries = {
|
|
1215
|
+
l1: { count: live(1).length, tokens: sumLevelTokens(1) },
|
|
1216
|
+
l2: { count: live(2).length, tokens: sumLevelTokens(2) },
|
|
1217
|
+
l3: { count: live(3).length, tokens: sumLevelTokens(3) },
|
|
1218
|
+
};
|
|
1035
1219
|
return {
|
|
1036
|
-
head
|
|
1037
|
-
tail
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
l2: { count: live(2).length, tokens: sumLevelTokens(2) },
|
|
1041
|
-
l3: { count: live(3).length, tokens: sumLevelTokens(3) },
|
|
1042
|
-
},
|
|
1220
|
+
head,
|
|
1221
|
+
tail,
|
|
1222
|
+
middleRaw: { messages: 0, tokens: 0 },
|
|
1223
|
+
summaries,
|
|
1043
1224
|
pending: {
|
|
1044
1225
|
chunks: this.chunks.filter(c => !c.compressed).length,
|
|
1045
1226
|
merges: this.mergeQueue.length,
|
|
1046
1227
|
},
|
|
1228
|
+
total: {
|
|
1229
|
+
messages: head.messages + tail.messages
|
|
1230
|
+
+ (summaries.l1.count + summaries.l2.count + summaries.l3.count) * 2,
|
|
1231
|
+
tokens: head.tokens + tail.tokens
|
|
1232
|
+
+ summaries.l1.tokens + summaries.l2.tokens + summaries.l3.tokens,
|
|
1233
|
+
},
|
|
1047
1234
|
};
|
|
1048
1235
|
}
|
|
1049
|
-
// ============================================================================
|
|
1050
|
-
// Legacy (single-level) path
|
|
1051
|
-
// ============================================================================
|
|
1052
|
-
selectLegacy(store, _log, budget) {
|
|
1053
|
-
const entries = [];
|
|
1054
|
-
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
1055
|
-
let totalTokens = 0;
|
|
1056
|
-
const messages = store.getAll();
|
|
1057
|
-
const msgCap = this.config.maxMessageTokens;
|
|
1058
|
-
// 1. Head window: preserved verbatim as raw copies
|
|
1059
|
-
const headStart = this.getHeadWindowStartIndex(store);
|
|
1060
|
-
const headEnd = this.getHeadWindowEnd(store);
|
|
1061
|
-
for (let i = headStart; i < headEnd && i < messages.length; i++) {
|
|
1062
|
-
const msg = messages[i];
|
|
1063
|
-
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1064
|
-
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
1065
|
-
if (this.isOverBudget(totalTokens + tokens, maxTokens))
|
|
1066
|
-
break;
|
|
1067
|
-
entries.push({
|
|
1068
|
-
index: entries.length,
|
|
1069
|
-
sourceMessageId: msg.id,
|
|
1070
|
-
sourceRelation: 'copy',
|
|
1071
|
-
participant: msg.participant,
|
|
1072
|
-
content,
|
|
1073
|
-
});
|
|
1074
|
-
totalTokens += tokens;
|
|
1075
|
-
}
|
|
1076
|
-
// Mark the last head entry as a cache boundary (even if budget truncated the window)
|
|
1077
|
-
if (entries.length > 0) {
|
|
1078
|
-
entries[entries.length - 1].cacheMarker = true;
|
|
1079
|
-
}
|
|
1080
|
-
// 2. Middle zone: compressed chunks as diary pairs, uncompressed as raw messages.
|
|
1081
|
-
const rawRecentStart = this.getRecentWindowStart(store);
|
|
1082
|
-
// Track which message IDs are covered by chunks
|
|
1083
|
-
const coveredByChunks = new Set();
|
|
1084
|
-
for (const chunk of this.chunks) {
|
|
1085
|
-
for (const m of chunk.messages)
|
|
1086
|
-
coveredByChunks.add(m.id);
|
|
1087
|
-
if (chunk.compressed && chunk.diary) {
|
|
1088
|
-
const contextLabel = this.config.summaryContextLabel ?? 'Here is a summary of earlier conversation context:';
|
|
1089
|
-
const summaryParticipant = this.config.summaryParticipant ?? 'Summary';
|
|
1090
|
-
const questionEntry = {
|
|
1091
|
-
index: entries.length,
|
|
1092
|
-
participant: 'Context Manager',
|
|
1093
|
-
content: [{ type: 'text', text: contextLabel }],
|
|
1094
|
-
sourceRelation: 'derived',
|
|
1095
|
-
};
|
|
1096
|
-
// Synthesised summary turns must respect maxMessageTokens just like raw
|
|
1097
|
-
// copies do — otherwise a runaway diary can starve recent messages.
|
|
1098
|
-
const answerContent = [{ type: 'text', text: chunk.diary }];
|
|
1099
|
-
const answerEntry = {
|
|
1100
|
-
index: entries.length + 1,
|
|
1101
|
-
participant: summaryParticipant,
|
|
1102
|
-
content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
|
|
1103
|
-
sourceRelation: 'derived',
|
|
1104
|
-
};
|
|
1105
|
-
const pairTokens = this.estimateTokens(questionEntry.content) +
|
|
1106
|
-
this.estimateTokens(answerEntry.content);
|
|
1107
|
-
if (this.isOverBudget(totalTokens + pairTokens, maxTokens))
|
|
1108
|
-
break;
|
|
1109
|
-
entries.push(questionEntry);
|
|
1110
|
-
entries.push(answerEntry);
|
|
1111
|
-
totalTokens += pairTokens;
|
|
1112
|
-
}
|
|
1113
|
-
else {
|
|
1114
|
-
// Uncompressed: emit raw messages so they aren't lost
|
|
1115
|
-
for (const msg of chunk.messages) {
|
|
1116
|
-
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1117
|
-
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
1118
|
-
if (this.isOverBudget(totalTokens + tokens, maxTokens))
|
|
1119
|
-
break;
|
|
1120
|
-
entries.push({
|
|
1121
|
-
index: entries.length,
|
|
1122
|
-
sourceMessageId: msg.id,
|
|
1123
|
-
sourceRelation: 'copy',
|
|
1124
|
-
participant: msg.participant,
|
|
1125
|
-
content,
|
|
1126
|
-
});
|
|
1127
|
-
totalTokens += tokens;
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
// Emit gap messages in the compressible zone not covered by any chunk.
|
|
1132
|
-
// Compressible zone: [0, headStart) ∪ [headEnd, rawRecentStart)
|
|
1133
|
-
for (let i = 0; i < rawRecentStart && i < messages.length; i++) {
|
|
1134
|
-
// Skip head window messages (already emitted verbatim above)
|
|
1135
|
-
if (i >= headStart && i < headEnd)
|
|
1136
|
-
continue;
|
|
1137
|
-
if (coveredByChunks.has(messages[i].id))
|
|
1138
|
-
continue;
|
|
1139
|
-
const msg = messages[i];
|
|
1140
|
-
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1141
|
-
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
1142
|
-
if (this.isOverBudget(totalTokens + tokens, maxTokens))
|
|
1143
|
-
break;
|
|
1144
|
-
entries.push({
|
|
1145
|
-
index: entries.length,
|
|
1146
|
-
sourceMessageId: msg.id,
|
|
1147
|
-
sourceRelation: 'copy',
|
|
1148
|
-
participant: msg.participant,
|
|
1149
|
-
content,
|
|
1150
|
-
});
|
|
1151
|
-
totalTokens += tokens;
|
|
1152
|
-
}
|
|
1153
|
-
// 3. Recent uncompressed messages (skip those already in head window)
|
|
1154
|
-
const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
|
|
1155
|
-
this.emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokens);
|
|
1156
|
-
this.trimOrphanedToolUse(entries);
|
|
1157
|
-
this.pruneToolEntries(entries);
|
|
1158
|
-
return entries;
|
|
1159
|
-
}
|
|
1160
1236
|
/**
|
|
1161
1237
|
* Emit recent-window messages, evicting OLDEST-first when the budget is tight.
|
|
1162
1238
|
*
|
|
@@ -1170,7 +1246,7 @@ export class AutobiographicalStrategy {
|
|
|
1170
1246
|
*/
|
|
1171
1247
|
emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokensBefore) {
|
|
1172
1248
|
if (recentStart >= messages.length)
|
|
1173
|
-
return;
|
|
1249
|
+
return { messages: 0, tokens: 0 };
|
|
1174
1250
|
const accepted = [];
|
|
1175
1251
|
let acceptedTokens = 0;
|
|
1176
1252
|
for (let i = messages.length - 1; i >= recentStart; i--) {
|
|
@@ -1190,9 +1266,13 @@ export class AutobiographicalStrategy {
|
|
|
1190
1266
|
!this.hasToolUse(messages[accepted[0]])) {
|
|
1191
1267
|
accepted.shift();
|
|
1192
1268
|
}
|
|
1269
|
+
let emittedTokens = 0;
|
|
1193
1270
|
for (const i of accepted) {
|
|
1194
1271
|
const msg = messages[i];
|
|
1195
1272
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1273
|
+
const tokens = msgCap > 0
|
|
1274
|
+
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
1275
|
+
: store.estimateTokens(msg);
|
|
1196
1276
|
entries.push({
|
|
1197
1277
|
index: entries.length,
|
|
1198
1278
|
sourceMessageId: msg.id,
|
|
@@ -1200,86 +1280,9 @@ export class AutobiographicalStrategy {
|
|
|
1200
1280
|
participant: msg.participant,
|
|
1201
1281
|
content,
|
|
1202
1282
|
});
|
|
1283
|
+
emittedTokens += tokens;
|
|
1203
1284
|
}
|
|
1204
|
-
|
|
1205
|
-
async compressChunkLegacy(chunk, ctx) {
|
|
1206
|
-
if (!ctx.membrane) {
|
|
1207
|
-
throw new Error('No membrane instance for compression');
|
|
1208
|
-
}
|
|
1209
|
-
const priorContext = this.buildPriorContextLegacy(chunk, ctx);
|
|
1210
|
-
const chunkContent = this.formatChunkForCompression(chunk);
|
|
1211
|
-
const prompt = this.config.diaryUserPrompt ?? this.config.summaryUserPrompt;
|
|
1212
|
-
const systemPrompt = this.config.diarySystemPrompt ?? this.config.summarySystemPrompt;
|
|
1213
|
-
const messages = [
|
|
1214
|
-
...priorContext,
|
|
1215
|
-
{
|
|
1216
|
-
participant: 'Context Manager',
|
|
1217
|
-
content: [{ type: 'text', text: prompt.replace('{content}', chunkContent) }],
|
|
1218
|
-
},
|
|
1219
|
-
];
|
|
1220
|
-
const request = {
|
|
1221
|
-
messages: messages.map((m) => ({
|
|
1222
|
-
participant: m.participant,
|
|
1223
|
-
content: m.content,
|
|
1224
|
-
})),
|
|
1225
|
-
system: systemPrompt,
|
|
1226
|
-
config: {
|
|
1227
|
-
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
1228
|
-
maxTokens: 2000,
|
|
1229
|
-
},
|
|
1230
|
-
};
|
|
1231
|
-
try {
|
|
1232
|
-
const response = await ctx.membrane.complete(request);
|
|
1233
|
-
const diaryText = response.content
|
|
1234
|
-
.filter((b) => b.type === 'text')
|
|
1235
|
-
.map((b) => b.text)
|
|
1236
|
-
.join('\n');
|
|
1237
|
-
chunk.compressed = true;
|
|
1238
|
-
chunk.diary = diaryText;
|
|
1239
|
-
this._compressionCount++;
|
|
1240
|
-
}
|
|
1241
|
-
catch (error) {
|
|
1242
|
-
console.error('Failed to compress chunk:', error);
|
|
1243
|
-
throw error;
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
buildPriorContextLegacy(chunk, ctx) {
|
|
1247
|
-
const context = [];
|
|
1248
|
-
for (const prevChunk of this.chunks) {
|
|
1249
|
-
if (prevChunk.index >= chunk.index)
|
|
1250
|
-
break;
|
|
1251
|
-
if (!prevChunk.compressed || !prevChunk.diary)
|
|
1252
|
-
continue;
|
|
1253
|
-
context.push({
|
|
1254
|
-
participant: 'Context Manager',
|
|
1255
|
-
content: [{ type: 'text', text: this.config.summaryContextLabel ?? 'Summary of earlier context:' }],
|
|
1256
|
-
});
|
|
1257
|
-
context.push({
|
|
1258
|
-
participant: this.config.summaryParticipant ?? 'Summary',
|
|
1259
|
-
content: [{ type: 'text', text: prevChunk.diary }],
|
|
1260
|
-
});
|
|
1261
|
-
}
|
|
1262
|
-
// Find the actual position of this chunk's first message in the full array
|
|
1263
|
-
const messages = ctx.messageStore.getAll();
|
|
1264
|
-
const firstMsgId = chunk.messages[0]?.id;
|
|
1265
|
-
const chunkAbsStart = firstMsgId
|
|
1266
|
-
? messages.findIndex(m => m.id === firstMsgId)
|
|
1267
|
-
: -1;
|
|
1268
|
-
if (chunkAbsStart > 0) {
|
|
1269
|
-
const precedingStart = Math.max(0, chunkAbsStart - 50);
|
|
1270
|
-
let tokens = 0;
|
|
1271
|
-
for (let i = chunkAbsStart - 1; i >= precedingStart && tokens < 15000; i--) {
|
|
1272
|
-
const msg = messages[i];
|
|
1273
|
-
if (!msg)
|
|
1274
|
-
break;
|
|
1275
|
-
tokens += ctx.messageStore.estimateTokens(msg);
|
|
1276
|
-
context.unshift({
|
|
1277
|
-
participant: msg.participant,
|
|
1278
|
-
content: msg.content,
|
|
1279
|
-
});
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1282
|
-
return context;
|
|
1285
|
+
return { messages: accepted.length, tokens: emittedTokens };
|
|
1283
1286
|
}
|
|
1284
1287
|
// ============================================================================
|
|
1285
1288
|
// Hierarchical (L1/L2/L3) path
|
|
@@ -1312,6 +1315,7 @@ export class AutobiographicalStrategy {
|
|
|
1312
1315
|
* No system prompt — framing via message structure only.
|
|
1313
1316
|
*/
|
|
1314
1317
|
async compressChunkHierarchical(chunk, ctx) {
|
|
1318
|
+
phaseChannel.report('compress-chunk'); // liveness-watchdog phase
|
|
1315
1319
|
if (!ctx.membrane) {
|
|
1316
1320
|
throw new Error('No membrane instance for compression');
|
|
1317
1321
|
}
|
|
@@ -1493,6 +1497,10 @@ export class AutobiographicalStrategy {
|
|
|
1493
1497
|
let logSummaryId;
|
|
1494
1498
|
try {
|
|
1495
1499
|
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
1500
|
+
// Text-only on purpose: this is the SUMMARIZER's one-shot response —
|
|
1501
|
+
// its thinking/redacted_thinking blocks are scratch work, not part of
|
|
1502
|
+
// the agent's history, and signed thinking is never valid inside a
|
|
1503
|
+
// rewritten summary anyway.
|
|
1496
1504
|
const summaryText = response.content
|
|
1497
1505
|
.filter((b) => b.type === 'text')
|
|
1498
1506
|
.map(b => b.text)
|
|
@@ -1566,6 +1574,7 @@ export class AutobiographicalStrategy {
|
|
|
1566
1574
|
* summaries when the queue eventually drains.
|
|
1567
1575
|
*/
|
|
1568
1576
|
checkMergeThreshold() {
|
|
1577
|
+
phaseChannel.report('merge-threshold'); // liveness-watchdog phase
|
|
1569
1578
|
if (this.config.speculativeProduction) {
|
|
1570
1579
|
this.checkMergeThresholdRecursive();
|
|
1571
1580
|
return;
|
|
@@ -1913,6 +1922,7 @@ export class AutobiographicalStrategy {
|
|
|
1913
1922
|
let logNewSummaryId;
|
|
1914
1923
|
try {
|
|
1915
1924
|
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
1925
|
+
// Text-only on purpose: summarizer scratch thinking is not agent history
|
|
1916
1926
|
const mergedText = response.content
|
|
1917
1927
|
.filter((b) => b.type === 'text')
|
|
1918
1928
|
.map(b => b.text)
|
|
@@ -1992,6 +2002,8 @@ export class AutobiographicalStrategy {
|
|
|
1992
2002
|
* See `docs/adaptive-resolution-design.md` §3, §5.
|
|
1993
2003
|
*/
|
|
1994
2004
|
selectAdaptive(store, budget) {
|
|
2005
|
+
phaseChannel.report('context-build'); // liveness-watchdog phase
|
|
2006
|
+
this.rsBegin();
|
|
1995
2007
|
const entries = [];
|
|
1996
2008
|
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
1997
2009
|
const messages = store.getAll();
|
|
@@ -2022,10 +2034,11 @@ export class AutobiographicalStrategy {
|
|
|
2022
2034
|
totalTokens += tokens;
|
|
2023
2035
|
headMessageIds.add(msg.id);
|
|
2024
2036
|
headTokens += tokens;
|
|
2037
|
+
this.rsRaw('head', tokens);
|
|
2025
2038
|
}
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2039
|
+
// (Cache breakpoints are placed in one pass over the FINAL ordered entries
|
|
2040
|
+
// below — see placeCacheMarkers — capturing the stable folded prefix, not
|
|
2041
|
+
// just the head boundary.)
|
|
2029
2042
|
// Compute tail message IDs (will be emitted at end)
|
|
2030
2043
|
const effectiveRecentStart = Math.max(recentStart, headEnd);
|
|
2031
2044
|
for (let i = effectiveRecentStart; i < messages.length; i++) {
|
|
@@ -2047,6 +2060,11 @@ export class AutobiographicalStrategy {
|
|
|
2047
2060
|
// Pinned-position set so the picker doesn't fold messages the user
|
|
2048
2061
|
// explicitly marked as keep-raw. Built once and reused.
|
|
2049
2062
|
const pinnedSet = this.pinnedPositions(messages);
|
|
2063
|
+
// V2 dynamic-pin fold-depth bounds (level / maxLevel). A position with a
|
|
2064
|
+
// bound is NOT a classic force-raw pin — the KV-stable controller must be
|
|
2065
|
+
// able to move it to/within its bound — so it renders as `pinned: false`
|
|
2066
|
+
// carrying `pinLevel` / `pinMaxLevel` instead.
|
|
2067
|
+
const pinBounds = this.pinLevelBounds(messages);
|
|
2050
2068
|
// O(1) summary lookup for findAncestorAt — avoids O(summaries) find()
|
|
2051
2069
|
// calls during emission.
|
|
2052
2070
|
const summariesById = new Map();
|
|
@@ -2059,13 +2077,18 @@ export class AutobiographicalStrategy {
|
|
|
2059
2077
|
const tokens = msgCap > 0
|
|
2060
2078
|
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
2061
2079
|
: store.estimateTokens(msg);
|
|
2080
|
+
const bound = pinBounds.get(i);
|
|
2062
2081
|
pickerChunks.push({
|
|
2063
2082
|
id: msg.id,
|
|
2064
2083
|
sequence: i,
|
|
2065
2084
|
rawTokens: tokens,
|
|
2066
2085
|
currentResolution: this.resolutions.get(msg.id) ?? 0,
|
|
2067
2086
|
lockedByAgent: this.locked.has(msg.id),
|
|
2068
|
-
|
|
2087
|
+
// A classic pin (in pinnedSet with no level bound) stays force-raw. A
|
|
2088
|
+
// leveled pin is not force-raw; it carries its bound instead.
|
|
2089
|
+
pinned: pinnedSet.has(i) && bound === undefined,
|
|
2090
|
+
pinLevel: bound?.level,
|
|
2091
|
+
pinMaxLevel: bound?.maxLevel,
|
|
2069
2092
|
l1Id: ch?.summaryId,
|
|
2070
2093
|
});
|
|
2071
2094
|
}
|
|
@@ -2111,7 +2134,13 @@ export class AutobiographicalStrategy {
|
|
|
2111
2134
|
recallPairTokens.set(s.id, s.tokens + 20);
|
|
2112
2135
|
}
|
|
2113
2136
|
// ----- 4. Run the picker -----
|
|
2114
|
-
|
|
2137
|
+
// The picker's token count ALREADY includes the pinned head+tail (it gets
|
|
2138
|
+
// headTokens/tailTokens in pickerInputs and result.finalTokens covers them).
|
|
2139
|
+
// So the budget it folds against is the full maxTokens — NOT maxTokens-head,
|
|
2140
|
+
// which double-counts the head (reserves it twice: once here, once because
|
|
2141
|
+
// finalTokens already includes it). The old form threw ~head-tokens early at
|
|
2142
|
+
// tight budgets and quietly under-used the budget by ~head everywhere.
|
|
2143
|
+
const totalBudget = maxTokens;
|
|
2115
2144
|
const slack = this.config.compressionSlackRatio ?? 0.1;
|
|
2116
2145
|
const foldingBudget = {
|
|
2117
2146
|
totalBudget,
|
|
@@ -2120,8 +2149,7 @@ export class AutobiographicalStrategy {
|
|
|
2120
2149
|
};
|
|
2121
2150
|
const headSetForPicker = new Set(headMessageIds);
|
|
2122
2151
|
const tailSetForPicker = new Set(tailMessageIds);
|
|
2123
|
-
const
|
|
2124
|
-
const result = picker.run({
|
|
2152
|
+
const pickerInputs = {
|
|
2125
2153
|
chunks: pickerChunks,
|
|
2126
2154
|
summaries: summariesMap,
|
|
2127
2155
|
recallPairTokens,
|
|
@@ -2129,7 +2157,9 @@ export class AutobiographicalStrategy {
|
|
|
2129
2157
|
tailChunkIds: tailSetForPicker,
|
|
2130
2158
|
headTokens,
|
|
2131
2159
|
tailTokens,
|
|
2132
|
-
}
|
|
2160
|
+
};
|
|
2161
|
+
const picker = this.buildPicker(pickerInputs);
|
|
2162
|
+
const result = picker.run(pickerInputs, foldingBudget);
|
|
2133
2163
|
// Commit the new resolutions back to strategy state for next compile.
|
|
2134
2164
|
// Persist to chronicle only if anything actually changed — avoids
|
|
2135
2165
|
// unnecessary state-slot writes on no-op compiles (which is the common
|
|
@@ -2236,6 +2266,7 @@ export class AutobiographicalStrategy {
|
|
|
2236
2266
|
content,
|
|
2237
2267
|
});
|
|
2238
2268
|
totalTokens += tokens;
|
|
2269
|
+
this.rsRaw('middleRaw', tokens);
|
|
2239
2270
|
}
|
|
2240
2271
|
else {
|
|
2241
2272
|
// summary run — emit Q+A pair, dedup at the strategy level
|
|
@@ -2263,6 +2294,7 @@ export class AutobiographicalStrategy {
|
|
|
2263
2294
|
entries.push(questionEntry);
|
|
2264
2295
|
entries.push(answerEntry);
|
|
2265
2296
|
totalTokens += pairTokens;
|
|
2297
|
+
this.rsSummary(ancestor.level, pairTokens);
|
|
2266
2298
|
}
|
|
2267
2299
|
}
|
|
2268
2300
|
currentRun = null;
|
|
@@ -2336,6 +2368,7 @@ export class AutobiographicalStrategy {
|
|
|
2336
2368
|
content,
|
|
2337
2369
|
});
|
|
2338
2370
|
totalTokens += tokens;
|
|
2371
|
+
this.rsRaw('middleRaw', tokens);
|
|
2339
2372
|
i++;
|
|
2340
2373
|
}
|
|
2341
2374
|
else {
|
|
@@ -2353,6 +2386,7 @@ export class AutobiographicalStrategy {
|
|
|
2353
2386
|
content,
|
|
2354
2387
|
});
|
|
2355
2388
|
totalTokens += tokens;
|
|
2389
|
+
this.rsRaw('middleRaw', tokens);
|
|
2356
2390
|
i++;
|
|
2357
2391
|
continue;
|
|
2358
2392
|
}
|
|
@@ -2380,11 +2414,13 @@ export class AutobiographicalStrategy {
|
|
|
2380
2414
|
entries.push(questionEntry);
|
|
2381
2415
|
entries.push(answerEntry);
|
|
2382
2416
|
totalTokens += pairTokens;
|
|
2417
|
+
this.rsSummary(ancestor.level, pairTokens);
|
|
2383
2418
|
i++;
|
|
2384
2419
|
}
|
|
2385
2420
|
}
|
|
2386
2421
|
// ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
|
|
2387
|
-
this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
2422
|
+
const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
2423
|
+
this.rsRaw('tail', tailStats.tokens, tailStats.messages);
|
|
2388
2424
|
// ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
|
|
2389
2425
|
// Both head and tail emission paths emit shards as separate ContextEntries.
|
|
2390
2426
|
// The middle path already merges consecutive same-bodyGroup raw shards into
|
|
@@ -2392,9 +2428,62 @@ export class AutobiographicalStrategy {
|
|
|
2392
2428
|
// a sharded message renders as ONE API message regardless of which region
|
|
2393
2429
|
// it falls into (preserves KV cache through region transitions).
|
|
2394
2430
|
const merged = this.mergeAdjacentBodyGroupRaw(entries, store);
|
|
2431
|
+
this.pruneToolEntries(merged);
|
|
2395
2432
|
this.trimOrphanedToolUse(merged);
|
|
2433
|
+
// Strip stale images BEFORE placing markers and committing stats, so both
|
|
2434
|
+
// describe the post-strip context the agent actually receives.
|
|
2435
|
+
this.applyImageStripping(merged, store);
|
|
2436
|
+
// Place ≤4 cache breakpoints across the FINAL ordered entries so the
|
|
2437
|
+
// provider can reuse the stable folded prefix — not just the head. With a
|
|
2438
|
+
// single head marker the cache hit is ~2%; well-placed breakpoints take the
|
|
2439
|
+
// real strategy to ~50% (docs/kv-stable-context-control.md — marker
|
|
2440
|
+
// placement is the dominant KV lever).
|
|
2441
|
+
this.placeCacheMarkers(merged, headMessageIds, tailMessageIds);
|
|
2442
|
+
this.rsEnd();
|
|
2396
2443
|
return merged;
|
|
2397
2444
|
}
|
|
2445
|
+
/**
|
|
2446
|
+
* Place up to four `cache_control` breakpoints across the final ordered
|
|
2447
|
+
* entries: the head/system boundary, the end of the folded history (the
|
|
2448
|
+
* stable prefix that persists turn-to-turn — the most valuable), a mid-history
|
|
2449
|
+
* seam, and the very end (for pure-append reuse). Mirrors `placeMarkers` in
|
|
2450
|
+
* the adaptive layer but operates on emitted entries. Idempotent; clears any
|
|
2451
|
+
* pre-existing markers first.
|
|
2452
|
+
*/
|
|
2453
|
+
placeCacheMarkers(entries, headMessageIds, tailMessageIds) {
|
|
2454
|
+
for (const e of entries)
|
|
2455
|
+
if (e.cacheMarker)
|
|
2456
|
+
e.cacheMarker = false;
|
|
2457
|
+
const n = entries.length;
|
|
2458
|
+
if (n === 0)
|
|
2459
|
+
return;
|
|
2460
|
+
let lastHead = -1;
|
|
2461
|
+
let firstTail = n;
|
|
2462
|
+
for (let i = 0; i < n; i++) {
|
|
2463
|
+
const sid = entries[i].sourceMessageId;
|
|
2464
|
+
if (sid && headMessageIds.has(sid))
|
|
2465
|
+
lastHead = i;
|
|
2466
|
+
}
|
|
2467
|
+
for (let i = 0; i < n; i++) {
|
|
2468
|
+
const sid = entries[i].sourceMessageId;
|
|
2469
|
+
if (sid && tailMessageIds.has(sid)) {
|
|
2470
|
+
firstTail = i;
|
|
2471
|
+
break;
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
const historyEnd = firstTail - 1; // last middle (folded-history) entry
|
|
2475
|
+
const marks = new Set();
|
|
2476
|
+
if (lastHead >= 0)
|
|
2477
|
+
marks.add(lastHead); // system / head block
|
|
2478
|
+
if (historyEnd > lastHead)
|
|
2479
|
+
marks.add(historyEnd); // stable folded prefix (the big one)
|
|
2480
|
+
if (historyEnd - lastHead > 2)
|
|
2481
|
+
marks.add(lastHead + Math.floor((historyEnd - lastHead) / 2)); // mid-history
|
|
2482
|
+
marks.add(n - 1); // end → pure-append reuse
|
|
2483
|
+
for (const idx of marks)
|
|
2484
|
+
if (idx >= 0 && idx < n)
|
|
2485
|
+
entries[idx].cacheMarker = true;
|
|
2486
|
+
}
|
|
2398
2487
|
/**
|
|
2399
2488
|
* Walk an entries array; for every run of consecutive entries that
|
|
2400
2489
|
* (a) have sourceRelation: 'copy' (raw, not a synthesized recall pair)
|
|
@@ -2483,6 +2572,20 @@ export class AutobiographicalStrategy {
|
|
|
2483
2572
|
this._adaptivePicker = new Picker(strategy);
|
|
2484
2573
|
return this._adaptivePicker;
|
|
2485
2574
|
}
|
|
2575
|
+
/**
|
|
2576
|
+
* Build the picker for this compile. Instance folding strategies (kv-stable)
|
|
2577
|
+
* need the per-compile `PickerInputs` at construction, so they're built fresh
|
|
2578
|
+
* here; stateless ones (flat-profile / oldest-first) reuse the memoized picker.
|
|
2579
|
+
*/
|
|
2580
|
+
buildPicker(inputs) {
|
|
2581
|
+
if (this.config.foldingStrategy === 'kv-stable') {
|
|
2582
|
+
return new Picker(new KvStableStrategy(inputs, {
|
|
2583
|
+
reachTokens: this.config.kvStableReachTokens,
|
|
2584
|
+
mergeThreshold: this.config.mergeThreshold,
|
|
2585
|
+
}));
|
|
2586
|
+
}
|
|
2587
|
+
return this.getAdaptivePicker();
|
|
2588
|
+
}
|
|
2486
2589
|
/**
|
|
2487
2590
|
* Walk the summary tree to find the L_k ancestor of a message.
|
|
2488
2591
|
* Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
|
|
@@ -2556,6 +2659,8 @@ export class AutobiographicalStrategy {
|
|
|
2556
2659
|
* Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
|
|
2557
2660
|
*/
|
|
2558
2661
|
selectHierarchical(store, budget) {
|
|
2662
|
+
phaseChannel.report('context-build'); // liveness-watchdog phase
|
|
2663
|
+
this.rsBegin();
|
|
2559
2664
|
const entries = [];
|
|
2560
2665
|
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
2561
2666
|
const messages = store.getAll();
|
|
@@ -2578,6 +2683,7 @@ export class AutobiographicalStrategy {
|
|
|
2578
2683
|
content,
|
|
2579
2684
|
});
|
|
2580
2685
|
totalTokens += tokens;
|
|
2686
|
+
this.rsRaw('head', tokens);
|
|
2581
2687
|
}
|
|
2582
2688
|
// Mark the last head entry as a cache boundary (even if budget truncated the window)
|
|
2583
2689
|
if (entries.length > 0) {
|
|
@@ -2727,6 +2833,7 @@ export class AutobiographicalStrategy {
|
|
|
2727
2833
|
entries.push(questionEntry);
|
|
2728
2834
|
entries.push(answerEntry);
|
|
2729
2835
|
totalTokens += pairTokens;
|
|
2836
|
+
this.rsSummary(summary.level, pairTokens);
|
|
2730
2837
|
}
|
|
2731
2838
|
else {
|
|
2732
2839
|
const msg = item.msg;
|
|
@@ -2744,6 +2851,7 @@ export class AutobiographicalStrategy {
|
|
|
2744
2851
|
content,
|
|
2745
2852
|
});
|
|
2746
2853
|
totalTokens += tokens;
|
|
2854
|
+
this.rsRaw('middleRaw', tokens);
|
|
2747
2855
|
}
|
|
2748
2856
|
}
|
|
2749
2857
|
}
|
|
@@ -2775,6 +2883,8 @@ export class AutobiographicalStrategy {
|
|
|
2775
2883
|
entries.push(questionEntry);
|
|
2776
2884
|
entries.push(answerEntry);
|
|
2777
2885
|
totalTokens += pairTokens;
|
|
2886
|
+
for (const s of selectedSummaries)
|
|
2887
|
+
this.rsSummary(s.level, s.tokens);
|
|
2778
2888
|
}
|
|
2779
2889
|
// Sort by position so uncompressed-middle messages and pins both
|
|
2780
2890
|
// appear in their chronological place after the combined recall pair.
|
|
@@ -2794,6 +2904,7 @@ export class AutobiographicalStrategy {
|
|
|
2794
2904
|
content,
|
|
2795
2905
|
});
|
|
2796
2906
|
totalTokens += tokens;
|
|
2907
|
+
this.rsRaw('middleRaw', tokens);
|
|
2797
2908
|
}
|
|
2798
2909
|
}
|
|
2799
2910
|
}
|
|
@@ -2802,9 +2913,14 @@ export class AutobiographicalStrategy {
|
|
|
2802
2913
|
// budget, the latest messages (the ones the agent actually needs to act
|
|
2803
2914
|
// on) are preserved and the oldest recent-window messages are dropped.
|
|
2804
2915
|
const effectiveRecentStart = Math.max(recentStart, headEnd);
|
|
2805
|
-
this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
2916
|
+
const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
2917
|
+
this.rsRaw('tail', tailStats.tokens, tailStats.messages);
|
|
2806
2918
|
this.trimOrphanedToolUse(entries);
|
|
2807
2919
|
this.pruneToolEntries(entries);
|
|
2920
|
+
// Strip stale images before committing stats so RenderStats.total reflects
|
|
2921
|
+
// the post-strip context (this path places no cache markers).
|
|
2922
|
+
this.applyImageStripping(entries, store);
|
|
2923
|
+
this.rsEnd();
|
|
2808
2924
|
return entries;
|
|
2809
2925
|
}
|
|
2810
2926
|
// ============================================================================
|
|
@@ -3025,6 +3141,7 @@ export class AutobiographicalStrategy {
|
|
|
3025
3141
|
},
|
|
3026
3142
|
};
|
|
3027
3143
|
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
3144
|
+
// Text-only on purpose: summarizer scratch thinking is not agent history
|
|
3028
3145
|
return response.content
|
|
3029
3146
|
.filter((b) => b.type === 'text')
|
|
3030
3147
|
.map(b => b.text)
|
|
@@ -3156,7 +3273,6 @@ export class AutobiographicalStrategy {
|
|
|
3156
3273
|
const existing = existingCompressed.get(key);
|
|
3157
3274
|
if (existing) {
|
|
3158
3275
|
chunk.compressed = true;
|
|
3159
|
-
chunk.diary = existing.diary;
|
|
3160
3276
|
chunk.summaryId = existing.summaryId;
|
|
3161
3277
|
}
|
|
3162
3278
|
// In hierarchical mode, also check if a summary exists for this chunk
|
|
@@ -3172,6 +3288,97 @@ export class AutobiographicalStrategy {
|
|
|
3172
3288
|
chunkKey(chunk) {
|
|
3173
3289
|
return chunk.messages.map((m) => m.id).join(':');
|
|
3174
3290
|
}
|
|
3291
|
+
/** True if any content block is a live image. */
|
|
3292
|
+
hasImageBlock(content) {
|
|
3293
|
+
return content.some((b) => b.type === 'image');
|
|
3294
|
+
}
|
|
3295
|
+
/** Message index marking the image-strip depth boundary: walks newest→oldest
|
|
3296
|
+
* summing the same per-message estimate as getRecentWindowStart, and returns
|
|
3297
|
+
* the index of the first message still within `depthTokens`. Messages before
|
|
3298
|
+
* this index have their images stripped to placeholders. */
|
|
3299
|
+
getImageStripStart(store, depthTokens) {
|
|
3300
|
+
const messages = store.getAll();
|
|
3301
|
+
let tokens = 0;
|
|
3302
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
3303
|
+
tokens += store.estimateTokens(messages[i]);
|
|
3304
|
+
if (tokens > depthTokens)
|
|
3305
|
+
return i + 1;
|
|
3306
|
+
}
|
|
3307
|
+
return 0;
|
|
3308
|
+
}
|
|
3309
|
+
/** Text substituted for an image block once it leaves the live-image window. */
|
|
3310
|
+
static IMAGE_PLACEHOLDER = '[image dropped from live context]';
|
|
3311
|
+
/** Post-pass over compiled entries: replace image blocks with a text
|
|
3312
|
+
* placeholder once they fall outside the live-image window — either deeper
|
|
3313
|
+
* than `imageStripDepthTokens` from the newest message, or beyond the
|
|
3314
|
+
* `maxLiveImages` most-recent images (counted newest-first). Summaries are
|
|
3315
|
+
* already text, so they're naturally unaffected. The adjacent
|
|
3316
|
+
* "[image attachment: <name>]" text added at ingest preserves the filename,
|
|
3317
|
+
* so the placeholder itself stays terse. Reduces tokens, so it never pushes
|
|
3318
|
+
* a compiled context back over budget.
|
|
3319
|
+
*
|
|
3320
|
+
* Runs INSIDE each select path, *before* `rsEnd()` and `placeCacheMarkers`,
|
|
3321
|
+
* so the committed render stats (and the cache breakpoints) describe the
|
|
3322
|
+
* post-strip context. As it strips, it decrements the matching raw bucket of
|
|
3323
|
+
* the in-progress render stats by the reclaimed tokens, keeping
|
|
3324
|
+
* `RenderStats.total` equal to the real rendered size. */
|
|
3325
|
+
applyImageStripping(entries, store) {
|
|
3326
|
+
const maxLive = this.config.maxLiveImages ?? 0; // 0 = unlimited count
|
|
3327
|
+
const depthTokens = this.config.imageStripDepthTokens ?? 0; // 0 = no depth strip
|
|
3328
|
+
if (maxLive === 0 && depthTokens === 0)
|
|
3329
|
+
return; // policy disabled
|
|
3330
|
+
const messages = store.getAll();
|
|
3331
|
+
const posById = new Map();
|
|
3332
|
+
for (let i = 0; i < messages.length; i++)
|
|
3333
|
+
posById.set(messages[i].id, i);
|
|
3334
|
+
const stripStart = depthTokens > 0 ? this.getImageStripStart(store, depthTokens) : 0;
|
|
3335
|
+
// Same region windows select() bucketed by, so a stripped image's reclaimed
|
|
3336
|
+
// tokens come back out of the bucket it was originally tallied into.
|
|
3337
|
+
const headStart = this.getHeadWindowStartIndex(store);
|
|
3338
|
+
const headEnd = this.getHeadWindowEnd(store);
|
|
3339
|
+
const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
|
|
3340
|
+
const bucketAt = (pos) => {
|
|
3341
|
+
if (pos < 0)
|
|
3342
|
+
return 'middleRaw'; // no resolvable region — keep total == Σbuckets
|
|
3343
|
+
if (pos >= headStart && pos < headEnd)
|
|
3344
|
+
return 'head';
|
|
3345
|
+
if (pos >= recentStart)
|
|
3346
|
+
return 'tail';
|
|
3347
|
+
return 'middleRaw';
|
|
3348
|
+
};
|
|
3349
|
+
const placeholderTokens = Math.ceil(AutobiographicalStrategy.IMAGE_PLACEHOLDER.length / 4);
|
|
3350
|
+
// Image-bearing entries, newest-first by source position. Entries with no
|
|
3351
|
+
// resolvable source position sort last (pos -1) and never count as "live".
|
|
3352
|
+
const ordered = entries
|
|
3353
|
+
.map((entry, idx) => ({
|
|
3354
|
+
idx,
|
|
3355
|
+
pos: entry.sourceMessageId !== undefined ? posById.get(entry.sourceMessageId) ?? -1 : -1,
|
|
3356
|
+
}))
|
|
3357
|
+
.filter(({ idx }) => this.hasImageBlock(entries[idx].content))
|
|
3358
|
+
.sort((a, b) => b.pos - a.pos);
|
|
3359
|
+
let keptImages = 0;
|
|
3360
|
+
for (const { idx, pos } of ordered) {
|
|
3361
|
+
const entry = entries[idx];
|
|
3362
|
+
const tooDeep = depthTokens > 0 && (pos < 0 || pos < stripStart);
|
|
3363
|
+
const bucket = bucketAt(pos);
|
|
3364
|
+
entry.content = entry.content.map((block) => {
|
|
3365
|
+
if (block.type !== 'image')
|
|
3366
|
+
return block;
|
|
3367
|
+
const overCount = maxLive > 0 && keptImages >= maxLive;
|
|
3368
|
+
if (tooDeep || overCount) {
|
|
3369
|
+
// The renderer estimates an image at `tokenEstimate ?? 1600` (see
|
|
3370
|
+
// message-store/context-log). Hand that back to the bucket, less the
|
|
3371
|
+
// placeholder text that replaces it, so the stats match the output.
|
|
3372
|
+
const reclaimed = (block.tokenEstimate ?? 1600) - placeholderTokens;
|
|
3373
|
+
if (this._rs && reclaimed > 0)
|
|
3374
|
+
this._rs[bucket].tokens -= reclaimed;
|
|
3375
|
+
return { type: 'text', text: AutobiographicalStrategy.IMAGE_PLACEHOLDER };
|
|
3376
|
+
}
|
|
3377
|
+
keptImages++;
|
|
3378
|
+
return block;
|
|
3379
|
+
});
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3175
3382
|
getRecentWindowStart(store) {
|
|
3176
3383
|
const messages = store.getAll();
|
|
3177
3384
|
let tokens = 0;
|