@dot-ai/core 0.5.2 → 0.7.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 (132) hide show
  1. package/dist/boot-cache.d.ts +40 -0
  2. package/dist/boot-cache.d.ts.map +1 -0
  3. package/dist/boot-cache.js +72 -0
  4. package/dist/boot-cache.js.map +1 -0
  5. package/dist/capabilities.d.ts +35 -0
  6. package/dist/capabilities.d.ts.map +1 -0
  7. package/dist/capabilities.js +17 -0
  8. package/dist/capabilities.js.map +1 -0
  9. package/dist/config.d.ts +7 -23
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +131 -108
  12. package/dist/config.js.map +1 -1
  13. package/dist/extension-api.d.ts +65 -0
  14. package/dist/extension-api.d.ts.map +1 -0
  15. package/dist/extension-api.js +2 -0
  16. package/dist/extension-api.js.map +1 -0
  17. package/dist/extension-loader.d.ts +19 -0
  18. package/dist/extension-loader.d.ts.map +1 -0
  19. package/dist/extension-loader.js +113 -0
  20. package/dist/extension-loader.js.map +1 -0
  21. package/dist/extension-runner.d.ts +62 -0
  22. package/dist/extension-runner.d.ts.map +1 -0
  23. package/dist/extension-runner.js +260 -0
  24. package/dist/extension-runner.js.map +1 -0
  25. package/dist/extension-types.d.ts +312 -0
  26. package/dist/extension-types.d.ts.map +1 -0
  27. package/dist/extension-types.js +89 -0
  28. package/dist/extension-types.js.map +1 -0
  29. package/dist/format.d.ts +13 -1
  30. package/dist/format.d.ts.map +1 -1
  31. package/dist/format.js +131 -15
  32. package/dist/format.js.map +1 -1
  33. package/dist/format.spec.d.ts +2 -0
  34. package/dist/format.spec.d.ts.map +1 -0
  35. package/dist/format.spec.js +140 -0
  36. package/dist/format.spec.js.map +1 -0
  37. package/dist/index.d.ts +21 -14
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +21 -14
  40. package/dist/index.js.map +1 -1
  41. package/dist/logger.d.ts +1 -1
  42. package/dist/logger.d.ts.map +1 -1
  43. package/dist/package-manager.d.ts +30 -0
  44. package/dist/package-manager.d.ts.map +1 -0
  45. package/dist/package-manager.js +91 -0
  46. package/dist/package-manager.js.map +1 -0
  47. package/dist/runtime.d.ts +119 -0
  48. package/dist/runtime.d.ts.map +1 -0
  49. package/dist/runtime.js +441 -0
  50. package/dist/runtime.js.map +1 -0
  51. package/dist/types.d.ts +29 -10
  52. package/dist/types.d.ts.map +1 -1
  53. package/package.json +4 -1
  54. package/src/__tests__/capabilities.test.ts +72 -0
  55. package/src/__tests__/config.test.ts +22 -120
  56. package/src/__tests__/extension-loader.test.ts +84 -0
  57. package/src/__tests__/extension-runner.test.ts +228 -0
  58. package/src/__tests__/fixtures/extensions/ctx-aware.js +26 -0
  59. package/src/__tests__/fixtures/extensions/security-gate.js +20 -0
  60. package/src/__tests__/fixtures/extensions/session-analytics.js +28 -0
  61. package/src/__tests__/fixtures/extensions/smart-context.js +10 -0
  62. package/src/__tests__/format.test.ts +207 -2
  63. package/src/__tests__/runtime.test.ts +141 -0
  64. package/src/boot-cache.ts +104 -0
  65. package/src/capabilities.ts +49 -0
  66. package/src/config.ts +131 -133
  67. package/src/extension-api.ts +99 -0
  68. package/src/extension-loader.ts +127 -0
  69. package/src/extension-runner.ts +297 -0
  70. package/src/extension-types.ts +416 -0
  71. package/src/format.spec.ts +175 -0
  72. package/src/format.test.ts +218 -0
  73. package/src/format.ts +140 -16
  74. package/src/index.ts +68 -30
  75. package/src/logger.ts +1 -1
  76. package/src/package-manager.ts +119 -0
  77. package/src/runtime.ts +562 -0
  78. package/src/types.ts +36 -14
  79. package/tsconfig.json +1 -1
  80. package/tsconfig.tsbuildinfo +1 -1
  81. package/.ai/memory/2026-03-04.md +0 -2
  82. package/.ai/tasks.json +0 -7
  83. package/dist/__tests__/config.test.d.ts +0 -2
  84. package/dist/__tests__/config.test.d.ts.map +0 -1
  85. package/dist/__tests__/config.test.js +0 -128
  86. package/dist/__tests__/config.test.js.map +0 -1
  87. package/dist/__tests__/e2e.test.d.ts +0 -2
  88. package/dist/__tests__/e2e.test.d.ts.map +0 -1
  89. package/dist/__tests__/e2e.test.js +0 -211
  90. package/dist/__tests__/e2e.test.js.map +0 -1
  91. package/dist/__tests__/engine.test.d.ts +0 -2
  92. package/dist/__tests__/engine.test.d.ts.map +0 -1
  93. package/dist/__tests__/engine.test.js +0 -271
  94. package/dist/__tests__/engine.test.js.map +0 -1
  95. package/dist/__tests__/format.test.d.ts +0 -2
  96. package/dist/__tests__/format.test.d.ts.map +0 -1
  97. package/dist/__tests__/format.test.js +0 -200
  98. package/dist/__tests__/format.test.js.map +0 -1
  99. package/dist/__tests__/labels.test.d.ts +0 -2
  100. package/dist/__tests__/labels.test.d.ts.map +0 -1
  101. package/dist/__tests__/labels.test.js +0 -82
  102. package/dist/__tests__/labels.test.js.map +0 -1
  103. package/dist/__tests__/loader.test.d.ts +0 -2
  104. package/dist/__tests__/loader.test.d.ts.map +0 -1
  105. package/dist/__tests__/loader.test.js +0 -161
  106. package/dist/__tests__/loader.test.js.map +0 -1
  107. package/dist/__tests__/logger.test.d.ts +0 -2
  108. package/dist/__tests__/logger.test.d.ts.map +0 -1
  109. package/dist/__tests__/logger.test.js +0 -95
  110. package/dist/__tests__/logger.test.js.map +0 -1
  111. package/dist/__tests__/nodes.test.d.ts +0 -2
  112. package/dist/__tests__/nodes.test.d.ts.map +0 -1
  113. package/dist/__tests__/nodes.test.js +0 -83
  114. package/dist/__tests__/nodes.test.js.map +0 -1
  115. package/dist/contracts.d.ts +0 -56
  116. package/dist/contracts.d.ts.map +0 -1
  117. package/dist/contracts.js +0 -2
  118. package/dist/contracts.js.map +0 -1
  119. package/dist/engine.d.ts +0 -38
  120. package/dist/engine.d.ts.map +0 -1
  121. package/dist/engine.js +0 -88
  122. package/dist/engine.js.map +0 -1
  123. package/dist/loader.d.ts +0 -26
  124. package/dist/loader.d.ts.map +0 -1
  125. package/dist/loader.js +0 -120
  126. package/dist/loader.js.map +0 -1
  127. package/src/__tests__/e2e.test.ts +0 -257
  128. package/src/__tests__/engine.test.ts +0 -305
  129. package/src/__tests__/loader.test.ts +0 -191
  130. package/src/contracts.ts +0 -71
  131. package/src/engine.ts +0 -145
  132. package/src/loader.ts +0 -152
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { mkdtemp, writeFile, mkdir } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { loadConfig, resolveConfig } from '../config.js';
5
+ import { loadConfig } from '../config.js';
6
6
 
7
7
  let testDir: string;
8
8
 
@@ -17,150 +17,52 @@ describe('loadConfig', () => {
17
17
  expect(config).toEqual({});
18
18
  });
19
19
 
20
- it('returns empty config when .ai directory has no dot-ai.yml', async () => {
20
+ it('returns empty config for nonexistent workspace', async () => {
21
21
  const config = await loadConfig('/nonexistent/path/to/workspace');
22
22
  expect(config).toEqual({});
23
23
  });
24
24
 
25
- it('parses a simple memory section', async () => {
25
+ it('loads settings.json with extensions', async () => {
26
26
  await writeFile(
27
- join(testDir, '.ai', 'dot-ai.yml'),
28
- `memory:\n use: @dot-ai/provider-file-memory\n`,
27
+ join(testDir, '.ai', 'settings.json'),
28
+ JSON.stringify({
29
+ extensions: ['.ai/extensions/custom.ts'],
30
+ packages: ['npm:@dot-ai/ext-memory@1.0.0'],
31
+ }),
29
32
  'utf-8',
30
33
  );
31
34
  const config = await loadConfig(testDir);
32
- expect(config.memory).toEqual({ use: '@dot-ai/provider-file-memory' });
35
+ expect(config.extensions?.paths).toEqual(['.ai/extensions/custom.ts']);
36
+ expect(config.extensions?.packages).toEqual(['npm:@dot-ai/ext-memory@1.0.0']);
33
37
  });
34
38
 
35
- it('parses multiple provider sections', async () => {
39
+ it('loads settings.json with debug section', async () => {
36
40
  await writeFile(
37
- join(testDir, '.ai', 'dot-ai.yml'),
38
- [
39
- 'memory:',
40
- ' use: @dot-ai/provider-file-memory',
41
- 'skills:',
42
- ' use: @dot-ai/provider-file-skills',
43
- 'routing:',
44
- ' use: @dot-ai/provider-rules-routing',
45
- ].join('\n'),
41
+ join(testDir, '.ai', 'settings.json'),
42
+ JSON.stringify({ debug: { logPath: '/tmp/log' } }),
46
43
  'utf-8',
47
44
  );
48
45
  const config = await loadConfig(testDir);
49
- expect(config.memory?.use).toBe('@dot-ai/provider-file-memory');
50
- expect(config.skills?.use).toBe('@dot-ai/provider-file-skills');
51
- expect(config.routing?.use).toBe('@dot-ai/provider-rules-routing');
46
+ expect(config.debug?.logPath).toBe('/tmp/log');
52
47
  });
53
48
 
54
- it('parses nested with block', async () => {
49
+ it('loads settings.json with workspace section', async () => {
55
50
  await writeFile(
56
- join(testDir, '.ai', 'dot-ai.yml'),
57
- [
58
- 'memory:',
59
- ' use: @dot-ai/cockpit-memory',
60
- ' url: http://localhost:3010',
61
- ].join('\n'),
51
+ join(testDir, '.ai', 'settings.json'),
52
+ JSON.stringify({ workspace: { scanDirs: 'apps,libs' } }),
62
53
  'utf-8',
63
54
  );
64
55
  const config = await loadConfig(testDir);
65
- expect(config.memory?.use).toBe('@dot-ai/cockpit-memory');
66
- expect(config.memory?.with?.['url']).toBe('http://localhost:3010');
56
+ expect(config.workspace?.scanDirs).toBe('apps,libs');
67
57
  });
68
58
 
69
- it('resolves ${ENV_VAR} references', async () => {
70
- process.env['DOT_AI_TEST_URL'] = 'http://test-server:9999';
59
+ it('handles empty settings.json', async () => {
71
60
  await writeFile(
72
- join(testDir, '.ai', 'dot-ai.yml'),
73
- [
74
- 'memory:',
75
- ' use: @dot-ai/cockpit-memory',
76
- ' url: ${DOT_AI_TEST_URL}',
77
- ].join('\n'),
61
+ join(testDir, '.ai', 'settings.json'),
62
+ '{}',
78
63
  'utf-8',
79
64
  );
80
65
  const config = await loadConfig(testDir);
81
- expect(config.memory?.with?.['url']).toBe('http://test-server:9999');
82
- delete process.env['DOT_AI_TEST_URL'];
83
- });
84
-
85
- it('replaces undefined ENV_VAR with empty string', async () => {
86
- delete process.env['MISSING_ENV_VAR'];
87
- await writeFile(
88
- join(testDir, '.ai', 'dot-ai.yml'),
89
- [
90
- 'memory:',
91
- ' use: @dot-ai/provider-file-memory',
92
- ' key: ${MISSING_ENV_VAR}',
93
- ].join('\n'),
94
- 'utf-8',
95
- );
96
- const config = await loadConfig(testDir);
97
- expect(config.memory?.with?.['key']).toBe('');
98
- });
99
-
100
- it('skips comment lines (# prefix)', async () => {
101
- await writeFile(
102
- join(testDir, '.ai', 'dot-ai.yml'),
103
- [
104
- '# This is a comment',
105
- 'memory:',
106
- ' # nested comment',
107
- ' use: @dot-ai/provider-file-memory',
108
- ].join('\n'),
109
- 'utf-8',
110
- );
111
- const config = await loadConfig(testDir);
112
- expect(config.memory?.use).toBe('@dot-ai/provider-file-memory');
113
- });
114
- });
115
-
116
- describe('resolveConfig', () => {
117
- it('fills all defaults for empty config', () => {
118
- const resolved = resolveConfig({});
119
- expect(resolved.memory.use).toBe('@dot-ai/provider-file-memory');
120
- expect(resolved.skills.use).toBe('@dot-ai/provider-file-skills');
121
- expect(resolved.identity.use).toBe('@dot-ai/provider-file-identity');
122
- expect(resolved.routing.use).toBe('@dot-ai/provider-rules-routing');
123
- expect(resolved.tasks.use).toBe('@dot-ai/provider-file-tasks');
124
- expect(resolved.tools.use).toBe('@dot-ai/provider-file-tools');
125
- });
126
-
127
- it('preserves existing memory config', () => {
128
- const resolved = resolveConfig({ memory: { use: '@dot-ai/cockpit-memory', with: { url: 'http://x' } } });
129
- expect(resolved.memory.use).toBe('@dot-ai/cockpit-memory');
130
- expect(resolved.memory.with?.['url']).toBe('http://x');
131
- });
132
-
133
- it('fills defaults only for missing providers', () => {
134
- const resolved = resolveConfig({ memory: { use: '@dot-ai/cockpit-memory' } });
135
- expect(resolved.memory.use).toBe('@dot-ai/cockpit-memory');
136
- expect(resolved.skills.use).toBe('@dot-ai/provider-file-skills');
137
- expect(resolved.routing.use).toBe('@dot-ai/provider-rules-routing');
138
- });
139
-
140
- it('returns a Required<DotAiConfig> with all keys present', () => {
141
- const resolved = resolveConfig({});
142
- const keys = ['memory', 'skills', 'identity', 'routing', 'tasks', 'tools'] as const;
143
- for (const key of keys) {
144
- expect(resolved[key]).toBeDefined();
145
- expect(typeof resolved[key].use).toBe('string');
146
- }
147
- });
148
-
149
- it('preserves all six provided providers', () => {
150
- const full = {
151
- memory: { use: 'mem' },
152
- skills: { use: 'ski' },
153
- identity: { use: 'idn' },
154
- routing: { use: 'rte' },
155
- tasks: { use: 'tsk' },
156
- tools: { use: 'tls' },
157
- };
158
- const resolved = resolveConfig(full);
159
- expect(resolved.memory.use).toBe('mem');
160
- expect(resolved.skills.use).toBe('ski');
161
- expect(resolved.identity.use).toBe('idn');
162
- expect(resolved.routing.use).toBe('rte');
163
- expect(resolved.tasks.use).toBe('tsk');
164
- expect(resolved.tools.use).toBe('tls');
66
+ expect(config).toEqual({});
165
67
  });
166
68
  });
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdir, writeFile, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { discoverExtensions } from '../extension-loader.js';
6
+
7
+ // Use temp directory for test fixtures
8
+ const testDir = join(tmpdir(), 'dot-ai-ext-test-' + Date.now());
9
+
10
+ beforeEach(async () => {
11
+ await mkdir(join(testDir, '.ai', 'extensions'), { recursive: true });
12
+ });
13
+
14
+ afterEach(async () => {
15
+ await rm(testDir, { recursive: true, force: true });
16
+ });
17
+
18
+ describe('discoverExtensions', () => {
19
+ it('discovers .ts files in .ai/extensions/', async () => {
20
+ await writeFile(join(testDir, '.ai', 'extensions', 'my-ext.ts'), 'export default function() {}');
21
+ const paths = await discoverExtensions(testDir);
22
+ expect(paths).toContainEqual(expect.stringContaining('my-ext.ts'));
23
+ });
24
+
25
+ it('discovers .js files in .ai/extensions/', async () => {
26
+ await writeFile(join(testDir, '.ai', 'extensions', 'my-ext.js'), 'export default function() {}');
27
+ const paths = await discoverExtensions(testDir);
28
+ expect(paths).toContainEqual(expect.stringContaining('my-ext.js'));
29
+ });
30
+
31
+ it('discovers subdirs with index.ts', async () => {
32
+ await mkdir(join(testDir, '.ai', 'extensions', 'subext'), { recursive: true });
33
+ await writeFile(join(testDir, '.ai', 'extensions', 'subext', 'index.ts'), 'export default function() {}');
34
+ const paths = await discoverExtensions(testDir);
35
+ expect(paths).toContainEqual(expect.stringContaining('subext/index.ts'));
36
+ });
37
+
38
+ it('discovers subdirs with index.js when no index.ts', async () => {
39
+ await mkdir(join(testDir, '.ai', 'extensions', 'jsext'), { recursive: true });
40
+ await writeFile(join(testDir, '.ai', 'extensions', 'jsext', 'index.js'), 'export default function() {}');
41
+ const paths = await discoverExtensions(testDir);
42
+ expect(paths).toContainEqual(expect.stringContaining('jsext/index.js'));
43
+ });
44
+
45
+ it('reads dot-ai field from package.json', async () => {
46
+ const extDir = join(testDir, '.ai', 'extensions', 'pkg-ext');
47
+ await mkdir(extDir, { recursive: true });
48
+ await writeFile(join(extDir, 'package.json'), JSON.stringify({
49
+ name: 'pkg-ext',
50
+ 'dot-ai': { extensions: ['src/index.ts'] },
51
+ }));
52
+ await mkdir(join(extDir, 'src'), { recursive: true });
53
+ await writeFile(join(extDir, 'src', 'index.ts'), 'export default function() {}');
54
+ const paths = await discoverExtensions(testDir);
55
+ expect(paths).toContainEqual(expect.stringContaining('src/index.ts'));
56
+ });
57
+
58
+ it('deduplicates paths', async () => {
59
+ await writeFile(join(testDir, '.ai', 'extensions', 'dedup.ts'), 'export default function() {}');
60
+ const paths = await discoverExtensions(testDir);
61
+ const matching = paths.filter(p => p.includes('dedup.ts'));
62
+ expect(matching).toHaveLength(1);
63
+ });
64
+
65
+ it('returns empty array when directory does not exist', async () => {
66
+ const paths = await discoverExtensions('/nonexistent/path');
67
+ expect(paths).toEqual([]);
68
+ });
69
+
70
+ it('discovers from config.paths', async () => {
71
+ const customDir = join(testDir, 'custom-extensions');
72
+ await mkdir(customDir, { recursive: true });
73
+ await writeFile(join(customDir, 'custom.ts'), 'export default function() {}');
74
+ const paths = await discoverExtensions(testDir, { paths: ['custom-extensions'] });
75
+ expect(paths).toContainEqual(expect.stringContaining('custom.ts'));
76
+ });
77
+
78
+ it('ignores non .ts/.js files', async () => {
79
+ await writeFile(join(testDir, '.ai', 'extensions', 'readme.md'), '# Readme');
80
+ await writeFile(join(testDir, '.ai', 'extensions', 'data.json'), '{}');
81
+ const paths = await discoverExtensions(testDir);
82
+ expect(paths).toHaveLength(0);
83
+ });
84
+ });
@@ -0,0 +1,228 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { ExtensionRunner, EventBus } from '../extension-runner.js';
3
+ import type { LoadedExtension } from '../extension-types.js';
4
+
5
+ function createMockExtension(overrides?: Partial<LoadedExtension>): LoadedExtension {
6
+ return {
7
+ path: '/mock/ext.ts',
8
+ handlers: new Map(),
9
+ tools: new Map(),
10
+ tiers: new Set(),
11
+ ...overrides,
12
+ };
13
+ }
14
+
15
+ describe('ExtensionRunner', () => {
16
+ describe('fire', () => {
17
+ it('fires events to registered handlers', async () => {
18
+ const handler = vi.fn().mockResolvedValue({ inject: 'hello' });
19
+ const ext = createMockExtension({
20
+ handlers: new Map([['context_inject', [handler]]]),
21
+ });
22
+ const runner = new ExtensionRunner([ext]);
23
+ const results = await runner.fire('context_inject', { prompt: 'test', labels: [] });
24
+ expect(handler).toHaveBeenCalledWith({ prompt: 'test', labels: [] }, undefined);
25
+ expect(results).toEqual([{ inject: 'hello' }]);
26
+ });
27
+
28
+ it('collects results from multiple extensions', async () => {
29
+ const ext1 = createMockExtension({
30
+ handlers: new Map([['context_inject', [vi.fn().mockResolvedValue({ inject: 'a' })]]]),
31
+ });
32
+ const ext2 = createMockExtension({
33
+ handlers: new Map([['context_inject', [vi.fn().mockResolvedValue({ inject: 'b' })]]]),
34
+ });
35
+ const runner = new ExtensionRunner([ext1, ext2]);
36
+ const results = await runner.fire('context_inject', {});
37
+ expect(results).toEqual([{ inject: 'a' }, { inject: 'b' }]);
38
+ });
39
+
40
+ it('skips void results', async () => {
41
+ const handler = vi.fn().mockResolvedValue(undefined);
42
+ const ext = createMockExtension({
43
+ handlers: new Map([['agent_end', [handler]]]),
44
+ });
45
+ const runner = new ExtensionRunner([ext]);
46
+ const results = await runner.fire('agent_end', { response: 'done' });
47
+ expect(results).toEqual([]);
48
+ });
49
+
50
+ it('handles errors in handlers (log and continue)', async () => {
51
+ const goodHandler = vi.fn().mockResolvedValue({ inject: 'ok' });
52
+ const badHandler = vi.fn().mockRejectedValue(new Error('boom'));
53
+ const ext = createMockExtension({
54
+ handlers: new Map([['context_inject', [badHandler, goodHandler]]]),
55
+ });
56
+ const runner = new ExtensionRunner([ext]);
57
+ const results = await runner.fire('context_inject', {});
58
+ expect(results).toEqual([{ inject: 'ok' }]);
59
+ });
60
+
61
+ it('passes ctx as second argument to handlers', async () => {
62
+ const handler = vi.fn().mockResolvedValue({ inject: 'with-ctx' });
63
+ const ext = createMockExtension({
64
+ handlers: new Map([['context_inject', [handler]]]),
65
+ });
66
+ const runner = new ExtensionRunner([ext]);
67
+ const ctx = { workspaceRoot: '/test', events: { on: vi.fn(), emit: vi.fn() } };
68
+ await runner.fire('context_inject', { prompt: 'test', labels: [] }, ctx);
69
+ expect(handler).toHaveBeenCalledWith({ prompt: 'test', labels: [] }, ctx);
70
+ });
71
+
72
+ it('returns empty array for events with no handlers', async () => {
73
+ const ext = createMockExtension();
74
+ const runner = new ExtensionRunner([ext]);
75
+ const results = await runner.fire('nonexistent', {});
76
+ expect(results).toEqual([]);
77
+ });
78
+ });
79
+
80
+ describe('fireUntilBlocked', () => {
81
+ it('stops at first block', async () => {
82
+ const blockHandler = vi.fn().mockResolvedValue({ decision: 'block', reason: 'not allowed' });
83
+ const neverCalled = vi.fn().mockResolvedValue({ decision: 'allow' });
84
+ const ext1 = createMockExtension({
85
+ handlers: new Map([['tool_call', [blockHandler]]]),
86
+ });
87
+ const ext2 = createMockExtension({
88
+ handlers: new Map([['tool_call', [neverCalled]]]),
89
+ });
90
+ const runner = new ExtensionRunner([ext1, ext2]);
91
+ const result = await runner.fireUntilBlocked('tool_call', { tool: 'Write', input: {} });
92
+ expect(result).toEqual({ decision: 'block', reason: 'not allowed' });
93
+ expect(neverCalled).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it('returns null when all allow', async () => {
97
+ const allowHandler = vi.fn().mockResolvedValue({ decision: 'allow' });
98
+ const ext = createMockExtension({
99
+ handlers: new Map([['tool_call', [allowHandler]]]),
100
+ });
101
+ const runner = new ExtensionRunner([ext]);
102
+ const result = await runner.fireUntilBlocked('tool_call', { tool: 'Read', input: {} });
103
+ expect(result).toBeNull();
104
+ });
105
+
106
+ it('returns null when no handlers', async () => {
107
+ const runner = new ExtensionRunner([createMockExtension()]);
108
+ const result = await runner.fireUntilBlocked('tool_call', { tool: 'Read', input: {} });
109
+ expect(result).toBeNull();
110
+ });
111
+
112
+ it('handles errors in handlers', async () => {
113
+ const badHandler = vi.fn().mockRejectedValue(new Error('boom'));
114
+ const ext = createMockExtension({
115
+ handlers: new Map([['tool_call', [badHandler]]]),
116
+ });
117
+ const runner = new ExtensionRunner([ext]);
118
+ const result = await runner.fireUntilBlocked('tool_call', { tool: 'Read', input: {} });
119
+ expect(result).toBeNull();
120
+ });
121
+
122
+ it('passes ctx to tool_call handlers', async () => {
123
+ const handler = vi.fn().mockResolvedValue({ decision: 'allow' });
124
+ const ext = createMockExtension({
125
+ handlers: new Map([['tool_call', [handler]]]),
126
+ });
127
+ const runner = new ExtensionRunner([ext]);
128
+ const ctx = { workspaceRoot: '/test', events: { on: vi.fn(), emit: vi.fn() } };
129
+ await runner.fireUntilBlocked('tool_call', { tool: 'Read', input: {} }, ctx);
130
+ expect(handler).toHaveBeenCalledWith({ tool: 'Read', input: {} }, ctx);
131
+ });
132
+ });
133
+
134
+ describe('tools', () => {
135
+ it('merges tools across extensions', () => {
136
+ const tool1 = { name: 'tool_a', description: 'A', parameters: {}, execute: vi.fn() };
137
+ const tool2 = { name: 'tool_b', description: 'B', parameters: {}, execute: vi.fn() };
138
+ const ext1 = createMockExtension({ tools: new Map([['tool_a', tool1]]) });
139
+ const ext2 = createMockExtension({ tools: new Map([['tool_b', tool2]]) });
140
+ const runner = new ExtensionRunner([ext1, ext2]);
141
+ expect(runner.tools).toHaveLength(2);
142
+ expect(runner.tools.map(t => t.name)).toEqual(['tool_a', 'tool_b']);
143
+ });
144
+
145
+ it('last-wins for duplicate tool names (override)', () => {
146
+ const tool1 = { name: 'dup', description: 'A', parameters: {}, execute: vi.fn() };
147
+ const tool2 = { name: 'dup', description: 'B', parameters: {}, execute: vi.fn() };
148
+ const ext1 = createMockExtension({ tools: new Map([['dup', tool1]]) });
149
+ const ext2 = createMockExtension({ tools: new Map([['dup', tool2]]) });
150
+ const logger = { log: vi.fn(), flush: vi.fn().mockResolvedValue(undefined) };
151
+ const runner = new ExtensionRunner([ext1, ext2], logger);
152
+ const tools = runner.tools;
153
+ expect(tools).toHaveLength(1);
154
+ expect(tools[0].description).toBe('B'); // last wins
155
+ expect(logger.log).toHaveBeenCalledWith(
156
+ expect.objectContaining({ event: 'tool_override' }),
157
+ );
158
+ });
159
+ });
160
+
161
+ describe('diagnostics', () => {
162
+ it('reports correct counts and tiers', () => {
163
+ const ext = createMockExtension({
164
+ path: '/ext/test.ts',
165
+ handlers: new Map([
166
+ ['context_inject', [vi.fn(), vi.fn()]],
167
+ ['tool_call', [vi.fn()]],
168
+ ]),
169
+ tools: new Map([['my_tool', { name: 'my_tool', description: '', parameters: {}, execute: vi.fn() }]]),
170
+ tiers: new Set(['universal'] as const),
171
+ });
172
+ const runner = new ExtensionRunner([ext]);
173
+ const diag = runner.diagnostics;
174
+ expect(diag).toHaveLength(1);
175
+ expect(diag[0].path).toBe('/ext/test.ts');
176
+ expect(diag[0].handlerCounts).toEqual({ context_inject: 2, tool_call: 1 });
177
+ expect(diag[0].toolNames).toEqual(['my_tool']);
178
+ expect(diag[0].tiers).toEqual(['universal']);
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']));
188
+ });
189
+ });
190
+ });
191
+
192
+ describe('EventBus', () => {
193
+ it('calls registered handlers', () => {
194
+ const bus = new EventBus();
195
+ const handler = vi.fn();
196
+ bus.on('test', handler);
197
+ bus.emit('test', 'arg1', 'arg2');
198
+ expect(handler).toHaveBeenCalledWith('arg1', 'arg2');
199
+ });
200
+
201
+ it('handles errors in handlers silently', () => {
202
+ const bus = new EventBus();
203
+ bus.on('test', () => { throw new Error('boom'); });
204
+ const good = vi.fn();
205
+ bus.on('test', good);
206
+ bus.emit('test');
207
+ expect(good).toHaveBeenCalled();
208
+ });
209
+
210
+ it('does nothing for events with no listeners', () => {
211
+ const bus = new EventBus();
212
+ expect(() => bus.emit('nonexistent')).not.toThrow();
213
+ });
214
+
215
+ it('removes handler with off()', () => {
216
+ const bus = new EventBus();
217
+ const handler = vi.fn();
218
+ bus.on('test', handler);
219
+ bus.off('test', handler);
220
+ bus.emit('test');
221
+ expect(handler).not.toHaveBeenCalled();
222
+ });
223
+
224
+ it('off() does nothing for non-existent event', () => {
225
+ const bus = new EventBus();
226
+ expect(() => bus.off('nope', vi.fn())).not.toThrow();
227
+ });
228
+ });
@@ -0,0 +1,26 @@
1
+ /** Extension that uses ctx to access providers and workspace at event time */
2
+ export default function(api) {
3
+ api.on('tool_call', async (event, ctx) => {
4
+ // Use ctx.providers to check memory before allowing writes
5
+ if (event.tool === 'Write' && ctx?.providers?.memory) {
6
+ const memories = await ctx.providers.memory.search('blocked-files');
7
+ if (memories.some(m => m.content.includes(event.input.file_path))) {
8
+ return { decision: 'block', reason: `Blocked by memory policy: ${event.input.file_path}` };
9
+ }
10
+ }
11
+ });
12
+
13
+ api.on('context_inject', async (event, ctx) => {
14
+ // Use ctx.workspaceRoot in injected context
15
+ if (ctx?.workspaceRoot) {
16
+ return { inject: `Workspace: ${ctx.workspaceRoot}` };
17
+ }
18
+ });
19
+
20
+ api.on('session_start', async (_event, ctx) => {
21
+ // Use ctx.events to announce session start
22
+ if (ctx?.events) {
23
+ ctx.events.emit('extension:ctx-aware:started', { workspace: ctx.workspaceRoot });
24
+ }
25
+ });
26
+ }
@@ -0,0 +1,20 @@
1
+ /** Security gate extension — blocks writes to sensitive files */
2
+ export default function(api) {
3
+ api.on('tool_call', async (event) => {
4
+ const sensitivePatterns = [/\.env$/i, /\.key$/i, /\.pem$/i];
5
+
6
+ if (event.tool === 'Write' || event.tool === 'Edit') {
7
+ const filePath = event.input.file_path ?? event.input.path ?? '';
8
+ if (typeof filePath === 'string' && sensitivePatterns.some(p => p.test(filePath))) {
9
+ return { decision: 'block', reason: `Blocked: writing to sensitive file ${filePath}` };
10
+ }
11
+ }
12
+
13
+ if (event.tool === 'Bash') {
14
+ const cmd = event.input.command ?? '';
15
+ if (typeof cmd === 'string' && /rm\s+-rf\s+\//.test(cmd)) {
16
+ return { decision: 'block', reason: 'Blocked: dangerous rm -rf / command' };
17
+ }
18
+ }
19
+ });
20
+ }
@@ -0,0 +1,28 @@
1
+ /** Session analytics extension — tracks tool usage stats */
2
+ export default function(api) {
3
+ const stats = { toolCalls: 0, toolsUsed: new Map() };
4
+
5
+ api.on('tool_call', async (event) => {
6
+ stats.toolCalls++;
7
+ stats.toolsUsed.set(event.tool, (stats.toolsUsed.get(event.tool) ?? 0) + 1);
8
+ });
9
+
10
+ api.on('agent_end', async () => {
11
+ // In a real extension, this would log to a file or API
12
+ // For testing, we just track the stats
13
+ });
14
+
15
+ // Register a tool to query analytics
16
+ api.registerTool({
17
+ name: 'session_stats',
18
+ description: 'Get session analytics (tool call counts)',
19
+ parameters: { type: 'object', properties: {}, required: [] },
20
+ async execute() {
21
+ const topTools = Array.from(stats.toolsUsed.entries())
22
+ .sort((a, b) => b[1] - a[1])
23
+ .map(([name, count]) => `${name}: ${count}`)
24
+ .join(', ');
25
+ return { content: `Total calls: ${stats.toolCalls}. Tools: ${topTools || 'none'}` };
26
+ },
27
+ });
28
+ }
@@ -0,0 +1,10 @@
1
+ /** Smart context extension — injects extra context via context_inject */
2
+ export default function(api) {
3
+ api.on('context_inject', async (event) => {
4
+ // Simple keyword-based context injection
5
+ const keywords = event.labels?.map(l => l.name) ?? [];
6
+ if (keywords.includes('memory')) {
7
+ return { inject: '> Note: This workspace uses dot-ai memory system. Use memory_recall/memory_store tools.' };
8
+ }
9
+ });
10
+ }