@animalabs/context-manager 0.5.2 → 0.5.4

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 (110) hide show
  1. package/dist/src/adaptive/index.d.ts +6 -0
  2. package/dist/src/adaptive/index.d.ts.map +1 -1
  3. package/dist/src/adaptive/index.js +8 -0
  4. package/dist/src/adaptive/index.js.map +1 -1
  5. package/dist/src/adaptive/kv-cache-sim.d.ts +128 -0
  6. package/dist/src/adaptive/kv-cache-sim.d.ts.map +1 -0
  7. package/dist/src/adaptive/kv-cache-sim.js +0 -0
  8. package/dist/src/adaptive/kv-cache-sim.js.map +1 -0
  9. package/dist/src/adaptive/kv-control.d.ts +152 -0
  10. package/dist/src/adaptive/kv-control.d.ts.map +1 -0
  11. package/dist/src/adaptive/kv-control.js +368 -0
  12. package/dist/src/adaptive/kv-control.js.map +1 -0
  13. package/dist/src/adaptive/kv-replay.d.ts +76 -0
  14. package/dist/src/adaptive/kv-replay.d.ts.map +1 -0
  15. package/dist/src/adaptive/kv-replay.js +175 -0
  16. package/dist/src/adaptive/kv-replay.js.map +1 -0
  17. package/dist/src/adaptive/render-offsets.d.ts +61 -0
  18. package/dist/src/adaptive/render-offsets.d.ts.map +1 -0
  19. package/dist/src/adaptive/render-offsets.js +88 -0
  20. package/dist/src/adaptive/render-offsets.js.map +1 -0
  21. package/dist/src/adaptive/strategies/kv-stable.d.ts +48 -0
  22. package/dist/src/adaptive/strategies/kv-stable.d.ts.map +1 -0
  23. package/dist/src/adaptive/strategies/kv-stable.js +102 -0
  24. package/dist/src/adaptive/strategies/kv-stable.js.map +1 -0
  25. package/dist/src/adaptive/summary-tree.d.ts +98 -0
  26. package/dist/src/adaptive/summary-tree.d.ts.map +1 -0
  27. package/dist/src/adaptive/summary-tree.js +214 -0
  28. package/dist/src/adaptive/summary-tree.js.map +1 -0
  29. package/dist/src/context-log.d.ts.map +1 -1
  30. package/dist/src/context-log.js +7 -2
  31. package/dist/src/context-log.js.map +1 -1
  32. package/dist/src/index.d.ts +1 -1
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +1 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/message-store.js +1 -1
  37. package/dist/src/message-store.js.map +1 -1
  38. package/dist/src/normalize-tool-messages.d.ts +33 -0
  39. package/dist/src/normalize-tool-messages.d.ts.map +1 -1
  40. package/dist/src/normalize-tool-messages.js +59 -0
  41. package/dist/src/normalize-tool-messages.js.map +1 -1
  42. package/dist/src/strategies/autobiographical.d.ts +101 -39
  43. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  44. package/dist/src/strategies/autobiographical.js +398 -222
  45. package/dist/src/strategies/autobiographical.js.map +1 -1
  46. package/dist/src/types/strategy.d.ts +40 -1
  47. package/dist/src/types/strategy.d.ts.map +1 -1
  48. package/dist/src/types/strategy.js +2 -0
  49. package/dist/src/types/strategy.js.map +1 -1
  50. package/dist/test/adaptive/kv-cache-sim.test.d.ts +16 -0
  51. package/dist/test/adaptive/kv-cache-sim.test.d.ts.map +1 -0
  52. package/dist/test/adaptive/kv-cache-sim.test.js +173 -0
  53. package/dist/test/adaptive/kv-cache-sim.test.js.map +1 -0
  54. package/dist/test/adaptive/kv-control.test.d.ts +14 -0
  55. package/dist/test/adaptive/kv-control.test.d.ts.map +1 -0
  56. package/dist/test/adaptive/kv-control.test.js +130 -0
  57. package/dist/test/adaptive/kv-control.test.js.map +1 -0
  58. package/dist/test/adaptive/kv-replay.test.d.ts +15 -0
  59. package/dist/test/adaptive/kv-replay.test.d.ts.map +1 -0
  60. package/dist/test/adaptive/kv-replay.test.js +112 -0
  61. package/dist/test/adaptive/kv-replay.test.js.map +1 -0
  62. package/dist/test/adaptive/kv-stable-strategy.test.d.ts +10 -0
  63. package/dist/test/adaptive/kv-stable-strategy.test.d.ts.map +1 -0
  64. package/dist/test/adaptive/kv-stable-strategy.test.js +89 -0
  65. package/dist/test/adaptive/kv-stable-strategy.test.js.map +1 -0
  66. package/dist/test/adaptive/render-offsets.test.d.ts +11 -0
  67. package/dist/test/adaptive/render-offsets.test.d.ts.map +1 -0
  68. package/dist/test/adaptive/render-offsets.test.js +85 -0
  69. package/dist/test/adaptive/render-offsets.test.js.map +1 -0
  70. package/dist/test/adaptive/summary-tree.test.d.ts +5 -0
  71. package/dist/test/adaptive/summary-tree.test.d.ts.map +1 -0
  72. package/dist/test/adaptive/summary-tree.test.js +72 -0
  73. package/dist/test/adaptive/summary-tree.test.js.map +1 -0
  74. package/dist/test/chunker-tool-boundary.test.d.ts +12 -0
  75. package/dist/test/chunker-tool-boundary.test.d.ts.map +1 -0
  76. package/dist/test/chunker-tool-boundary.test.js +89 -0
  77. package/dist/test/chunker-tool-boundary.test.js.map +1 -0
  78. package/dist/test/compression-shape-invariants.test.d.ts +22 -0
  79. package/dist/test/compression-shape-invariants.test.d.ts.map +1 -0
  80. package/dist/test/compression-shape-invariants.test.js +312 -0
  81. package/dist/test/compression-shape-invariants.test.js.map +1 -0
  82. package/dist/test/image-strip-render-stats.test.d.ts +2 -0
  83. package/dist/test/image-strip-render-stats.test.d.ts.map +1 -0
  84. package/dist/test/image-strip-render-stats.test.js +130 -0
  85. package/dist/test/image-strip-render-stats.test.js.map +1 -0
  86. package/dist/test/image-stripping.test.d.ts +13 -0
  87. package/dist/test/image-stripping.test.d.ts.map +1 -0
  88. package/dist/test/image-stripping.test.js +104 -0
  89. package/dist/test/image-stripping.test.js.map +1 -0
  90. package/dist/test/normalize-tool-messages.test.js +61 -1
  91. package/dist/test/normalize-tool-messages.test.js.map +1 -1
  92. package/dist/test/render-stats.test.d.ts +2 -0
  93. package/dist/test/render-stats.test.d.ts.map +1 -0
  94. package/dist/test/render-stats.test.js +91 -0
  95. package/dist/test/render-stats.test.js.map +1 -0
  96. package/dist/tsconfig.tsbuildinfo +1 -1
  97. package/package.json +2 -2
  98. package/src/adaptive/index.ts +48 -0
  99. package/src/adaptive/kv-cache-sim.ts +0 -0
  100. package/src/adaptive/kv-control.ts +497 -0
  101. package/src/adaptive/kv-replay.ts +238 -0
  102. package/src/adaptive/render-offsets.ts +116 -0
  103. package/src/adaptive/strategies/kv-stable.ts +123 -0
  104. package/src/adaptive/summary-tree.ts +252 -0
  105. package/src/context-log.ts +7 -2
  106. package/src/index.ts +1 -1
  107. package/src/message-store.ts +1 -1
  108. package/src/normalize-tool-messages.ts +61 -0
  109. package/src/strategies/autobiographical.ts +414 -271
  110. package/src/types/strategy.ts +40 -1
@@ -0,0 +1,252 @@
1
+ /**
2
+ * SummaryTree — a typed, structural view over the adaptive summary forest.
3
+ *
4
+ * Built from PickerInputs (chunks + summaries + per-summary recall-pair token
5
+ * counts). Provides the navigation a frontier solver needs — ancestor chains,
6
+ * recall-pair token costs, leaf coverage, root/children traversal — with NO
7
+ * rendering or resolution state. Pure and deterministic.
8
+ *
9
+ * Structure mirrors the model the picker already uses:
10
+ * - `chunk.l1Id` + the `SummaryEntry.parentId` chain define the upward path.
11
+ * - `SummaryEntry.sourceLevel === 0` means `sourceIds` are leaf (message) ids;
12
+ * otherwise they are child summary ids.
13
+ * - A folded node's token cost is its recall-pair size
14
+ * (`recallPairTokens` ?? `SummaryEntry.tokens`) — matching
15
+ * `MutableFoldingState.computeTokens`.
16
+ *
17
+ * This is the substrate for the V2 best-fit tree-knapsack DP. See
18
+ * `docs/best-fit-frontier-resolution.md` §3, §6, §11.
19
+ */
20
+
21
+ import type { ChunkId, SummaryId } from './folding-strategy.js';
22
+ import type { PickerInputs } from './picker.js';
23
+ import type { SummaryEntry } from '../types/strategy.js';
24
+ import { getSummaryParentId } from '../types/strategy.js';
25
+
26
+ /** A raw chunk (resolution 0) — a leaf of the summary forest. */
27
+ export interface LeafNode {
28
+ kind: 'leaf';
29
+ chunkId: ChunkId;
30
+ /** Position in source order (lower = older). */
31
+ sequence: number;
32
+ rawTokens: number;
33
+ /** L1 summary covering this leaf, if any. */
34
+ l1Id?: SummaryId;
35
+ }
36
+
37
+ /** An L_k summary node covering a contiguous range of leaves. */
38
+ export interface SummaryNode {
39
+ kind: 'summary';
40
+ id: SummaryId;
41
+ level: number;
42
+ /** Rendered tokens of this node's recall pair (the cost of folding to it). */
43
+ recallTokens: number;
44
+ /** Immediate children: leaf chunk ids (when childrenAreLeaves) or summary ids. */
45
+ childIds: string[];
46
+ /** True when childIds are leaf chunk ids (sourceLevel === 0). */
47
+ childrenAreLeaves: boolean;
48
+ /** All covered leaf chunk ids, recursively, in source order. */
49
+ leafChunkIds: ChunkId[];
50
+ /** Min/max source sequence of covered leaves (−1 if none resolve). */
51
+ firstSequence: number;
52
+ lastSequence: number;
53
+ sourceRange: { first: string; last: string };
54
+ /** Parent summary id, if a higher level has been produced. */
55
+ parentId?: SummaryId;
56
+ }
57
+
58
+ export type TreeNode = LeafNode | SummaryNode;
59
+
60
+ /** Token cost of rendering a node collapsed: raw for a leaf, recall for a summary. */
61
+ export function nodeTokens(node: TreeNode): number {
62
+ return node.kind === 'leaf' ? node.rawTokens : node.recallTokens;
63
+ }
64
+
65
+ export class SummaryTree {
66
+ private readonly leaves = new Map<ChunkId, LeafNode>();
67
+ private readonly nodes = new Map<SummaryId, SummaryNode>();
68
+ private readonly summaries: ReadonlyMap<SummaryId, SummaryEntry>;
69
+ private readonly recallPairTokens: ReadonlyMap<SummaryId, number>;
70
+ private readonly leafSeq = new Map<ChunkId, number>();
71
+ private rootCache: TreeNode[] | null = null;
72
+
73
+ constructor(inputs: PickerInputs) {
74
+ this.summaries = inputs.summaries;
75
+ this.recallPairTokens = inputs.recallPairTokens ?? new Map();
76
+
77
+ for (const c of inputs.chunks) {
78
+ this.leafSeq.set(c.id, c.sequence);
79
+ this.leaves.set(c.id, {
80
+ kind: 'leaf',
81
+ chunkId: c.id,
82
+ sequence: c.sequence,
83
+ rawTokens: c.rawTokens,
84
+ l1Id: c.l1Id,
85
+ });
86
+ }
87
+ for (const [, s] of this.summaries) {
88
+ this.nodes.set(s.id, this.buildNode(s));
89
+ }
90
+ }
91
+
92
+ // ---- node access ----
93
+
94
+ /** All leaf nodes in source order (oldest first). */
95
+ orderedLeaves(): LeafNode[] {
96
+ return [...this.leaves.values()].sort((a, b) => a.sequence - b.sequence);
97
+ }
98
+
99
+ /** All summary nodes (unordered). */
100
+ allSummaries(): SummaryNode[] {
101
+ return [...this.nodes.values()];
102
+ }
103
+
104
+ leaf(chunkId: ChunkId): LeafNode | null {
105
+ return this.leaves.get(chunkId) ?? null;
106
+ }
107
+
108
+ summary(id: SummaryId): SummaryNode | null {
109
+ return this.nodes.get(id) ?? null;
110
+ }
111
+
112
+ /** Recall-pair tokens for a summary, or null if unknown. */
113
+ recallTokens(id: SummaryId): number | null {
114
+ return this.nodes.get(id)?.recallTokens ?? null;
115
+ }
116
+
117
+ /** Immediate children of a summary node (leaves or sub-summaries). */
118
+ children(node: SummaryNode): TreeNode[] {
119
+ const out: TreeNode[] = [];
120
+ if (node.childrenAreLeaves) {
121
+ for (const cid of node.childIds) {
122
+ const leaf = this.leaves.get(cid);
123
+ if (leaf) out.push(leaf);
124
+ }
125
+ } else {
126
+ for (const sid of node.childIds) {
127
+ const sub = this.nodes.get(sid);
128
+ if (sub) out.push(sub);
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /** All leaf chunk ids under a summary, in source order. */
135
+ leavesUnder(id: SummaryId): ChunkId[] {
136
+ return this.nodes.get(id)?.leafChunkIds ?? [];
137
+ }
138
+
139
+ /**
140
+ * The L_level ancestor summary of a chunk, or null if not present. Matches
141
+ * `MutableFoldingState.ancestorAt` (walks the parentId chain from l1Id).
142
+ */
143
+ ancestorAt(chunkId: ChunkId, level: number): SummaryNode | null {
144
+ if (level <= 0) return null;
145
+ const leaf = this.leaves.get(chunkId);
146
+ if (!leaf || !leaf.l1Id) return null;
147
+ let cur = this.nodes.get(leaf.l1Id);
148
+ while (cur && cur.level < level) {
149
+ if (!cur.parentId) return null;
150
+ cur = this.nodes.get(cur.parentId);
151
+ }
152
+ return cur && cur.level === level ? cur : null;
153
+ }
154
+
155
+ /** Highest level k for which a summary covering this chunk exists (0 = none). */
156
+ maxLevel(chunkId: ChunkId): number {
157
+ const leaf = this.leaves.get(chunkId);
158
+ if (!leaf || !leaf.l1Id) return 0;
159
+ let cur = this.nodes.get(leaf.l1Id);
160
+ let max = 0;
161
+ while (cur) {
162
+ max = cur.level;
163
+ if (!cur.parentId) break;
164
+ cur = this.nodes.get(cur.parentId);
165
+ }
166
+ return max;
167
+ }
168
+
169
+ /**
170
+ * Forest roots over all chunks: top-level summaries (no parent present in the
171
+ * map) plus any leaf not covered by an L1. Each leaf belongs to exactly one
172
+ * root's subtree. Deterministically ordered by source sequence.
173
+ */
174
+ roots(): TreeNode[] {
175
+ if (this.rootCache) return this.rootCache;
176
+ const out: TreeNode[] = [];
177
+ const covered = new Set<ChunkId>();
178
+ for (const node of this.nodes.values()) {
179
+ const parent = node.parentId ? this.nodes.get(node.parentId) : undefined;
180
+ if (parent) continue; // has a real parent → not a root
181
+ out.push(node);
182
+ for (const lid of node.leafChunkIds) covered.add(lid);
183
+ }
184
+ for (const leaf of this.leaves.values()) {
185
+ if (!covered.has(leaf.chunkId)) out.push(leaf);
186
+ }
187
+ out.sort((a, b) => sequenceOf(a) - sequenceOf(b));
188
+ this.rootCache = out;
189
+ return out;
190
+ }
191
+
192
+ // ---- internals ----
193
+
194
+ private buildNode(s: SummaryEntry): SummaryNode {
195
+ const leafChunkIds = this.collectLeafIds(s);
196
+ let firstSequence = Infinity;
197
+ let lastSequence = -Infinity;
198
+ for (const id of leafChunkIds) {
199
+ const seq = this.leafSeq.get(id);
200
+ if (seq === undefined) continue;
201
+ if (seq < firstSequence) firstSequence = seq;
202
+ if (seq > lastSequence) lastSequence = seq;
203
+ }
204
+ return {
205
+ kind: 'summary',
206
+ id: s.id,
207
+ level: s.level,
208
+ recallTokens: this.recallPairTokens.get(s.id) ?? s.tokens,
209
+ childIds: [...s.sourceIds],
210
+ childrenAreLeaves: s.sourceLevel === 0,
211
+ leafChunkIds,
212
+ firstSequence: firstSequence === Infinity ? -1 : firstSequence,
213
+ lastSequence: lastSequence === -Infinity ? -1 : lastSequence,
214
+ sourceRange: s.sourceRange,
215
+ parentId: getSummaryParentId(s),
216
+ };
217
+ }
218
+
219
+ /** Walk down to leaf chunk ids; sourceLevel 0 => sourceIds are leaves.
220
+ * Deduplicates leaves and guards against revisiting a summary, so a chunk
221
+ * reachable via multiple branches is counted once (real chronicles can have
222
+ * such overlap; double-counting breaks range/contiguity and value math). */
223
+ private collectLeafIds(summary: SummaryEntry): ChunkId[] {
224
+ const out: ChunkId[] = [];
225
+ const seenLeaves = new Set<ChunkId>();
226
+ const seenSummaries = new Set<SummaryId>();
227
+ const visit = (s: SummaryEntry): void => {
228
+ if (seenSummaries.has(s.id)) return;
229
+ seenSummaries.add(s.id);
230
+ if (s.sourceLevel === 0) {
231
+ for (const mid of s.sourceIds) {
232
+ if (!seenLeaves.has(mid)) {
233
+ seenLeaves.add(mid);
234
+ out.push(mid);
235
+ }
236
+ }
237
+ } else {
238
+ for (const sid of s.sourceIds) {
239
+ const child = this.summaries.get(sid);
240
+ if (child) visit(child);
241
+ }
242
+ }
243
+ };
244
+ visit(summary);
245
+ out.sort((a, b) => (this.leafSeq.get(a) ?? 0) - (this.leafSeq.get(b) ?? 0));
246
+ return out;
247
+ }
248
+ }
249
+
250
+ function sequenceOf(n: TreeNode): number {
251
+ return n.kind === 'leaf' ? n.sequence : n.firstSequence;
252
+ }
@@ -276,7 +276,12 @@ export class ContextLog {
276
276
  case 'text':
277
277
  return this.tokenEstimator(block.text);
278
278
  case 'thinking':
279
- return this.tokenEstimator(block.thinking);
279
+ // Signature-only blocks (display:'omitted') carry empty thinking text
280
+ return this.tokenEstimator(block.thinking ?? '');
281
+ case 'redacted_thinking':
282
+ // Encrypted reasoning payload, round-tripped verbatim — rough
283
+ // estimate from the data length so budgeting isn't blind to it.
284
+ return this.tokenEstimator((block as { data?: string }).data ?? '');
280
285
  case 'tool_use':
281
286
  return this.tokenEstimator(JSON.stringify(block.input)) + 20;
282
287
  case 'tool_result':
@@ -286,7 +291,7 @@ export class ContextLog {
286
291
  }
287
292
  return block.content.reduce((sum, b) => sum + this.estimateBlockTokens(b), 0);
288
293
  case 'image':
289
- return block.tokenEstimate ?? 1000;
294
+ return block.tokenEstimate ?? 1600; // ~1568px image ≈ 1600 tokens (Anthropic)
290
295
  case 'document':
291
296
  case 'audio':
292
297
  case 'video':
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ export { AutobiographicalStrategy, type AutobiographicalProgressSnapshot } from
14
14
  export { KnowledgeStrategy } from './strategies/knowledge.js';
15
15
 
16
16
  // Utilities
17
- export { splitMixedToolMessages } from './normalize-tool-messages.js';
17
+ export { splitMixedToolMessages, stripUnpairedToolBlocks } from './normalize-tool-messages.js';
18
18
 
19
19
  // Types
20
20
  export type {
@@ -409,7 +409,7 @@ export class MessageStore {
409
409
  }
410
410
  return 0;
411
411
  case 'image':
412
- return block.tokenEstimate ?? 1000; // Default estimate for images
412
+ return block.tokenEstimate ?? 1600; // ~1568px image 1600 tokens (Anthropic)
413
413
  case 'document':
414
414
  case 'audio':
415
415
  case 'video':
@@ -69,3 +69,64 @@ export function splitMixedToolMessages<T extends { participant: string; content:
69
69
  }
70
70
  return out;
71
71
  }
72
+
73
+ /**
74
+ * Strip `tool_use` blocks whose IDs have no matching `tool_result` anywhere
75
+ * in the message list, and `tool_result` blocks whose IDs have no matching
76
+ * `tool_use`. If stripping leaves a message with no blocks, replace its
77
+ * content with a placeholder text block so the message stays structurally
78
+ * valid (collapse/rendering paths assume non-empty content).
79
+ *
80
+ * Why this exists: the Anthropic API requires every `tool_use` to be
81
+ * followed by its `tool_result` in the immediately-next message (and every
82
+ * `tool_result` to follow its `tool_use`). Two ways this becomes a problem
83
+ * in compression:
84
+ *
85
+ * - chunk boundaries: the chunker can cut a cycle, leaving a `tool_use`
86
+ * at the tail of chunk N and its `tool_result` at the head of N+1.
87
+ * `rebuildChunks` defers closing on a `tool_use` to avoid this, but
88
+ * can't help when the `tool_use` is the very last message in the
89
+ * store (no next message to ride along).
90
+ * - any future call site that builds a request from raw messages without
91
+ * also handling cycle pairing.
92
+ *
93
+ * The placeholder is intentionally generic ("[tool call omitted]") rather
94
+ * than naming a specific cause: this function fires for the chunk-boundary
95
+ * case AND the very-last-message case AND any future not-yet-anticipated
96
+ * source of unpaired blocks. Anything more specific would mislead the
97
+ * debugger reading the compressed conversation later.
98
+ */
99
+ export function stripUnpairedToolBlocks<T extends { participant: string; content: ContentBlock[] }>(
100
+ messages: readonly T[],
101
+ ): Array<{ participant: string; content: ContentBlock[] }> {
102
+ const useIds = new Set<string>();
103
+ const resultIds = new Set<string>();
104
+ for (const msg of messages) {
105
+ for (const block of msg.content) {
106
+ if (block.type === 'tool_use') useIds.add((block as { id: string }).id);
107
+ else if (block.type === 'tool_result') {
108
+ resultIds.add((block as { toolUseId: string }).toolUseId);
109
+ }
110
+ }
111
+ }
112
+ return messages.map((msg) => {
113
+ const trimmed = msg.content.filter((block) => {
114
+ if (block.type === 'tool_use') {
115
+ return resultIds.has((block as { id: string }).id);
116
+ }
117
+ if (block.type === 'tool_result') {
118
+ return useIds.has((block as { toolUseId: string }).toolUseId);
119
+ }
120
+ return true;
121
+ });
122
+ if (trimmed.length === msg.content.length) {
123
+ return { participant: msg.participant, content: msg.content };
124
+ }
125
+ return {
126
+ participant: msg.participant,
127
+ content: trimmed.length > 0
128
+ ? trimmed
129
+ : [{ type: 'text', text: '[tool call omitted]' }],
130
+ };
131
+ });
132
+ }