@animalabs/context-manager 0.5.4 → 0.5.9

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 (128) hide show
  1. package/dist/src/adaptive/kv-control.d.ts +84 -33
  2. package/dist/src/adaptive/kv-control.d.ts.map +1 -1
  3. package/dist/src/adaptive/kv-control.js +510 -145
  4. package/dist/src/adaptive/kv-control.js.map +1 -1
  5. package/dist/src/adaptive/picker.d.ts +21 -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 +12 -4
  9. package/dist/src/adaptive/strategies/kv-stable.d.ts.map +1 -1
  10. package/dist/src/adaptive/strategies/kv-stable.js +67 -7
  11. package/dist/src/adaptive/strategies/kv-stable.js.map +1 -1
  12. package/dist/src/context-log.d.ts.map +1 -1
  13. package/dist/src/context-log.js +11 -0
  14. package/dist/src/context-log.js.map +1 -1
  15. package/dist/src/context-manager.d.ts +38 -6
  16. package/dist/src/context-manager.d.ts.map +1 -1
  17. package/dist/src/context-manager.js +51 -0
  18. package/dist/src/context-manager.js.map +1 -1
  19. package/dist/src/index.d.ts +4 -2
  20. package/dist/src/index.d.ts.map +1 -1
  21. package/dist/src/index.js +3 -0
  22. package/dist/src/index.js.map +1 -1
  23. package/dist/src/message-store.d.ts +60 -0
  24. package/dist/src/message-store.d.ts.map +1 -1
  25. package/dist/src/message-store.js +165 -10
  26. package/dist/src/message-store.js.map +1 -1
  27. package/dist/src/phase-channel.d.ts +13 -0
  28. package/dist/src/phase-channel.d.ts.map +1 -0
  29. package/dist/src/phase-channel.js +55 -0
  30. package/dist/src/phase-channel.js.map +1 -0
  31. package/dist/src/strategies/autobiographical.d.ts +247 -8
  32. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  33. package/dist/src/strategies/autobiographical.js +1419 -141
  34. package/dist/src/strategies/autobiographical.js.map +1 -1
  35. package/dist/src/strategies/index.d.ts +1 -1
  36. package/dist/src/strategies/index.d.ts.map +1 -1
  37. package/dist/src/strategies/index.js.map +1 -1
  38. package/dist/src/strategies/knowledge.d.ts +8 -0
  39. package/dist/src/strategies/knowledge.d.ts.map +1 -1
  40. package/dist/src/strategies/knowledge.js +23 -1
  41. package/dist/src/strategies/knowledge.js.map +1 -1
  42. package/dist/src/types/index.d.ts +1 -1
  43. package/dist/src/types/index.d.ts.map +1 -1
  44. package/dist/src/types/index.js.map +1 -1
  45. package/dist/src/types/strategy.d.ts +112 -11
  46. package/dist/src/types/strategy.d.ts.map +1 -1
  47. package/dist/src/types/strategy.js.map +1 -1
  48. package/dist/test/adaptive/doc-plus-chat.test.js +7 -3
  49. package/dist/test/adaptive/doc-plus-chat.test.js.map +1 -1
  50. package/dist/test/adaptive/hard-fail.test.js +22 -0
  51. package/dist/test/adaptive/hard-fail.test.js.map +1 -1
  52. package/dist/test/adaptive/ingestion.test.js +3 -1
  53. package/dist/test/adaptive/ingestion.test.js.map +1 -1
  54. package/dist/test/adaptive/kv-control.test.js +17 -11
  55. package/dist/test/adaptive/kv-control.test.js.map +1 -1
  56. package/dist/test/adaptive/pin-at-level.test.d.ts +16 -0
  57. package/dist/test/adaptive/pin-at-level.test.d.ts.map +1 -0
  58. package/dist/test/adaptive/pin-at-level.test.js +133 -0
  59. package/dist/test/adaptive/pin-at-level.test.js.map +1 -0
  60. package/dist/test/adaptive/single-path-solve.test.d.ts +16 -0
  61. package/dist/test/adaptive/single-path-solve.test.d.ts.map +1 -0
  62. package/dist/test/adaptive/single-path-solve.test.js +204 -0
  63. package/dist/test/adaptive/single-path-solve.test.js.map +1 -0
  64. package/dist/test/anti-redundancy-budget.test.d.ts +17 -0
  65. package/dist/test/anti-redundancy-budget.test.d.ts.map +1 -0
  66. package/dist/test/anti-redundancy-budget.test.js +197 -0
  67. package/dist/test/anti-redundancy-budget.test.js.map +1 -0
  68. package/dist/test/chunk-persistence.test.d.ts +29 -0
  69. package/dist/test/chunk-persistence.test.d.ts.map +1 -0
  70. package/dist/test/chunk-persistence.test.js +290 -0
  71. package/dist/test/chunk-persistence.test.js.map +1 -0
  72. package/dist/test/compression-dedup.test.d.ts +20 -0
  73. package/dist/test/compression-dedup.test.d.ts.map +1 -0
  74. package/dist/test/compression-dedup.test.js +92 -0
  75. package/dist/test/compression-dedup.test.js.map +1 -0
  76. package/dist/test/compression-shape-invariants.test.js +40 -3
  77. package/dist/test/compression-shape-invariants.test.js.map +1 -1
  78. package/dist/test/head-window-order.test.d.ts +19 -0
  79. package/dist/test/head-window-order.test.d.ts.map +1 -0
  80. package/dist/test/head-window-order.test.js +159 -0
  81. package/dist/test/head-window-order.test.js.map +1 -0
  82. package/dist/test/image-strip-render-stats.test.js +25 -3
  83. package/dist/test/image-strip-render-stats.test.js.map +1 -1
  84. package/dist/test/knowledge-tool-boundary.test.d.ts +14 -0
  85. package/dist/test/knowledge-tool-boundary.test.d.ts.map +1 -0
  86. package/dist/test/knowledge-tool-boundary.test.js +93 -0
  87. package/dist/test/knowledge-tool-boundary.test.js.map +1 -0
  88. package/dist/test/l1-holdback.test.d.ts +15 -0
  89. package/dist/test/l1-holdback.test.d.ts.map +1 -0
  90. package/dist/test/l1-holdback.test.js +119 -0
  91. package/dist/test/l1-holdback.test.js.map +1 -0
  92. package/dist/test/merge-contiguity.test.d.ts +11 -0
  93. package/dist/test/merge-contiguity.test.d.ts.map +1 -0
  94. package/dist/test/merge-contiguity.test.js +74 -0
  95. package/dist/test/merge-contiguity.test.js.map +1 -0
  96. package/dist/test/message-store-window.test.d.ts +13 -0
  97. package/dist/test/message-store-window.test.d.ts.map +1 -0
  98. package/dist/test/message-store-window.test.js +177 -0
  99. package/dist/test/message-store-window.test.js.map +1 -0
  100. package/dist/test/pins.test.js +40 -0
  101. package/dist/test/pins.test.js.map +1 -1
  102. package/dist/test/speculation-cap.test.d.ts +9 -3
  103. package/dist/test/speculation-cap.test.d.ts.map +1 -1
  104. package/dist/test/speculation-cap.test.js +179 -3
  105. package/dist/test/speculation-cap.test.js.map +1 -1
  106. package/dist/test/summary-log-consistency.test.d.ts +18 -0
  107. package/dist/test/summary-log-consistency.test.d.ts.map +1 -0
  108. package/dist/test/summary-log-consistency.test.js +97 -0
  109. package/dist/test/summary-log-consistency.test.js.map +1 -0
  110. package/dist/test/tool-pairing-invariant.test.d.ts +15 -0
  111. package/dist/test/tool-pairing-invariant.test.d.ts.map +1 -0
  112. package/dist/test/tool-pairing-invariant.test.js +225 -0
  113. package/dist/test/tool-pairing-invariant.test.js.map +1 -0
  114. package/dist/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +11 -3
  116. package/src/adaptive/kv-control.ts +554 -152
  117. package/src/adaptive/picker.ts +21 -0
  118. package/src/adaptive/strategies/kv-stable.ts +62 -12
  119. package/src/context-log.ts +11 -0
  120. package/src/context-manager.ts +61 -4
  121. package/src/index.ts +6 -2
  122. package/src/message-store.ts +202 -9
  123. package/src/phase-channel.ts +57 -0
  124. package/src/strategies/autobiographical.ts +1482 -167
  125. package/src/strategies/index.ts +1 -1
  126. package/src/strategies/knowledge.ts +25 -1
  127. package/src/types/index.ts +1 -0
  128. package/src/types/strategy.ts +120 -7
@@ -81,11 +81,32 @@ 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.
87
100
  */
88
101
  l1Id?: SummaryId;
102
+ /**
103
+ * Salience ∈ [0,1] — the coefficient on this chunk's information loss
104
+ * (design §13.3). Lower = folds earlier and costs less when folded:
105
+ * content whose payload is externalized (code on disk, tool output,
106
+ * images, link drops) vs conversation that exists nowhere else. Default 1.
107
+ * Honored by the kv-stable controller; never overrides hard protections.
108
+ */
109
+ salience?: number;
89
110
  }
90
111
 
91
112
  export interface PickerInputs {
@@ -30,14 +30,16 @@ import type {
30
30
  } from '../folding-strategy.js';
31
31
  import type { PickerInputs } from '../picker.js';
32
32
  import { SummaryTree } from '../summary-tree.js';
33
- import { planControlledFrontier } from '../kv-control.js';
33
+ import { planControlledFrontier, type ControlPlan } from '../kv-control.js';
34
34
 
35
35
  export interface KvStableOptions {
36
- /** Per-turn divergence reach the structural perturbation cap P, in tokens.
37
- * Smaller = gentler per-turn KV churn (shallow divergence) but less efficient
38
- * compression. Exceeded only to stay under the hard wall. Default: the hard
39
- * budget (effectively unbounded within the window). */
36
+ /** Trust region P on per-turn perturbation, in tokens the provider would
37
+ * re-read (exact kvCost see design §13.4). Within it the solver adopts
38
+ * the relevance-ideal cut; beyond it it amortizes via suffix adoption or
39
+ * overrides with cause. Default: the hard budget (never binds). */
40
40
  reachTokens?: number;
41
+ /** Quality-gap override threshold (design §13.4). Default 0.35. */
42
+ qualityGapRatio?: number;
41
43
  /** Base-k summary grouping (matches the strategy's mergeThreshold). Default 6. */
42
44
  mergeThreshold?: number;
43
45
  }
@@ -49,6 +51,7 @@ export class KvStableStrategy implements FoldingStrategy {
49
51
  private readonly opts: KvStableOptions;
50
52
  private readonly fPrev: Map<ChunkId, number>;
51
53
  private target: Map<ChunkId, number> | null = null;
54
+ private _lastPlan: ControlPlan | null = null;
52
55
 
53
56
  constructor(inputs: PickerInputs, opts: KvStableOptions = {}) {
54
57
  this.inputs = inputs;
@@ -66,38 +69,85 @@ export class KvStableStrategy implements FoldingStrategy {
66
69
  return this.target;
67
70
  }
68
71
 
72
+ /** The full control plan behind the target (perturbation, override) — for
73
+ * observability at the call site (`[kv-escalation]` logging). Null before
74
+ * the first `selectNextFold`. */
75
+ lastPlan(): ControlPlan | null {
76
+ return this._lastPlan;
77
+ }
78
+
69
79
  private solve(budget: FoldingBudget): Map<ChunkId, number> {
70
80
  const tree = new SummaryTree(this.inputs);
71
81
 
72
- // Flat zone (forced raw, never folded): head, tail, pinned. Frozen (kept at
73
- // their carried resolution, never folded): locked.
82
+ // Flat zone (forced raw, never folded): head, tail, classic pins. Frozen
83
+ // (kept at their carried resolution, never folded): locked. V2 dynamic pins
84
+ // (`pinLevel` / `pinMaxLevel`) are honored as a fixed level or a hard fold-
85
+ // depth cap — see `planControlledFrontier`.
74
86
  const rawZone = new Set<ChunkId>();
75
87
  const frozen = new Set<ChunkId>();
88
+ const fixedLevels = new Map<ChunkId, number>();
89
+ const pinCaps = new Map<ChunkId, number>();
90
+ const fixedPins: Array<{ id: ChunkId; level: number }> = [];
76
91
  let now = 0;
77
92
  for (const c of this.inputs.chunks) {
78
93
  if (c.sequence > now) now = c.sequence;
79
94
  if (this.inputs.headChunkIds.has(c.id) || this.inputs.tailChunkIds.has(c.id) || c.pinned) {
80
95
  rawZone.add(c.id);
96
+ } else if (c.pinLevel !== undefined) {
97
+ // Pin-at-level-k: fix exactly at k (0 = raw). k=0 is equivalent to a
98
+ // classic raw pin, so route it through the raw zone; k>0 is resolved to
99
+ // its whole L_k node below (group-consistent — see the loop after).
100
+ if (c.pinLevel <= 0) rawZone.add(c.id);
101
+ else fixedPins.push({ id: c.id, level: c.pinLevel });
102
+ } else if (c.pinMaxLevel !== undefined) {
103
+ // Pin-max-level: a hard fold-depth cap. maxLevel 0 ≡ classic raw pin.
104
+ if (c.pinMaxLevel <= 0) rawZone.add(c.id);
105
+ else pinCaps.set(c.id, c.pinMaxLevel);
106
+ if (c.lockedByAgent) frozen.add(c.id);
81
107
  } else if (c.lockedByAgent) {
82
108
  frozen.add(c.id);
83
109
  }
84
110
  }
85
111
 
86
- return planControlledFrontier(this.inputs, tree, {
112
+ // Group-consistency for pin-at-level-k: an L_k recall pair is atomic over
113
+ // its whole covered range, so "cut through the L_k node" (design §7) fixes
114
+ // EVERY leaf under that node at k — not just the addressed chunk. Fixing a
115
+ // single sub-chunk while its siblings render raw is an unrenderable (and
116
+ // non-converging) frontier. Clamp k to the deepest produced level for the
117
+ // chunk; if none exists, fall back to fixing just the chunk (the controller
118
+ // clamps it further). Skip leaves already forced raw (head/tail/classic pin).
119
+ for (const { id, level } of fixedPins) {
120
+ const eff = Math.min(level, tree.maxLevel(id));
121
+ if (eff <= 0) { rawZone.add(id); continue; }
122
+ const node = tree.ancestorAt(id, eff);
123
+ if (!node) { fixedLevels.set(id, eff); continue; }
124
+ for (const leaf of node.leafChunkIds) {
125
+ if (rawZone.has(leaf)) continue;
126
+ // Finest requirement wins if two pins overlap a leaf.
127
+ const prev = fixedLevels.get(leaf);
128
+ fixedLevels.set(leaf, prev === undefined ? eff : Math.min(prev, eff));
129
+ }
130
+ }
131
+
132
+ const plan = planControlledFrontier(this.inputs, tree, {
87
133
  previous: this.fPrev,
88
- // Bidirectional within the slack band: fold when over the hard budget,
89
- // un-fold to use headroom when under the soft target. Both reach-bounded;
90
- // [targetBudget, totalBudget] is the quiet dead band.
134
+ // Single-path solve (design §13.4): the [targetBudget, totalBudget] band
135
+ // is the quiet dead band; the trust region and overrides do the rest.
91
136
  foldAtTokens: budget.totalBudget,
92
137
  expandAtTokens: budget.targetBudget,
93
138
  targetTokens: budget.targetBudget,
94
139
  windowTokens: budget.totalBudget,
95
140
  reachTokens: this.opts.reachTokens,
141
+ qualityGapRatio: this.opts.qualityGapRatio,
96
142
  rawZone,
97
143
  frozen,
144
+ fixedLevels,
145
+ pinCaps,
98
146
  now,
99
147
  mergeThreshold: this.opts.mergeThreshold,
100
- }).resolutions;
148
+ });
149
+ this._lastPlan = plan;
150
+ return plan.resolutions;
101
151
  }
102
152
 
103
153
  /** Emit one op moving the live state toward the target frontier. */
@@ -323,6 +323,17 @@ export class ContextLog {
323
323
  }
324
324
 
325
325
  private getInternal(index: number): ContextEntryInternal | null {
326
+ // Point lookup through chronicle's per-item cache — O(item size).
327
+ // See MessageStore.getInternal for why this must never fetch the
328
+ // full state per index.
329
+ //
330
+ // Feature-detect: getStateItemJson landed in chronicle 0.2.2; boxes
331
+ // still on <= 0.2.1 (npm copies) fall back to the full-materialization
332
+ // path so a routine `git pull` of this package can never crash them.
333
+ if (typeof (this.store as { getStateItemJson?: unknown }).getStateItemJson === 'function') {
334
+ const item = this.store.getStateItemJson(this.stateId, index);
335
+ return (item as ContextEntryInternal | null) ?? null;
336
+ }
326
337
  const all = this.getAllInternal();
327
338
  return all[index] ?? null;
328
339
  }
@@ -1,5 +1,5 @@
1
1
  import { JsStore } from '@animalabs/chronicle';
2
- import type { Membrane, NormalizedMessage, ContentBlock } from '@animalabs/membrane';
2
+ import type { Membrane, NormalizedMessage, ContentBlock, ToolDefinition } from '@animalabs/membrane';
3
3
  import type {
4
4
  MessageId,
5
5
  Sequence,
@@ -16,13 +16,14 @@ import type {
16
16
  ContextInjection,
17
17
  CompileResult,
18
18
  ProtectedRange,
19
+ PinLevelOptions,
19
20
  SearchQuery,
20
21
  SearchResult,
21
22
  SummaryEntry,
22
23
  } from './types/index.js';
23
24
  import { isResettableStrategy, isPinnableStrategy, isSearchableStrategy, isRenderStatsCapable } from './types/index.js';
24
25
  import type { RenderStats } from './types/index.js';
25
- import { MessageStore, MessageStoreEvent } from './message-store.js';
26
+ import { MessageStore, MessageStoreEvent, MessageStoreListener, MessageWindow, MessageWindowOptions } from './message-store.js';
26
27
  import { ContextLog } from './context-log.js';
27
28
  import { PassthroughStrategy } from './strategies/passthrough.js';
28
29
  import { splitMixedToolMessages } from './normalize-tool-messages.js';
@@ -300,6 +301,31 @@ export class ContextManager {
300
301
  return this.messageStore.getAll();
301
302
  }
302
303
 
304
+ /**
305
+ * Get the total number of messages — O(1).
306
+ */
307
+ getMessageCount(): number {
308
+ return this.messageStore.length();
309
+ }
310
+
311
+ /**
312
+ * Get a window of messages by slot index — O(window), not O(all).
313
+ * See MessageStore.getWindow for options (blob resolution, bodyGroup
314
+ * alignment). Intended for viewers/paginated UIs.
315
+ */
316
+ getMessageWindow(offset: number, limit: number, opts?: MessageWindowOptions): MessageWindow {
317
+ return this.messageStore.getWindow(offset, limit, opts);
318
+ }
319
+
320
+ /**
321
+ * Subscribe to message-store mutations (add/edit/remove/removeRange).
322
+ * Returns a detacher. Unlike trace events, this fires for ALL stored
323
+ * messages including assistant turns and tool results.
324
+ */
325
+ onMessage(listener: MessageStoreListener): () => void {
326
+ return this.messageStore.addListener(listener);
327
+ }
328
+
303
329
  /**
304
330
  * Query messages by filter criteria.
305
331
  * Useful for finding messages from external sources, by participant, etc.
@@ -640,19 +666,29 @@ export class ContextManager {
640
666
  *
641
667
  * Throws if the active strategy doesn't support pins.
642
668
  */
643
- pinRange(firstMessageId: MessageId, lastMessageId: MessageId, opts?: { name?: string }): string {
669
+ pinRange(firstMessageId: MessageId, lastMessageId: MessageId, opts?: PinLevelOptions): string {
644
670
  if (!isPinnableStrategy(this.strategy)) {
645
671
  throw new Error('Active strategy does not support pins');
646
672
  }
647
673
  return this.strategy.pinRange(firstMessageId, lastMessageId, opts);
648
674
  }
649
675
 
676
+ /**
677
+ * V2 dynamic pin-at-level-k: fix a range to render at EXACTLY fold level
678
+ * `level` (0 = raw) — the frontier cut passes through that L_k node. Honored
679
+ * only by `foldingStrategy: 'kv-stable'`; other strategies fall back to
680
+ * treating the range as raw. Returns the new pin id.
681
+ */
682
+ pinAtLevel(firstMessageId: MessageId, lastMessageId: MessageId, level: number, opts?: { name?: string }): string {
683
+ return this.pinRange(firstMessageId, lastMessageId, { name: opts?.name, level });
684
+ }
685
+
650
686
  /**
651
687
  * Mark a single message as a "document" (semantically a body of
652
688
  * information to retain in full). Same effect as a single-message pin
653
689
  * with `kind: 'document'`. Returns the new pin id.
654
690
  */
655
- markDocument(messageId: MessageId, opts?: { name?: string }): string {
691
+ markDocument(messageId: MessageId, opts?: PinLevelOptions): string {
656
692
  if (!isPinnableStrategy(this.strategy)) {
657
693
  throw new Error('Active strategy does not support documents');
658
694
  }
@@ -758,7 +794,22 @@ export class ContextManager {
758
794
  }
759
795
  }
760
796
 
797
+ /**
798
+ * Live tool definitions for the owning agent, refreshed by the host on
799
+ * every activation (Agent.buildActivationRequest in agent-framework).
800
+ * Threaded into StrategyContext so compression/summarizer LLM calls can
801
+ * declare the same tools as the live instance — required to avoid
802
+ * reasoning_extraction refusals on transcripts containing tool blocks.
803
+ */
804
+ private toolDefinitions?: ToolDefinition[];
805
+
806
+ /** Host hook: record the agent's current tool definitions (see above). */
807
+ setToolDefinitions(tools: ToolDefinition[] | undefined): void {
808
+ if (tools && tools.length > 0) this.toolDefinitions = tools;
809
+ }
810
+
761
811
  private createStrategyContext(): StrategyContext {
812
+ const self = this;
762
813
  return {
763
814
  messageStore: this.messageStore.createView(),
764
815
  contextLog: this.contextLog.createView(),
@@ -766,6 +817,12 @@ export class ContextManager {
766
817
  currentSequence: this.store.currentSequence(),
767
818
  store: this.store,
768
819
  namespace: this.strategyNamespace,
820
+ // Live getter, not a snapshot: strategies capture a ctx object once and
821
+ // reuse it across a long-running drain (driveSpeculativeDrain recurses
822
+ // with the same ctx). A snapshot taken before the session's first
823
+ // activation would freeze `tools` as undefined for the drain's entire
824
+ // lifetime — the getter always reflects the latest activation.
825
+ get tools() { return self.toolDefinitions; },
769
826
  };
770
827
  }
771
828
 
package/src/index.ts CHANGED
@@ -2,15 +2,19 @@
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
- export type { MessageStoreEvent, MessageStoreListener } from './message-store.js';
10
+ export type { MessageStoreEvent, MessageStoreListener, MessageWindow, MessageWindowOptions } from './message-store.js';
11
+ export { concatBodyGroups } from './adaptive/render.js';
8
12
  export { ContextLog } from './context-log.js';
9
13
  export { BlobManager } from './blob-manager.js';
10
14
 
11
15
  // Strategies
12
16
  export { PassthroughStrategy } from './strategies/passthrough.js';
13
- export { AutobiographicalStrategy, type AutobiographicalProgressSnapshot } from './strategies/autobiographical.js';
17
+ export { AutobiographicalStrategy, type AutobiographicalProgressSnapshot, type Chunk } from './strategies/autobiographical.js';
14
18
  export { KnowledgeStrategy } from './strategies/knowledge.js';
15
19
 
16
20
  // Utilities
@@ -28,6 +28,39 @@ export type MessageStoreEvent =
28
28
  */
29
29
  export type MessageStoreListener = (event: MessageStoreEvent) => void;
30
30
 
31
+ /**
32
+ * Options for windowed message reads.
33
+ */
34
+ export interface MessageWindowOptions {
35
+ /**
36
+ * Re-inline blob media (images/documents) into content blocks, matching
37
+ * the behavior of get()/getAll(). Default true. Viewers that only need
38
+ * text/thinking/tool blocks should pass false to avoid inflating large
39
+ * base64 payloads.
40
+ */
41
+ resolveBlobs?: boolean;
42
+ /**
43
+ * Extend the window edges outward so that no bodyGroup (shard run of a
44
+ * single large message) is split across the window boundary. Default
45
+ * false (exact offset/limit semantics).
46
+ */
47
+ alignToBodyGroups?: boolean;
48
+ }
49
+
50
+ /**
51
+ * A window of messages plus enough metadata to page through the store.
52
+ */
53
+ export interface MessageWindow {
54
+ messages: StoredMessage[];
55
+ /**
56
+ * Actual first slot index of the returned window. May be lower than the
57
+ * requested offset when alignToBodyGroups extended the window backward.
58
+ */
59
+ startIndex: number;
60
+ /** Total number of messages in the store at read time. */
61
+ totalCount: number;
62
+ }
63
+
31
64
  /**
32
65
  * Options for token estimation.
33
66
  */
@@ -358,19 +391,72 @@ export class MessageStore {
358
391
  );
359
392
  }
360
393
 
394
+ /**
395
+ * Get a window of messages by slot index — O(window), not O(all).
396
+ *
397
+ * Backed by chronicle's `getStateSlice` (0.2.2+) with a
398
+ * full-materialization fallback for older chronicle copies, mirroring
399
+ * the feature-detect in getInternal().
400
+ */
401
+ getWindow(offset: number, limit: number, opts: MessageWindowOptions = {}): MessageWindow {
402
+ const totalCount = this.length();
403
+ let start = Math.max(0, Math.min(offset, totalCount));
404
+ let end = Math.min(start + Math.max(0, limit), totalCount);
405
+
406
+ if (start >= end) {
407
+ return { messages: [], startIndex: Math.min(start, totalCount), totalCount };
408
+ }
409
+
410
+ if (opts.alignToBodyGroups) {
411
+ // Shards of a bodyGroup are contiguous by construction (removeRange
412
+ // refuses to bisect a group). Walk edges outward with O(item) point
413
+ // lookups until the group boundary.
414
+ const first = this.getInternal(start);
415
+ if (first?.bodyGroupId !== undefined) {
416
+ while (start > 0) {
417
+ const prev = this.getInternal(start - 1);
418
+ if (prev?.bodyGroupId !== first.bodyGroupId) break;
419
+ start--;
420
+ }
421
+ }
422
+ const last = this.getInternal(end - 1);
423
+ if (last?.bodyGroupId !== undefined) {
424
+ while (end < totalCount) {
425
+ const next = this.getInternal(end);
426
+ if (next?.bodyGroupId !== last.bodyGroupId) break;
427
+ end++;
428
+ }
429
+ }
430
+ }
431
+
432
+ const internals = this.getSliceInternal(start, end - start);
433
+ const resolveBlobs = opts.resolveBlobs !== false;
434
+ return {
435
+ messages: internals.map((internal, i) =>
436
+ this.internalToStored(internal, internal.id, start + i, resolveBlobs)
437
+ ),
438
+ startIndex: start,
439
+ totalCount,
440
+ };
441
+ }
442
+
361
443
  /**
362
444
  * Get messages from a specific index.
445
+ * Negative indices count from the end, matching Array.prototype.slice.
363
446
  */
364
447
  getFrom(index: number): StoredMessage[] {
365
- return this.getAll().slice(index);
448
+ const len = this.length();
449
+ const start = index < 0 ? Math.max(0, len + index) : Math.min(index, len);
450
+ return this.getWindow(start, len - start).messages;
366
451
  }
367
452
 
368
453
  /**
369
454
  * Get the last N messages.
370
455
  */
371
456
  getTail(count: number): StoredMessage[] {
372
- const all = this.getAll();
373
- return all.slice(Math.max(0, all.length - count));
457
+ const len = this.length();
458
+ const n = Math.max(0, Math.min(count, len));
459
+ return this.getWindow(len - n, n).messages;
374
460
  }
375
461
 
376
462
  /**
@@ -391,21 +477,62 @@ export class MessageStore {
391
477
  return tokens;
392
478
  }
393
479
 
480
+ /**
481
+ * Closed-loop calibration multiplier applied to every estimate (default 1).
482
+ * Owned by the strategy: it compares real `usage` totals against the
483
+ * compile-time estimate and feeds the EMA back here, so the store's
484
+ * numbers track the live model/content mix instead of a fixed heuristic.
485
+ */
486
+ private tokenCalibration = 1;
487
+
488
+ setTokenCalibration(factor: number): void {
489
+ if (Number.isFinite(factor) && factor > 0.25 && factor < 4) this.tokenCalibration = factor;
490
+ }
491
+
492
+ getTokenCalibration(): number {
493
+ return this.tokenCalibration;
494
+ }
495
+
496
+ /**
497
+ * Hidden-CoT price for a signed thinking block whose text was summarized/
498
+ * redacted away (2026-07-12): the block estimates as EMPTY client-side, but
499
+ * signed replay bills the FULL original chain of thought. Measured on
500
+ * mythos production turns: median ~608 tokens/block, mean ~590, p90 ~1210.
501
+ * A per-block `tokenEstimate` stamped at creation (from usage residuals)
502
+ * takes precedence; this constant is the fallback for unstamped history.
503
+ */
504
+ static readonly HIDDEN_THINKING_TOKENS_DEFAULT = 600;
505
+
394
506
  private estimateBlockTokens(block: ContentBlock): number {
507
+ return Math.round(this.estimateBlockTokensRaw(block) * this.tokenCalibration);
508
+ }
509
+
510
+ private estimateBlockTokensRaw(block: ContentBlock): number {
395
511
  switch (block.type) {
396
512
  case 'text':
397
513
  return this.tokenEstimator(block.text);
398
- case 'thinking':
514
+ case 'thinking': {
515
+ // Stamped price wins; a signed-but-empty block is a HIDDEN full CoT
516
+ // (never "no thinking") — price it at the measured default.
517
+ const stamped = (block as { tokenEstimate?: number }).tokenEstimate;
518
+ if (typeof stamped === 'number') return stamped;
519
+ const hasSignature =
520
+ typeof (block as { signature?: string }).signature === 'string' &&
521
+ ((block as { signature?: string }).signature as string).length > 0;
522
+ if (hasSignature && (!block.thinking || block.thinking.length === 0)) {
523
+ return MessageStore.HIDDEN_THINKING_TOKENS_DEFAULT;
524
+ }
399
525
  return this.tokenEstimator(block.thinking);
526
+ }
400
527
  case 'tool_use':
401
- return this.tokenEstimator(JSON.stringify(block.input)) + 20; // overhead for name, id
528
+ return jsonTokenEstimator(JSON.stringify(block.input)) + 20; // overhead for name, id
402
529
  case 'tool_result':
403
530
  if (!block.content) return 0;
404
531
  if (typeof block.content === 'string') {
405
- return this.tokenEstimator(block.content);
532
+ return jsonTokenEstimator(block.content);
406
533
  }
407
534
  if (Array.isArray(block.content)) {
408
- return block.content.reduce((sum, b) => sum + this.estimateBlockTokens(b), 0);
535
+ return block.content.reduce((sum, b) => sum + this.estimateBlockTokensRaw(b), 0);
409
536
  }
410
537
  return 0;
411
538
  case 'image':
@@ -429,6 +556,8 @@ export class MessageStore {
429
556
  getFrom: (index) => this.getFrom(index),
430
557
  getTail: (count) => this.getTail(count),
431
558
  length: () => this.length(),
559
+ setTokenCalibration: (f: number) => this.setTokenCalibration(f),
560
+ getTokenCalibration: () => this.getTokenCalibration(),
432
561
  estimateTokens: (msg) => this.estimateTokens(msg),
433
562
  };
434
563
  }
@@ -545,7 +674,35 @@ export class MessageStore {
545
674
  return state as StoredMessageInternal[];
546
675
  }
547
676
 
677
+ private getSliceInternal(offset: number, limit: number): StoredMessageInternal[] {
678
+ // Windowed read — O(window) JSON conversion instead of materializing
679
+ // the entire state slot. getStateSlice landed in chronicle 0.2.2 and
680
+ // returns the window as a JSON-array Buffer; feature-detect so boxes
681
+ // on <= 0.2.1 fall back to full materialization (same pattern as
682
+ // getInternal below).
683
+ const s = this.store as { getStateSlice?: (id: string, offset: number, limit: number) => Buffer | null };
684
+ if (typeof s.getStateSlice === 'function') {
685
+ const buf = s.getStateSlice(this.stateId, offset, limit);
686
+ if (!buf) return [];
687
+ return JSON.parse(buf.toString('utf-8')) as StoredMessageInternal[];
688
+ }
689
+ return this.getAllInternal().slice(offset, offset + limit);
690
+ }
691
+
548
692
  private getInternal(index: number): StoredMessageInternal | null {
693
+ // Point lookup through chronicle's per-item cache — O(item size).
694
+ // Never fetch the full state for a single index: with a 4.6k-message
695
+ // session, each full `getStateJson` materialization cost ~15ms, and
696
+ // per-entry get() loops turned renders into minutes (observed on
697
+ // Lena, 2026-07-02, /debug/context at 51–108s).
698
+ //
699
+ // Feature-detect: getStateItemJson landed in chronicle 0.2.2; boxes
700
+ // still on <= 0.2.1 (npm copies) fall back to the full-materialization
701
+ // path so a routine `git pull` of this package can never crash them.
702
+ if (typeof (this.store as { getStateItemJson?: unknown }).getStateItemJson === 'function') {
703
+ const item = this.store.getStateItemJson(this.stateId, index);
704
+ return (item as StoredMessageInternal | null) ?? null;
705
+ }
549
706
  const all = this.getAllInternal();
550
707
  return all[index] ?? null;
551
708
  }
@@ -554,6 +711,7 @@ export class MessageStore {
554
711
  internal: StoredMessageInternal,
555
712
  id: MessageId,
556
713
  _index: number,
714
+ resolveBlobs: boolean = true,
557
715
  ): StoredMessage {
558
716
  const stored: StoredMessage = {
559
717
  id,
@@ -570,7 +728,12 @@ export class MessageStore {
570
728
  // so /undo of a 800-message conversation forked ~400 messages back.
571
729
  sequence: internal.sequence,
572
730
  participant: internal.participant,
573
- content: this.blobManager.resolveBlobs(internal.content),
731
+ // When resolveBlobs is false, blob_ref placeholder blocks are passed
732
+ // through un-inflated (StoredContentBlock ⊂ wire-safe superset of
733
+ // ContentBlock for viewer purposes).
734
+ content: resolveBlobs
735
+ ? this.blobManager.resolveBlobs(internal.content)
736
+ : (internal.content as unknown as ContentBlock[]),
574
737
  metadata: internal.metadata,
575
738
  timestamp: new Date(internal.timestamp),
576
739
  causedBy: internal.causedBy,
@@ -587,6 +750,36 @@ export class MessageStore {
587
750
  /**
588
751
  * Default token estimator: chars / 4
589
752
  */
753
+ /**
754
+ * Content-class token rates (2026-07-12, measured on mythos production
755
+ * requests by reconciling real `usage` against per-class char counts):
756
+ * - prose (Discord multiparty, markdown, emoji) ≈ 2.9 chars/token
757
+ * - JSON / tool i/o / code ≈ 2.3 chars/token
758
+ * The old flat chars/4 under-priced real windows by ~1.7-1.9x (a 183.6k
759
+ * "hard budget" compiled to a 344k request — straight into the refusal
760
+ * band). Rates are deliberately slightly conservative; the closed-loop
761
+ * calibration multiplier trims the residual per agent.
762
+ */
763
+ const PROSE_CHARS_PER_TOKEN = 2.9;
764
+ const DENSE_CHARS_PER_TOKEN = 2.3;
765
+
590
766
  function defaultTokenEstimator(text: string): number {
591
- return Math.ceil(text.length / 4);
767
+ if (!text) return 0;
768
+ // Cheap density probe: JSON/code punctuation and non-ASCII share.
769
+ let dense = 0;
770
+ const n = Math.min(text.length, 2000);
771
+ for (let i = 0; i < n; i++) {
772
+ const c = text.charCodeAt(i);
773
+ if (c > 126) { dense++; continue; } // non-ASCII (emoji, accents, CJK)
774
+ const ch = text[i];
775
+ if (ch === '{' || ch === '}' || ch === '[' || ch === ']' || ch === '"' || ch === ':' || ch === '_' || ch === '/' || ch === '=' || ch === '`') dense++;
776
+ }
777
+ const rate = dense / n > 0.12 ? DENSE_CHARS_PER_TOKEN : PROSE_CHARS_PER_TOKEN;
778
+ return Math.ceil(text.length / rate);
779
+ }
780
+
781
+ /** JSON-ish payloads (tool inputs/results) always use the dense rate. */
782
+ function jsonTokenEstimator(text: string): number {
783
+ if (!text) return 0;
784
+ return Math.ceil(text.length / DENSE_CHARS_PER_TOKEN);
592
785
  }
@@ -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
+ }