@animalabs/context-manager 0.5.4 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/src/adaptive/kv-control.d.ts +15 -1
  2. package/dist/src/adaptive/kv-control.d.ts.map +1 -1
  3. package/dist/src/adaptive/kv-control.js +91 -25
  4. package/dist/src/adaptive/kv-control.js.map +1 -1
  5. package/dist/src/adaptive/picker.d.ts +13 -0
  6. package/dist/src/adaptive/picker.d.ts.map +1 -1
  7. package/dist/src/adaptive/picker.js.map +1 -1
  8. package/dist/src/adaptive/strategies/kv-stable.d.ts.map +1 -1
  9. package/dist/src/adaptive/strategies/kv-stable.js +53 -2
  10. package/dist/src/adaptive/strategies/kv-stable.js.map +1 -1
  11. package/dist/src/context-manager.d.ts +10 -5
  12. package/dist/src/context-manager.d.ts.map +1 -1
  13. package/dist/src/context-manager.js +9 -0
  14. package/dist/src/context-manager.js.map +1 -1
  15. package/dist/src/index.d.ts +1 -0
  16. package/dist/src/index.d.ts.map +1 -1
  17. package/dist/src/index.js +2 -0
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/phase-channel.d.ts +13 -0
  20. package/dist/src/phase-channel.d.ts.map +1 -0
  21. package/dist/src/phase-channel.js +55 -0
  22. package/dist/src/phase-channel.js.map +1 -0
  23. package/dist/src/strategies/autobiographical.d.ts +40 -6
  24. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  25. package/dist/src/strategies/autobiographical.js +136 -17
  26. package/dist/src/strategies/autobiographical.js.map +1 -1
  27. package/dist/src/types/index.d.ts +1 -1
  28. package/dist/src/types/index.d.ts.map +1 -1
  29. package/dist/src/types/index.js.map +1 -1
  30. package/dist/src/types/strategy.d.ts +30 -6
  31. package/dist/src/types/strategy.d.ts.map +1 -1
  32. package/dist/src/types/strategy.js.map +1 -1
  33. package/dist/test/adaptive/pin-at-level.test.d.ts +16 -0
  34. package/dist/test/adaptive/pin-at-level.test.d.ts.map +1 -0
  35. package/dist/test/adaptive/pin-at-level.test.js +133 -0
  36. package/dist/test/adaptive/pin-at-level.test.js.map +1 -0
  37. package/dist/test/pins.test.js +40 -0
  38. package/dist/test/pins.test.js.map +1 -1
  39. package/dist/test/speculation-cap.test.d.ts +9 -3
  40. package/dist/test/speculation-cap.test.d.ts.map +1 -1
  41. package/dist/test/speculation-cap.test.js +175 -3
  42. package/dist/test/speculation-cap.test.js.map +1 -1
  43. package/dist/tsconfig.tsbuildinfo +1 -1
  44. package/package.json +1 -1
  45. package/src/adaptive/kv-control.ts +102 -25
  46. package/src/adaptive/picker.ts +13 -0
  47. package/src/adaptive/strategies/kv-stable.ts +40 -2
  48. package/src/context-manager.ts +13 -2
  49. package/src/index.ts +3 -0
  50. package/src/phase-channel.ts +57 -0
  51. package/src/strategies/autobiographical.ts +139 -19
  52. package/src/types/index.ts +1 -0
  53. package/src/types/strategy.ts +31 -2
@@ -81,6 +81,19 @@ export interface PickerChunk {
81
81
  lockedByAgent: boolean;
82
82
  bodyGroupId?: string;
83
83
  pinned: boolean;
84
+ /**
85
+ * V2 dynamic pin — fix this chunk at EXACTLY this fold level (0 = raw). Set
86
+ * from a `ProtectedRange.level`. Such a chunk is NOT `pinned` (the controller
87
+ * must be able to move it to its level), but the KV-stable controller holds it
88
+ * there and never folds/un-folds it. Ignored by non-kv-stable strategies.
89
+ */
90
+ pinLevel?: number;
91
+ /**
92
+ * V2 dynamic pin — this chunk may fold no deeper than this level (hard cap).
93
+ * Set from a `ProtectedRange.maxLevel`. Honored only by the KV-stable
94
+ * controller (in normal and emergency shedding). Ignored elsewhere.
95
+ */
96
+ pinMaxLevel?: number;
84
97
  /**
85
98
  * The L1 summary covering this chunk, if any. Higher levels are derived
86
99
  * by walking parentId pointers in the summary tree.
@@ -69,20 +69,56 @@ export class KvStableStrategy implements FoldingStrategy {
69
69
  private solve(budget: FoldingBudget): Map<ChunkId, number> {
70
70
  const tree = new SummaryTree(this.inputs);
71
71
 
72
- // Flat zone (forced raw, never folded): head, tail, pinned. Frozen (kept at
73
- // their carried resolution, never folded): locked.
72
+ // Flat zone (forced raw, never folded): head, tail, classic pins. Frozen
73
+ // (kept at their carried resolution, never folded): locked. V2 dynamic pins
74
+ // (`pinLevel` / `pinMaxLevel`) are honored as a fixed level or a hard fold-
75
+ // depth cap — see `planControlledFrontier`.
74
76
  const rawZone = new Set<ChunkId>();
75
77
  const frozen = new Set<ChunkId>();
78
+ const fixedLevels = new Map<ChunkId, number>();
79
+ const pinCaps = new Map<ChunkId, number>();
80
+ const fixedPins: Array<{ id: ChunkId; level: number }> = [];
76
81
  let now = 0;
77
82
  for (const c of this.inputs.chunks) {
78
83
  if (c.sequence > now) now = c.sequence;
79
84
  if (this.inputs.headChunkIds.has(c.id) || this.inputs.tailChunkIds.has(c.id) || c.pinned) {
80
85
  rawZone.add(c.id);
86
+ } else if (c.pinLevel !== undefined) {
87
+ // Pin-at-level-k: fix exactly at k (0 = raw). k=0 is equivalent to a
88
+ // classic raw pin, so route it through the raw zone; k>0 is resolved to
89
+ // its whole L_k node below (group-consistent — see the loop after).
90
+ if (c.pinLevel <= 0) rawZone.add(c.id);
91
+ else fixedPins.push({ id: c.id, level: c.pinLevel });
92
+ } else if (c.pinMaxLevel !== undefined) {
93
+ // Pin-max-level: a hard fold-depth cap. maxLevel 0 ≡ classic raw pin.
94
+ if (c.pinMaxLevel <= 0) rawZone.add(c.id);
95
+ else pinCaps.set(c.id, c.pinMaxLevel);
96
+ if (c.lockedByAgent) frozen.add(c.id);
81
97
  } else if (c.lockedByAgent) {
82
98
  frozen.add(c.id);
83
99
  }
84
100
  }
85
101
 
102
+ // Group-consistency for pin-at-level-k: an L_k recall pair is atomic over
103
+ // its whole covered range, so "cut through the L_k node" (design §7) fixes
104
+ // EVERY leaf under that node at k — not just the addressed chunk. Fixing a
105
+ // single sub-chunk while its siblings render raw is an unrenderable (and
106
+ // non-converging) frontier. Clamp k to the deepest produced level for the
107
+ // chunk; if none exists, fall back to fixing just the chunk (the controller
108
+ // clamps it further). Skip leaves already forced raw (head/tail/classic pin).
109
+ for (const { id, level } of fixedPins) {
110
+ const eff = Math.min(level, tree.maxLevel(id));
111
+ if (eff <= 0) { rawZone.add(id); continue; }
112
+ const node = tree.ancestorAt(id, eff);
113
+ if (!node) { fixedLevels.set(id, eff); continue; }
114
+ for (const leaf of node.leafChunkIds) {
115
+ if (rawZone.has(leaf)) continue;
116
+ // Finest requirement wins if two pins overlap a leaf.
117
+ const prev = fixedLevels.get(leaf);
118
+ fixedLevels.set(leaf, prev === undefined ? eff : Math.min(prev, eff));
119
+ }
120
+ }
121
+
86
122
  return planControlledFrontier(this.inputs, tree, {
87
123
  previous: this.fPrev,
88
124
  // Bidirectional within the slack band: fold when over the hard budget,
@@ -95,6 +131,8 @@ export class KvStableStrategy implements FoldingStrategy {
95
131
  reachTokens: this.opts.reachTokens,
96
132
  rawZone,
97
133
  frozen,
134
+ fixedLevels,
135
+ pinCaps,
98
136
  now,
99
137
  mergeThreshold: this.opts.mergeThreshold,
100
138
  }).resolutions;
@@ -16,6 +16,7 @@ import type {
16
16
  ContextInjection,
17
17
  CompileResult,
18
18
  ProtectedRange,
19
+ PinLevelOptions,
19
20
  SearchQuery,
20
21
  SearchResult,
21
22
  SummaryEntry,
@@ -640,19 +641,29 @@ export class ContextManager {
640
641
  *
641
642
  * Throws if the active strategy doesn't support pins.
642
643
  */
643
- pinRange(firstMessageId: MessageId, lastMessageId: MessageId, opts?: { name?: string }): string {
644
+ pinRange(firstMessageId: MessageId, lastMessageId: MessageId, opts?: PinLevelOptions): string {
644
645
  if (!isPinnableStrategy(this.strategy)) {
645
646
  throw new Error('Active strategy does not support pins');
646
647
  }
647
648
  return this.strategy.pinRange(firstMessageId, lastMessageId, opts);
648
649
  }
649
650
 
651
+ /**
652
+ * V2 dynamic pin-at-level-k: fix a range to render at EXACTLY fold level
653
+ * `level` (0 = raw) — the frontier cut passes through that L_k node. Honored
654
+ * only by `foldingStrategy: 'kv-stable'`; other strategies fall back to
655
+ * treating the range as raw. Returns the new pin id.
656
+ */
657
+ pinAtLevel(firstMessageId: MessageId, lastMessageId: MessageId, level: number, opts?: { name?: string }): string {
658
+ return this.pinRange(firstMessageId, lastMessageId, { name: opts?.name, level });
659
+ }
660
+
650
661
  /**
651
662
  * Mark a single message as a "document" (semantically a body of
652
663
  * information to retain in full). Same effect as a single-message pin
653
664
  * with `kind: 'document'`. Returns the new pin id.
654
665
  */
655
- markDocument(messageId: MessageId, opts?: { name?: string }): string {
666
+ markDocument(messageId: MessageId, opts?: PinLevelOptions): string {
656
667
  if (!isPinnableStrategy(this.strategy)) {
657
668
  throw new Error('Active strategy does not support documents');
658
669
  }
package/src/index.ts CHANGED
@@ -2,6 +2,9 @@
2
2
  export { ContextManager } from './context-manager.js';
3
3
  export type { ContextManagerConfig } from './context-manager.js';
4
4
 
5
+ // Phase channel (liveness-watchdog observability hook)
6
+ export { phaseChannel, enterPhase, withPhase, withPhaseAsync } from './phase-channel.js';
7
+
5
8
  // Storage
6
9
  export { MessageStore } from './message-store.js';
7
10
  export type { MessageStoreEvent, MessageStoreListener } from './message-store.js';
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Phase channel — a process-wide observability hook for labelling the
3
+ * synchronous operation the main thread is currently running.
4
+ *
5
+ * Long synchronous work (context assembly, compression, merge-graph walks) can
6
+ * wedge the single-threaded event loop. An external liveness watchdog (in the
7
+ * host/agent-framework) can detect the wedge but not name what was running —
8
+ * unless the running code labels itself here. This module is a no-op sink by
9
+ * default; the watchdog installs `report` on start so labels reach its wedge
10
+ * report. context-manager owns the channel (no upward dependency); the watchdog
11
+ * (which depends on context-manager) wires it.
12
+ */
13
+ const stack: string[] = [];
14
+
15
+ export const phaseChannel: {
16
+ /** Installed by the watchdog; receives the current (innermost) phase label. */
17
+ report: (label: string) => void;
18
+ } = {
19
+ report: () => {},
20
+ };
21
+
22
+ /** Enter a named phase. Returns a disposer that restores the previous phase. */
23
+ export function enterPhase(label: string): () => void {
24
+ stack.push(label);
25
+ phaseChannel.report(label);
26
+ let done = false;
27
+ return () => {
28
+ if (done) return;
29
+ done = true;
30
+ // Pop this label (tolerate out-of-order disposal defensively).
31
+ const idx = stack.lastIndexOf(label);
32
+ if (idx !== -1) stack.splice(idx, 1);
33
+ phaseChannel.report(stack[stack.length - 1] ?? 'idle');
34
+ };
35
+ }
36
+
37
+ /** Run a synchronous section under a phase label (nesting-safe). */
38
+ export function withPhase<T>(label: string, fn: () => T): T {
39
+ const leave = enterPhase(label);
40
+ try {
41
+ return fn();
42
+ } finally {
43
+ leave();
44
+ }
45
+ }
46
+
47
+ /** Run an async section under a phase label. Note: this labels across awaits,
48
+ * which is what we want — the synchronous spans inside (where a wedge would
49
+ * occur) stay attributed to this phase. */
50
+ export async function withPhaseAsync<T>(label: string, fn: () => Promise<T>): Promise<T> {
51
+ const leave = enterPhase(label);
52
+ try {
53
+ return await fn();
54
+ } finally {
55
+ leave();
56
+ }
57
+ }
@@ -1,6 +1,7 @@
1
1
  import type { JsStore } from '@animalabs/chronicle';
2
2
  import type { Membrane, NormalizedRequest, ContentBlock, CompleteOptions } from '@animalabs/membrane';
3
3
  import { NativeFormatter } from '@animalabs/membrane';
4
+ import { phaseChannel } from '../phase-channel.js';
4
5
  import type {
5
6
  ContextStrategy,
6
7
  ResettableStrategy,
@@ -15,6 +16,7 @@ import type {
15
16
  SummaryLevel,
16
17
  SummaryEntry,
17
18
  ProtectedRange,
19
+ PinLevelOptions,
18
20
  SearchQuery,
19
21
  SearchResult,
20
22
  RenderStats,
@@ -252,6 +254,21 @@ export interface AutobiographicalProgressSnapshot {
252
254
  pending: boolean;
253
255
  }
254
256
 
257
+ /**
258
+ * Validate + normalize the optional V2 pin fold-depth bounds. Returns only the
259
+ * fields that are present and valid (non-negative integers), so a classic pin
260
+ * with no bounds persists exactly as before. `level` takes precedence over
261
+ * `maxLevel` (pin-at-k is stronger than a cap), so they're never both emitted.
262
+ */
263
+ function normalizePinLevels(opts?: PinLevelOptions): { level?: number; maxLevel?: number } {
264
+ const clean = (v: number | undefined): number | undefined =>
265
+ typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : undefined;
266
+ const level = clean(opts?.level);
267
+ if (level !== undefined) return { level };
268
+ const maxLevel = clean(opts?.maxLevel);
269
+ return maxLevel !== undefined ? { maxLevel } : {};
270
+ }
271
+
255
272
  /**
256
273
  * Autobiographical chunking strategy.
257
274
  * Compresses old conversation chunks into summaries in the model's own words.
@@ -271,6 +288,14 @@ export class AutobiographicalStrategy implements ResettableStrategy {
271
288
  protected pendingCompression: Promise<void> | null = null;
272
289
  protected compressionQueue: number[] = [];
273
290
  protected _compressionCount = 0;
291
+ /**
292
+ * Monotonic counter of tick() operations that actually processed a queue item
293
+ * (compressed a chunk or executed a merge). `driveSpeculativeDrain` recurses
294
+ * while this advances — a length-delta check would falsely read "no progress"
295
+ * when a productive tick also enqueues a follow-on item (net queue length
296
+ * unchanged), halting the drain with work still queued.
297
+ */
298
+ protected _drainProgress = 0;
274
299
 
275
300
  // Hierarchical state
276
301
  protected summaries: SummaryEntry[] = [];
@@ -583,7 +608,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
583
608
  * Pin a range of messages so they aren't compressed and render raw at
584
609
  * their original position. Returns the pin id.
585
610
  */
586
- pinRange(firstMessageId: string, lastMessageId: string, opts?: { name?: string }): string {
611
+ pinRange(firstMessageId: string, lastMessageId: string, opts?: PinLevelOptions): string {
587
612
  const id = `pin-${this.pinIdCounter++}`;
588
613
  this.pins.push({
589
614
  id,
@@ -592,6 +617,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
592
617
  kind: 'pin',
593
618
  name: opts?.name,
594
619
  created: Date.now(),
620
+ ...normalizePinLevels(opts),
595
621
  });
596
622
  this.persistPins();
597
623
  return id;
@@ -602,7 +628,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
602
628
  * information the agent wants to retain in full. Functionally a
603
629
  * single-message pin with `kind: 'document'`.
604
630
  */
605
- markDocument(messageId: string, opts?: { name?: string }): string {
631
+ markDocument(messageId: string, opts?: PinLevelOptions): string {
606
632
  const id = `pin-${this.pinIdCounter++}`;
607
633
  this.pins.push({
608
634
  id,
@@ -611,11 +637,22 @@ export class AutobiographicalStrategy implements ResettableStrategy {
611
637
  kind: 'document',
612
638
  name: opts?.name,
613
639
  created: Date.now(),
640
+ ...normalizePinLevels(opts),
614
641
  });
615
642
  this.persistPins();
616
643
  return id;
617
644
  }
618
645
 
646
+ /**
647
+ * V2 dynamic pin-at-level-k convenience: fix a range to render at EXACTLY
648
+ * fold level `level` (0 = raw). Honored only by `foldingStrategy: 'kv-stable'`;
649
+ * other strategies fall back to treating the range as raw. Equivalent to
650
+ * `pinRange(first, last, { level })`.
651
+ */
652
+ pinAtLevel(firstMessageId: string, lastMessageId: string, level: number, opts?: { name?: string }): string {
653
+ return this.pinRange(firstMessageId, lastMessageId, { name: opts?.name, level });
654
+ }
655
+
619
656
  /** Remove a pin or document mark by id. Returns true if removed. */
620
657
  unpin(pinId: string): boolean {
621
658
  const before = this.pins.length;
@@ -735,6 +772,42 @@ export class AutobiographicalStrategy implements ResettableStrategy {
735
772
  return out;
736
773
  }
737
774
 
775
+ /**
776
+ * Resolve the V2 dynamic-pin fold-depth bounds (`ProtectedRange.level` /
777
+ * `maxLevel`) to message positions. Only pins that carry a bound appear; a
778
+ * classic raw pin (no bound) is absent here and handled by `pinnedPositions`.
779
+ * When ranges overlap, the FINEST requirement wins (lowest effective level):
780
+ * a fixed `level` clamps both ends; a `maxLevel` only caps depth. Honored
781
+ * solely by the KV-stable controller — see `ProtectedRange`.
782
+ */
783
+ protected pinLevelBounds(messages: StoredMessage[]): Map<number, { level?: number; maxLevel?: number }> {
784
+ const out = new Map<number, { level?: number; maxLevel?: number }>();
785
+ if (this.pins.length === 0) return out;
786
+ const positionOf = new Map<string, number>();
787
+ for (let i = 0; i < messages.length; i++) positionOf.set(messages[i].id, i);
788
+
789
+ for (const pin of this.pins) {
790
+ if (pin.level === undefined && pin.maxLevel === undefined) continue;
791
+ const first = positionOf.get(pin.firstMessageId);
792
+ const last = positionOf.get(pin.lastMessageId);
793
+ if (first === undefined || last === undefined) continue;
794
+ const lo = Math.min(first, last);
795
+ const hi = Math.max(first, last);
796
+ for (let i = lo; i <= hi; i++) {
797
+ const prev = out.get(i) ?? {};
798
+ // A fixed level is the strongest constraint; when two pins fix the same
799
+ // position, the shallower (lower) level wins (finest requirement).
800
+ if (pin.level !== undefined) {
801
+ prev.level = prev.level === undefined ? pin.level : Math.min(prev.level, pin.level);
802
+ } else if (pin.maxLevel !== undefined) {
803
+ prev.maxLevel = prev.maxLevel === undefined ? pin.maxLevel : Math.min(prev.maxLevel, pin.maxLevel);
804
+ }
805
+ out.set(i, prev);
806
+ }
807
+ }
808
+ return out;
809
+ }
810
+
738
811
  /**
739
812
  * Append a summary to the in-memory list and to the chronicle AppendLog.
740
813
  * Single point so subclasses inherit persistence.
@@ -1004,20 +1077,31 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1004
1077
  */
1005
1078
  protected driveSpeculativeDrain(ctx: StrategyContext): void {
1006
1079
  if (this.pendingCompression) return;
1007
- if (this.compressionQueue.length === 0 && this.mergeQueue.length === 0) return;
1008
- if (this.isAtSpeculativeCap()) return;
1009
- if (!this.shouldCompressPreflight()) return;
1010
-
1011
- const beforeChunks = this.compressionQueue.length;
1012
- const beforeMerges = this.mergeQueue.length;
1080
+ // Merges consolidate existing L_k summaries into L_{k+1} and REDUCE the
1081
+ // unmerged-L1 count; L1 compression PRODUCES new unmerged L1s. The
1082
+ // speculation cap / preflight throttle *production* only — they must never
1083
+ // gate merges, otherwise exceeding the cap (e.g. after a manual backfill)
1084
+ // permanently deadlocks the drain: too many unmerged L1s trips the cap,
1085
+ // which blocks the very merges that would bring the count back down.
1086
+ const hasMerges = this.config.hierarchical === true && this.mergeQueue.length > 0;
1087
+ const hasCompression = this.compressionQueue.length > 0;
1088
+ if (!hasCompression && !hasMerges) return;
1089
+ // Only bail when the *sole* available work is L1 compression that the cap
1090
+ // or preflight currently forbids. Merge work always proceeds.
1091
+ if (!hasMerges && (this.isAtSpeculativeCap() || !this.shouldCompressPreflight())) return;
1092
+
1093
+ const progressBefore = this._drainProgress;
1013
1094
 
1014
1095
  this.tick(ctx)
1015
1096
  .then(() => {
1016
- const afterChunks = this.compressionQueue.length;
1017
- const afterMerges = this.mergeQueue.length;
1018
- // Made progress if either queue shrank.
1019
- const progressed = afterChunks < beforeChunks || afterMerges < beforeMerges;
1020
- if (!progressed) return;
1097
+ // Progress = the tick actually processed a queue item (compress or
1098
+ // merge), tracked by `_drainProgress`. A queue-length delta is the
1099
+ // wrong signal: a productive merge tick can also enqueue a follow-on
1100
+ // merge, leaving the length unchanged which the old check misread as
1101
+ // "no progress" and halted the drain mid-backlog. A genuine no-op tick
1102
+ // (empty queues, at-cap with no merges, no membrane) doesn't advance
1103
+ // the counter, so this still stops cleanly (no runaway recursion).
1104
+ if (this._drainProgress === progressBefore) return;
1021
1105
  // Recurse to drain more. queueMicrotask defers until the current
1022
1106
  // task is done, letting other code (the agent's stream consumer)
1023
1107
  // interleave.
@@ -1029,14 +1113,23 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1029
1113
  }
1030
1114
 
1031
1115
  /**
1032
- * Whether the strategy's pending+queued L1 budget has reached the cap
1116
+ * Whether the count of *produced, unmerged* L1 summaries has reached the cap
1033
1117
  * configured by `maxSpeculativeL1s`. If no cap is set, always false.
1118
+ *
1119
+ * The cap bounds how many L1 summaries may sit un-consolidated before the
1120
+ * strategy must merge them into L_{k+1} (bounding prefix churn / merge debt).
1121
+ * It deliberately does NOT count the pending `compressionQueue`: that queue is
1122
+ * the backlog of work to be *drained*, not produced summaries. Counting it
1123
+ * here would let a large backlog permanently trip the cap and block the very
1124
+ * compression that would clear it — a deadlock (merges relieve the cap, but
1125
+ * compression of the backlog never resumes). The throttle is on produced L1s;
1126
+ * the queue drains freely, with merges keeping the unmerged count under the cap.
1034
1127
  */
1035
1128
  protected isAtSpeculativeCap(): boolean {
1036
1129
  const cap = this.config.maxSpeculativeL1s;
1037
1130
  if (cap === undefined || cap < 0) return false;
1038
1131
  const unmergedL1s = this.summaries.filter(s => s.level === 1 && !s.mergedInto).length;
1039
- return unmergedL1s + this.compressionQueue.length > cap;
1132
+ return unmergedL1s > cap;
1040
1133
  }
1041
1134
 
1042
1135
  /**
@@ -1050,6 +1143,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1050
1143
  }
1051
1144
 
1052
1145
  async tick(ctx: StrategyContext): Promise<void> {
1146
+ phaseChannel.report('compress-tick'); // liveness-watchdog phase
1053
1147
  if (this.pendingCompression) return;
1054
1148
 
1055
1149
  if (!ctx.membrane) {
@@ -1057,9 +1151,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1057
1151
  return;
1058
1152
  }
1059
1153
 
1060
- // Priority 1: Compress raw chunks → L1
1061
- if (this.compressionQueue.length > 0) {
1154
+ // Priority 1: Compress raw chunks → L1. Skipped while at the speculative
1155
+ // cap (maxSpeculativeL1s) so we don't pile up more unmerged L1s; the merge
1156
+ // priority below still runs to consolidate existing L1s and relieve the cap.
1157
+ // No cap configured → isAtSpeculativeCap() is always false → unchanged.
1158
+ if (this.compressionQueue.length > 0 && !this.isAtSpeculativeCap()) {
1062
1159
  const chunkIndex = this.compressionQueue.shift()!;
1160
+ this._drainProgress++; // consumed a queue item (real work or stale-cleanup)
1063
1161
  const chunk = this.chunks[chunkIndex];
1064
1162
 
1065
1163
  if (!chunk || chunk.compressed) return;
@@ -1085,6 +1183,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1085
1183
  // and the next tick() retries it.
1086
1184
  if (this.config.hierarchical && this.mergeQueue.length > 0) {
1087
1185
  const merge = this.mergeQueue[0]!;
1186
+ this._drainProgress++; // executing a merge is real work, even if a
1187
+ // follow-on merge gets enqueued and the queue length nets out unchanged
1088
1188
  this.pendingCompression = this.executeMerge(merge.level, merge.sourceIds, ctx);
1089
1189
 
1090
1190
  try {
@@ -1403,6 +1503,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1403
1503
  * No system prompt — framing via message structure only.
1404
1504
  */
1405
1505
  protected async compressChunkHierarchical(chunk: Chunk, ctx: StrategyContext): Promise<void> {
1506
+ phaseChannel.report('compress-chunk'); // liveness-watchdog phase
1406
1507
  if (!ctx.membrane) {
1407
1508
  throw new Error('No membrane instance for compression');
1408
1509
  }
@@ -1681,6 +1782,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1681
1782
  * summaries when the queue eventually drains.
1682
1783
  */
1683
1784
  protected checkMergeThreshold(): void {
1785
+ phaseChannel.report('merge-threshold'); // liveness-watchdog phase
1684
1786
  if (this.config.speculativeProduction) {
1685
1787
  this.checkMergeThresholdRecursive();
1686
1788
  return;
@@ -2148,6 +2250,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2148
2250
  * See `docs/adaptive-resolution-design.md` §3, §5.
2149
2251
  */
2150
2252
  protected selectAdaptive(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
2253
+ phaseChannel.report('context-build'); // liveness-watchdog phase
2151
2254
  this.rsBegin();
2152
2255
  const entries: ContextEntry[] = [];
2153
2256
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
@@ -2211,6 +2314,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2211
2314
  // Pinned-position set so the picker doesn't fold messages the user
2212
2315
  // explicitly marked as keep-raw. Built once and reused.
2213
2316
  const pinnedSet = this.pinnedPositions(messages);
2317
+ // V2 dynamic-pin fold-depth bounds (level / maxLevel). A position with a
2318
+ // bound is NOT a classic force-raw pin — the KV-stable controller must be
2319
+ // able to move it to/within its bound — so it renders as `pinned: false`
2320
+ // carrying `pinLevel` / `pinMaxLevel` instead.
2321
+ const pinBounds = this.pinLevelBounds(messages);
2214
2322
 
2215
2323
  // O(1) summary lookup for findAncestorAt — avoids O(summaries) find()
2216
2324
  // calls during emission.
@@ -2224,13 +2332,18 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2224
2332
  const tokens = msgCap > 0
2225
2333
  ? Math.min(store.estimateTokens(msg), msgCap + 50)
2226
2334
  : store.estimateTokens(msg);
2335
+ const bound = pinBounds.get(i);
2227
2336
  pickerChunks.push({
2228
2337
  id: msg.id,
2229
2338
  sequence: i,
2230
2339
  rawTokens: tokens,
2231
2340
  currentResolution: this.resolutions.get(msg.id) ?? 0,
2232
2341
  lockedByAgent: this.locked.has(msg.id),
2233
- pinned: pinnedSet.has(i),
2342
+ // A classic pin (in pinnedSet with no level bound) stays force-raw. A
2343
+ // leveled pin is not force-raw; it carries its bound instead.
2344
+ pinned: pinnedSet.has(i) && bound === undefined,
2345
+ pinLevel: bound?.level,
2346
+ pinMaxLevel: bound?.maxLevel,
2234
2347
  l1Id: ch?.summaryId,
2235
2348
  });
2236
2349
  }
@@ -2279,7 +2392,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2279
2392
  }
2280
2393
 
2281
2394
  // ----- 4. Run the picker -----
2282
- const totalBudget = maxTokens - totalTokens; // tokens left after head
2395
+ // The picker's token count ALREADY includes the pinned head+tail (it gets
2396
+ // headTokens/tailTokens in pickerInputs and result.finalTokens covers them).
2397
+ // So the budget it folds against is the full maxTokens — NOT maxTokens-head,
2398
+ // which double-counts the head (reserves it twice: once here, once because
2399
+ // finalTokens already includes it). The old form threw ~head-tokens early at
2400
+ // tight budgets and quietly under-used the budget by ~head everywhere.
2401
+ const totalBudget = maxTokens;
2283
2402
  const slack = this.config.compressionSlackRatio ?? 0.1;
2284
2403
  const foldingBudget: FoldingBudget = {
2285
2404
  totalBudget,
@@ -2819,6 +2938,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2819
2938
  * Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
2820
2939
  */
2821
2940
  protected selectHierarchical(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
2941
+ phaseChannel.report('context-build'); // liveness-watchdog phase
2822
2942
  this.rsBegin();
2823
2943
  const entries: ContextEntry[] = [];
2824
2944
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
@@ -38,6 +38,7 @@ export type {
38
38
  KnowledgeConfig,
39
39
  ResettableStrategy,
40
40
  ProtectedRange,
41
+ PinLevelOptions,
41
42
  SearchQuery,
42
43
  SearchResult,
43
44
  SearchableStrategy,
@@ -186,8 +186,8 @@ export function isResettableStrategy(s: ContextStrategy): s is ResettableStrateg
186
186
  * original chronological position. Implemented by AutobiographicalStrategy.
187
187
  */
188
188
  export interface PinnableStrategy extends ContextStrategy {
189
- pinRange(firstMessageId: string, lastMessageId: string, opts?: { name?: string }): string;
190
- markDocument(messageId: string, opts?: { name?: string }): string;
189
+ pinRange(firstMessageId: string, lastMessageId: string, opts?: PinLevelOptions): string;
190
+ markDocument(messageId: string, opts?: PinLevelOptions): string;
191
191
  unpin(pinId: string): boolean;
192
192
  listPins(): ReadonlyArray<ProtectedRange>;
193
193
  }
@@ -596,6 +596,35 @@ export interface ProtectedRange {
596
596
  name?: string;
597
597
  /** Creation timestamp (ms since epoch). */
598
598
  created: number;
599
+ /**
600
+ * Pin-AT-level-k (V2 dynamic pins, `docs/best-fit-frontier-resolution.md` §7).
601
+ * Fix the covered chunks to render at EXACTLY this fold level (0 = raw): the
602
+ * frontier cut passes through the L_k node — neither folded deeper nor
603
+ * un-folded shallower. When set, this range is NOT a classic force-raw pin.
604
+ * Takes precedence over `maxLevel`. Omitted → not a fixed-level pin.
605
+ *
606
+ * Honored only by the KV-stable controller (`foldingStrategy: 'kv-stable'`);
607
+ * other strategies fall back to treating the range as raw (a safe superset).
608
+ */
609
+ level?: number;
610
+ /**
611
+ * Pin-max-level (V2 dynamic pins): the covered chunks may fold no DEEPER than
612
+ * this level — raw..L_k allowed, deeper forbidden — a hard cap honored even
613
+ * under the window-pressure emergency. `maxLevel: 0` ≡ classic pin-raw.
614
+ * Ignored when `level` is set. Omitted → this pin imposes no depth cap.
615
+ *
616
+ * Honored only by the KV-stable controller; see `level`.
617
+ */
618
+ maxLevel?: number;
619
+ }
620
+
621
+ /** Optional fold-depth bounds for a V2 dynamic pin (see `ProtectedRange`). */
622
+ export interface PinLevelOptions {
623
+ name?: string;
624
+ /** Pin AT exactly this level (0 = raw). Fixes the cut through the L_k node. */
625
+ level?: number;
626
+ /** Fold no deeper than this level (hard cap; `0` ≡ classic pin-raw). */
627
+ maxLevel?: number;
599
628
  }
600
629
 
601
630
  /**