@animalabs/context-manager 0.3.0 → 0.4.0

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.
Files changed (50) hide show
  1. package/dist/src/context-manager.d.ts +69 -2
  2. package/dist/src/context-manager.d.ts.map +1 -1
  3. package/dist/src/context-manager.js +128 -10
  4. package/dist/src/context-manager.js.map +1 -1
  5. package/dist/src/strategies/autobiographical.d.ts +205 -3
  6. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  7. package/dist/src/strategies/autobiographical.js +767 -59
  8. package/dist/src/strategies/autobiographical.js.map +1 -1
  9. package/dist/src/types/index.d.ts +2 -2
  10. package/dist/src/types/index.d.ts.map +1 -1
  11. package/dist/src/types/index.js +1 -1
  12. package/dist/src/types/index.js.map +1 -1
  13. package/dist/src/types/strategy.d.ts +208 -0
  14. package/dist/src/types/strategy.d.ts.map +1 -1
  15. package/dist/src/types/strategy.js +21 -0
  16. package/dist/src/types/strategy.js.map +1 -1
  17. package/dist/test/non-blocking-compile.test.d.ts +22 -0
  18. package/dist/test/non-blocking-compile.test.d.ts.map +1 -0
  19. package/dist/test/non-blocking-compile.test.js +114 -0
  20. package/dist/test/non-blocking-compile.test.js.map +1 -0
  21. package/dist/test/pins.test.d.ts +10 -0
  22. package/dist/test/pins.test.d.ts.map +1 -0
  23. package/dist/test/pins.test.js +164 -0
  24. package/dist/test/pins.test.js.map +1 -0
  25. package/dist/test/recall-positioning.test.d.ts +16 -0
  26. package/dist/test/recall-positioning.test.d.ts.map +1 -0
  27. package/dist/test/recall-positioning.test.js +181 -0
  28. package/dist/test/recall-positioning.test.js.map +1 -0
  29. package/dist/test/search.test.d.ts +10 -0
  30. package/dist/test/search.test.d.ts.map +1 -0
  31. package/dist/test/search.test.js +141 -0
  32. package/dist/test/search.test.js.map +1 -0
  33. package/dist/test/speculation-cap.test.d.ts +17 -0
  34. package/dist/test/speculation-cap.test.d.ts.map +1 -0
  35. package/dist/test/speculation-cap.test.js +157 -0
  36. package/dist/test/speculation-cap.test.js.map +1 -0
  37. package/dist/test/strategy-persistence.test.d.ts +15 -0
  38. package/dist/test/strategy-persistence.test.d.ts.map +1 -0
  39. package/dist/test/strategy-persistence.test.js +244 -0
  40. package/dist/test/strategy-persistence.test.js.map +1 -0
  41. package/dist/test/tool-pruning.test.d.ts +10 -0
  42. package/dist/test/tool-pruning.test.d.ts.map +1 -0
  43. package/dist/test/tool-pruning.test.js +140 -0
  44. package/dist/test/tool-pruning.test.js.map +1 -0
  45. package/dist/tsconfig.tsbuildinfo +1 -1
  46. package/package.json +1 -1
  47. package/src/context-manager.ts +142 -10
  48. package/src/strategies/autobiographical.ts +815 -61
  49. package/src/types/index.ts +14 -1
  50. package/src/types/strategy.ts +223 -0
@@ -1,3 +1,4 @@
1
+ import type { JsStore } from '@animalabs/chronicle';
1
2
  import type { Membrane, NormalizedRequest, ContentBlock, CompleteOptions } from '@animalabs/membrane';
2
3
  import { NativeFormatter } from '@animalabs/membrane';
3
4
  import type {
@@ -13,6 +14,9 @@ import type {
13
14
  AutobiographicalConfig,
14
15
  SummaryLevel,
15
16
  SummaryEntry,
17
+ ProtectedRange,
18
+ SearchQuery,
19
+ SearchResult,
16
20
  } from '../types/index.js';
17
21
  import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
18
22
 
@@ -86,6 +90,20 @@ export class AutobiographicalStrategy implements ResettableStrategy {
86
90
  /** Cached result of getHeadWindowStartIndex to avoid repeated linear scans. */
87
91
  private _cachedHeadStartIndex: { id: string | null; msgCount: number; result: number } | null = null;
88
92
 
93
+ /** Chronicle store for persistent state. Set in `initialize()`. */
94
+ protected store: JsStore | null = null;
95
+ /** Namespace for state-id scoping. Set in `initialize()`. */
96
+ protected ns: string = '';
97
+ protected get summariesStateId(): string { return `${this.ns}/autobio:summaries`; }
98
+ protected get counterStateId(): string { return `${this.ns}/autobio:counter`; }
99
+ protected get mergeQueueStateId(): string { return `${this.ns}/autobio:mergeQueue`; }
100
+ protected get pinsStateId(): string { return `${this.ns}/autobio:pins`; }
101
+
102
+ /** Protected ranges (pins + documents). Loaded from chronicle in initialize. */
103
+ protected pins: ProtectedRange[] = [];
104
+ /** Monotonically increasing counter for pin ids. Persisted as part of the pins snapshot. */
105
+ protected pinIdCounter = 0;
106
+
89
107
  constructor(config: Partial<AutobiographicalConfig> = {}) {
90
108
  this.config = { ...DEFAULT_AUTOBIOGRAPHICAL_CONFIG, ...config };
91
109
  // Hierarchical is on by default; set hierarchical: false to use legacy single-level
@@ -100,8 +118,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
100
118
  }
101
119
 
102
120
  async initialize(ctx: StrategyContext): Promise<void> {
121
+ // Bind to the chronicle store + namespace for persistent strategy state.
122
+ this.store = ctx.store;
123
+ this.ns = ctx.namespace;
124
+ this.registerStates();
125
+ this.loadPersistedState();
126
+
103
127
  // Restore headWindowStartId from last topic transition message
104
128
  const messages = ctx.messageStore.getAll();
129
+ this.headWindowStartId = null;
105
130
  for (let i = messages.length - 1; i >= 0; i--) {
106
131
  if (this.isTopicTransitionMessage(messages[i])) {
107
132
  this.headWindowStartId = messages[i].id;
@@ -111,6 +136,296 @@ export class AutobiographicalStrategy implements ResettableStrategy {
111
136
  this.rebuildChunks(ctx.messageStore);
112
137
  }
113
138
 
139
+ /**
140
+ * Register the three Chronicle state slots this strategy uses.
141
+ * Idempotent — chronicle throws if a state is already registered, which we
142
+ * swallow (the existing slot is what we want).
143
+ */
144
+ protected registerStates(): void {
145
+ if (!this.store) return;
146
+ try {
147
+ this.store.registerState({
148
+ id: this.summariesStateId,
149
+ strategy: 'append_log',
150
+ deltaSnapshotEvery: 50,
151
+ fullSnapshotEvery: 10,
152
+ });
153
+ } catch { /* already registered */ }
154
+ try {
155
+ this.store.registerState({
156
+ id: this.counterStateId,
157
+ strategy: 'snapshot',
158
+ });
159
+ } catch { /* already registered */ }
160
+ try {
161
+ this.store.registerState({
162
+ id: this.mergeQueueStateId,
163
+ strategy: 'snapshot',
164
+ });
165
+ } catch { /* already registered */ }
166
+ try {
167
+ this.store.registerState({
168
+ id: this.pinsStateId,
169
+ strategy: 'snapshot',
170
+ });
171
+ } catch { /* already registered */ }
172
+ }
173
+
174
+ /**
175
+ * Load summaries, counter, and pending merges from chronicle into the
176
+ * in-memory mirrors. Called on every (re)initialize so branch switches
177
+ * pick up the new branch's state.
178
+ */
179
+ protected loadPersistedState(): void {
180
+ if (!this.store) {
181
+ this.summaries = [];
182
+ this.summaryIdCounter = 0;
183
+ this.mergeQueue = [];
184
+ this.pins = [];
185
+ this.pinIdCounter = 0;
186
+ return;
187
+ }
188
+ const summaries = this.store.getStateJson(this.summariesStateId);
189
+ this.summaries = Array.isArray(summaries) ? (summaries as SummaryEntry[]) : [];
190
+
191
+ const counter = this.store.getStateJson(this.counterStateId);
192
+ this.summaryIdCounter = typeof counter === 'number' ? counter : 0;
193
+
194
+ const queue = this.store.getStateJson(this.mergeQueueStateId);
195
+ this.mergeQueue = Array.isArray(queue)
196
+ ? (queue as Array<{ level: SummaryLevel; sourceIds: string[] }>)
197
+ : [];
198
+
199
+ const pinsState = this.store.getStateJson(this.pinsStateId);
200
+ if (pinsState && typeof pinsState === 'object' && Array.isArray((pinsState as { pins?: unknown }).pins)) {
201
+ const ps = pinsState as { pins: ProtectedRange[]; counter?: number };
202
+ this.pins = ps.pins;
203
+ this.pinIdCounter = typeof ps.counter === 'number' ? ps.counter : ps.pins.length;
204
+ } else {
205
+ this.pins = [];
206
+ this.pinIdCounter = 0;
207
+ }
208
+ }
209
+
210
+ /** Persist the current pins + counter as a single snapshot. */
211
+ protected persistPins(): void {
212
+ this.store?.setStateJson(this.pinsStateId, {
213
+ pins: this.pins,
214
+ counter: this.pinIdCounter,
215
+ });
216
+ }
217
+
218
+ // ============================================================================
219
+ // Pins / documents (protected ranges)
220
+ // ============================================================================
221
+
222
+ /**
223
+ * Pin a range of messages so they aren't compressed and render raw at
224
+ * their original position. Returns the pin id.
225
+ */
226
+ pinRange(firstMessageId: string, lastMessageId: string, opts?: { name?: string }): string {
227
+ const id = `pin-${this.pinIdCounter++}`;
228
+ this.pins.push({
229
+ id,
230
+ firstMessageId,
231
+ lastMessageId,
232
+ kind: 'pin',
233
+ name: opts?.name,
234
+ created: Date.now(),
235
+ });
236
+ this.persistPins();
237
+ return id;
238
+ }
239
+
240
+ /**
241
+ * Mark a single message as a "document" — semantically a body of
242
+ * information the agent wants to retain in full. Functionally a
243
+ * single-message pin with `kind: 'document'`.
244
+ */
245
+ markDocument(messageId: string, opts?: { name?: string }): string {
246
+ const id = `pin-${this.pinIdCounter++}`;
247
+ this.pins.push({
248
+ id,
249
+ firstMessageId: messageId,
250
+ lastMessageId: messageId,
251
+ kind: 'document',
252
+ name: opts?.name,
253
+ created: Date.now(),
254
+ });
255
+ this.persistPins();
256
+ return id;
257
+ }
258
+
259
+ /** Remove a pin or document mark by id. Returns true if removed. */
260
+ unpin(pinId: string): boolean {
261
+ const before = this.pins.length;
262
+ this.pins = this.pins.filter(p => p.id !== pinId);
263
+ if (this.pins.length < before) {
264
+ this.persistPins();
265
+ return true;
266
+ }
267
+ return false;
268
+ }
269
+
270
+ /** Read-only list of all current pins. */
271
+ listPins(): ReadonlyArray<ProtectedRange> {
272
+ return this.pins;
273
+ }
274
+
275
+ // ============================================================================
276
+ // Search (gap #7)
277
+ // ============================================================================
278
+
279
+ /**
280
+ * Look up a single summary by id. Returns null if not found.
281
+ */
282
+ getSummary(id: string): SummaryEntry | null {
283
+ return this.summaries.find(s => s.id === id) ?? null;
284
+ }
285
+
286
+ /**
287
+ * Search summaries by substring or regex over their content.
288
+ *
289
+ * Result ordering: matches by descending hit count, then by descending
290
+ * `created` timestamp (newest first within the same hit count).
291
+ *
292
+ * Default behavior: only "live" (unmerged) summaries are searched. Set
293
+ * `includeMerged: true` to also include summaries that have been folded
294
+ * into a higher level.
295
+ */
296
+ searchSummaries(query: SearchQuery): SearchResult[] {
297
+ const limit = query.limit ?? 50;
298
+ const includeMerged = query.includeMerged ?? false;
299
+
300
+ // Build the matcher
301
+ let matcher: ((content: string) => number) | null = null;
302
+ if (query.regex) {
303
+ const flags = query.regex.flags.includes('g') ? query.regex.flags : query.regex.flags + 'g';
304
+ const re = new RegExp(query.regex.source, flags);
305
+ matcher = (content: string) => {
306
+ const matches = content.match(re);
307
+ return matches ? matches.length : 0;
308
+ };
309
+ } else if (query.text) {
310
+ const needle = query.text.toLowerCase();
311
+ matcher = (content: string) => {
312
+ const hay = content.toLowerCase();
313
+ let count = 0;
314
+ let idx = 0;
315
+ while ((idx = hay.indexOf(needle, idx)) !== -1) {
316
+ count++;
317
+ idx += needle.length || 1;
318
+ }
319
+ return count;
320
+ };
321
+ } else {
322
+ // No pattern: every summary "matches" once
323
+ matcher = () => 1;
324
+ }
325
+
326
+ const levelsFilter = query.levels && query.levels.length > 0 ? new Set(query.levels) : null;
327
+
328
+ const results: SearchResult[] = [];
329
+ for (const s of this.summaries) {
330
+ if (!includeMerged && s.mergedInto) continue;
331
+ if (levelsFilter && !levelsFilter.has(s.level)) continue;
332
+ const matches = matcher(s.content);
333
+ if (matches > 0) {
334
+ results.push({ summary: s, matches });
335
+ }
336
+ }
337
+
338
+ results.sort((a, b) => {
339
+ if (b.matches !== a.matches) return b.matches - a.matches;
340
+ return b.summary.created - a.summary.created;
341
+ });
342
+
343
+ return results.slice(0, limit);
344
+ }
345
+
346
+ /**
347
+ * Whether a given message position is inside any protected range.
348
+ * Uses a position map (computed by caller) so callers can avoid
349
+ * repeated per-message lookups in tight loops.
350
+ */
351
+ protected isPositionPinned(position: number, pinPositions: Set<number>): boolean {
352
+ return pinPositions.has(position);
353
+ }
354
+
355
+ /**
356
+ * Build a set of message-store positions covered by any pin. O(N pins · K range).
357
+ * Returns positions for which the message exists; orphan pins (deleted
358
+ * messages) are silently skipped.
359
+ */
360
+ protected pinnedPositions(messages: StoredMessage[]): Set<number> {
361
+ if (this.pins.length === 0) return new Set();
362
+ const positionOf = new Map<string, number>();
363
+ for (let i = 0; i < messages.length; i++) {
364
+ positionOf.set(messages[i].id, i);
365
+ }
366
+ const out = new Set<number>();
367
+ for (const pin of this.pins) {
368
+ const first = positionOf.get(pin.firstMessageId);
369
+ const last = positionOf.get(pin.lastMessageId);
370
+ if (first === undefined || last === undefined) continue;
371
+ const lo = Math.min(first, last);
372
+ const hi = Math.max(first, last);
373
+ for (let i = lo; i <= hi; i++) out.add(i);
374
+ }
375
+ return out;
376
+ }
377
+
378
+ /**
379
+ * Append a summary to the in-memory list and to the chronicle AppendLog.
380
+ * Single point so subclasses inherit persistence.
381
+ */
382
+ protected pushSummary(entry: SummaryEntry): void {
383
+ this.summaries.push(entry);
384
+ this.store?.appendToStateJson(this.summariesStateId, entry);
385
+ }
386
+
387
+ /**
388
+ * Mark a summary as merged into a higher-level summary, updating the
389
+ * chronicle copy at the same index. Index is the position in `this.summaries`.
390
+ */
391
+ protected setMergedInto(entry: SummaryEntry, mergedIntoId: string): void {
392
+ entry.mergedInto = mergedIntoId;
393
+ if (!this.store) return;
394
+ const index = this.summaries.indexOf(entry);
395
+ if (index < 0) return;
396
+ this.store.editStateItem(
397
+ this.summariesStateId,
398
+ index,
399
+ Buffer.from(JSON.stringify(entry)),
400
+ );
401
+ }
402
+
403
+ /**
404
+ * Allocate the next summary-id counter value and persist the new counter.
405
+ */
406
+ protected nextSummaryIdCounter(): number {
407
+ const value = this.summaryIdCounter++;
408
+ this.store?.setStateJson(this.counterStateId, this.summaryIdCounter);
409
+ return value;
410
+ }
411
+
412
+ /**
413
+ * Push to the merge queue and persist the new queue snapshot.
414
+ */
415
+ protected enqueueMerge(merge: { level: SummaryLevel; sourceIds: string[] }): void {
416
+ this.mergeQueue.push(merge);
417
+ this.store?.setStateJson(this.mergeQueueStateId, this.mergeQueue);
418
+ }
419
+
420
+ /**
421
+ * Pop from the merge queue and persist the new queue snapshot.
422
+ */
423
+ protected dequeueMerge(): { level: SummaryLevel; sourceIds: string[] } | undefined {
424
+ const merge = this.mergeQueue.shift();
425
+ this.store?.setStateJson(this.mergeQueueStateId, this.mergeQueue);
426
+ return merge;
427
+ }
428
+
114
429
  checkReadiness(): ReadinessState {
115
430
  if (this.pendingCompression) {
116
431
  return {
@@ -141,16 +456,75 @@ export class AutobiographicalStrategy implements ResettableStrategy {
141
456
  async onNewMessage(message: StoredMessage, ctx: StrategyContext): Promise<void> {
142
457
  this.rebuildChunks(ctx.messageStore);
143
458
 
144
- // Auto-tick: fire compression in the background so it runs without
145
- // the framework explicitly calling tick(). compile() will await
146
- // pendingCompression via checkReadiness().
147
- if (this.config.autoTickOnNewMessage && this.compressionQueue.length > 0 && !this.pendingCompression) {
148
- this.tick(ctx).catch((err) =>
149
- console.error('AutobiographicalStrategy: auto-tick error:', err)
150
- );
459
+ // Auto-tick: fire speculative compression in the background. After
460
+ // each tick completes, if the queue still has work AND we're under
461
+ // the speculation cap AND preflight allows, schedule another tick.
462
+ // This drains the queue ahead of need rather than one-chunk-per-
463
+ // user-turn (reactive). Combined with ContextManager.compile not
464
+ // awaiting pendingCompression, the agent's response and background
465
+ // compression run truly in parallel.
466
+ if (this.config.autoTickOnNewMessage && !this.pendingCompression) {
467
+ this.driveSpeculativeDrain(ctx);
151
468
  }
152
469
  }
153
470
 
471
+ /**
472
+ * Background-drain loop: keeps calling tick() while there's queued work,
473
+ * subject to the speculation cap and preflight hook. Recurses via
474
+ * `queueMicrotask` so one chunk's compression doesn't block the
475
+ * scheduling of the next.
476
+ *
477
+ * Stops if a tick fails to make progress (queue size unchanged) — guards
478
+ * against runaway recursion when tick is a no-op (e.g. no membrane
479
+ * configured, or a subclass override that doesn't process the queue).
480
+ */
481
+ protected driveSpeculativeDrain(ctx: StrategyContext): void {
482
+ if (this.pendingCompression) return;
483
+ if (this.compressionQueue.length === 0 && this.mergeQueue.length === 0) return;
484
+ if (this.isAtSpeculativeCap()) return;
485
+ if (!this.shouldCompressPreflight()) return;
486
+
487
+ const beforeChunks = this.compressionQueue.length;
488
+ const beforeMerges = this.mergeQueue.length;
489
+
490
+ this.tick(ctx)
491
+ .then(() => {
492
+ const afterChunks = this.compressionQueue.length;
493
+ const afterMerges = this.mergeQueue.length;
494
+ // Made progress if either queue shrank.
495
+ const progressed = afterChunks < beforeChunks || afterMerges < beforeMerges;
496
+ if (!progressed) return;
497
+ // Recurse to drain more. queueMicrotask defers until the current
498
+ // task is done, letting other code (the agent's stream consumer)
499
+ // interleave.
500
+ queueMicrotask(() => this.driveSpeculativeDrain(ctx));
501
+ })
502
+ .catch((err) => {
503
+ console.error('AutobiographicalStrategy: speculative-drain error:', err);
504
+ });
505
+ }
506
+
507
+ /**
508
+ * Whether the strategy's pending+queued L1 budget has reached the cap
509
+ * configured by `maxSpeculativeL1s`. If no cap is set, always false.
510
+ */
511
+ protected isAtSpeculativeCap(): boolean {
512
+ const cap = this.config.maxSpeculativeL1s;
513
+ if (cap === undefined || cap < 0) return false;
514
+ const unmergedL1s = this.summaries.filter(s => s.level === 1 && !s.mergedInto).length;
515
+ return unmergedL1s + this.compressionQueue.length > cap;
516
+ }
517
+
518
+ /**
519
+ * Preflight hook for whether speculative compression should fire on
520
+ * `onNewMessage`. Returns true by default (current eager behavior).
521
+ * Subclasses can override for predictive scheduling — e.g. only fire
522
+ * when the live tail token count is approaching some threshold.
523
+ */
524
+ protected shouldCompressPreflight(): boolean {
525
+ return true;
526
+ }
527
+
154
528
  async tick(ctx: StrategyContext): Promise<void> {
155
529
  if (this.pendingCompression) return;
156
530
 
@@ -179,12 +553,27 @@ export class AutobiographicalStrategy implements ResettableStrategy {
179
553
  }
180
554
 
181
555
  // Priority 2: Execute pending merges (hierarchical only)
556
+ //
557
+ // Peek at the head rather than dequeueing eagerly: dequeueMerge persists
558
+ // the shorter queue *before* the LLM call leaves the building, so a
559
+ // transient failure (429, network drop, timeout, executeMerge throw)
560
+ // would silently lose the merge from disk and the sources would sit at
561
+ // level N-1 with no mergedInto pointers forever. Commit the removal
562
+ // only after the merge succeeds; on failure, the queue keeps its entry
563
+ // and the next tick() retries it.
182
564
  if (this.config.hierarchical && this.mergeQueue.length > 0) {
183
- const merge = this.mergeQueue.shift()!;
565
+ const merge = this.mergeQueue[0]!;
184
566
  this.pendingCompression = this.executeMerge(merge.level, merge.sourceIds, ctx);
185
567
 
186
568
  try {
187
569
  await this.pendingCompression;
570
+ // Success: drop from head and persist the shorter queue. We
571
+ // re-check that head is still our merge in case some future code
572
+ // path mutates the queue mid-await (today no other site does,
573
+ // but the assertion makes that invariant explicit).
574
+ if (this.mergeQueue[0] === merge) {
575
+ this.dequeueMerge();
576
+ }
188
577
  } finally {
189
578
  this.pendingCompression = null;
190
579
  }
@@ -221,6 +610,56 @@ export class AutobiographicalStrategy implements ResettableStrategy {
221
610
  };
222
611
  }
223
612
 
613
+ /**
614
+ * Richer per-render stats: requires a message store view to compute the
615
+ * head + tail (recent window) sizes. Returns counts AND token sums per
616
+ * summary level, so observers can see "how much of the agent's context
617
+ * is in raw tail vs folded into L1/L2/L3."
618
+ *
619
+ * Useful for TUI / dashboards. The token sums use the strategy's own
620
+ * token estimates (which match what `select()` uses for budget math).
621
+ */
622
+ getRenderStats(store: MessageStoreView): {
623
+ head: { messages: number; tokens: number };
624
+ tail: { messages: number; tokens: number };
625
+ summaries: {
626
+ l1: { count: number; tokens: number };
627
+ l2: { count: number; tokens: number };
628
+ l3: { count: number; tokens: number };
629
+ };
630
+ pending: { chunks: number; merges: number };
631
+ } {
632
+ const messages = store.getAll();
633
+ const headStart = this.getHeadWindowStartIndex(store);
634
+ const headEnd = this.getHeadWindowEnd(store);
635
+ const recentStart = this.getRecentWindowStart(store);
636
+
637
+ const sumTokens = (slice: StoredMessage[]): number =>
638
+ slice.reduce((acc, m) => acc + store.estimateTokens(m), 0);
639
+
640
+ const headMsgs = messages.slice(headStart, headEnd);
641
+ const tailMsgs = messages.slice(recentStart);
642
+
643
+ const live = (level: SummaryLevel) =>
644
+ this.summaries.filter(s => s.level === level && !s.mergedInto);
645
+ const sumLevelTokens = (level: SummaryLevel): number =>
646
+ live(level).reduce((acc, s) => acc + s.tokens, 0);
647
+
648
+ return {
649
+ head: { messages: headMsgs.length, tokens: sumTokens(headMsgs) },
650
+ tail: { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) },
651
+ summaries: {
652
+ l1: { count: live(1).length, tokens: sumLevelTokens(1) },
653
+ l2: { count: live(2).length, tokens: sumLevelTokens(2) },
654
+ l3: { count: live(3).length, tokens: sumLevelTokens(3) },
655
+ },
656
+ pending: {
657
+ chunks: this.chunks.filter(c => !c.compressed).length,
658
+ merges: this.mergeQueue.length,
659
+ },
660
+ };
661
+ }
662
+
224
663
  // ============================================================================
225
664
  // Legacy (single-level) path
226
665
  // ============================================================================
@@ -243,7 +682,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
243
682
  const msg = messages[i];
244
683
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
245
684
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
246
- if (totalTokens + tokens > maxTokens) break;
685
+ if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
247
686
 
248
687
  entries.push({
249
688
  index: entries.length,
@@ -291,7 +730,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
291
730
  const pairTokens = this.estimateTokens(questionEntry.content) +
292
731
  this.estimateTokens(answerEntry.content);
293
732
 
294
- if (totalTokens + pairTokens > maxTokens) break;
733
+ if (this.isOverBudget(totalTokens + pairTokens, maxTokens)) break;
295
734
 
296
735
  entries.push(questionEntry);
297
736
  entries.push(answerEntry);
@@ -301,7 +740,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
301
740
  for (const msg of chunk.messages) {
302
741
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
303
742
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
304
- if (totalTokens + tokens > maxTokens) break;
743
+ if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
305
744
 
306
745
  entries.push({
307
746
  index: entries.length,
@@ -324,7 +763,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
324
763
  const msg = messages[i];
325
764
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
326
765
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
327
- if (totalTokens + tokens > maxTokens) break;
766
+ if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
328
767
 
329
768
  entries.push({
330
769
  index: entries.length,
@@ -341,6 +780,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
341
780
  this.emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokens);
342
781
 
343
782
  this.trimOrphanedToolUse(entries);
783
+ this.pruneToolEntries(entries);
344
784
  return entries;
345
785
  }
346
786
 
@@ -373,7 +813,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
373
813
  const tokens = msgCap > 0
374
814
  ? Math.min(store.estimateTokens(msg), msgCap + 50)
375
815
  : store.estimateTokens(msg);
376
- if (totalTokensBefore + acceptedTokens + tokens > maxTokens) break;
816
+ if (this.isOverBudget(totalTokensBefore + acceptedTokens + tokens, maxTokens)) break;
377
817
  accepted.push(i);
378
818
  acceptedTokens += tokens;
379
819
  }
@@ -429,7 +869,6 @@ export class AutobiographicalStrategy implements ResettableStrategy {
429
869
  config: {
430
870
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
431
871
  maxTokens: 2000,
432
- temperature: 0,
433
872
  },
434
873
  };
435
874
 
@@ -582,7 +1021,6 @@ export class AutobiographicalStrategy implements ResettableStrategy {
582
1021
  config: {
583
1022
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
584
1023
  maxTokens: Math.round(targetTokens * 1.5),
585
- temperature: 0,
586
1024
  },
587
1025
  };
588
1026
 
@@ -595,7 +1033,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
595
1033
 
596
1034
  const messageIds = chunk.messages.map(m => m.id);
597
1035
  const entry: SummaryEntry = {
598
- id: `L1-${this.summaryIdCounter++}`,
1036
+ id: `L1-${this.nextSummaryIdCounter()}`,
599
1037
  level: 1,
600
1038
  content: summaryText,
601
1039
  tokens: Math.ceil(summaryText.length / 4),
@@ -609,7 +1047,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
609
1047
  phaseType: chunk.phaseType,
610
1048
  };
611
1049
 
612
- this.summaries.push(entry);
1050
+ this.pushSummary(entry);
613
1051
  chunk.compressed = true;
614
1052
  chunk.summaryId = entry.id;
615
1053
  this._compressionCount++;
@@ -624,25 +1062,43 @@ export class AutobiographicalStrategy implements ResettableStrategy {
624
1062
  /**
625
1063
  * Check if unmerged summary counts exceed the merge threshold.
626
1064
  * Enqueues merge operations if so.
1065
+ *
1066
+ * Skips L1s/L2s that are already in a pending merge — without this guard,
1067
+ * each new summary above threshold re-enqueues a merge for the same
1068
+ * already-eligible siblings, producing N near-identical higher-level
1069
+ * summaries when the queue eventually drains.
627
1070
  */
628
1071
  protected checkMergeThreshold(): void {
629
1072
  const threshold = this.config.mergeThreshold ?? 6;
630
1073
 
1074
+ // IDs that are already part of a queued merge — exclude them from
1075
+ // eligibility so we don't re-enqueue.
1076
+ const queuedL1 = new Set<string>();
1077
+ const queuedL2 = new Set<string>();
1078
+ for (const m of this.mergeQueue) {
1079
+ const set = m.level === 2 ? queuedL1 : queuedL2;
1080
+ for (const id of m.sourceIds) set.add(id);
1081
+ }
1082
+
631
1083
  // Check L1 → L2
632
- const unmergedL1 = this.summaries.filter(s => s.level === 1 && !s.mergedInto);
1084
+ const unmergedL1 = this.summaries.filter(
1085
+ s => s.level === 1 && !s.mergedInto && !queuedL1.has(s.id),
1086
+ );
633
1087
  if (unmergedL1.length >= threshold) {
634
1088
  const toMerge = unmergedL1.slice(0, threshold);
635
- this.mergeQueue.push({
1089
+ this.enqueueMerge({
636
1090
  level: 2,
637
1091
  sourceIds: toMerge.map(s => s.id),
638
1092
  });
639
1093
  }
640
1094
 
641
1095
  // Check L2 → L3
642
- const unmergedL2 = this.summaries.filter(s => s.level === 2 && !s.mergedInto);
1096
+ const unmergedL2 = this.summaries.filter(
1097
+ s => s.level === 2 && !s.mergedInto && !queuedL2.has(s.id),
1098
+ );
643
1099
  if (unmergedL2.length >= threshold) {
644
1100
  const toMerge = unmergedL2.slice(0, threshold);
645
- this.mergeQueue.push({
1101
+ this.enqueueMerge({
646
1102
  level: 3,
647
1103
  sourceIds: toMerge.map(s => s.id),
648
1104
  });
@@ -671,6 +1127,17 @@ export class AutobiographicalStrategy implements ResettableStrategy {
671
1127
  return;
672
1128
  }
673
1129
 
1130
+ // Defensive: if every source is already mergedInto something, this is a
1131
+ // stale queue entry (could happen if multiple merges for the same
1132
+ // sourceIds were enqueued before the dedup fix in checkMergeThreshold).
1133
+ // Skip rather than produce a redundant near-identical higher-level entry.
1134
+ if (sources.every(s => s.mergedInto)) {
1135
+ console.warn(
1136
+ `executeMerge: all sources already merged into ${sources[0].mergedInto}, skipping (stale queue entry)`,
1137
+ );
1138
+ return;
1139
+ }
1140
+
674
1141
  const targetTokens = this.config.summaryTargetTokens ?? 2000;
675
1142
  const participant = this.config.summaryParticipant ?? 'Claude';
676
1143
 
@@ -716,7 +1183,6 @@ export class AutobiographicalStrategy implements ResettableStrategy {
716
1183
  config: {
717
1184
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
718
1185
  maxTokens: Math.round(targetTokens * 1.5),
719
- temperature: 0,
720
1186
  },
721
1187
  };
722
1188
 
@@ -735,7 +1201,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
735
1201
 
736
1202
  const sourceLevel = (targetLevel - 1) as 0 | 1 | 2;
737
1203
  const newEntry: SummaryEntry = {
738
- id: `L${targetLevel}-${this.summaryIdCounter++}`,
1204
+ id: `L${targetLevel}-${this.nextSummaryIdCounter()}`,
739
1205
  level: targetLevel,
740
1206
  content: mergedText,
741
1207
  tokens: Math.ceil(mergedText.length / 4),
@@ -745,11 +1211,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
745
1211
  created: Date.now(),
746
1212
  };
747
1213
 
748
- this.summaries.push(newEntry);
1214
+ // Append the new merged entry first, then mark sources. Persist each
1215
+ // mergedInto edit individually so chronicle reflects the same shape as
1216
+ // the in-memory mirror. (If the process crashes mid-loop, restart sees
1217
+ // the new entry plus a partial set of marked sources; un-marked sources
1218
+ // would re-trigger a merge — accept the rare duplicate over data loss.)
1219
+ this.pushSummary(newEntry);
749
1220
 
750
- // Mark sources as merged
751
1221
  for (const source of sources) {
752
- source.mergedInto = newEntry.id;
1222
+ this.setMergedInto(source, newEntry.id);
753
1223
  }
754
1224
 
755
1225
  // Check if this merge triggers a further merge
@@ -779,7 +1249,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
779
1249
  const msg = messages[i];
780
1250
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
781
1251
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
782
- if (totalTokens + tokens > maxTokens) break;
1252
+ if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
783
1253
 
784
1254
  entries.push({
785
1255
  index: entries.length,
@@ -816,7 +1286,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
816
1286
  let l3Used = 0;
817
1287
  for (const s of shownL3) {
818
1288
  if (l3Used + s.tokens > l3Budget) break;
819
- if (totalTokens + totalSummaryTokens + s.tokens > maxTokens) break;
1289
+ if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens)) break;
820
1290
  selectedSummaries.push(s);
821
1291
  l3Used += s.tokens;
822
1292
  totalSummaryTokens += s.tokens;
@@ -828,7 +1298,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
828
1298
  const l2Effective = l2Budget + l3Carryover;
829
1299
  for (const s of shownL2) {
830
1300
  if (l2Used + s.tokens > l2Effective) break;
831
- if (totalTokens + totalSummaryTokens + s.tokens > maxTokens) break;
1301
+ if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens)) break;
832
1302
  selectedSummaries.push(s);
833
1303
  l2Used += s.tokens;
834
1304
  totalSummaryTokens += s.tokens;
@@ -844,35 +1314,179 @@ export class AutobiographicalStrategy implements ResettableStrategy {
844
1314
  selectedSummaries.push(...l1Selected);
845
1315
  totalSummaryTokens += l1Used;
846
1316
 
847
- // Emit summaries as a single Q&A pair
848
- if (selectedSummaries.length > 0) {
849
- const contextLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?';
850
- const combinedText = selectedSummaries.map(s => s.content).join('\n\n---\n\n');
1317
+ // Emit summaries + pinned messages between head and recent windows.
1318
+ //
1319
+ // Default (positionedRecallPairs=true): one Q/A pair per summary,
1320
+ // interleaved with raw pinned messages, all sorted chronologically by
1321
+ // source-range / message position. Each memory appears in its temporal
1322
+ // place rather than as a wall of unrelated recollections.
1323
+ //
1324
+ // Legacy (positionedRecallPairs=false): summaries concatenated into one
1325
+ // Q/A pair between head and tail; pinned messages still emit raw, in
1326
+ // their chronological positions, after the combined recall pair.
1327
+ const positionOf = new Map<string, number>();
1328
+ for (let i = 0; i < messages.length; i++) {
1329
+ positionOf.set(messages[i].id, i);
1330
+ }
1331
+ const pinnedPositionsSet = this.pinnedPositions(messages);
1332
+ // Pinned messages between head and recent (head/recent pinned ones
1333
+ // already emit raw via Phase 0 / Phase 4).
1334
+ const pinnedInMiddle: { msg: StoredMessage; position: number }[] = [];
1335
+ const pinnedIdsInMiddle = new Set<string>();
1336
+ for (let i = headEnd; i < recentStart; i++) {
1337
+ if (pinnedPositionsSet.has(i)) {
1338
+ pinnedInMiddle.push({ msg: messages[i], position: i });
1339
+ pinnedIdsInMiddle.add(messages[i].id);
1340
+ }
1341
+ }
851
1342
 
852
- const questionEntry: ContextEntry = {
853
- index: entries.length,
854
- participant: 'Context Manager',
855
- content: [{ type: 'text', text: contextLabel }],
856
- sourceRelation: 'derived',
857
- };
858
- // Synthesised summary turns must respect maxMessageTokens. With L1+L2+L3
859
- // budgets defaulting to 30k each, an unconstrained concatenation can push
860
- // a single assistant turn past 90k tokens, eating the inference budget
861
- // and starving recent messages (postmortem 2026-05-04, bug B).
862
- const answerContent: ContentBlock[] = [{ type: 'text', text: combinedText }];
863
- const answerEntry: ContextEntry = {
864
- index: entries.length + 1,
865
- participant: this.config.summaryParticipant ?? 'Claude',
866
- content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
867
- sourceRelation: 'derived',
868
- };
1343
+ // Uncompressed-chunk fallback: messages in the middle region whose
1344
+ // chunk hasn't been summarized yet. Without this, a message that
1345
+ // rolled out of the recent window into a queued-but-not-yet-compressed
1346
+ // chunk would vanish from rendered context — there'd be no summary to
1347
+ // emit (compression hasn't run) and Phase 4 only walks recentStart
1348
+ // onwards. Mirrors selectLegacy's "Uncompressed: emit raw" behavior
1349
+ // around line 738, but here we interleave chronologically with
1350
+ // summaries and pins via the unified items list below.
1351
+ //
1352
+ // This matters because compile() was made non-blocking in commit
1353
+ // `3e42e98` (drops the prior `await readiness.pendingWork`); without
1354
+ // this fallback, the trade was silent data-loss for messages caught
1355
+ // in the queued-but-not-yet-compressed window. Now compile()'s
1356
+ // freshness contract is: summaries may lag the very latest L1, but
1357
+ // no message ever disappears.
1358
+ const uncompressedInMiddle: { msg: StoredMessage; position: number }[] = [];
1359
+ for (const chunk of this.chunks) {
1360
+ if (chunk.compressed) continue;
1361
+ for (const msg of chunk.messages) {
1362
+ const pos = positionOf.get(msg.id);
1363
+ if (pos === undefined) continue;
1364
+ if (pos < headEnd || pos >= recentStart) continue;
1365
+ if (pinnedIdsInMiddle.has(msg.id)) continue;
1366
+ uncompressedInMiddle.push({ msg, position: pos });
1367
+ }
1368
+ }
1369
+
1370
+ // Merged list of raw messages to emit in the middle region —
1371
+ // either a pin or a message whose chunk hasn't compressed yet.
1372
+ // Both render identically (raw, at their chronological position).
1373
+ const middleRaw: { msg: StoredMessage; position: number }[] = [
1374
+ ...pinnedInMiddle,
1375
+ ...uncompressedInMiddle,
1376
+ ];
1377
+
1378
+ if (selectedSummaries.length > 0 || middleRaw.length > 0) {
1379
+ const summaryParticipant = this.config.summaryParticipant ?? 'Claude';
1380
+
1381
+ if (this.config.positionedRecallPairs !== false) {
1382
+ // Build a unified, chronologically-sorted item list.
1383
+ type Item =
1384
+ | { kind: 'summary'; position: number; summary: SummaryEntry }
1385
+ | { kind: 'pin'; position: number; msg: StoredMessage };
1386
+
1387
+ const items: Item[] = [];
1388
+ for (const s of selectedSummaries) {
1389
+ const pos = positionOf.get(s.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
1390
+ items.push({ kind: 'summary', position: pos, summary: s });
1391
+ }
1392
+ for (const p of middleRaw) {
1393
+ items.push({ kind: 'pin', position: p.position, msg: p.msg });
1394
+ }
1395
+ items.sort((a, b) => a.position - b.position);
1396
+
1397
+ for (const item of items) {
1398
+ if (item.kind === 'summary') {
1399
+ const summary = item.summary;
1400
+ const headerText = this.buildRecallHeader(summary);
1401
+ const questionEntry: ContextEntry = {
1402
+ index: entries.length,
1403
+ participant: 'Context Manager',
1404
+ content: [{ type: 'text', text: headerText }],
1405
+ sourceRelation: 'derived',
1406
+ };
1407
+ const answerContent: ContentBlock[] = [{ type: 'text', text: summary.content }];
1408
+ const answerEntry: ContextEntry = {
1409
+ index: entries.length + 1,
1410
+ participant: summaryParticipant,
1411
+ content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
1412
+ sourceRelation: 'derived',
1413
+ };
1414
+ const pairTokens =
1415
+ this.estimateTokens(questionEntry.content) +
1416
+ this.estimateTokens(answerEntry.content);
1417
+ if (this.isOverBudget(totalTokens + pairTokens, maxTokens)) break;
1418
+ entries.push(questionEntry);
1419
+ entries.push(answerEntry);
1420
+ totalTokens += pairTokens;
1421
+ } else {
1422
+ const msg = item.msg;
1423
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1424
+ const tokens = msgCap > 0
1425
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
1426
+ : store.estimateTokens(msg);
1427
+ if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
1428
+ entries.push({
1429
+ index: entries.length,
1430
+ sourceMessageId: msg.id,
1431
+ sourceRelation: 'copy',
1432
+ participant: msg.participant,
1433
+ content,
1434
+ });
1435
+ totalTokens += tokens;
1436
+ }
1437
+ }
1438
+ } else {
1439
+ // Legacy combined-pair mode for summaries; pins still emit raw at
1440
+ // their positions after the combined pair.
1441
+ if (selectedSummaries.length > 0) {
1442
+ const contextLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?';
1443
+ const combinedText = selectedSummaries.map(s => s.content).join('\n\n---\n\n');
869
1444
 
870
- const pairTokens = this.estimateTokens(questionEntry.content) +
871
- this.estimateTokens(answerEntry.content);
1445
+ const questionEntry: ContextEntry = {
1446
+ index: entries.length,
1447
+ participant: 'Context Manager',
1448
+ content: [{ type: 'text', text: contextLabel }],
1449
+ sourceRelation: 'derived',
1450
+ };
1451
+ // Synthesised summary turns must respect maxMessageTokens. With L1+L2+L3
1452
+ // budgets defaulting to 30k each, an unconstrained concatenation can push
1453
+ // a single assistant turn past 90k tokens, eating the inference budget
1454
+ // and starving recent messages (postmortem 2026-05-04, bug B).
1455
+ const answerContent: ContentBlock[] = [{ type: 'text', text: combinedText }];
1456
+ const answerEntry: ContextEntry = {
1457
+ index: entries.length + 1,
1458
+ participant: summaryParticipant,
1459
+ content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
1460
+ sourceRelation: 'derived',
1461
+ };
1462
+
1463
+ const pairTokens = this.estimateTokens(questionEntry.content) +
1464
+ this.estimateTokens(answerEntry.content);
1465
+
1466
+ entries.push(questionEntry);
1467
+ entries.push(answerEntry);
1468
+ totalTokens += pairTokens;
1469
+ }
872
1470
 
873
- entries.push(questionEntry);
874
- entries.push(answerEntry);
875
- totalTokens += pairTokens;
1471
+ // Sort by position so uncompressed-middle messages and pins both
1472
+ // appear in their chronological place after the combined recall pair.
1473
+ const middleRawSorted = [...middleRaw].sort((a, b) => a.position - b.position);
1474
+ for (const { msg } of middleRawSorted) {
1475
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1476
+ const tokens = msgCap > 0
1477
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
1478
+ : store.estimateTokens(msg);
1479
+ if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
1480
+ entries.push({
1481
+ index: entries.length,
1482
+ sourceMessageId: msg.id,
1483
+ sourceRelation: 'copy',
1484
+ participant: msg.participant,
1485
+ content,
1486
+ });
1487
+ totalTokens += tokens;
1488
+ }
1489
+ }
876
1490
  }
877
1491
 
878
1492
  // Phase 4: Recent uncompressed messages (skip head window overlap).
@@ -883,6 +1497,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
883
1497
  this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
884
1498
 
885
1499
  this.trimOrphanedToolUse(entries);
1500
+ this.pruneToolEntries(entries);
886
1501
  return entries;
887
1502
  }
888
1503
 
@@ -923,13 +1538,60 @@ export class AutobiographicalStrategy implements ResettableStrategy {
923
1538
  let used = 0;
924
1539
  for (const s of shownL1) {
925
1540
  if (used + s.tokens > budget) break;
926
- if (used + s.tokens > maxTokens) break;
1541
+ if (this.isOverBudget(used + s.tokens, maxTokens)) break;
927
1542
  selected.push(s);
928
1543
  used += s.tokens;
929
1544
  }
930
1545
  return { selected, tokensUsed: used };
931
1546
  }
932
1547
 
1548
+ /**
1549
+ * True if `projectedTotal` exceeds `max` AND the strategy is configured
1550
+ * to enforce budget. When `enforceBudget: false`, always returns false
1551
+ * — the rendering pipeline emits the full ideal context regardless of
1552
+ * budget overage. Caller's API will reject if it exceeds the model's
1553
+ * context window; the philosophy is "surface overage, don't hide it."
1554
+ */
1555
+ protected isOverBudget(projectedTotal: number, max: number): boolean {
1556
+ if (this.config.enforceBudget === false) return false;
1557
+ return projectedTotal > max;
1558
+ }
1559
+
1560
+ /**
1561
+ * Sort selected summaries by source-range start position, so per-pair
1562
+ * recall emission appears in chronological order. Falls back to the
1563
+ * created timestamp for summaries whose source-range first message is
1564
+ * no longer in the store.
1565
+ */
1566
+ protected sortSummariesChronologically(
1567
+ summaries: SummaryEntry[],
1568
+ messages: StoredMessage[],
1569
+ ): SummaryEntry[] {
1570
+ const positionOf = new Map<string, number>();
1571
+ for (let i = 0; i < messages.length; i++) {
1572
+ positionOf.set(messages[i].id, i);
1573
+ }
1574
+ return [...summaries].sort((a, b) => {
1575
+ const posA = positionOf.get(a.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
1576
+ const posB = positionOf.get(b.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
1577
+ if (posA !== posB) return posA - posB;
1578
+ return a.created - b.created;
1579
+ });
1580
+ }
1581
+
1582
+ /**
1583
+ * Render the per-pair recall header from the configured template.
1584
+ * Substitutions: {id} {level} {first} {last}.
1585
+ */
1586
+ protected buildRecallHeader(summary: SummaryEntry): string {
1587
+ const template = this.config.recallHeaderTemplate ?? '[Recall {id}]';
1588
+ return template
1589
+ .replace(/\{id\}/g, summary.id)
1590
+ .replace(/\{level\}/g, String(summary.level))
1591
+ .replace(/\{first\}/g, summary.sourceRange.first)
1592
+ .replace(/\{last\}/g, summary.sourceRange.last);
1593
+ }
1594
+
933
1595
  // ============================================================================
934
1596
  // Head window reset / topic transition
935
1597
  // ============================================================================
@@ -996,7 +1658,6 @@ export class AutobiographicalStrategy implements ResettableStrategy {
996
1658
  config: {
997
1659
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
998
1660
  maxTokens: 1500,
999
- temperature: 0,
1000
1661
  },
1001
1662
  };
1002
1663
 
@@ -1032,16 +1693,24 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1032
1693
  // ============================================================================
1033
1694
 
1034
1695
  /**
1035
- * Get messages in the compressible zone: outside both head window and recent window.
1036
- * Returns messages from [0, headStart) [headEnd, recentStart).
1696
+ * Get messages in the compressible zone: outside both head window and
1697
+ * recent window AND not inside any pinned range. Returns messages from
1698
+ * [0, headStart) ∪ [headEnd, recentStart) minus any positions covered
1699
+ * by a pin or document mark.
1037
1700
  */
1038
1701
  protected getCompressibleMessages(store: MessageStoreView): StoredMessage[] {
1039
1702
  const messages = store.getAll();
1040
1703
  const headStart = this.getHeadWindowStartIndex(store);
1041
1704
  const headEnd = this.getHeadWindowEnd(store);
1042
1705
  const recentStart = this.getRecentWindowStart(store);
1043
- return messages.slice(0, recentStart)
1044
- .filter((_, i) => i < headStart || i >= headEnd);
1706
+ const pinned = this.pinnedPositions(messages);
1707
+ const out: StoredMessage[] = [];
1708
+ for (let i = 0; i < recentStart; i++) {
1709
+ if (i >= headStart && i < headEnd) continue;
1710
+ if (pinned.has(i)) continue;
1711
+ out.push(messages[i]);
1712
+ }
1713
+ return out;
1045
1714
  }
1046
1715
 
1047
1716
  /**
@@ -1257,6 +1926,91 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1257
1926
  }
1258
1927
  }
1259
1928
 
1929
+ /**
1930
+ * Prune tool_use / tool_result blocks in-place:
1931
+ * 1. Truncate `tool_use.input` blocks whose serialized JSON exceeds
1932
+ * `toolUseInputMaxTokens`.
1933
+ * 2. For each tool name, keep only the last N `tool_result` blocks
1934
+ * per `toolResultMaxLastN`; older ones get their `content` replaced
1935
+ * with a brief marker referencing the tool name and how many newer
1936
+ * results exist below.
1937
+ *
1938
+ * Both passes are no-ops when the corresponding config is unset/0.
1939
+ * Pruning runs AFTER selection and orphan-trimming, so it doesn't
1940
+ * affect chunk formation or the recall/pin layout.
1941
+ */
1942
+ protected pruneToolEntries(entries: ContextEntry[]): void {
1943
+ // Pass 1: build toolUseId → toolName map and apply input truncation
1944
+ const toolUseInputCap = this.config.toolUseInputMaxTokens ?? 0;
1945
+ const toolUseIdToName = new Map<string, string>();
1946
+
1947
+ for (const entry of entries) {
1948
+ for (let i = 0; i < entry.content.length; i++) {
1949
+ const block = entry.content[i];
1950
+ if (block.type !== 'tool_use') continue;
1951
+ toolUseIdToName.set(block.id, block.name);
1952
+
1953
+ if (toolUseInputCap > 0) {
1954
+ const inputJson = JSON.stringify(block.input);
1955
+ const inputTokens = Math.ceil(inputJson.length / 4);
1956
+ if (inputTokens > toolUseInputCap) {
1957
+ const keys = Object.keys(block.input).slice(0, 5);
1958
+ entry.content[i] = {
1959
+ ...block,
1960
+ input: {
1961
+ _truncated: true,
1962
+ _originalTokens: inputTokens,
1963
+ _keys: keys,
1964
+ },
1965
+ };
1966
+ }
1967
+ }
1968
+ }
1969
+ }
1970
+
1971
+ // Pass 2: collect tool_result occurrences per tool name, in order
1972
+ const occurrencesByTool = new Map<string, Array<{ entry: ContextEntry; blockIndex: number }>>();
1973
+ for (const entry of entries) {
1974
+ for (let i = 0; i < entry.content.length; i++) {
1975
+ const block = entry.content[i];
1976
+ if (block.type !== 'tool_result') continue;
1977
+ const toolName = toolUseIdToName.get(block.toolUseId);
1978
+ if (!toolName) continue;
1979
+ let arr = occurrencesByTool.get(toolName);
1980
+ if (!arr) {
1981
+ arr = [];
1982
+ occurrencesByTool.set(toolName, arr);
1983
+ }
1984
+ arr.push({ entry, blockIndex: i });
1985
+ }
1986
+ }
1987
+
1988
+ // Pass 3: apply per-tool max-last-N
1989
+ const cfg = this.config.toolResultMaxLastN;
1990
+ if (cfg === undefined) return;
1991
+
1992
+ for (const [toolName, occs] of occurrencesByTool) {
1993
+ let limit: number | undefined;
1994
+ if (typeof cfg === 'number') limit = cfg;
1995
+ else if (typeof cfg === 'object') limit = cfg[toolName];
1996
+ if (limit === undefined || limit < 0) continue;
1997
+
1998
+ const excessCount = occs.length - limit;
1999
+ if (excessCount <= 0) continue;
2000
+
2001
+ for (let i = 0; i < excessCount; i++) {
2002
+ const { entry, blockIndex } = occs[i];
2003
+ const orig = entry.content[blockIndex];
2004
+ if (orig.type !== 'tool_result') continue;
2005
+ const fresherCount = occs.length - i - 1;
2006
+ entry.content[blockIndex] = {
2007
+ ...orig,
2008
+ content: `[Result truncated — tool '${toolName}' has ${fresherCount} more recent result${fresherCount === 1 ? '' : 's'} below]`,
2009
+ };
2010
+ }
2011
+ }
2012
+ }
2013
+
1260
2014
  protected isChunkOldEnough(chunk: Chunk): boolean {
1261
2015
  return true;
1262
2016
  }