@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.
- package/dist/src/blob-manager.d.ts +37 -0
- package/dist/src/blob-manager.d.ts.map +1 -0
- package/dist/src/blob-manager.js +128 -0
- package/dist/src/blob-manager.js.map +1 -0
- package/dist/src/context-log.d.ts +99 -0
- package/dist/src/context-log.d.ts.map +1 -0
- package/dist/src/context-log.js +277 -0
- package/dist/src/context-log.js.map +1 -0
- package/dist/src/context-manager.d.ts +245 -0
- package/dist/src/context-manager.d.ts.map +1 -0
- package/dist/src/context-manager.js +553 -0
- package/dist/src/context-manager.js.map +1 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +12 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/message-store.d.ts +135 -0
- package/dist/src/message-store.d.ts.map +1 -0
- package/dist/src/message-store.js +372 -0
- package/dist/src/message-store.js.map +1 -0
- package/dist/src/strategies/autobiographical.d.ts +199 -0
- package/dist/src/strategies/autobiographical.d.ts.map +1 -0
- package/dist/src/strategies/autobiographical.js +1122 -0
- package/dist/src/strategies/autobiographical.js.map +1 -0
- package/dist/src/strategies/index.d.ts +3 -0
- package/dist/src/strategies/index.d.ts.map +1 -0
- package/dist/src/strategies/index.js +3 -0
- package/dist/src/strategies/index.js.map +1 -0
- package/dist/src/strategies/knowledge.d.ts +46 -0
- package/dist/src/strategies/knowledge.d.ts.map +1 -0
- package/dist/src/strategies/knowledge.js +270 -0
- package/dist/src/strategies/knowledge.js.map +1 -0
- package/dist/src/strategies/passthrough.d.ts +17 -0
- package/dist/src/strategies/passthrough.d.ts.map +1 -0
- package/dist/src/strategies/passthrough.js +69 -0
- package/dist/src/strategies/passthrough.js.map +1 -0
- package/dist/src/types/context.d.ts +108 -0
- package/dist/src/types/context.d.ts.map +1 -0
- package/dist/src/types/context.js +2 -0
- package/dist/src/types/context.js.map +1 -0
- package/dist/src/types/index.d.ts +5 -0
- package/dist/src/types/index.d.ts.map +1 -0
- package/dist/src/types/index.js +2 -0
- package/dist/src/types/index.js.map +1 -0
- package/dist/src/types/message.d.ts +129 -0
- package/dist/src/types/message.d.ts.map +1 -0
- package/dist/src/types/message.js +2 -0
- package/dist/src/types/message.js.map +1 -0
- package/dist/src/types/strategy.d.ts +233 -0
- package/dist/src/types/strategy.d.ts.map +1 -0
- package/dist/src/types/strategy.js +32 -0
- package/dist/src/types/strategy.js.map +1 -0
- package/dist/test/autobiographical.test.d.ts +2 -0
- package/dist/test/autobiographical.test.d.ts.map +1 -0
- package/dist/test/autobiographical.test.js +46 -0
- package/dist/test/autobiographical.test.js.map +1 -0
- package/dist/test/head-window-reset.test.d.ts +17 -0
- package/dist/test/head-window-reset.test.d.ts.map +1 -0
- package/dist/test/head-window-reset.test.js +342 -0
- package/dist/test/head-window-reset.test.js.map +1 -0
- package/dist/test/integration.test.d.ts +2 -0
- package/dist/test/integration.test.d.ts.map +1 -0
- package/dist/test/integration.test.js +1341 -0
- package/dist/test/integration.test.js.map +1 -0
- package/dist/test/knowledge.test.d.ts +2 -0
- package/dist/test/knowledge.test.d.ts.map +1 -0
- package/dist/test/knowledge.test.js +617 -0
- package/dist/test/knowledge.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +48 -0
- package/src/blob-manager.ts +155 -0
- package/src/context-log.ts +342 -0
- package/src/context-manager.ts +726 -0
- package/src/index.ts +50 -0
- package/src/message-store.ts +479 -0
- package/src/strategies/autobiographical.ts +1355 -0
- package/src/strategies/index.ts +2 -0
- package/src/strategies/knowledge.ts +336 -0
- package/src/strategies/passthrough.ts +98 -0
- package/src/types/context.ts +119 -0
- package/src/types/index.ts +42 -0
- package/src/types/message.ts +140 -0
- 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
|