@dot-ai/core 0.8.0 → 0.10.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/boot-cache.d.ts +1 -1
- package/dist/boot-cache.d.ts.map +1 -1
- package/dist/extension-api.d.ts +10 -9
- package/dist/extension-api.d.ts.map +1 -1
- package/dist/extension-loader.d.ts +1 -1
- package/dist/extension-loader.d.ts.map +1 -1
- package/dist/extension-loader.js +20 -7
- package/dist/extension-loader.js.map +1 -1
- package/dist/extension-runner.d.ts +9 -4
- package/dist/extension-runner.d.ts.map +1 -1
- package/dist/extension-runner.js +34 -13
- package/dist/extension-runner.js.map +1 -1
- package/dist/extension-types.d.ts +15 -115
- package/dist/extension-types.d.ts.map +1 -1
- package/dist/extension-types.js +1 -88
- package/dist/extension-types.js.map +1 -1
- package/dist/format.d.ts +21 -0
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +74 -0
- package/dist/format.js.map +1 -1
- package/dist/index.d.ts +4 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -3
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +31 -43
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +60 -176
- package/dist/runtime.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/extension-runner.test.ts +245 -32
- package/src/__tests__/fixtures/extensions/ctx-aware.js +12 -3
- package/src/__tests__/fixtures/extensions/smart-context.js +12 -3
- package/src/__tests__/format.test.ts +178 -1
- package/src/__tests__/runtime.test.ts +38 -10
- package/src/boot-cache.ts +1 -1
- package/src/extension-api.ts +10 -15
- package/src/extension-loader.ts +19 -10
- package/src/extension-runner.ts +44 -15
- package/src/extension-types.ts +26 -195
- package/src/format.ts +100 -0
- package/src/index.ts +3 -11
- package/src/runtime.ts +73 -221
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -7,7 +7,10 @@ function createMockExtension(overrides?: Partial<LoadedExtension>): LoadedExtens
|
|
|
7
7
|
path: '/mock/ext.ts',
|
|
8
8
|
handlers: new Map(),
|
|
9
9
|
tools: new Map(),
|
|
10
|
-
|
|
10
|
+
commands: new Map(),
|
|
11
|
+
skills: new Map(),
|
|
12
|
+
identities: new Map(),
|
|
13
|
+
labels: new Set(),
|
|
11
14
|
...overrides,
|
|
12
15
|
};
|
|
13
16
|
}
|
|
@@ -15,26 +18,26 @@ function createMockExtension(overrides?: Partial<LoadedExtension>): LoadedExtens
|
|
|
15
18
|
describe('ExtensionRunner', () => {
|
|
16
19
|
describe('fire', () => {
|
|
17
20
|
it('fires events to registered handlers', async () => {
|
|
18
|
-
const handler = vi.fn().mockResolvedValue({
|
|
21
|
+
const handler = vi.fn().mockResolvedValue({ data: 'hello' });
|
|
19
22
|
const ext = createMockExtension({
|
|
20
|
-
handlers: new Map([['
|
|
23
|
+
handlers: new Map([['agent_end', [handler]]]),
|
|
21
24
|
});
|
|
22
25
|
const runner = new ExtensionRunner([ext]);
|
|
23
|
-
const results = await runner.fire('
|
|
24
|
-
expect(handler).toHaveBeenCalledWith({
|
|
25
|
-
expect(results).toEqual([{
|
|
26
|
+
const results = await runner.fire('agent_end', { response: 'test' });
|
|
27
|
+
expect(handler).toHaveBeenCalledWith({ response: 'test' }, undefined);
|
|
28
|
+
expect(results).toEqual([{ data: 'hello' }]);
|
|
26
29
|
});
|
|
27
30
|
|
|
28
31
|
it('collects results from multiple extensions', async () => {
|
|
29
32
|
const ext1 = createMockExtension({
|
|
30
|
-
handlers: new Map([['
|
|
33
|
+
handlers: new Map([['agent_end', [vi.fn().mockResolvedValue({ data: 'a' })]]]),
|
|
31
34
|
});
|
|
32
35
|
const ext2 = createMockExtension({
|
|
33
|
-
handlers: new Map([['
|
|
36
|
+
handlers: new Map([['agent_end', [vi.fn().mockResolvedValue({ data: 'b' })]]]),
|
|
34
37
|
});
|
|
35
38
|
const runner = new ExtensionRunner([ext1, ext2]);
|
|
36
|
-
const results = await runner.fire('
|
|
37
|
-
expect(results).toEqual([{
|
|
39
|
+
const results = await runner.fire('agent_end', {});
|
|
40
|
+
expect(results).toEqual([{ data: 'a' }, { data: 'b' }]);
|
|
38
41
|
});
|
|
39
42
|
|
|
40
43
|
it('skips void results', async () => {
|
|
@@ -48,25 +51,25 @@ describe('ExtensionRunner', () => {
|
|
|
48
51
|
});
|
|
49
52
|
|
|
50
53
|
it('handles errors in handlers (log and continue)', async () => {
|
|
51
|
-
const goodHandler = vi.fn().mockResolvedValue({
|
|
54
|
+
const goodHandler = vi.fn().mockResolvedValue({ data: 'ok' });
|
|
52
55
|
const badHandler = vi.fn().mockRejectedValue(new Error('boom'));
|
|
53
56
|
const ext = createMockExtension({
|
|
54
|
-
handlers: new Map([['
|
|
57
|
+
handlers: new Map([['agent_end', [badHandler, goodHandler]]]),
|
|
55
58
|
});
|
|
56
59
|
const runner = new ExtensionRunner([ext]);
|
|
57
|
-
const results = await runner.fire('
|
|
58
|
-
expect(results).toEqual([{
|
|
60
|
+
const results = await runner.fire('agent_end', {});
|
|
61
|
+
expect(results).toEqual([{ data: 'ok' }]);
|
|
59
62
|
});
|
|
60
63
|
|
|
61
64
|
it('passes ctx as second argument to handlers', async () => {
|
|
62
|
-
const handler = vi.fn().mockResolvedValue({
|
|
65
|
+
const handler = vi.fn().mockResolvedValue({ data: 'with-ctx' });
|
|
63
66
|
const ext = createMockExtension({
|
|
64
|
-
handlers: new Map([['
|
|
67
|
+
handlers: new Map([['agent_end', [handler]]]),
|
|
65
68
|
});
|
|
66
69
|
const runner = new ExtensionRunner([ext]);
|
|
67
70
|
const ctx = { workspaceRoot: '/test', events: { on: vi.fn(), emit: vi.fn() } };
|
|
68
|
-
await runner.fire('
|
|
69
|
-
expect(handler).toHaveBeenCalledWith({
|
|
71
|
+
await runner.fire('agent_end', { response: 'test' }, ctx);
|
|
72
|
+
expect(handler).toHaveBeenCalledWith({ response: 'test' }, ctx);
|
|
70
73
|
});
|
|
71
74
|
|
|
72
75
|
it('returns empty array for events with no handlers', async () => {
|
|
@@ -158,33 +161,243 @@ describe('ExtensionRunner', () => {
|
|
|
158
161
|
});
|
|
159
162
|
});
|
|
160
163
|
|
|
164
|
+
describe('skills', () => {
|
|
165
|
+
it('merges skills across extensions', () => {
|
|
166
|
+
const skill1 = { name: 'deploy', description: 'Deploy', labels: ['deploy'], content: '...' };
|
|
167
|
+
const skill2 = { name: 'security', description: 'Security', labels: ['security'], content: '...' };
|
|
168
|
+
const ext1 = createMockExtension({ skills: new Map([['deploy', skill1]]) });
|
|
169
|
+
const ext2 = createMockExtension({ skills: new Map([['security', skill2]]) });
|
|
170
|
+
const runner = new ExtensionRunner([ext1, ext2]);
|
|
171
|
+
expect(runner.skills).toHaveLength(2);
|
|
172
|
+
expect(runner.skills.map(s => s.name)).toEqual(['deploy', 'security']);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('identities', () => {
|
|
177
|
+
it('merges identities across extensions', () => {
|
|
178
|
+
const id1 = { type: 'agents', content: '# AGENTS', source: 'ext-file-identity', priority: 100 };
|
|
179
|
+
const ext1 = createMockExtension({ identities: new Map([['agents:root', id1]]) });
|
|
180
|
+
const runner = new ExtensionRunner([ext1]);
|
|
181
|
+
expect(runner.identities).toHaveLength(1);
|
|
182
|
+
expect(runner.identities[0].type).toBe('agents');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('vocabularyLabels', () => {
|
|
187
|
+
it('collects labels from all extensions', () => {
|
|
188
|
+
const ext1 = createMockExtension({ labels: new Set(['deploy', 'security']) });
|
|
189
|
+
const ext2 = createMockExtension({ labels: new Set(['question', 'deploy']) });
|
|
190
|
+
const runner = new ExtensionRunner([ext1, ext2]);
|
|
191
|
+
const labels = runner.vocabularyLabels;
|
|
192
|
+
expect(labels).toContain('deploy');
|
|
193
|
+
expect(labels).toContain('security');
|
|
194
|
+
expect(labels).toContain('question');
|
|
195
|
+
// No duplicates
|
|
196
|
+
expect(labels.filter(l => l === 'deploy')).toHaveLength(1);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('fireCollectSections', () => {
|
|
201
|
+
it('collects sections from multiple handlers', async () => {
|
|
202
|
+
const handler1 = vi.fn().mockResolvedValue({
|
|
203
|
+
sections: [{ id: 'sec-a', content: 'A' }],
|
|
204
|
+
});
|
|
205
|
+
const handler2 = vi.fn().mockResolvedValue({
|
|
206
|
+
sections: [{ id: 'sec-b', content: 'B' }],
|
|
207
|
+
});
|
|
208
|
+
const ext1 = createMockExtension({ handlers: new Map([['context_enrich', [handler1]]]) });
|
|
209
|
+
const ext2 = createMockExtension({ handlers: new Map([['context_enrich', [handler2]]]) });
|
|
210
|
+
const runner = new ExtensionRunner([ext1, ext2]);
|
|
211
|
+
const result = await runner.fireCollectSections('context_enrich');
|
|
212
|
+
expect(result.sections).toHaveLength(2);
|
|
213
|
+
const ids = result.sections.map((s: { id?: string }) => s.id);
|
|
214
|
+
expect(ids).toContain('sec-a');
|
|
215
|
+
expect(ids).toContain('sec-b');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('deduplicates sections by id (last-wins)', async () => {
|
|
219
|
+
const handler1 = vi.fn().mockResolvedValue({
|
|
220
|
+
sections: [{ id: 'shared', content: 'first' }],
|
|
221
|
+
});
|
|
222
|
+
const handler2 = vi.fn().mockResolvedValue({
|
|
223
|
+
sections: [{ id: 'shared', content: 'last' }],
|
|
224
|
+
});
|
|
225
|
+
const ext1 = createMockExtension({ handlers: new Map([['context_enrich', [handler1]]]) });
|
|
226
|
+
const ext2 = createMockExtension({ handlers: new Map([['context_enrich', [handler2]]]) });
|
|
227
|
+
const runner = new ExtensionRunner([ext1, ext2]);
|
|
228
|
+
const result = await runner.fireCollectSections('context_enrich');
|
|
229
|
+
expect(result.sections).toHaveLength(1);
|
|
230
|
+
expect(result.sections[0].content).toBe('last');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('keeps anonymous sections (no id)', async () => {
|
|
234
|
+
const handler1 = vi.fn().mockResolvedValue({
|
|
235
|
+
sections: [{ content: 'anon-1' }],
|
|
236
|
+
});
|
|
237
|
+
const handler2 = vi.fn().mockResolvedValue({
|
|
238
|
+
sections: [{ content: 'anon-2' }],
|
|
239
|
+
});
|
|
240
|
+
const ext1 = createMockExtension({ handlers: new Map([['context_enrich', [handler1]]]) });
|
|
241
|
+
const ext2 = createMockExtension({ handlers: new Map([['context_enrich', [handler2]]]) });
|
|
242
|
+
const runner = new ExtensionRunner([ext1, ext2]);
|
|
243
|
+
const result = await runner.fireCollectSections('context_enrich');
|
|
244
|
+
expect(result.sections).toHaveLength(2);
|
|
245
|
+
const contents = result.sections.map((s: { content: string }) => s.content);
|
|
246
|
+
expect(contents).toContain('anon-1');
|
|
247
|
+
expect(contents).toContain('anon-2');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('concatenates systemPrompt strings', async () => {
|
|
251
|
+
const handler1 = vi.fn().mockResolvedValue({ systemPrompt: 'You are helpful.' });
|
|
252
|
+
const handler2 = vi.fn().mockResolvedValue({ systemPrompt: 'Be concise.' });
|
|
253
|
+
const ext1 = createMockExtension({ handlers: new Map([['context_enrich', [handler1]]]) });
|
|
254
|
+
const ext2 = createMockExtension({ handlers: new Map([['context_enrich', [handler2]]]) });
|
|
255
|
+
const runner = new ExtensionRunner([ext1, ext2]);
|
|
256
|
+
const result = await runner.fireCollectSections('context_enrich');
|
|
257
|
+
expect(result.systemPrompt).toBe('You are helpful.\nBe concise.');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('returns empty sections array and empty systemPrompt when no handlers', async () => {
|
|
261
|
+
const runner = new ExtensionRunner([createMockExtension()]);
|
|
262
|
+
const result = await runner.fireCollectSections('context_enrich');
|
|
263
|
+
expect(result.sections).toEqual([]);
|
|
264
|
+
expect(result.systemPrompt).toBe('');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('handles handler errors gracefully', async () => {
|
|
268
|
+
const badHandler = vi.fn().mockRejectedValue(new Error('boom'));
|
|
269
|
+
const goodHandler = vi.fn().mockResolvedValue({
|
|
270
|
+
sections: [{ id: 'ok', content: 'good' }],
|
|
271
|
+
});
|
|
272
|
+
const ext = createMockExtension({
|
|
273
|
+
handlers: new Map([['context_enrich', [badHandler, goodHandler]]]),
|
|
274
|
+
});
|
|
275
|
+
const runner = new ExtensionRunner([ext]);
|
|
276
|
+
const result = await runner.fireCollectSections('context_enrich');
|
|
277
|
+
expect(result.sections).toHaveLength(1);
|
|
278
|
+
expect(result.sections[0].id).toBe('ok');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('fireFirstResult', () => {
|
|
283
|
+
it('returns first non-null result', async () => {
|
|
284
|
+
const handler = vi.fn().mockResolvedValue({ route: 'chat' });
|
|
285
|
+
const ext = createMockExtension({ handlers: new Map([['route', [handler]]]) });
|
|
286
|
+
const runner = new ExtensionRunner([ext]);
|
|
287
|
+
const result = await runner.fireFirstResult('route', { message: 'hi' });
|
|
288
|
+
expect(result).toEqual({ route: 'chat' });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('stops after first result (second handler not called)', async () => {
|
|
292
|
+
const handler1 = vi.fn().mockResolvedValue({ route: 'first' });
|
|
293
|
+
const handler2 = vi.fn().mockResolvedValue({ route: 'second' });
|
|
294
|
+
const ext1 = createMockExtension({ handlers: new Map([['route', [handler1]]]) });
|
|
295
|
+
const ext2 = createMockExtension({ handlers: new Map([['route', [handler2]]]) });
|
|
296
|
+
const runner = new ExtensionRunner([ext1, ext2]);
|
|
297
|
+
const result = await runner.fireFirstResult('route', {});
|
|
298
|
+
expect(result).toEqual({ route: 'first' });
|
|
299
|
+
expect(handler2).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('returns null when no handler returns a value', async () => {
|
|
303
|
+
const handler = vi.fn().mockResolvedValue(undefined);
|
|
304
|
+
const ext = createMockExtension({ handlers: new Map([['route', [handler]]]) });
|
|
305
|
+
const runner = new ExtensionRunner([ext]);
|
|
306
|
+
const result = await runner.fireFirstResult('route', {});
|
|
307
|
+
expect(result).toBeNull();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('skips null/undefined results and returns next non-null', async () => {
|
|
311
|
+
const handler1 = vi.fn().mockResolvedValue(null);
|
|
312
|
+
const handler2 = vi.fn().mockResolvedValue(undefined);
|
|
313
|
+
const handler3 = vi.fn().mockResolvedValue({ route: 'found' });
|
|
314
|
+
const ext = createMockExtension({
|
|
315
|
+
handlers: new Map([['route', [handler1, handler2, handler3]]]),
|
|
316
|
+
});
|
|
317
|
+
const runner = new ExtensionRunner([ext]);
|
|
318
|
+
const result = await runner.fireFirstResult('route', {});
|
|
319
|
+
expect(result).toEqual({ route: 'found' });
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe('fireChainTransform', () => {
|
|
324
|
+
it('chains transform through handlers', async () => {
|
|
325
|
+
const handler1 = vi.fn().mockImplementation(async (data: { value: number }) => ({
|
|
326
|
+
value: data.value + 1,
|
|
327
|
+
}));
|
|
328
|
+
const handler2 = vi.fn().mockImplementation(async (data: { value: number }) => ({
|
|
329
|
+
value: data.value * 2,
|
|
330
|
+
}));
|
|
331
|
+
const ext = createMockExtension({
|
|
332
|
+
handlers: new Map([['label_extract', [handler1, handler2]]]),
|
|
333
|
+
});
|
|
334
|
+
const runner = new ExtensionRunner([ext]);
|
|
335
|
+
const result = await runner.fireChainTransform('label_extract', { value: 3 });
|
|
336
|
+
// (3 + 1) * 2 = 8
|
|
337
|
+
expect(result).toEqual({ value: 8 });
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('keeps previous value when handler returns undefined', async () => {
|
|
341
|
+
const handler1 = vi.fn().mockResolvedValue({ value: 42 });
|
|
342
|
+
const handler2 = vi.fn().mockResolvedValue(undefined);
|
|
343
|
+
const ext = createMockExtension({
|
|
344
|
+
handlers: new Map([['label_extract', [handler1, handler2]]]),
|
|
345
|
+
});
|
|
346
|
+
const runner = new ExtensionRunner([ext]);
|
|
347
|
+
const result = await runner.fireChainTransform('label_extract', { value: 0 });
|
|
348
|
+
expect(result).toEqual({ value: 42 });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('short-circuits on input event with consumed: true', async () => {
|
|
352
|
+
const handler1 = vi.fn().mockResolvedValue({ text: 'consumed', consumed: true });
|
|
353
|
+
const handler2 = vi.fn().mockResolvedValue({ text: 'never' });
|
|
354
|
+
const ext = createMockExtension({
|
|
355
|
+
handlers: new Map([['input', [handler1, handler2]]]),
|
|
356
|
+
});
|
|
357
|
+
const runner = new ExtensionRunner([ext]);
|
|
358
|
+
const result = await runner.fireChainTransform('input', { text: 'original' });
|
|
359
|
+
expect(result).toEqual({ text: 'consumed', consumed: true });
|
|
360
|
+
expect(handler2).not.toHaveBeenCalled();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('handles single handler', async () => {
|
|
364
|
+
const handler = vi.fn().mockResolvedValue({ label: 'question' });
|
|
365
|
+
const ext = createMockExtension({
|
|
366
|
+
handlers: new Map([['label_extract', [handler]]]),
|
|
367
|
+
});
|
|
368
|
+
const runner = new ExtensionRunner([ext]);
|
|
369
|
+
const result = await runner.fireChainTransform('label_extract', { label: 'unknown' });
|
|
370
|
+
expect(result).toEqual({ label: 'question' });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('returns initial data when no handlers', async () => {
|
|
374
|
+
const runner = new ExtensionRunner([createMockExtension()]);
|
|
375
|
+
const initial = { value: 99 };
|
|
376
|
+
const result = await runner.fireChainTransform('label_extract', initial);
|
|
377
|
+
expect(result).toEqual({ value: 99 });
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
161
381
|
describe('diagnostics', () => {
|
|
162
|
-
it('reports correct counts
|
|
382
|
+
it('reports correct counts', () => {
|
|
163
383
|
const ext = createMockExtension({
|
|
164
384
|
path: '/ext/test.ts',
|
|
165
385
|
handlers: new Map([
|
|
166
|
-
['
|
|
386
|
+
['context_enrich', [vi.fn(), vi.fn()]],
|
|
167
387
|
['tool_call', [vi.fn()]],
|
|
168
388
|
]),
|
|
169
389
|
tools: new Map([['my_tool', { name: 'my_tool', description: '', parameters: {}, execute: vi.fn() }]]),
|
|
170
|
-
|
|
390
|
+
skills: new Map([['deploy', { name: 'deploy', description: '', labels: [] }]]),
|
|
391
|
+
identities: new Map([['agents:root', { type: 'agents', content: '', source: '', priority: 100 }]]),
|
|
171
392
|
});
|
|
172
393
|
const runner = new ExtensionRunner([ext]);
|
|
173
394
|
const diag = runner.diagnostics;
|
|
174
395
|
expect(diag).toHaveLength(1);
|
|
175
396
|
expect(diag[0].path).toBe('/ext/test.ts');
|
|
176
|
-
expect(diag[0].handlerCounts).toEqual({
|
|
397
|
+
expect(diag[0].handlerCounts).toEqual({ context_enrich: 2, tool_call: 1 });
|
|
177
398
|
expect(diag[0].toolNames).toEqual(['my_tool']);
|
|
178
|
-
expect(diag[0].
|
|
179
|
-
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
describe('usedTiers', () => {
|
|
183
|
-
it('aggregates tiers from all extensions', () => {
|
|
184
|
-
const ext1 = createMockExtension({ tiers: new Set(['universal'] as const) });
|
|
185
|
-
const ext2 = createMockExtension({ tiers: new Set(['rich'] as const) });
|
|
186
|
-
const runner = new ExtensionRunner([ext1, ext2]);
|
|
187
|
-
expect(runner.usedTiers).toEqual(new Set(['universal', 'rich']));
|
|
399
|
+
expect(diag[0].skillNames).toEqual(['deploy']);
|
|
400
|
+
expect(diag[0].identityNames).toEqual(['agents:root']);
|
|
188
401
|
});
|
|
189
402
|
});
|
|
190
403
|
});
|
|
@@ -10,10 +10,19 @@ export default function(api) {
|
|
|
10
10
|
}
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
-
api.on('
|
|
14
|
-
// Use ctx.workspaceRoot in
|
|
13
|
+
api.on('context_enrich', async (_event, ctx) => {
|
|
14
|
+
// Use ctx.workspaceRoot in enriched context
|
|
15
15
|
if (ctx?.workspaceRoot) {
|
|
16
|
-
return {
|
|
16
|
+
return {
|
|
17
|
+
sections: [{
|
|
18
|
+
id: 'ctx-aware:workspace',
|
|
19
|
+
title: 'Workspace',
|
|
20
|
+
content: `Workspace: ${ctx.workspaceRoot}`,
|
|
21
|
+
priority: 30,
|
|
22
|
+
source: 'ctx-aware',
|
|
23
|
+
trimStrategy: 'drop',
|
|
24
|
+
}],
|
|
25
|
+
};
|
|
17
26
|
}
|
|
18
27
|
});
|
|
19
28
|
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
/** Smart context extension —
|
|
1
|
+
/** Smart context extension — enriches context via context_enrich */
|
|
2
2
|
export default function(api) {
|
|
3
|
-
api.on('
|
|
3
|
+
api.on('context_enrich', async (event) => {
|
|
4
4
|
// Simple keyword-based context injection
|
|
5
5
|
const keywords = event.labels?.map(l => l.name) ?? [];
|
|
6
6
|
if (keywords.includes('memory')) {
|
|
7
|
-
return {
|
|
7
|
+
return {
|
|
8
|
+
sections: [{
|
|
9
|
+
id: 'smart-context:memory',
|
|
10
|
+
title: 'Memory',
|
|
11
|
+
content: '> Note: This workspace uses dot-ai memory system. Use memory_recall/memory_store tools.',
|
|
12
|
+
priority: 50,
|
|
13
|
+
source: 'smart-context',
|
|
14
|
+
trimStrategy: 'drop',
|
|
15
|
+
}],
|
|
16
|
+
};
|
|
8
17
|
}
|
|
9
18
|
});
|
|
10
19
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { formatContext, formatToolHints } from '../format.js';
|
|
2
|
+
import { formatContext, formatToolHints, formatSections, assembleSections, trimSections } from '../format.js';
|
|
3
3
|
import type { EnrichedContext, Identity, MemoryEntry, Skill, Tool } from '../types.js';
|
|
4
4
|
import type { Capability } from '../capabilities.js';
|
|
5
|
+
import type { Section } from '../extension-types.js';
|
|
5
6
|
|
|
6
7
|
function makeContext(overrides?: Partial<EnrichedContext>): EnrichedContext {
|
|
7
8
|
return {
|
|
@@ -429,6 +430,182 @@ describe('formatToolHints', () => {
|
|
|
429
430
|
});
|
|
430
431
|
});
|
|
431
432
|
|
|
433
|
+
function makeSection(overrides?: Partial<Section>): Section {
|
|
434
|
+
return {
|
|
435
|
+
id: 'test:section',
|
|
436
|
+
title: 'Test',
|
|
437
|
+
content: 'test content',
|
|
438
|
+
priority: 50,
|
|
439
|
+
source: 'test',
|
|
440
|
+
...overrides,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
describe('assembleSections', () => {
|
|
445
|
+
it('returns empty string for empty array', () => {
|
|
446
|
+
expect(assembleSections([])).toBe('');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('formats single section with title as ## Title\\n\\ncontent', () => {
|
|
450
|
+
const section = makeSection({ title: 'My Title', content: 'my content' });
|
|
451
|
+
const result = assembleSections([section]);
|
|
452
|
+
expect(result).toBe('## My Title\n\nmy content');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('joins multiple sections with \\n\\n---\\n\\n', () => {
|
|
456
|
+
const sections = [
|
|
457
|
+
makeSection({ title: 'First', content: 'first content' }),
|
|
458
|
+
makeSection({ title: 'Second', content: 'second content' }),
|
|
459
|
+
];
|
|
460
|
+
const result = assembleSections(sections);
|
|
461
|
+
expect(result).toBe('## First\n\nfirst content\n\n---\n\n## Second\n\nsecond content');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('handles sections without title (just content)', () => {
|
|
465
|
+
const section = makeSection({ title: '', content: 'bare content' });
|
|
466
|
+
const result = assembleSections([section]);
|
|
467
|
+
expect(result).toBe('bare content');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('mixes titled and untitled sections', () => {
|
|
471
|
+
const sections = [
|
|
472
|
+
makeSection({ title: 'With Title', content: 'titled content' }),
|
|
473
|
+
makeSection({ title: '', content: 'untitled content' }),
|
|
474
|
+
];
|
|
475
|
+
const result = assembleSections(sections);
|
|
476
|
+
expect(result).toBe('## With Title\n\ntitled content\n\n---\n\nuntitled content');
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe('formatSections', () => {
|
|
481
|
+
it('sorts sections by priority DESC', () => {
|
|
482
|
+
const sections = [
|
|
483
|
+
makeSection({ id: 'low', title: 'Low', content: 'low priority', priority: 10 }),
|
|
484
|
+
makeSection({ id: 'high', title: 'High', content: 'high priority', priority: 100 }),
|
|
485
|
+
makeSection({ id: 'mid', title: 'Mid', content: 'mid priority', priority: 50 }),
|
|
486
|
+
];
|
|
487
|
+
const result = formatSections(sections);
|
|
488
|
+
const highPos = result.indexOf('high priority');
|
|
489
|
+
const midPos = result.indexOf('mid priority');
|
|
490
|
+
const lowPos = result.indexOf('low priority');
|
|
491
|
+
expect(highPos).toBeLessThan(midPos);
|
|
492
|
+
expect(midPos).toBeLessThan(lowPos);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('returns assembleSections output (no trimming when no budget)', () => {
|
|
496
|
+
const sections = [
|
|
497
|
+
makeSection({ title: 'A', content: 'content a', priority: 10 }),
|
|
498
|
+
makeSection({ title: 'B', content: 'content b', priority: 20 }),
|
|
499
|
+
];
|
|
500
|
+
// formatSections sorts by priority DESC, so B first
|
|
501
|
+
const result = formatSections(sections);
|
|
502
|
+
expect(result).toBe('## B\n\ncontent b\n\n---\n\n## A\n\ncontent a');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('applies trimming when tokenBudget is set', () => {
|
|
506
|
+
// Create a protected section (never drop) with large content that will exceed the budget
|
|
507
|
+
// and a smaller section that can be dropped. The protected one gets truncated but not dropped.
|
|
508
|
+
const bigContent = 'X'.repeat(10000);
|
|
509
|
+
const sections = [
|
|
510
|
+
makeSection({ id: 'protected', title: 'Protected', content: bigContent, priority: 50, trimStrategy: 'never' }),
|
|
511
|
+
];
|
|
512
|
+
// Budget well below the content size — trimming will be attempted but 'never' prevents drop
|
|
513
|
+
// The result is the original content since truncate strategy isn't set
|
|
514
|
+
const result = formatSections(sections, { tokenBudget: 10 });
|
|
515
|
+
// The section is preserved (never dropped)
|
|
516
|
+
expect(result).toContain('## Protected');
|
|
517
|
+
// Content is shorter than original since no truncate strategy, section cannot be dropped either
|
|
518
|
+
// so the function returns it as-is
|
|
519
|
+
expect(result).toContain('X');
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('returns empty string for empty sections array', () => {
|
|
523
|
+
expect(formatSections([])).toBe('');
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
describe('trimSections', () => {
|
|
528
|
+
it('returns sections unchanged when under budget', () => {
|
|
529
|
+
const sections = [makeSection({ content: 'short content' })];
|
|
530
|
+
// Large budget so no trimming needed
|
|
531
|
+
const result = trimSections(sections, 100000);
|
|
532
|
+
expect(result).toEqual(sections);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('truncates sections with trimStrategy truncate when content > 2000 chars', () => {
|
|
536
|
+
const longContent = 'A'.repeat(3000);
|
|
537
|
+
// Use a budget that is exceeded by the long content but satisfied after truncation to 2000 chars.
|
|
538
|
+
// Truncated content: 2000 chars + '\n\n[...truncated]' = ~2016 chars ≈ 504 tokens.
|
|
539
|
+
// With title '## Test\n\n' overhead (~12 chars) ≈ 504 tokens total.
|
|
540
|
+
// Use budget=600 so truncation satisfies it but not dropping.
|
|
541
|
+
const sections = [
|
|
542
|
+
makeSection({ id: 'big', content: longContent, trimStrategy: 'truncate' }),
|
|
543
|
+
];
|
|
544
|
+
const result = trimSections(sections, 600);
|
|
545
|
+
expect(result).toHaveLength(1);
|
|
546
|
+
expect(result[0].content).toContain('[...truncated]');
|
|
547
|
+
expect(result[0].content).toContain('A'.repeat(2000));
|
|
548
|
+
expect(result[0].content).not.toContain('A'.repeat(2001));
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('does not truncate sections with content <= 2000 chars even with truncate strategy', () => {
|
|
552
|
+
const shortContent = 'A'.repeat(500);
|
|
553
|
+
const sections = [
|
|
554
|
+
makeSection({ id: 'small', content: shortContent, trimStrategy: 'truncate' }),
|
|
555
|
+
makeSection({ id: 'other', content: 'other content', priority: 10 }),
|
|
556
|
+
];
|
|
557
|
+
// Force a tight budget but the truncate candidate is too short to truncate
|
|
558
|
+
// so the other (droppable) section will be dropped instead
|
|
559
|
+
const result = trimSections(sections, 1);
|
|
560
|
+
const truncatedSection = result.find(s => s.id === 'small');
|
|
561
|
+
if (truncatedSection) {
|
|
562
|
+
expect(truncatedSection.content).not.toContain('[...truncated]');
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('drops sections with trimStrategy drop (lowest priority first)', () => {
|
|
567
|
+
const sections = [
|
|
568
|
+
makeSection({ id: 'high', title: 'High', content: 'high priority content', priority: 100 }),
|
|
569
|
+
makeSection({ id: 'low', title: 'Low', content: 'low priority content', priority: 1, trimStrategy: 'drop' }),
|
|
570
|
+
];
|
|
571
|
+
// Budget that requires dropping sections — very tight
|
|
572
|
+
const result = trimSections(sections, 1);
|
|
573
|
+
const ids = result.map(s => s.id);
|
|
574
|
+
expect(ids).not.toContain('low');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('never drops sections with trimStrategy never', () => {
|
|
578
|
+
const sections = [
|
|
579
|
+
makeSection({ id: 'protected', title: 'Protected', content: 'protected content', priority: 1, trimStrategy: 'never' }),
|
|
580
|
+
makeSection({ id: 'droppable', title: 'Droppable', content: 'droppable content', priority: 50 }),
|
|
581
|
+
];
|
|
582
|
+
// Very tight budget forces drops but 'never' section must survive
|
|
583
|
+
const result = trimSections(sections, 1);
|
|
584
|
+
const ids = result.map(s => s.id);
|
|
585
|
+
expect(ids).toContain('protected');
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('logs budget warning when trimming occurs', () => {
|
|
589
|
+
const bigContent = 'A'.repeat(10000);
|
|
590
|
+
const sections = [
|
|
591
|
+
makeSection({ id: 'big', content: bigContent, trimStrategy: 'truncate' }),
|
|
592
|
+
];
|
|
593
|
+
const logger = { log: vi.fn() };
|
|
594
|
+
trimSections(sections, 1, logger);
|
|
595
|
+
expect(logger.log).toHaveBeenCalled();
|
|
596
|
+
const logCall = logger.log.mock.calls[0][0];
|
|
597
|
+
expect(logCall.event).toBe('budget_trimmed');
|
|
598
|
+
expect(logCall.phase).toBe('format');
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('does not log when no trimming occurs', () => {
|
|
602
|
+
const sections = [makeSection({ content: 'short' })];
|
|
603
|
+
const logger = { log: vi.fn() };
|
|
604
|
+
trimSections(sections, 100000, logger);
|
|
605
|
+
expect(logger.log).not.toHaveBeenCalled();
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
432
609
|
describe('skillDisclosure: progressive', () => {
|
|
433
610
|
it('shows only description, not content, in progressive mode', () => {
|
|
434
611
|
const ctx = makeEmptyContext();
|
|
@@ -26,23 +26,24 @@ describe('DotAiRuntime', () => {
|
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
describe('processPrompt', () => {
|
|
29
|
-
it('returns sections, labels,
|
|
29
|
+
it('returns sections, labels, routing', async () => {
|
|
30
30
|
const runtime = new DotAiRuntime({ workspaceRoot: '/tmp/nonexistent' });
|
|
31
31
|
await runtime.boot();
|
|
32
32
|
const result = await runtime.processPrompt('hello world');
|
|
33
|
-
expect(result.formatted).toBeDefined();
|
|
34
|
-
expect(result.enriched).toBeDefined();
|
|
35
|
-
expect(result.capabilities).toBeDefined();
|
|
36
33
|
expect(result.labels).toBeDefined();
|
|
37
34
|
expect(result.sections).toBeDefined();
|
|
35
|
+
expect(Array.isArray(result.sections)).toBe(true);
|
|
36
|
+
expect(result.routing).toBeDefined();
|
|
38
37
|
});
|
|
39
|
-
});
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
it('fires agent_end without throwing', async () => {
|
|
39
|
+
it('includes core system section', async () => {
|
|
43
40
|
const runtime = new DotAiRuntime({ workspaceRoot: '/tmp/nonexistent' });
|
|
44
41
|
await runtime.boot();
|
|
45
|
-
await runtime.
|
|
42
|
+
const result = await runtime.processPrompt('hello');
|
|
43
|
+
const systemSection = result.sections.find(s => s.id === 'dot-ai:system');
|
|
44
|
+
expect(systemSection).toBeDefined();
|
|
45
|
+
expect(systemSection!.priority).toBe(95);
|
|
46
|
+
expect(systemSection!.source).toBe('core');
|
|
46
47
|
});
|
|
47
48
|
});
|
|
48
49
|
|
|
@@ -74,6 +75,19 @@ describe('DotAiRuntime', () => {
|
|
|
74
75
|
});
|
|
75
76
|
});
|
|
76
77
|
|
|
78
|
+
describe('executeTool', () => {
|
|
79
|
+
it('throws when not booted', async () => {
|
|
80
|
+
const runtime = new DotAiRuntime({ workspaceRoot: '/tmp/nonexistent' });
|
|
81
|
+
await expect(runtime.executeTool('test', {})).rejects.toThrow('Runtime not booted');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('throws for unknown tool', async () => {
|
|
85
|
+
const runtime = new DotAiRuntime({ workspaceRoot: '/tmp/nonexistent' });
|
|
86
|
+
await runtime.boot();
|
|
87
|
+
await expect(runtime.executeTool('nonexistent', {})).rejects.toThrow('Tool not found');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
77
91
|
describe('shutdown', () => {
|
|
78
92
|
it('fires session_end and flushes', async () => {
|
|
79
93
|
const flushFn = vi.fn().mockResolvedValue(undefined);
|
|
@@ -122,12 +136,26 @@ describe('DotAiRuntime', () => {
|
|
|
122
136
|
expect(runtime.commands).toEqual([]);
|
|
123
137
|
});
|
|
124
138
|
|
|
125
|
-
it('
|
|
139
|
+
it('skills returns empty array when no extensions', async () => {
|
|
140
|
+
const runtime = new DotAiRuntime({ workspaceRoot: '/tmp/nonexistent' });
|
|
141
|
+
await runtime.boot();
|
|
142
|
+
expect(runtime.skills).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('identities returns empty array when no extensions', async () => {
|
|
146
|
+
const runtime = new DotAiRuntime({ workspaceRoot: '/tmp/nonexistent' });
|
|
147
|
+
await runtime.boot();
|
|
148
|
+
expect(runtime.identities).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('diagnostics include vocabulary size and counts', async () => {
|
|
126
152
|
const runtime = new DotAiRuntime({ workspaceRoot: '/tmp/nonexistent' });
|
|
127
153
|
await runtime.boot();
|
|
128
154
|
const diag = runtime.diagnostics;
|
|
129
155
|
expect(diag.vocabularySize).toBeDefined();
|
|
130
|
-
expect(diag.capabilityCount).toBe(0);
|
|
156
|
+
expect(diag.capabilityCount).toBe(0);
|
|
157
|
+
expect(diag.skillCount).toBe(0);
|
|
158
|
+
expect(diag.identityCount).toBe(0);
|
|
131
159
|
expect(diag.extensions).toEqual([]);
|
|
132
160
|
});
|
|
133
161
|
|
package/src/boot-cache.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface BootCacheData {
|
|
|
11
11
|
version: 1;
|
|
12
12
|
/** Checksum of inputs that produced this cache */
|
|
13
13
|
checksum: string;
|
|
14
|
-
/** Label vocabulary from
|
|
14
|
+
/** Label vocabulary from registered resources */
|
|
15
15
|
vocabulary: string[];
|
|
16
16
|
/** Extension paths that were loaded */
|
|
17
17
|
extensionPaths: string[];
|