@animalabs/context-manager 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/src/blob-manager.d.ts +37 -0
  2. package/dist/src/blob-manager.d.ts.map +1 -0
  3. package/dist/src/blob-manager.js +128 -0
  4. package/dist/src/blob-manager.js.map +1 -0
  5. package/dist/src/context-log.d.ts +99 -0
  6. package/dist/src/context-log.d.ts.map +1 -0
  7. package/dist/src/context-log.js +277 -0
  8. package/dist/src/context-log.js.map +1 -0
  9. package/dist/src/context-manager.d.ts +245 -0
  10. package/dist/src/context-manager.d.ts.map +1 -0
  11. package/dist/src/context-manager.js +553 -0
  12. package/dist/src/context-manager.js.map +1 -0
  13. package/dist/src/index.d.ts +12 -0
  14. package/dist/src/index.d.ts.map +1 -0
  15. package/dist/src/index.js +12 -0
  16. package/dist/src/index.js.map +1 -0
  17. package/dist/src/message-store.d.ts +135 -0
  18. package/dist/src/message-store.d.ts.map +1 -0
  19. package/dist/src/message-store.js +372 -0
  20. package/dist/src/message-store.js.map +1 -0
  21. package/dist/src/strategies/autobiographical.d.ts +199 -0
  22. package/dist/src/strategies/autobiographical.d.ts.map +1 -0
  23. package/dist/src/strategies/autobiographical.js +1122 -0
  24. package/dist/src/strategies/autobiographical.js.map +1 -0
  25. package/dist/src/strategies/index.d.ts +3 -0
  26. package/dist/src/strategies/index.d.ts.map +1 -0
  27. package/dist/src/strategies/index.js +3 -0
  28. package/dist/src/strategies/index.js.map +1 -0
  29. package/dist/src/strategies/knowledge.d.ts +46 -0
  30. package/dist/src/strategies/knowledge.d.ts.map +1 -0
  31. package/dist/src/strategies/knowledge.js +270 -0
  32. package/dist/src/strategies/knowledge.js.map +1 -0
  33. package/dist/src/strategies/passthrough.d.ts +17 -0
  34. package/dist/src/strategies/passthrough.d.ts.map +1 -0
  35. package/dist/src/strategies/passthrough.js +69 -0
  36. package/dist/src/strategies/passthrough.js.map +1 -0
  37. package/dist/src/types/context.d.ts +108 -0
  38. package/dist/src/types/context.d.ts.map +1 -0
  39. package/dist/src/types/context.js +2 -0
  40. package/dist/src/types/context.js.map +1 -0
  41. package/dist/src/types/index.d.ts +5 -0
  42. package/dist/src/types/index.d.ts.map +1 -0
  43. package/dist/src/types/index.js +2 -0
  44. package/dist/src/types/index.js.map +1 -0
  45. package/dist/src/types/message.d.ts +129 -0
  46. package/dist/src/types/message.d.ts.map +1 -0
  47. package/dist/src/types/message.js +2 -0
  48. package/dist/src/types/message.js.map +1 -0
  49. package/dist/src/types/strategy.d.ts +233 -0
  50. package/dist/src/types/strategy.d.ts.map +1 -0
  51. package/dist/src/types/strategy.js +32 -0
  52. package/dist/src/types/strategy.js.map +1 -0
  53. package/dist/test/autobiographical.test.d.ts +2 -0
  54. package/dist/test/autobiographical.test.d.ts.map +1 -0
  55. package/dist/test/autobiographical.test.js +46 -0
  56. package/dist/test/autobiographical.test.js.map +1 -0
  57. package/dist/test/head-window-reset.test.d.ts +17 -0
  58. package/dist/test/head-window-reset.test.d.ts.map +1 -0
  59. package/dist/test/head-window-reset.test.js +342 -0
  60. package/dist/test/head-window-reset.test.js.map +1 -0
  61. package/dist/test/integration.test.d.ts +2 -0
  62. package/dist/test/integration.test.d.ts.map +1 -0
  63. package/dist/test/integration.test.js +1341 -0
  64. package/dist/test/integration.test.js.map +1 -0
  65. package/dist/test/knowledge.test.d.ts +2 -0
  66. package/dist/test/knowledge.test.d.ts.map +1 -0
  67. package/dist/test/knowledge.test.js +617 -0
  68. package/dist/test/knowledge.test.js.map +1 -0
  69. package/dist/tsconfig.tsbuildinfo +1 -0
  70. package/package.json +48 -0
  71. package/src/blob-manager.ts +155 -0
  72. package/src/context-log.ts +342 -0
  73. package/src/context-manager.ts +726 -0
  74. package/src/index.ts +50 -0
  75. package/src/message-store.ts +479 -0
  76. package/src/strategies/autobiographical.ts +1355 -0
  77. package/src/strategies/index.ts +2 -0
  78. package/src/strategies/knowledge.ts +336 -0
  79. package/src/strategies/passthrough.ts +98 -0
  80. package/src/types/context.ts +119 -0
  81. package/src/types/index.ts +42 -0
  82. package/src/types/message.ts +140 -0
  83. package/src/types/strategy.ts +282 -0
@@ -0,0 +1,617 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { rmSync, existsSync } from 'node:fs';
4
+ import { ContextManager, KnowledgeStrategy } from '../src/index.js';
5
+ const TEST_STORE_PATH = './test-knowledge-store';
6
+ function cleanup() {
7
+ if (existsSync(TEST_STORE_PATH)) {
8
+ rmSync(TEST_STORE_PATH, { recursive: true, force: true });
9
+ }
10
+ }
11
+ // Helper to access protected state via the strategy instance
12
+ function getStrategyState(strategy) {
13
+ // Access protected fields for testing via property access
14
+ const s = strategy;
15
+ return {
16
+ chunks: s.chunks,
17
+ summaries: s.summaries,
18
+ compressionQueue: s.compressionQueue,
19
+ };
20
+ }
21
+ describe('KnowledgeStrategy', () => {
22
+ before(() => cleanup());
23
+ after(() => cleanup());
24
+ describe('Phase Classification', () => {
25
+ it('should classify research tool messages as research', async () => {
26
+ cleanup();
27
+ const strategy = new KnowledgeStrategy({
28
+ targetChunkTokens: 100,
29
+ recentWindowTokens: 50,
30
+ researchToolPrefixes: ['mcpl:', 'zulip:'],
31
+ });
32
+ const manager = await ContextManager.open({
33
+ path: TEST_STORE_PATH,
34
+ strategy,
35
+ });
36
+ // Research phase: tool_use with mcpl: prefix
37
+ manager.addMessage('Claude', [{
38
+ type: 'tool_use',
39
+ id: 'tu1',
40
+ name: 'mcpl:search',
41
+ input: { query: 'test' },
42
+ }]);
43
+ manager.addMessage('User', [{
44
+ type: 'tool_result',
45
+ toolUseId: 'tu1',
46
+ content: 'Found 3 results',
47
+ }]);
48
+ // Synthesis phase: plain dialogue
49
+ manager.addMessage('Claude', [{ type: 'text', text: 'Based on the results...' }]);
50
+ manager.addMessage('User', [{ type: 'text', text: 'What do you think?' }]);
51
+ // More research
52
+ manager.addMessage('Claude', [{
53
+ type: 'tool_use',
54
+ id: 'tu2',
55
+ name: 'zulip:fetch_messages',
56
+ input: { stream: 'general' },
57
+ }]);
58
+ manager.addMessage('User', [{
59
+ type: 'tool_result',
60
+ toolUseId: 'tu2',
61
+ content: 'Messages fetched',
62
+ }]);
63
+ // Pad to push messages out of recent window
64
+ for (let i = 0; i < 8; i++) {
65
+ manager.addMessage('User', [{ type: 'text', text: `Padding message ${i} ${'x'.repeat(20)}` }]);
66
+ }
67
+ // Force chunk rebuild by compiling
68
+ await manager.compile();
69
+ const state = getStrategyState(strategy);
70
+ // Should have created chunks with phase types
71
+ const researchChunks = state.chunks.filter(c => c.phaseType === 'research');
72
+ const synthesisChunks = state.chunks.filter(c => c.phaseType === 'synthesis');
73
+ assert.ok(researchChunks.length > 0, 'Should have at least one research chunk');
74
+ assert.ok(synthesisChunks.length > 0, 'Should have at least one synthesis chunk');
75
+ manager.close();
76
+ });
77
+ it('should classify lesson tool messages as lesson', async () => {
78
+ cleanup();
79
+ const strategy = new KnowledgeStrategy({
80
+ targetChunkTokens: 100,
81
+ recentWindowTokens: 50,
82
+ lessonToolNames: ['lessons:create'],
83
+ });
84
+ const manager = await ContextManager.open({
85
+ path: TEST_STORE_PATH,
86
+ strategy,
87
+ });
88
+ // Lesson phase
89
+ manager.addMessage('Claude', [{
90
+ type: 'tool_use',
91
+ id: 'tu1',
92
+ name: 'lessons:create',
93
+ input: { title: 'Test Lesson' },
94
+ }]);
95
+ manager.addMessage('User', [{
96
+ type: 'tool_result',
97
+ toolUseId: 'tu1',
98
+ content: 'Lesson created',
99
+ }]);
100
+ // Pad
101
+ for (let i = 0; i < 8; i++) {
102
+ manager.addMessage('User', [{ type: 'text', text: `Padding ${i} ${'x'.repeat(20)}` }]);
103
+ }
104
+ await manager.compile();
105
+ const state = getStrategyState(strategy);
106
+ const lessonChunks = state.chunks.filter(c => c.phaseType === 'lesson');
107
+ assert.ok(lessonChunks.length > 0, 'Should have at least one lesson chunk');
108
+ manager.close();
109
+ });
110
+ it('should classify subagent tool messages as subagent', async () => {
111
+ cleanup();
112
+ const strategy = new KnowledgeStrategy({
113
+ targetChunkTokens: 100,
114
+ recentWindowTokens: 50,
115
+ subagentToolPrefixes: ['subagent:'],
116
+ });
117
+ const manager = await ContextManager.open({
118
+ path: TEST_STORE_PATH,
119
+ strategy,
120
+ });
121
+ manager.addMessage('Claude', [{
122
+ type: 'tool_use',
123
+ id: 'tu1',
124
+ name: 'subagent:spawn',
125
+ input: { task: 'research' },
126
+ }]);
127
+ manager.addMessage('User', [{
128
+ type: 'tool_result',
129
+ toolUseId: 'tu1',
130
+ content: 'Subagent result',
131
+ }]);
132
+ for (let i = 0; i < 8; i++) {
133
+ manager.addMessage('User', [{ type: 'text', text: `Padding ${i} ${'x'.repeat(20)}` }]);
134
+ }
135
+ await manager.compile();
136
+ const state = getStrategyState(strategy);
137
+ const subagentChunks = state.chunks.filter(c => c.phaseType === 'subagent');
138
+ assert.ok(subagentChunks.length > 0, 'Should have at least one subagent chunk');
139
+ manager.close();
140
+ });
141
+ it('should inherit tool phase for tool_result without tool_use', async () => {
142
+ cleanup();
143
+ const strategy = new KnowledgeStrategy({
144
+ targetChunkTokens: 100,
145
+ recentWindowTokens: 50,
146
+ });
147
+ const manager = await ContextManager.open({
148
+ path: TEST_STORE_PATH,
149
+ strategy,
150
+ });
151
+ // Research tool_use
152
+ manager.addMessage('Claude', [{
153
+ type: 'tool_use',
154
+ id: 'tu1',
155
+ name: 'mcpl:search',
156
+ input: { query: 'test' },
157
+ }]);
158
+ // Standalone tool_result (no tool_use in same message) should inherit research
159
+ manager.addMessage('User', [{
160
+ type: 'tool_result',
161
+ toolUseId: 'tu1',
162
+ content: 'Search results',
163
+ }]);
164
+ for (let i = 0; i < 8; i++) {
165
+ manager.addMessage('User', [{ type: 'text', text: `Padding ${i} ${'x'.repeat(20)}` }]);
166
+ }
167
+ await manager.compile();
168
+ const state = getStrategyState(strategy);
169
+ // The chunk containing tool_use + tool_result should be research
170
+ if (state.chunks.length > 0) {
171
+ const firstChunk = state.chunks[0];
172
+ assert.strictEqual(firstChunk.phaseType, 'research', 'tool_result should inherit phase from preceding tool_use');
173
+ }
174
+ manager.close();
175
+ });
176
+ it('should classify pure dialogue as synthesis', async () => {
177
+ cleanup();
178
+ const strategy = new KnowledgeStrategy({
179
+ targetChunkTokens: 100,
180
+ recentWindowTokens: 50,
181
+ });
182
+ const manager = await ContextManager.open({
183
+ path: TEST_STORE_PATH,
184
+ strategy,
185
+ });
186
+ manager.addMessage('User', [{ type: 'text', text: 'What do you think about X?' }]);
187
+ manager.addMessage('Claude', [{ type: 'text', text: 'I think X is interesting because...' }]);
188
+ manager.addMessage('User', [{ type: 'text', text: 'That makes sense. And Y?' }]);
189
+ manager.addMessage('Claude', [{ type: 'text', text: 'Y connects to X through...' }]);
190
+ for (let i = 0; i < 6; i++) {
191
+ manager.addMessage('User', [{ type: 'text', text: `Padding ${i} ${'x'.repeat(20)}` }]);
192
+ }
193
+ await manager.compile();
194
+ const state = getStrategyState(strategy);
195
+ const synthesisChunks = state.chunks.filter(c => c.phaseType === 'synthesis');
196
+ assert.ok(synthesisChunks.length > 0, 'Pure dialogue should produce synthesis chunks');
197
+ manager.close();
198
+ });
199
+ it('should prioritize lesson over other classifications', async () => {
200
+ cleanup();
201
+ const strategy = new KnowledgeStrategy({
202
+ targetChunkTokens: 200,
203
+ recentWindowTokens: 50,
204
+ });
205
+ const manager = await ContextManager.open({
206
+ path: TEST_STORE_PATH,
207
+ strategy,
208
+ });
209
+ // Message with both lesson tool AND research tool — lesson should win
210
+ manager.addMessage('Claude', [
211
+ { type: 'tool_use', id: 'tu1', name: 'mcpl:search', input: {} },
212
+ { type: 'tool_use', id: 'tu2', name: 'lessons:create', input: {} },
213
+ ]);
214
+ manager.addMessage('User', [
215
+ { type: 'tool_result', toolUseId: 'tu1', content: 'found' },
216
+ { type: 'tool_result', toolUseId: 'tu2', content: 'created' },
217
+ ]);
218
+ for (let i = 0; i < 8; i++) {
219
+ manager.addMessage('User', [{ type: 'text', text: `Padding ${i} ${'x'.repeat(20)}` }]);
220
+ }
221
+ await manager.compile();
222
+ const state = getStrategyState(strategy);
223
+ if (state.chunks.length > 0) {
224
+ assert.strictEqual(state.chunks[0].phaseType, 'lesson', 'Lesson should take priority over research');
225
+ }
226
+ manager.close();
227
+ });
228
+ });
229
+ describe('Semantic Chunking', () => {
230
+ it('should create chunk boundaries at phase transitions', async () => {
231
+ cleanup();
232
+ const strategy = new KnowledgeStrategy({
233
+ targetChunkTokens: 5000, // High limit so size doesn't trigger boundaries
234
+ recentWindowTokens: 50,
235
+ });
236
+ const manager = await ContextManager.open({
237
+ path: TEST_STORE_PATH,
238
+ strategy,
239
+ });
240
+ // Research phase (3 messages)
241
+ manager.addMessage('Claude', [{
242
+ type: 'tool_use', id: 'tu1', name: 'mcpl:search', input: {},
243
+ }]);
244
+ manager.addMessage('User', [{
245
+ type: 'tool_result', toolUseId: 'tu1', content: 'Result 1',
246
+ }]);
247
+ manager.addMessage('Claude', [{
248
+ type: 'tool_use', id: 'tu2', name: 'mcpl:get', input: {},
249
+ }]);
250
+ manager.addMessage('User', [{
251
+ type: 'tool_result', toolUseId: 'tu2', content: 'Result 2',
252
+ }]);
253
+ // Synthesis phase (transition should trigger chunk boundary)
254
+ manager.addMessage('Claude', [{ type: 'text', text: 'Based on the research, I conclude...' }]);
255
+ manager.addMessage('User', [{ type: 'text', text: 'Makes sense' }]);
256
+ manager.addMessage('Claude', [{ type: 'text', text: 'Furthermore...' }]);
257
+ // Pad to push out of recent
258
+ for (let i = 0; i < 6; i++) {
259
+ manager.addMessage('User', [{ type: 'text', text: `Padding ${i} ${'x'.repeat(30)}` }]);
260
+ }
261
+ await manager.compile();
262
+ const state = getStrategyState(strategy);
263
+ // Should have at least 2 chunks: research + synthesis
264
+ assert.ok(state.chunks.length >= 2, `Expected at least 2 chunks (research + synthesis), got ${state.chunks.length}`);
265
+ // First chunk should be research, subsequent should include synthesis
266
+ if (state.chunks.length >= 2) {
267
+ const phases = state.chunks.map(c => c.phaseType);
268
+ assert.ok(phases.includes('research'), 'Should have a research chunk');
269
+ assert.ok(phases.includes('synthesis'), 'Should have a synthesis chunk');
270
+ }
271
+ manager.close();
272
+ });
273
+ it('should close chunks at minimum 2 messages', async () => {
274
+ cleanup();
275
+ const strategy = new KnowledgeStrategy({
276
+ targetChunkTokens: 5000,
277
+ recentWindowTokens: 50,
278
+ });
279
+ const manager = await ContextManager.open({
280
+ path: TEST_STORE_PATH,
281
+ strategy,
282
+ });
283
+ // Rapid transitions: research, synthesis, research, synthesis
284
+ // Each pair is 2 messages — the minimum chunk size
285
+ manager.addMessage('Claude', [{
286
+ type: 'tool_use', id: 'tu1', name: 'mcpl:search', input: {},
287
+ }]);
288
+ manager.addMessage('User', [{
289
+ type: 'tool_result', toolUseId: 'tu1', content: 'Result',
290
+ }]);
291
+ // Phase transition → should close research chunk (2 messages)
292
+ manager.addMessage('Claude', [{ type: 'text', text: 'Analysis...' }]);
293
+ manager.addMessage('User', [{ type: 'text', text: 'Ok' }]);
294
+ // Phase transition → should close synthesis chunk (2 messages)
295
+ manager.addMessage('Claude', [{
296
+ type: 'tool_use', id: 'tu2', name: 'mcpl:search', input: {},
297
+ }]);
298
+ manager.addMessage('User', [{
299
+ type: 'tool_result', toolUseId: 'tu2', content: 'Result 2',
300
+ }]);
301
+ // Pad
302
+ for (let i = 0; i < 6; i++) {
303
+ manager.addMessage('User', [{ type: 'text', text: `Padding ${i} ${'x'.repeat(30)}` }]);
304
+ }
305
+ await manager.compile();
306
+ const state = getStrategyState(strategy);
307
+ // Non-final chunks should have at least 2 messages;
308
+ // the final chunk is allowed to be as small as 1 message
309
+ for (let i = 0; i < state.chunks.length - 1; i++) {
310
+ const chunk = state.chunks[i];
311
+ assert.ok(chunk.messages.length >= 2, `Chunk ${chunk.index} has only ${chunk.messages.length} message(s)`);
312
+ }
313
+ if (state.chunks.length > 0) {
314
+ const last = state.chunks[state.chunks.length - 1];
315
+ assert.ok(last.messages.length >= 1, `Final chunk has ${last.messages.length} messages`);
316
+ }
317
+ manager.close();
318
+ });
319
+ });
320
+ describe('Asymmetric L1 Budget', () => {
321
+ it('should cap research summaries at configured budget fraction', async () => {
322
+ cleanup();
323
+ const strategy = new KnowledgeStrategy({
324
+ targetChunkTokens: 100,
325
+ recentWindowTokens: 50,
326
+ researchL1BudgetCap: 0.3,
327
+ });
328
+ // Access protected selectL1Summaries via the instance
329
+ const selectL1 = strategy.selectL1Summaries.bind(strategy);
330
+ // Create test summaries: 5 research, 5 synthesis
331
+ const summaries = [];
332
+ for (let i = 0; i < 5; i++) {
333
+ summaries.push({
334
+ id: `L1-r${i}`, level: 1, content: 'research summary', tokens: 100,
335
+ sourceLevel: 0, sourceIds: [`m${i}`],
336
+ sourceRange: { first: `m${i}`, last: `m${i}` },
337
+ created: Date.now(), phaseType: 'research',
338
+ });
339
+ }
340
+ for (let i = 0; i < 5; i++) {
341
+ summaries.push({
342
+ id: `L1-s${i}`, level: 1, content: 'synthesis summary', tokens: 100,
343
+ sourceLevel: 0, sourceIds: [`m${i + 5}`],
344
+ sourceRange: { first: `m${i + 5}`, last: `m${i + 5}` },
345
+ created: Date.now(), phaseType: 'synthesis',
346
+ });
347
+ }
348
+ const { selected, tokensUsed } = selectL1(summaries, 1000, 1000);
349
+ // Research should be capped at 30% of 1000 = 300 tokens = max 3 entries
350
+ const researchSelected = selected.filter((s) => s.phaseType === 'research');
351
+ const researchTokens = researchSelected.reduce((sum, s) => sum + s.tokens, 0);
352
+ assert.ok(researchTokens <= 300, `Research tokens (${researchTokens}) should not exceed 30% cap (300)`);
353
+ assert.ok(researchSelected.length <= 3, `Research entries (${researchSelected.length}) should be capped`);
354
+ manager_close_noop();
355
+ });
356
+ it('should guarantee synthesis floor', async () => {
357
+ cleanup();
358
+ const strategy = new KnowledgeStrategy({
359
+ targetChunkTokens: 100,
360
+ recentWindowTokens: 50,
361
+ synthesisL1BudgetFloor: 0.4,
362
+ });
363
+ const selectL1 = strategy.selectL1Summaries.bind(strategy);
364
+ // Synthesis entries that together exceed the floor
365
+ const summaries = [];
366
+ for (let i = 0; i < 10; i++) {
367
+ summaries.push({
368
+ id: `L1-s${i}`, level: 1, content: 'synthesis', tokens: 100,
369
+ sourceLevel: 0, sourceIds: [`m${i}`],
370
+ sourceRange: { first: `m${i}`, last: `m${i}` },
371
+ created: Date.now(), phaseType: 'synthesis',
372
+ });
373
+ }
374
+ const { selected, tokensUsed } = selectL1(summaries, 1000, 1000);
375
+ // Floor is 40% = 400. With 100-token entries, at least 4 should be selected
376
+ const synthesisSelected = selected.filter((s) => s.phaseType === 'synthesis');
377
+ assert.ok(synthesisSelected.length >= 4, `Should include at least 4 synthesis entries (floor=400, each=100), got ${synthesisSelected.length}`);
378
+ manager_close_noop();
379
+ });
380
+ it('should cap synthesis at configured cap', async () => {
381
+ cleanup();
382
+ const strategy = new KnowledgeStrategy({
383
+ targetChunkTokens: 100,
384
+ recentWindowTokens: 50,
385
+ synthesisL1BudgetFloor: 0.4,
386
+ synthesisL1BudgetCap: 0.7,
387
+ });
388
+ const selectL1 = strategy.selectL1Summaries.bind(strategy);
389
+ // Many synthesis entries
390
+ const summaries = [];
391
+ for (let i = 0; i < 15; i++) {
392
+ summaries.push({
393
+ id: `L1-s${i}`, level: 1, content: 'synthesis', tokens: 100,
394
+ sourceLevel: 0, sourceIds: [`m${i}`],
395
+ sourceRange: { first: `m${i}`, last: `m${i}` },
396
+ created: Date.now(), phaseType: 'synthesis',
397
+ });
398
+ }
399
+ const { selected, tokensUsed } = selectL1(summaries, 1000, 1000);
400
+ // Cap is 70% = 700. With 100-token entries, should select at most 7
401
+ assert.ok(selected.length <= 7, `Synthesis should be capped at 70% (max 7 entries), got ${selected.length}`);
402
+ manager_close_noop();
403
+ });
404
+ it('should prioritize synthesis over research', async () => {
405
+ cleanup();
406
+ const strategy = new KnowledgeStrategy({
407
+ targetChunkTokens: 100,
408
+ recentWindowTokens: 50,
409
+ researchL1BudgetCap: 0.3,
410
+ synthesisL1BudgetFloor: 0.4,
411
+ });
412
+ const selectL1 = strategy.selectL1Summaries.bind(strategy);
413
+ // Interleaved: synthesis, research, lesson
414
+ const summaries = [
415
+ // Synthesis
416
+ ...Array.from({ length: 3 }, (_, i) => ({
417
+ id: `L1-s${i}`, level: 1, content: 'synthesis', tokens: 200,
418
+ sourceLevel: 0, sourceIds: [`ms${i}`],
419
+ sourceRange: { first: `ms${i}`, last: `ms${i}` },
420
+ created: Date.now(), phaseType: 'synthesis',
421
+ })),
422
+ // Research
423
+ ...Array.from({ length: 3 }, (_, i) => ({
424
+ id: `L1-r${i}`, level: 1, content: 'research', tokens: 200,
425
+ sourceLevel: 0, sourceIds: [`mr${i}`],
426
+ sourceRange: { first: `mr${i}`, last: `mr${i}` },
427
+ created: Date.now(), phaseType: 'research',
428
+ })),
429
+ // Lessons
430
+ ...Array.from({ length: 2 }, (_, i) => ({
431
+ id: `L1-l${i}`, level: 1, content: 'lesson', tokens: 100,
432
+ sourceLevel: 0, sourceIds: [`ml${i}`],
433
+ sourceRange: { first: `ml${i}`, last: `ml${i}` },
434
+ created: Date.now(), phaseType: 'lesson',
435
+ })),
436
+ ];
437
+ // Budget = 1000
438
+ const { selected } = selectL1(summaries, 1000, 1000);
439
+ // Synthesis should come first (priority 1)
440
+ const synthesisCount = selected.filter((s) => s.phaseType === 'synthesis').length;
441
+ const researchCount = selected.filter((s) => s.phaseType === 'research').length;
442
+ const lessonCount = selected.filter((s) => s.phaseType === 'lesson').length;
443
+ assert.ok(synthesisCount > 0, 'Synthesis should be selected');
444
+ assert.ok(lessonCount > 0, 'Lessons should be selected (high priority)');
445
+ // Research capped at 30% of 1000 = 300, each is 200, so max 1
446
+ assert.ok(researchCount <= 1, `Research (${researchCount}) should be capped by 30% budget`);
447
+ manager_close_noop();
448
+ });
449
+ });
450
+ describe('Compression Prompts', () => {
451
+ it('should produce phase-specific compression instructions', async () => {
452
+ cleanup();
453
+ const strategy = new KnowledgeStrategy({
454
+ targetChunkTokens: 100,
455
+ recentWindowTokens: 50,
456
+ });
457
+ const getInstruction = strategy.getCompressionInstruction.bind(strategy);
458
+ const researchChunk = { phaseType: 'research' };
459
+ const synthesisChunk = { phaseType: 'synthesis' };
460
+ const lessonChunk = { phaseType: 'lesson' };
461
+ const subagentChunk = { phaseType: 'subagent' };
462
+ const researchInstr = getInstruction(researchChunk, 2000);
463
+ const synthesisInstr = getInstruction(synthesisChunk, 2000);
464
+ const lessonInstr = getInstruction(lessonChunk, 2000);
465
+ const subagentInstr = getInstruction(subagentChunk, 2000);
466
+ // Each should be different
467
+ assert.notStrictEqual(researchInstr, synthesisInstr);
468
+ assert.notStrictEqual(synthesisInstr, lessonInstr);
469
+ assert.notStrictEqual(lessonInstr, subagentInstr);
470
+ // Each should mention its domain
471
+ assert.ok(researchInstr.includes('research'), 'Research instruction should mention research');
472
+ assert.ok(synthesisInstr.includes('reasoning') || synthesisInstr.includes('conclusions'), 'Synthesis instruction should mention reasoning/conclusions');
473
+ assert.ok(lessonInstr.includes('lesson') || lessonInstr.includes('knowledge'), 'Lesson instruction should mention lessons/knowledge');
474
+ assert.ok(subagentInstr.includes('subagent'), 'Subagent instruction should mention subagent');
475
+ // All should mention [LEAD]
476
+ assert.ok(researchInstr.includes('[LEAD]'));
477
+ assert.ok(synthesisInstr.includes('[LEAD]'));
478
+ assert.ok(lessonInstr.includes('[LEAD]'));
479
+ assert.ok(subagentInstr.includes('[LEAD]'));
480
+ manager_close_noop();
481
+ });
482
+ it('should produce merge instructions that include source phase composition', async () => {
483
+ cleanup();
484
+ const strategy = new KnowledgeStrategy({
485
+ targetChunkTokens: 100,
486
+ recentWindowTokens: 50,
487
+ });
488
+ const getMerge = strategy.getMergeInstruction.bind(strategy);
489
+ const sources = [
490
+ { id: 'L1-0', level: 1, content: '', tokens: 100, sourceLevel: 0,
491
+ sourceIds: ['m0'], sourceRange: { first: 'm0', last: 'm0' },
492
+ created: Date.now(), phaseType: 'research' },
493
+ { id: 'L1-1', level: 1, content: '', tokens: 100, sourceLevel: 0,
494
+ sourceIds: ['m1'], sourceRange: { first: 'm1', last: 'm1' },
495
+ created: Date.now(), phaseType: 'research' },
496
+ { id: 'L1-2', level: 1, content: '', tokens: 100, sourceLevel: 0,
497
+ sourceIds: ['m2'], sourceRange: { first: 'm2', last: 'm2' },
498
+ created: Date.now(), phaseType: 'synthesis' },
499
+ ];
500
+ const instruction = getMerge(2, sources, 2000);
501
+ // Should mention the count and level
502
+ assert.ok(instruction.includes('3'), 'Should mention source count');
503
+ assert.ok(instruction.includes('L1'), 'Should mention source level');
504
+ // Should mention phase composition
505
+ assert.ok(instruction.includes('research'), 'Should mention research phase');
506
+ assert.ok(instruction.includes('synthesis'), 'Should mention synthesis phase');
507
+ // Should preserve [LEAD] instruction
508
+ assert.ok(instruction.includes('[LEAD]'), 'Should preserve LEAD instruction');
509
+ manager_close_noop();
510
+ });
511
+ it('should differentiate L2 and L3 merge instructions', async () => {
512
+ cleanup();
513
+ const strategy = new KnowledgeStrategy({
514
+ targetChunkTokens: 100,
515
+ recentWindowTokens: 50,
516
+ });
517
+ const getMerge = strategy.getMergeInstruction.bind(strategy);
518
+ const sources = [{
519
+ id: 'test', level: 1, content: '', tokens: 100, sourceLevel: 0,
520
+ sourceIds: ['m0'], sourceRange: { first: 'm0', last: 'm0' },
521
+ created: Date.now(), phaseType: 'synthesis',
522
+ }];
523
+ const l2Instruction = getMerge(2, sources, 2000);
524
+ const l3Instruction = getMerge(3, sources, 2000);
525
+ assert.ok(l2Instruction.includes('L1 summaries'), 'L2 merge should reference L1 sources');
526
+ assert.ok(l3Instruction.includes('L2 summaries'), 'L3 merge should reference L2 sources');
527
+ manager_close_noop();
528
+ });
529
+ });
530
+ describe('Constructor Type Safety', () => {
531
+ it('should preserve knowledge-specific config fields', () => {
532
+ const strategy = new KnowledgeStrategy({
533
+ researchToolPrefixes: ['custom:'],
534
+ subagentToolPrefixes: ['agent:'],
535
+ lessonToolNames: ['learn:save'],
536
+ researchL1BudgetCap: 0.2,
537
+ synthesisL1BudgetFloor: 0.5,
538
+ synthesisL1BudgetCap: 0.8,
539
+ maxResearchChunkTokens: 10000,
540
+ });
541
+ const config = strategy.knowledgeConfig;
542
+ assert.deepStrictEqual(config.researchToolPrefixes, ['custom:']);
543
+ assert.deepStrictEqual(config.subagentToolPrefixes, ['agent:']);
544
+ assert.deepStrictEqual(config.lessonToolNames, ['learn:save']);
545
+ assert.strictEqual(config.researchL1BudgetCap, 0.2);
546
+ assert.strictEqual(config.synthesisL1BudgetFloor, 0.5);
547
+ assert.strictEqual(config.synthesisL1BudgetCap, 0.8);
548
+ assert.strictEqual(config.maxResearchChunkTokens, 10000);
549
+ });
550
+ it('should force hierarchical mode', () => {
551
+ const strategy = new KnowledgeStrategy({ hierarchical: false });
552
+ const config = strategy.config;
553
+ assert.strictEqual(config.hierarchical, true, 'Should force hierarchical: true');
554
+ });
555
+ it('should have correct strategy name', () => {
556
+ const strategy = new KnowledgeStrategy();
557
+ assert.strictEqual(strategy.name, 'knowledge');
558
+ });
559
+ });
560
+ describe('Integration with Compression', () => {
561
+ it('should compress chunks with phase-aware prompts via mock membrane', async () => {
562
+ cleanup();
563
+ let lastRequest = null;
564
+ const mockMembrane = {
565
+ complete: async (request) => {
566
+ lastRequest = request;
567
+ return {
568
+ content: [{ type: 'text', text: 'Summary of the chunk' }],
569
+ };
570
+ },
571
+ };
572
+ const strategy = new KnowledgeStrategy({
573
+ targetChunkTokens: 100,
574
+ recentWindowTokens: 50,
575
+ });
576
+ const manager = await ContextManager.open({
577
+ path: TEST_STORE_PATH,
578
+ strategy,
579
+ membrane: mockMembrane,
580
+ });
581
+ // Research messages
582
+ manager.addMessage('Claude', [{
583
+ type: 'tool_use', id: 'tu1', name: 'mcpl:search', input: {},
584
+ }]);
585
+ manager.addMessage('User', [{
586
+ type: 'tool_result', toolUseId: 'tu1', content: 'Results ' + 'x'.repeat(200),
587
+ }]);
588
+ manager.addMessage('Claude', [{
589
+ type: 'tool_use', id: 'tu2', name: 'mcpl:get', input: {},
590
+ }]);
591
+ manager.addMessage('User', [{
592
+ type: 'tool_result', toolUseId: 'tu2', content: 'More results ' + 'x'.repeat(200),
593
+ }]);
594
+ // Push into compression zone
595
+ for (let i = 0; i < 8; i++) {
596
+ manager.addMessage('User', [{ type: 'text', text: `Pad ${i} ${'x'.repeat(50)}` }]);
597
+ }
598
+ // Trigger compression
599
+ await manager.compile();
600
+ if (!manager.isReady()) {
601
+ await manager.tick();
602
+ }
603
+ // Verify compression was called with research-specific prompt
604
+ if (lastRequest) {
605
+ const lastMessage = lastRequest.messages[lastRequest.messages.length - 1];
606
+ const text = lastMessage.content.map((b) => b.text).join('');
607
+ assert.ok(text.includes('research'), 'Compression prompt should mention research for research chunks');
608
+ }
609
+ manager.close();
610
+ });
611
+ });
612
+ });
613
+ // Utility for tests that don't open a manager
614
+ function manager_close_noop() {
615
+ // Tests that only use strategy methods directly don't need cleanup
616
+ }
617
+ //# sourceMappingURL=knowledge.test.js.map