@animalabs/context-manager 0.5.9 → 0.5.10
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/strategies/autobiographical.d.ts +16 -0
- package/dist/src/strategies/autobiographical.d.ts.map +1 -1
- package/dist/src/strategies/autobiographical.js +217 -31
- package/dist/src/strategies/autobiographical.js.map +1 -1
- package/dist/src/types/strategy.d.ts +10 -3
- package/dist/src/types/strategy.d.ts.map +1 -1
- package/dist/src/types/strategy.js.map +1 -1
- package/dist/test/adaptive/hard-fail.test.js +81 -0
- package/dist/test/adaptive/hard-fail.test.js.map +1 -1
- package/dist/test/merge-contiguity.test.js +10 -2
- package/dist/test/merge-contiguity.test.js.map +1 -1
- package/dist/test/strategy-persistence.test.js +41 -0
- package/dist/test/strategy-persistence.test.js.map +1 -1
- package/dist/test/summary-log-consistency.test.d.ts +5 -6
- package/dist/test/summary-log-consistency.test.d.ts.map +1 -1
- package/dist/test/summary-log-consistency.test.js +15 -10
- package/dist/test/summary-log-consistency.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/strategies/autobiographical.ts +245 -30
- package/src/types/strategy.ts +11 -3
|
@@ -778,6 +778,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
778
778
|
// Anthropic 400 "content must be non-empty". Never let them re-enter memory.
|
|
779
779
|
const nonEmpty = loaded.filter(s => s && typeof s.content === 'string' && s.content.trim().length > 0);
|
|
780
780
|
const droppedEmpty = loaded.length - nonEmpty.length;
|
|
781
|
+
const removedEmptyIds = new Set(
|
|
782
|
+
loaded
|
|
783
|
+
.filter(s => s && (typeof s.content !== 'string' || s.content.trim().length === 0))
|
|
784
|
+
.map(s => s.id),
|
|
785
|
+
);
|
|
781
786
|
if (droppedEmpty > 0) console.warn(`[autobiographical] dropped ${droppedEmpty} empty summary(ies) on load`);
|
|
782
787
|
// Dedupe by id, keeping the copy with mergedInto set (position of first
|
|
783
788
|
// occurrence preserved). Duplicate-id copies with diverging merge state
|
|
@@ -792,7 +797,48 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
792
797
|
}
|
|
793
798
|
const dupes = nonEmpty.length - byId.size;
|
|
794
799
|
if (dupes > 0) console.warn(`[autobiographical] deduped ${dupes} duplicate summary id(s) on load`);
|
|
795
|
-
|
|
800
|
+
// Dropping an invalid parent is only half the repair. Its children may
|
|
801
|
+
// still carry `mergedInto: <dropped-id>`, which makes them simultaneously
|
|
802
|
+
// unavailable to the picker (no parent to render) and ineligible for a
|
|
803
|
+
// replacement merge (they still look merged). Clear every dangling edge
|
|
804
|
+
// and persist the canonicalized array so the poison does not return on
|
|
805
|
+
// every restart.
|
|
806
|
+
let danglingParents = 0;
|
|
807
|
+
this.summaries = [...byId.values()].map((summary) => {
|
|
808
|
+
if (!summary.mergedInto || byId.has(summary.mergedInto)) return summary;
|
|
809
|
+
danglingParents++;
|
|
810
|
+
const { mergedInto: _dropped, ...repaired } = summary;
|
|
811
|
+
return repaired as SummaryEntry;
|
|
812
|
+
});
|
|
813
|
+
if (droppedEmpty > 0 || dupes > 0 || danglingParents > 0) {
|
|
814
|
+
this.store.setStateJson(this.summariesStateId, this.summaries);
|
|
815
|
+
console.warn(
|
|
816
|
+
`[autobiographical] repaired summary state: removed ${droppedEmpty} empty, ` +
|
|
817
|
+
`deduped ${dupes}, cleared ${danglingParents} dangling parent pointer(s)`,
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// An invalid L1 may also be referenced by a persisted chunk record. Make
|
|
822
|
+
// that chunk compressible again instead of leaving it permanently marked
|
|
823
|
+
// complete with no summary behind it.
|
|
824
|
+
if (this.chunkPersistenceEnabled && this.chunkRecords.length > 0) {
|
|
825
|
+
const validL1Ids = new Set(this.summaries.filter(s => s.level === 1).map(s => s.id));
|
|
826
|
+
let repairedChunkRecords = 0;
|
|
827
|
+
this.chunkRecords = this.chunkRecords.map((record) => {
|
|
828
|
+
if (!record.compressed || (record.summaryId && validL1Ids.has(record.summaryId))) {
|
|
829
|
+
return record;
|
|
830
|
+
}
|
|
831
|
+
repairedChunkRecords++;
|
|
832
|
+
const { summaryId: _dropped, ...rest } = record;
|
|
833
|
+
return { ...rest, compressed: false };
|
|
834
|
+
});
|
|
835
|
+
if (repairedChunkRecords > 0) {
|
|
836
|
+
this.store.setStateJson(this.chunksStateId, this.chunkRecords);
|
|
837
|
+
console.warn(
|
|
838
|
+
`[autobiographical] repaired ${repairedChunkRecords} chunk record(s) with missing L1 summaries`,
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
796
842
|
|
|
797
843
|
const counter = this.store.getStateJson(this.counterStateId);
|
|
798
844
|
this.summaryIdCounter = typeof counter === 'number' ? counter : 0;
|
|
@@ -801,6 +847,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
801
847
|
this.mergeQueue = Array.isArray(queue)
|
|
802
848
|
? (queue as Array<{ level: SummaryLevel; sourceIds: string[] }>)
|
|
803
849
|
: [];
|
|
850
|
+
const validMergeQueue = this.mergeQueue.filter(
|
|
851
|
+
merge => !merge.sourceIds.some(id => removedEmptyIds.has(id) && !byId.has(id)),
|
|
852
|
+
);
|
|
853
|
+
if (validMergeQueue.length !== this.mergeQueue.length) {
|
|
854
|
+
const removed = this.mergeQueue.length - validMergeQueue.length;
|
|
855
|
+
this.mergeQueue = validMergeQueue;
|
|
856
|
+
this.store.setStateJson(this.mergeQueueStateId, this.mergeQueue);
|
|
857
|
+
console.warn(`[autobiographical] removed ${removed} merge queue item(s) with missing sources`);
|
|
858
|
+
}
|
|
804
859
|
|
|
805
860
|
const pinsState = this.store.getStateJson(this.pinsStateId);
|
|
806
861
|
if (pinsState && typeof pinsState === 'object' && Array.isArray((pinsState as { pins?: unknown }).pins)) {
|
|
@@ -1071,6 +1126,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1071
1126
|
* Single point so subclasses inherit persistence.
|
|
1072
1127
|
*/
|
|
1073
1128
|
protected pushSummary(entry: SummaryEntry): void {
|
|
1129
|
+
if (typeof entry.content !== 'string' || entry.content.trim().length === 0) {
|
|
1130
|
+
throw new Error(
|
|
1131
|
+
`[autobiographical] refusing to persist empty summary ${entry.id} at L${entry.level}`,
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1074
1134
|
this.summaries.push(entry);
|
|
1075
1135
|
this.store?.appendToStateJson(this.summariesStateId, entry);
|
|
1076
1136
|
}
|
|
@@ -2131,6 +2191,17 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2131
2191
|
// (b) provide an alternative identity source that competes with the
|
|
2132
2192
|
// structural one carried by the conversation itself. Anchoring
|
|
2133
2193
|
// identity by the chronicle's actual head is more honest.
|
|
2194
|
+
// Own the byte wall here rather than delegating to membrane's shed: cap
|
|
2195
|
+
// the prompt's inline image bytes newest-first before the request is built.
|
|
2196
|
+
// A tighter budget than the live window's: a compression prompt also
|
|
2197
|
+
// carries the head, the whole recall frontier and the raw chunk, so the
|
|
2198
|
+
// image share must leave room for all of it under the API's 32MB cap.
|
|
2199
|
+
this.capCompressionImageBytes(
|
|
2200
|
+
llmMessages as Array<{ content: ContentBlock[] }>,
|
|
2201
|
+
this.config.maxCompressionImageBytes ??
|
|
2202
|
+
AutobiographicalStrategy.DEFAULT_MAX_COMPRESSION_IMAGE_BYTES,
|
|
2203
|
+
);
|
|
2204
|
+
|
|
2134
2205
|
const request: NormalizedRequest = {
|
|
2135
2206
|
// EXPLICIT image-loss opt-in (2026-07-12): summarizer prompts replay
|
|
2136
2207
|
// raw history that can carry more inline image bytes than the API's
|
|
@@ -2326,19 +2397,38 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2326
2397
|
withPos.push({ s, first: Math.min(first, last), last: Math.max(first, last) });
|
|
2327
2398
|
}
|
|
2328
2399
|
withPos.sort((a, b) => a.first - b.first);
|
|
2400
|
+
|
|
2401
|
+
// Split into contiguous runs (a gap larger than `gapLimit` starts a new one).
|
|
2402
|
+
const runs: Array<typeof withPos> = [];
|
|
2329
2403
|
let run: typeof withPos = [];
|
|
2330
2404
|
let runEnd = -Infinity;
|
|
2331
2405
|
for (const x of withPos) {
|
|
2332
2406
|
if (run.length > 0 && x.first - runEnd > gapLimit) {
|
|
2333
|
-
|
|
2407
|
+
runs.push(run);
|
|
2334
2408
|
run = [];
|
|
2335
2409
|
runEnd = -Infinity;
|
|
2336
2410
|
}
|
|
2337
2411
|
run.push(x);
|
|
2338
2412
|
runEnd = Math.max(runEnd, x.last);
|
|
2339
2413
|
}
|
|
2340
|
-
if (run.length
|
|
2341
|
-
|
|
2414
|
+
if (run.length > 0) runs.push(run);
|
|
2415
|
+
if (runs.length === 0) return null;
|
|
2416
|
+
|
|
2417
|
+
// ONLY THE NEWEST RUN CAN GROW (2026-07-12 starvation fix). Summaries are
|
|
2418
|
+
// always produced at the live end, so any INTERIOR run is stranded: it can
|
|
2419
|
+
// never reach `threshold` members, and waiting for it froze the whole
|
|
2420
|
+
// pyramid (mythos: L1 frontier [913-981]x5 + [4039-4131]x5 separated by a
|
|
2421
|
+
// 3058-message hole from the poison-node surgery — 10 unmerged L1s, 8
|
|
2422
|
+
// unmerged L2s, merge queue empty, fold floor climbing to 117k until the
|
|
2423
|
+
// picker died). Interior runs consolidate as soon as they have 2 members;
|
|
2424
|
+
// only the newest run waits for the full threshold.
|
|
2425
|
+
for (let i = 0; i < runs.length; i++) {
|
|
2426
|
+
const r = runs[i];
|
|
2427
|
+
const isNewest = i === runs.length - 1;
|
|
2428
|
+
if (r.length >= threshold) return r.slice(0, threshold).map((x) => x.s);
|
|
2429
|
+
if (!isNewest && r.length >= 2) return r.slice(0, threshold).map((x) => x.s);
|
|
2430
|
+
}
|
|
2431
|
+
return null;
|
|
2342
2432
|
}
|
|
2343
2433
|
|
|
2344
2434
|
protected checkMergeThreshold(): void {
|
|
@@ -2709,6 +2799,17 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2709
2799
|
const collapsed = this.collapseConsecutiveMessages(split);
|
|
2710
2800
|
const cleaned = stripUnpairedToolBlocks(collapsed);
|
|
2711
2801
|
|
|
2802
|
+
// Byte wall on the MERGE prompt too (2026-07-12). A merge expands its
|
|
2803
|
+
// sources ONE LEVEL DEEPER — an L2 merge therefore replays the RAW
|
|
2804
|
+
// messages under its L1s, images and all (including screenshots nested in
|
|
2805
|
+
// tool_results). This is the path that kept tripping membrane's transport
|
|
2806
|
+
// shed at 27MB after the L1 site was already capped. Own it here.
|
|
2807
|
+
this.capCompressionImageBytes(
|
|
2808
|
+
cleaned as Array<{ content: ContentBlock[] }>,
|
|
2809
|
+
this.config.maxCompressionImageBytes ??
|
|
2810
|
+
AutobiographicalStrategy.DEFAULT_MAX_COMPRESSION_IMAGE_BYTES,
|
|
2811
|
+
);
|
|
2812
|
+
|
|
2712
2813
|
// NO system prompt — identity is established by the head window
|
|
2713
2814
|
// (present at the start of llmMessages above) and by the prior
|
|
2714
2815
|
// recall pairs. Same rationale as compressChunkHierarchical.
|
|
@@ -2873,7 +2974,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2873
2974
|
// this method prices a message the way the stripped render will cost it.
|
|
2874
2975
|
const pse = this.postStripEstimates(store);
|
|
2875
2976
|
|
|
2876
|
-
// ----- 1. Build head/tail sets and
|
|
2977
|
+
// ----- 1. Build head/tail sets and reserve the tail before emitting -----
|
|
2877
2978
|
const headStart = this.getHeadWindowStartIndex(store);
|
|
2878
2979
|
const headEnd = this.getHeadWindowEnd(store);
|
|
2879
2980
|
const recentStart = this.getRecentWindowStart(store);
|
|
@@ -2883,14 +2984,45 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2883
2984
|
let headTokens = 0;
|
|
2884
2985
|
let tailTokens = 0;
|
|
2885
2986
|
|
|
2987
|
+
// Compute the fixed raw windows first. They are hard reservations, not
|
|
2988
|
+
// best-effort phases: foldable history may use only the space left after
|
|
2989
|
+
// every head and tail message has been accounted for.
|
|
2990
|
+
for (let i = headStart; i < headEnd && i < messages.length; i++) {
|
|
2991
|
+
const msg = messages[i];
|
|
2992
|
+
const tokens = msgCap > 0 ? Math.min(pse[i], msgCap + 50) : pse[i];
|
|
2993
|
+
headMessageIds.add(msg.id);
|
|
2994
|
+
headTokens += tokens;
|
|
2995
|
+
}
|
|
2996
|
+
const effectiveRecentStart = Math.max(recentStart, headEnd);
|
|
2997
|
+
for (let i = effectiveRecentStart; i < messages.length; i++) {
|
|
2998
|
+
const msg = messages[i];
|
|
2999
|
+
const tokens = msgCap > 0 ? Math.min(pse[i], msgCap + 50) : pse[i];
|
|
3000
|
+
tailMessageIds.add(msg.id);
|
|
3001
|
+
tailTokens += tokens;
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
if (headTokens + tailTokens > rejectionBudget) {
|
|
3005
|
+
throw new OverBudgetError({
|
|
3006
|
+
budget: rejectionBudget,
|
|
3007
|
+
actual: headTokens + tailTokens,
|
|
3008
|
+
diagnostics: {
|
|
3009
|
+
headTokens,
|
|
3010
|
+
tailTokens,
|
|
3011
|
+
middleTokens: 0,
|
|
3012
|
+
middleChunkCount: Math.max(0, effectiveRecentStart - headEnd),
|
|
3013
|
+
deepestLevel: 0,
|
|
3014
|
+
},
|
|
3015
|
+
});
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
const prefixBudget = rejectionBudget - tailTokens;
|
|
2886
3019
|
let totalTokens = 0;
|
|
2887
3020
|
|
|
2888
|
-
// Emit head entries verbatim
|
|
3021
|
+
// Emit the already-reserved head entries verbatim.
|
|
2889
3022
|
for (let i = headStart; i < headEnd && i < messages.length; i++) {
|
|
2890
3023
|
const msg = messages[i];
|
|
2891
3024
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
2892
3025
|
const tokens = msgCap > 0 ? Math.min(pse[i], msgCap + 50) : pse[i];
|
|
2893
|
-
if (totalTokens + tokens > rejectionBudget) break;
|
|
2894
3026
|
entries.push({
|
|
2895
3027
|
index: entries.length,
|
|
2896
3028
|
sourceMessageId: msg.id,
|
|
@@ -2899,23 +3031,12 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2899
3031
|
content,
|
|
2900
3032
|
});
|
|
2901
3033
|
totalTokens += tokens;
|
|
2902
|
-
headMessageIds.add(msg.id);
|
|
2903
|
-
headTokens += tokens;
|
|
2904
3034
|
this.rsRaw('head', tokens);
|
|
2905
3035
|
}
|
|
2906
3036
|
// (Cache breakpoints are placed in one pass over the FINAL ordered entries
|
|
2907
3037
|
// below — see placeCacheMarkers — capturing the stable folded prefix, not
|
|
2908
3038
|
// just the head boundary.)
|
|
2909
3039
|
|
|
2910
|
-
// Compute tail message IDs (will be emitted at end)
|
|
2911
|
-
const effectiveRecentStart = Math.max(recentStart, headEnd);
|
|
2912
|
-
for (let i = effectiveRecentStart; i < messages.length; i++) {
|
|
2913
|
-
const msg = messages[i];
|
|
2914
|
-
const tokens = msgCap > 0 ? Math.min(pse[i], msgCap + 50) : pse[i];
|
|
2915
|
-
tailMessageIds.add(msg.id);
|
|
2916
|
-
tailTokens += tokens;
|
|
2917
|
-
}
|
|
2918
|
-
|
|
2919
3040
|
// ----- 2. Build PickerChunks for messages in the middle -----
|
|
2920
3041
|
// For each compressible (non-head, non-tail) message we create one
|
|
2921
3042
|
// PickerChunk. Its l1Id is determined by the existing chunks that
|
|
@@ -3084,12 +3205,10 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3084
3205
|
this.handleProducedOps(result.produced);
|
|
3085
3206
|
}
|
|
3086
3207
|
|
|
3087
|
-
// Hard-fail
|
|
3088
|
-
//
|
|
3089
|
-
//
|
|
3090
|
-
|
|
3091
|
-
// to do next (raise budget, switch model, drop windows, etc.).
|
|
3092
|
-
if (result.exhausted && result.finalTokens > rejectionBudget) {
|
|
3208
|
+
// Hard-fail whenever the picker's current plan exceeds the hard budget.
|
|
3209
|
+
// A `produce` op only schedules a missing summary; it does not make the
|
|
3210
|
+
// current raw plan feasible and must never authorize an inference.
|
|
3211
|
+
if (result.finalTokens > rejectionBudget) {
|
|
3093
3212
|
throw new OverBudgetError({
|
|
3094
3213
|
budget: rejectionBudget,
|
|
3095
3214
|
actual: result.finalTokens,
|
|
@@ -3163,7 +3282,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3163
3282
|
// (`maxMessageTokens` is for per-message caps on chat / tool
|
|
3164
3283
|
// results, not for sharded bodyGroup composites.)
|
|
3165
3284
|
const tokens = this.estimateTokens(content);
|
|
3166
|
-
if (totalTokens + tokens >
|
|
3285
|
+
if (totalTokens + tokens > prefixBudget) {
|
|
3167
3286
|
currentRun = null;
|
|
3168
3287
|
return false;
|
|
3169
3288
|
}
|
|
@@ -3195,7 +3314,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3195
3314
|
sourceRelation: 'derived',
|
|
3196
3315
|
};
|
|
3197
3316
|
const pairTokens = this.estimateTokens(questionEntry.content) + this.estimateTokens(answerEntry.content);
|
|
3198
|
-
if (totalTokens + pairTokens >
|
|
3317
|
+
if (totalTokens + pairTokens > prefixBudget) {
|
|
3199
3318
|
currentRun = null;
|
|
3200
3319
|
return false;
|
|
3201
3320
|
}
|
|
@@ -3254,7 +3373,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3254
3373
|
if (resolution === 0) {
|
|
3255
3374
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
3256
3375
|
const tokens = msgCap > 0 ? Math.min(pse[i], msgCap + 50) : pse[i];
|
|
3257
|
-
if (totalTokens + tokens >
|
|
3376
|
+
if (totalTokens + tokens > prefixBudget) break;
|
|
3258
3377
|
entries.push({
|
|
3259
3378
|
index: entries.length,
|
|
3260
3379
|
sourceMessageId: msg.id,
|
|
@@ -3270,7 +3389,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3270
3389
|
if (!ancestor) {
|
|
3271
3390
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
3272
3391
|
const tokens = msgCap > 0 ? Math.min(pse[i], msgCap + 50) : pse[i];
|
|
3273
|
-
if (totalTokens + tokens >
|
|
3392
|
+
if (totalTokens + tokens > prefixBudget) break;
|
|
3274
3393
|
entries.push({
|
|
3275
3394
|
index: entries.length,
|
|
3276
3395
|
sourceMessageId: msg.id,
|
|
@@ -3302,7 +3421,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3302
3421
|
sourceRelation: 'derived',
|
|
3303
3422
|
};
|
|
3304
3423
|
const pairTokens = this.estimateTokens(questionEntry.content) + this.estimateTokens(answerEntry.content);
|
|
3305
|
-
if (totalTokens + pairTokens >
|
|
3424
|
+
if (totalTokens + pairTokens > prefixBudget) break;
|
|
3306
3425
|
entries.push(questionEntry);
|
|
3307
3426
|
entries.push(answerEntry);
|
|
3308
3427
|
totalTokens += pairTokens;
|
|
@@ -3311,8 +3430,21 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3311
3430
|
}
|
|
3312
3431
|
}
|
|
3313
3432
|
|
|
3314
|
-
// ----- 6. Emit
|
|
3433
|
+
// ----- 6. Emit the fully-reserved tail -----
|
|
3315
3434
|
const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, rejectionBudget, totalTokens, pse);
|
|
3435
|
+
if (tailStats.messages !== messages.length - effectiveRecentStart) {
|
|
3436
|
+
throw new OverBudgetError({
|
|
3437
|
+
budget: rejectionBudget,
|
|
3438
|
+
actual: totalTokens + tailTokens,
|
|
3439
|
+
diagnostics: {
|
|
3440
|
+
headTokens,
|
|
3441
|
+
tailTokens,
|
|
3442
|
+
middleTokens: totalTokens - headTokens,
|
|
3443
|
+
middleChunkCount: pickerChunks.length - headMessageIds.size - tailMessageIds.size,
|
|
3444
|
+
deepestLevel,
|
|
3445
|
+
},
|
|
3446
|
+
});
|
|
3447
|
+
}
|
|
3316
3448
|
this.rsRaw('tail', tailStats.tokens, tailStats.messages);
|
|
3317
3449
|
|
|
3318
3450
|
// ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
|
|
@@ -3335,6 +3467,33 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3335
3467
|
// Strip stale images BEFORE placing markers and committing stats, so both
|
|
3336
3468
|
// describe the post-strip context the agent actually receives.
|
|
3337
3469
|
this.applyImageStripping(merged, store);
|
|
3470
|
+
|
|
3471
|
+
// The newest stored message is the triggering turn for this compile. A
|
|
3472
|
+
// structural repair may rewrite tool blocks, but it must never erase that
|
|
3473
|
+
// turn. Body-group shards merge under the first shard's source id, so any
|
|
3474
|
+
// surviving member of the same group proves the newest shard is present.
|
|
3475
|
+
const newest = messages[messages.length - 1];
|
|
3476
|
+
if (newest) {
|
|
3477
|
+
const newestGroupIds = newest.bodyGroupId
|
|
3478
|
+
? new Set(messages.filter(m => m.bodyGroupId === newest.bodyGroupId).map(m => m.id))
|
|
3479
|
+
: new Set([newest.id]);
|
|
3480
|
+
const newestRetained = merged.some(
|
|
3481
|
+
entry => entry.sourceMessageId && newestGroupIds.has(entry.sourceMessageId),
|
|
3482
|
+
);
|
|
3483
|
+
if (!newestRetained) {
|
|
3484
|
+
throw new OverBudgetError({
|
|
3485
|
+
budget: rejectionBudget,
|
|
3486
|
+
actual: totalTokens + tailTokens,
|
|
3487
|
+
diagnostics: {
|
|
3488
|
+
headTokens,
|
|
3489
|
+
tailTokens,
|
|
3490
|
+
middleTokens: totalTokens - headTokens,
|
|
3491
|
+
middleChunkCount: pickerChunks.length - headMessageIds.size - tailMessageIds.size,
|
|
3492
|
+
deepestLevel,
|
|
3493
|
+
},
|
|
3494
|
+
});
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3338
3497
|
// Place ≤4 cache breakpoints across the FINAL ordered entries so the
|
|
3339
3498
|
// provider can reuse the stable folded prefix — not just the head. With a
|
|
3340
3499
|
// single head marker the cache hit is ~2%; well-placed breakpoints take the
|
|
@@ -4768,12 +4927,68 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
4768
4927
|
/** Byte wall default: 20MB of base64 (API total-request cap is 32MB). */
|
|
4769
4928
|
protected static readonly DEFAULT_MAX_LIVE_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
4770
4929
|
|
|
4930
|
+
/** Compression prompts carry head + recall frontier + raw chunk alongside
|
|
4931
|
+
* their images, so they get a tighter image budget than the live window. */
|
|
4932
|
+
protected static readonly DEFAULT_MAX_COMPRESSION_IMAGE_BYTES = 12 * 1024 * 1024;
|
|
4933
|
+
|
|
4771
4934
|
/** Base64 payload size of an image block (0 for non-base64 sources). */
|
|
4772
4935
|
protected static imageBlockBytes(b: unknown): number {
|
|
4773
4936
|
const src = (b as { source?: { data?: string } }).source;
|
|
4774
4937
|
return typeof src?.data === 'string' ? src.data.length : 0;
|
|
4775
4938
|
}
|
|
4776
4939
|
|
|
4940
|
+
/**
|
|
4941
|
+
* Cap inline image bytes in a COMPRESSION prompt (2026-07-12). The main
|
|
4942
|
+
* window enforces `maxLiveImageBytes` through the strip policy; the
|
|
4943
|
+
* summarizer's raw-chunk replay had no such wall and leaned on membrane's
|
|
4944
|
+
* byte shed instead — which is a transport backstop, not a policy owner
|
|
4945
|
+
* (it fired at 27MB on a live merge). Keep images newest-first within the
|
|
4946
|
+
* budget; older ones become the same loud placeholder the window uses, so
|
|
4947
|
+
* the summarizer is told plainly that it is not seeing them.
|
|
4948
|
+
* Returns the number of images replaced.
|
|
4949
|
+
*/
|
|
4950
|
+
protected capCompressionImageBytes(
|
|
4951
|
+
messages: Array<{ content: ContentBlock[] }>,
|
|
4952
|
+
capBytes: number,
|
|
4953
|
+
): number {
|
|
4954
|
+
if (capBytes <= 0) return 0;
|
|
4955
|
+
let kept = 0;
|
|
4956
|
+
let dropped = 0;
|
|
4957
|
+
// Recurse into tool_result content: an agent that drives a shell/plotter/
|
|
4958
|
+
// browser carries most of its image bytes NESTED in tool results, not as
|
|
4959
|
+
// top-level blocks. Capping only the top level left those untouched and
|
|
4960
|
+
// membrane's transport shed kept firing at 27MB (2026-07-12).
|
|
4961
|
+
const capBlocks = (blocks: ContentBlock[]): ContentBlock[] =>
|
|
4962
|
+
blocks.map((b) => {
|
|
4963
|
+
if (b.type === 'image') {
|
|
4964
|
+
const bytes = AutobiographicalStrategy.imageBlockBytes(b);
|
|
4965
|
+
if (kept + bytes <= capBytes) {
|
|
4966
|
+
kept += bytes;
|
|
4967
|
+
return b;
|
|
4968
|
+
}
|
|
4969
|
+
dropped++;
|
|
4970
|
+
return { type: 'text', text: AutobiographicalStrategy.IMAGE_PLACEHOLDER } as ContentBlock;
|
|
4971
|
+
}
|
|
4972
|
+
const nested = (b as { type: string; content?: unknown }).content;
|
|
4973
|
+
if (b.type === 'tool_result' && Array.isArray(nested)) {
|
|
4974
|
+
return { ...b, content: capBlocks(nested as ContentBlock[]) } as ContentBlock;
|
|
4975
|
+
}
|
|
4976
|
+
return b;
|
|
4977
|
+
});
|
|
4978
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4979
|
+
const m = messages[i];
|
|
4980
|
+
if (!Array.isArray(m.content)) continue;
|
|
4981
|
+
m.content = capBlocks(m.content);
|
|
4982
|
+
}
|
|
4983
|
+
if (dropped > 0) {
|
|
4984
|
+
console.error(
|
|
4985
|
+
`[autobiographical] compression prompt: replaced ${dropped} older image(s) with placeholders ` +
|
|
4986
|
+
`to stay under the ${Math.round(capBytes / 1e6)}MB image-byte budget (kept ${Math.round(kept / 1e6)}MB, newest-first)`,
|
|
4987
|
+
);
|
|
4988
|
+
}
|
|
4989
|
+
return dropped;
|
|
4990
|
+
}
|
|
4991
|
+
|
|
4777
4992
|
protected getRecentWindowStart(store: MessageStoreView): number {
|
|
4778
4993
|
const messages = store.getAll();
|
|
4779
4994
|
const pse = this.postStripEstimates(store);
|
package/src/types/strategy.ts
CHANGED
|
@@ -344,9 +344,9 @@ export interface AutobiographicalConfig {
|
|
|
344
344
|
|
|
345
345
|
/**
|
|
346
346
|
* Fractional grace above the configured hard context budget before an
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
347
|
+
* adaptive plan rejects the compile. The solver still targets the original
|
|
348
|
+
* budget; this only absorbs coarse fold quanta and indivisible raw-window
|
|
349
|
+
* overshoot. Default 0 (strict budget enforcement).
|
|
350
350
|
*/
|
|
351
351
|
overBudgetGraceRatio?: number;
|
|
352
352
|
|
|
@@ -382,6 +382,14 @@ export interface AutobiographicalConfig {
|
|
|
382
382
|
*/
|
|
383
383
|
maxLiveImageBytes?: number;
|
|
384
384
|
|
|
385
|
+
/**
|
|
386
|
+
* Image byte budget for COMPRESSION prompts (summarizer). Tighter than the
|
|
387
|
+
* live window's: the prompt also carries the head, the recall frontier and
|
|
388
|
+
* the raw chunk. Newest-first; older images become loud placeholders.
|
|
389
|
+
* Default 12MB of base64.
|
|
390
|
+
*/
|
|
391
|
+
maxCompressionImageBytes?: number;
|
|
392
|
+
|
|
385
393
|
/**
|
|
386
394
|
* Merge grouping: exclude candidates whose OWN source span exceeds this
|
|
387
395
|
* many messages (replay-era summaries can span the entire chronicle and
|