@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.
@@ -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
- this.summaries = [...byId.values()];
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
- if (run.length >= threshold) break; // oldest qualifying run wins
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 < threshold) return null;
2341
- return run.slice(0, threshold).map((x) => x.s);
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 emit head entries -----
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 check: if the picker exhausted itself but the final render
3088
- // would still exceed the HARD budget (not just the soft target), surface
3089
- // an OverBudgetError to the host rather than silently dropping entries.
3090
- // The strategy has done all it can; the application has to decide what
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 > rejectionBudget) {
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 > rejectionBudget) {
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 > rejectionBudget) break;
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 > rejectionBudget) break;
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 > rejectionBudget) break;
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 tail entries newest-first eviction (matches existing behavior) -----
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);
@@ -344,9 +344,9 @@ export interface AutobiographicalConfig {
344
344
 
345
345
  /**
346
346
  * Fractional grace above the configured hard context budget before an
347
- * exhausted adaptive picker rejects the compile. The solver still targets
348
- * the original budget; this only absorbs coarse fold quanta and indivisible
349
- * head/tail boundary overshoot. Default 0 (strict budget enforcement).
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