@animalabs/context-manager 0.5.5 → 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 (111) hide show
  1. package/dist/src/adaptive/kv-control.d.ts +69 -32
  2. package/dist/src/adaptive/kv-control.d.ts.map +1 -1
  3. package/dist/src/adaptive/kv-control.js +471 -172
  4. package/dist/src/adaptive/kv-control.js.map +1 -1
  5. package/dist/src/adaptive/picker.d.ts +8 -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 +14 -5
  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 +28 -1
  16. package/dist/src/context-manager.d.ts.map +1 -1
  17. package/dist/src/context-manager.js +42 -0
  18. package/dist/src/context-manager.js.map +1 -1
  19. package/dist/src/index.d.ts +3 -2
  20. package/dist/src/index.d.ts.map +1 -1
  21. package/dist/src/index.js +1 -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/strategies/autobiographical.d.ts +207 -2
  28. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  29. package/dist/src/strategies/autobiographical.js +1283 -124
  30. package/dist/src/strategies/autobiographical.js.map +1 -1
  31. package/dist/src/strategies/index.d.ts +1 -1
  32. package/dist/src/strategies/index.d.ts.map +1 -1
  33. package/dist/src/strategies/index.js.map +1 -1
  34. package/dist/src/strategies/knowledge.d.ts +8 -0
  35. package/dist/src/strategies/knowledge.d.ts.map +1 -1
  36. package/dist/src/strategies/knowledge.js +23 -1
  37. package/dist/src/strategies/knowledge.js.map +1 -1
  38. package/dist/src/types/strategy.d.ts +82 -5
  39. package/dist/src/types/strategy.d.ts.map +1 -1
  40. package/dist/src/types/strategy.js.map +1 -1
  41. package/dist/test/adaptive/doc-plus-chat.test.js +7 -3
  42. package/dist/test/adaptive/doc-plus-chat.test.js.map +1 -1
  43. package/dist/test/adaptive/hard-fail.test.js +22 -0
  44. package/dist/test/adaptive/hard-fail.test.js.map +1 -1
  45. package/dist/test/adaptive/ingestion.test.js +3 -1
  46. package/dist/test/adaptive/ingestion.test.js.map +1 -1
  47. package/dist/test/adaptive/kv-control.test.js +17 -11
  48. package/dist/test/adaptive/kv-control.test.js.map +1 -1
  49. package/dist/test/adaptive/single-path-solve.test.d.ts +16 -0
  50. package/dist/test/adaptive/single-path-solve.test.d.ts.map +1 -0
  51. package/dist/test/adaptive/single-path-solve.test.js +204 -0
  52. package/dist/test/adaptive/single-path-solve.test.js.map +1 -0
  53. package/dist/test/anti-redundancy-budget.test.d.ts +17 -0
  54. package/dist/test/anti-redundancy-budget.test.d.ts.map +1 -0
  55. package/dist/test/anti-redundancy-budget.test.js +197 -0
  56. package/dist/test/anti-redundancy-budget.test.js.map +1 -0
  57. package/dist/test/chunk-persistence.test.d.ts +29 -0
  58. package/dist/test/chunk-persistence.test.d.ts.map +1 -0
  59. package/dist/test/chunk-persistence.test.js +290 -0
  60. package/dist/test/chunk-persistence.test.js.map +1 -0
  61. package/dist/test/compression-dedup.test.d.ts +20 -0
  62. package/dist/test/compression-dedup.test.d.ts.map +1 -0
  63. package/dist/test/compression-dedup.test.js +92 -0
  64. package/dist/test/compression-dedup.test.js.map +1 -0
  65. package/dist/test/compression-shape-invariants.test.js +40 -3
  66. package/dist/test/compression-shape-invariants.test.js.map +1 -1
  67. package/dist/test/head-window-order.test.d.ts +19 -0
  68. package/dist/test/head-window-order.test.d.ts.map +1 -0
  69. package/dist/test/head-window-order.test.js +159 -0
  70. package/dist/test/head-window-order.test.js.map +1 -0
  71. package/dist/test/image-strip-render-stats.test.js +25 -3
  72. package/dist/test/image-strip-render-stats.test.js.map +1 -1
  73. package/dist/test/knowledge-tool-boundary.test.d.ts +14 -0
  74. package/dist/test/knowledge-tool-boundary.test.d.ts.map +1 -0
  75. package/dist/test/knowledge-tool-boundary.test.js +93 -0
  76. package/dist/test/knowledge-tool-boundary.test.js.map +1 -0
  77. package/dist/test/l1-holdback.test.d.ts +15 -0
  78. package/dist/test/l1-holdback.test.d.ts.map +1 -0
  79. package/dist/test/l1-holdback.test.js +119 -0
  80. package/dist/test/l1-holdback.test.js.map +1 -0
  81. package/dist/test/merge-contiguity.test.d.ts +11 -0
  82. package/dist/test/merge-contiguity.test.d.ts.map +1 -0
  83. package/dist/test/merge-contiguity.test.js +74 -0
  84. package/dist/test/merge-contiguity.test.js.map +1 -0
  85. package/dist/test/message-store-window.test.d.ts +13 -0
  86. package/dist/test/message-store-window.test.d.ts.map +1 -0
  87. package/dist/test/message-store-window.test.js +177 -0
  88. package/dist/test/message-store-window.test.js.map +1 -0
  89. package/dist/test/speculation-cap.test.js +4 -0
  90. package/dist/test/speculation-cap.test.js.map +1 -1
  91. package/dist/test/summary-log-consistency.test.d.ts +18 -0
  92. package/dist/test/summary-log-consistency.test.d.ts.map +1 -0
  93. package/dist/test/summary-log-consistency.test.js +97 -0
  94. package/dist/test/summary-log-consistency.test.js.map +1 -0
  95. package/dist/test/tool-pairing-invariant.test.d.ts +15 -0
  96. package/dist/test/tool-pairing-invariant.test.d.ts.map +1 -0
  97. package/dist/test/tool-pairing-invariant.test.js +225 -0
  98. package/dist/test/tool-pairing-invariant.test.js.map +1 -0
  99. package/dist/tsconfig.tsbuildinfo +1 -1
  100. package/package.json +11 -3
  101. package/src/adaptive/kv-control.ts +509 -184
  102. package/src/adaptive/picker.ts +8 -0
  103. package/src/adaptive/strategies/kv-stable.ts +22 -10
  104. package/src/context-log.ts +11 -0
  105. package/src/context-manager.ts +48 -2
  106. package/src/index.ts +3 -2
  107. package/src/message-store.ts +202 -9
  108. package/src/strategies/autobiographical.ts +1343 -148
  109. package/src/strategies/index.ts +1 -1
  110. package/src/strategies/knowledge.ts +25 -1
  111. package/src/types/strategy.ts +89 -5
@@ -99,6 +99,14 @@ export interface PickerChunk {
99
99
  * by walking parentId pointers in the summary tree.
100
100
  */
101
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;
102
110
  }
103
111
 
104
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,6 +69,13 @@ 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
 
@@ -119,23 +129,25 @@ export class KvStableStrategy implements FoldingStrategy {
119
129
  }
120
130
  }
121
131
 
122
- return planControlledFrontier(this.inputs, tree, {
132
+ const plan = planControlledFrontier(this.inputs, tree, {
123
133
  previous: this.fPrev,
124
- // Bidirectional within the slack band: fold when over the hard budget,
125
- // un-fold to use headroom when under the soft target. Both reach-bounded;
126
- // [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.
127
136
  foldAtTokens: budget.totalBudget,
128
137
  expandAtTokens: budget.targetBudget,
129
138
  targetTokens: budget.targetBudget,
130
139
  windowTokens: budget.totalBudget,
131
140
  reachTokens: this.opts.reachTokens,
141
+ qualityGapRatio: this.opts.qualityGapRatio,
132
142
  rawZone,
133
143
  frozen,
134
144
  fixedLevels,
135
145
  pinCaps,
136
146
  now,
137
147
  mergeThreshold: this.opts.mergeThreshold,
138
- }).resolutions;
148
+ });
149
+ this._lastPlan = plan;
150
+ return plan.resolutions;
139
151
  }
140
152
 
141
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,
@@ -23,7 +23,7 @@ import type {
23
23
  } from './types/index.js';
24
24
  import { isResettableStrategy, isPinnableStrategy, isSearchableStrategy, isRenderStatsCapable } from './types/index.js';
25
25
  import type { RenderStats } from './types/index.js';
26
- import { MessageStore, MessageStoreEvent } from './message-store.js';
26
+ import { MessageStore, MessageStoreEvent, MessageStoreListener, MessageWindow, MessageWindowOptions } from './message-store.js';
27
27
  import { ContextLog } from './context-log.js';
28
28
  import { PassthroughStrategy } from './strategies/passthrough.js';
29
29
  import { splitMixedToolMessages } from './normalize-tool-messages.js';
@@ -301,6 +301,31 @@ export class ContextManager {
301
301
  return this.messageStore.getAll();
302
302
  }
303
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
+
304
329
  /**
305
330
  * Query messages by filter criteria.
306
331
  * Useful for finding messages from external sources, by participant, etc.
@@ -769,7 +794,22 @@ export class ContextManager {
769
794
  }
770
795
  }
771
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
+
772
811
  private createStrategyContext(): StrategyContext {
812
+ const self = this;
773
813
  return {
774
814
  messageStore: this.messageStore.createView(),
775
815
  contextLog: this.contextLog.createView(),
@@ -777,6 +817,12 @@ export class ContextManager {
777
817
  currentSequence: this.store.currentSequence(),
778
818
  store: this.store,
779
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; },
780
826
  };
781
827
  }
782
828
 
package/src/index.ts CHANGED
@@ -7,13 +7,14 @@ export { phaseChannel, enterPhase, withPhase, withPhaseAsync } from './phase-cha
7
7
 
8
8
  // Storage
9
9
  export { MessageStore } from './message-store.js';
10
- 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';
11
12
  export { ContextLog } from './context-log.js';
12
13
  export { BlobManager } from './blob-manager.js';
13
14
 
14
15
  // Strategies
15
16
  export { PassthroughStrategy } from './strategies/passthrough.js';
16
- export { AutobiographicalStrategy, type AutobiographicalProgressSnapshot } from './strategies/autobiographical.js';
17
+ export { AutobiographicalStrategy, type AutobiographicalProgressSnapshot, type Chunk } from './strategies/autobiographical.js';
17
18
  export { KnowledgeStrategy } from './strategies/knowledge.js';
18
19
 
19
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
  }