@dot-ai/core 0.5.2

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 (96) hide show
  1. package/.ai/memory/2026-03-04.md +2 -0
  2. package/.ai/tasks.json +7 -0
  3. package/LICENSE +21 -0
  4. package/dist/__tests__/config.test.d.ts +2 -0
  5. package/dist/__tests__/config.test.d.ts.map +1 -0
  6. package/dist/__tests__/config.test.js +128 -0
  7. package/dist/__tests__/config.test.js.map +1 -0
  8. package/dist/__tests__/e2e.test.d.ts +2 -0
  9. package/dist/__tests__/e2e.test.d.ts.map +1 -0
  10. package/dist/__tests__/e2e.test.js +211 -0
  11. package/dist/__tests__/e2e.test.js.map +1 -0
  12. package/dist/__tests__/engine.test.d.ts +2 -0
  13. package/dist/__tests__/engine.test.d.ts.map +1 -0
  14. package/dist/__tests__/engine.test.js +271 -0
  15. package/dist/__tests__/engine.test.js.map +1 -0
  16. package/dist/__tests__/format.test.d.ts +2 -0
  17. package/dist/__tests__/format.test.d.ts.map +1 -0
  18. package/dist/__tests__/format.test.js +200 -0
  19. package/dist/__tests__/format.test.js.map +1 -0
  20. package/dist/__tests__/labels.test.d.ts +2 -0
  21. package/dist/__tests__/labels.test.d.ts.map +1 -0
  22. package/dist/__tests__/labels.test.js +82 -0
  23. package/dist/__tests__/labels.test.js.map +1 -0
  24. package/dist/__tests__/loader.test.d.ts +2 -0
  25. package/dist/__tests__/loader.test.d.ts.map +1 -0
  26. package/dist/__tests__/loader.test.js +161 -0
  27. package/dist/__tests__/loader.test.js.map +1 -0
  28. package/dist/__tests__/logger.test.d.ts +2 -0
  29. package/dist/__tests__/logger.test.d.ts.map +1 -0
  30. package/dist/__tests__/logger.test.js +95 -0
  31. package/dist/__tests__/logger.test.js.map +1 -0
  32. package/dist/__tests__/nodes.test.d.ts +2 -0
  33. package/dist/__tests__/nodes.test.d.ts.map +1 -0
  34. package/dist/__tests__/nodes.test.js +83 -0
  35. package/dist/__tests__/nodes.test.js.map +1 -0
  36. package/dist/config.d.ts +29 -0
  37. package/dist/config.d.ts.map +1 -0
  38. package/dist/config.js +141 -0
  39. package/dist/config.js.map +1 -0
  40. package/dist/contracts.d.ts +56 -0
  41. package/dist/contracts.d.ts.map +1 -0
  42. package/dist/contracts.js +2 -0
  43. package/dist/contracts.js.map +1 -0
  44. package/dist/engine.d.ts +38 -0
  45. package/dist/engine.d.ts.map +1 -0
  46. package/dist/engine.js +88 -0
  47. package/dist/engine.js.map +1 -0
  48. package/dist/format.d.ts +18 -0
  49. package/dist/format.d.ts.map +1 -0
  50. package/dist/format.js +89 -0
  51. package/dist/format.js.map +1 -0
  52. package/dist/index.d.ts +21 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +22 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/labels.d.ts +13 -0
  57. package/dist/labels.d.ts.map +1 -0
  58. package/dist/labels.js +36 -0
  59. package/dist/labels.js.map +1 -0
  60. package/dist/loader.d.ts +26 -0
  61. package/dist/loader.d.ts.map +1 -0
  62. package/dist/loader.js +120 -0
  63. package/dist/loader.js.map +1 -0
  64. package/dist/logger.d.ts +29 -0
  65. package/dist/logger.d.ts.map +1 -0
  66. package/dist/logger.js +29 -0
  67. package/dist/logger.js.map +1 -0
  68. package/dist/nodes.d.ts +15 -0
  69. package/dist/nodes.d.ts.map +1 -0
  70. package/dist/nodes.js +46 -0
  71. package/dist/nodes.js.map +1 -0
  72. package/dist/types.d.ts +111 -0
  73. package/dist/types.d.ts.map +1 -0
  74. package/dist/types.js +2 -0
  75. package/dist/types.js.map +1 -0
  76. package/package.json +23 -0
  77. package/src/__tests__/config.test.ts +166 -0
  78. package/src/__tests__/e2e.test.ts +257 -0
  79. package/src/__tests__/engine.test.ts +305 -0
  80. package/src/__tests__/format.test.ts +247 -0
  81. package/src/__tests__/labels.test.ts +96 -0
  82. package/src/__tests__/loader.test.ts +191 -0
  83. package/src/__tests__/logger.test.ts +113 -0
  84. package/src/__tests__/nodes.test.ts +103 -0
  85. package/src/config.ts +178 -0
  86. package/src/contracts.ts +71 -0
  87. package/src/engine.ts +145 -0
  88. package/src/format.ts +113 -0
  89. package/src/index.ts +63 -0
  90. package/src/labels.ts +40 -0
  91. package/src/loader.ts +152 -0
  92. package/src/logger.ts +49 -0
  93. package/src/nodes.ts +46 -0
  94. package/src/types.ts +123 -0
  95. package/tsconfig.json +23 -0
  96. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { registerProvider, clearProviders, createProviders } from '../loader.js';
3
+
4
+ beforeEach(() => {
5
+ clearProviders();
6
+ });
7
+
8
+ describe('clearProviders', () => {
9
+ it('resets the registry so registered factories are no longer used', async () => {
10
+ const factory = vi.fn().mockReturnValue({
11
+ search: vi.fn().mockResolvedValue([{ content: 'custom', type: 'fact', source: 'test' }]),
12
+ store: vi.fn().mockResolvedValue(undefined),
13
+ });
14
+ registerProvider('@dot-ai/provider-file-memory', factory);
15
+ clearProviders();
16
+
17
+ const providers = await createProviders({ memory: { use: '@dot-ai/provider-file-memory' } });
18
+ // After clear, factory should not be called — noop provider is used
19
+ expect(factory).not.toHaveBeenCalled();
20
+ const memories = await providers.memory.search('query');
21
+ expect(memories).toEqual([]);
22
+ });
23
+ });
24
+
25
+ describe('registerProvider + createProviders', () => {
26
+ it('uses registered memory factory', async () => {
27
+ const customMemory = {
28
+ search: vi.fn().mockResolvedValue([{ content: 'custom result', type: 'fact', source: 'test' }]),
29
+ store: vi.fn().mockResolvedValue(undefined),
30
+ };
31
+ registerProvider('@dot-ai/custom-memory', () => customMemory);
32
+
33
+ const providers = await createProviders({ memory: { use: '@dot-ai/custom-memory' } });
34
+ const results = await providers.memory.search('query');
35
+ expect(results[0]?.content).toBe('custom result');
36
+ });
37
+
38
+ it('passes options to the factory', async () => {
39
+ const factory = vi.fn().mockReturnValue({
40
+ search: vi.fn().mockResolvedValue([]),
41
+ store: vi.fn().mockResolvedValue(undefined),
42
+ });
43
+ registerProvider('@dot-ai/opts-memory', factory);
44
+
45
+ await createProviders({ memory: { use: '@dot-ai/opts-memory', with: { url: 'http://test' } } });
46
+ expect(factory).toHaveBeenCalledWith({ url: 'http://test' });
47
+ });
48
+
49
+ it('supports async factory (returns Promise)', async () => {
50
+ const customMemory = {
51
+ search: vi.fn().mockResolvedValue([]),
52
+ store: vi.fn().mockResolvedValue(undefined),
53
+ };
54
+ registerProvider('@dot-ai/async-memory', async () => customMemory);
55
+
56
+ const providers = await createProviders({ memory: { use: '@dot-ai/async-memory' } });
57
+ expect(providers.memory).toBe(customMemory);
58
+ });
59
+
60
+ it('registers and uses a skill provider', async () => {
61
+ const mockSkills = [{ name: 'my-skill', description: 'A skill', labels: ['test'] }];
62
+ registerProvider('@dot-ai/custom-skills', () => ({
63
+ list: vi.fn().mockResolvedValue(mockSkills),
64
+ match: vi.fn().mockResolvedValue([]),
65
+ load: vi.fn().mockResolvedValue(null),
66
+ }));
67
+
68
+ const providers = await createProviders({ skills: { use: '@dot-ai/custom-skills' } });
69
+ const skills = await providers.skills.list();
70
+ expect(skills).toEqual(mockSkills);
71
+ });
72
+
73
+ it('registers and uses an identity provider', async () => {
74
+ const mockIdentities = [{ type: 'agents', content: 'I am Kiwi', source: 'file', priority: 1 }];
75
+ registerProvider('@dot-ai/custom-identity', () => ({
76
+ load: vi.fn().mockResolvedValue(mockIdentities),
77
+ }));
78
+
79
+ const providers = await createProviders({ identity: { use: '@dot-ai/custom-identity' } });
80
+ const identities = await providers.identity.load();
81
+ expect(identities).toEqual(mockIdentities);
82
+ });
83
+ });
84
+
85
+ describe('auto-discovery via dynamic import', () => {
86
+ // NOTE: Dynamic import behavior is hard to mock reliably in vitest (import() is
87
+ // module-level). Auto-discovery with real packages is tested via E2E.
88
+ // The unit guarantee here is that an unknown provider name gracefully falls
89
+ // back to the noop implementation (i.e. tryImportProvider returns null for
90
+ // non-existent packages without throwing).
91
+ it('falls back to noop when provider package does not exist', async () => {
92
+ // '@dot-ai/nonexistent-provider' is not registered and cannot be imported
93
+ const providers = await createProviders({ memory: { use: '@dot-ai/nonexistent-provider' } });
94
+ const memories = await providers.memory.search('query');
95
+ expect(memories).toEqual([]);
96
+ });
97
+ });
98
+
99
+ describe('createProviders — noop fallbacks', () => {
100
+ // Use non-existent provider names to ensure noop fallbacks are returned
101
+ // (auto-discovery would find real packages for default names like @dot-ai/provider-file-memory)
102
+ const noopConfig = {
103
+ memory: { use: '@dot-ai/nonexistent-memory' },
104
+ skills: { use: '@dot-ai/nonexistent-skills' },
105
+ identity: { use: '@dot-ai/nonexistent-identity' },
106
+ routing: { use: '@dot-ai/nonexistent-routing' },
107
+ tasks: { use: '@dot-ai/nonexistent-tasks' },
108
+ tools: { use: '@dot-ai/nonexistent-tools' },
109
+ };
110
+
111
+ it('returns noop memory provider when nothing registered', async () => {
112
+ const providers = await createProviders(noopConfig);
113
+ const memories = await providers.memory.search('any query');
114
+ expect(memories).toEqual([]);
115
+ });
116
+
117
+ it('noop memory.store resolves without error', async () => {
118
+ const providers = await createProviders(noopConfig);
119
+ await expect(
120
+ providers.memory.store({ content: 'x', type: 'log' }),
121
+ ).resolves.toBeUndefined();
122
+ });
123
+
124
+ it('noop skills.list returns empty array', async () => {
125
+ const providers = await createProviders(noopConfig);
126
+ expect(await providers.skills.list()).toEqual([]);
127
+ });
128
+
129
+ it('noop skills.match returns empty array', async () => {
130
+ const providers = await createProviders(noopConfig);
131
+ expect(await providers.skills.match([])).toEqual([]);
132
+ });
133
+
134
+ it('noop skills.load returns null', async () => {
135
+ const providers = await createProviders(noopConfig);
136
+ expect(await providers.skills.load('any-skill')).toBeNull();
137
+ });
138
+
139
+ it('noop identity.load returns empty array', async () => {
140
+ const providers = await createProviders(noopConfig);
141
+ expect(await providers.identity.load()).toEqual([]);
142
+ });
143
+
144
+ it('noop routing.route returns a RoutingResult', async () => {
145
+ const providers = await createProviders(noopConfig);
146
+ const result = await providers.routing.route([]);
147
+ expect(result).toHaveProperty('model');
148
+ expect(result).toHaveProperty('reason');
149
+ expect(typeof result.model).toBe('string');
150
+ });
151
+
152
+ it('noop tasks.list returns empty array', async () => {
153
+ const providers = await createProviders(noopConfig);
154
+ expect(await providers.tasks.list()).toEqual([]);
155
+ });
156
+
157
+ it('noop tasks.get returns null', async () => {
158
+ const providers = await createProviders(noopConfig);
159
+ expect(await providers.tasks.get('123')).toBeNull();
160
+ });
161
+
162
+ it('noop tasks.create returns a task with generated id', async () => {
163
+ const providers = await createProviders(noopConfig);
164
+ const task = await providers.tasks.create({ text: 'Do something', status: 'pending' });
165
+ expect(task).toHaveProperty('id');
166
+ expect(task.text).toBe('Do something');
167
+ expect(task.status).toBe('pending');
168
+ });
169
+
170
+ it('noop tasks.update returns a task with the patched fields', async () => {
171
+ const providers = await createProviders(noopConfig);
172
+ const task = await providers.tasks.update('abc', { status: 'done' });
173
+ expect(task.id).toBe('abc');
174
+ expect(task.status).toBe('done');
175
+ });
176
+
177
+ it('noop tools.list returns empty array', async () => {
178
+ const providers = await createProviders(noopConfig);
179
+ expect(await providers.tools.list()).toEqual([]);
180
+ });
181
+
182
+ it('noop tools.match returns empty array', async () => {
183
+ const providers = await createProviders(noopConfig);
184
+ expect(await providers.tools.match([])).toEqual([]);
185
+ });
186
+
187
+ it('noop tools.load returns null', async () => {
188
+ const providers = await createProviders(noopConfig);
189
+ expect(await providers.tools.load('any-tool')).toBeNull();
190
+ });
191
+ });
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { NoopLogger, JsonFileLogger, StderrLogger } from '../logger.js';
6
+ import type { LogEntry } from '../logger.js';
7
+
8
+ const makeEntry = (overrides?: Partial<LogEntry>): LogEntry => ({
9
+ timestamp: '2026-03-04T12:00:00.000Z',
10
+ level: 'info',
11
+ phase: 'boot',
12
+ event: 'test_event',
13
+ ...overrides,
14
+ });
15
+
16
+ describe('NoopLogger', () => {
17
+ it('log does nothing', () => {
18
+ const logger = new NoopLogger();
19
+ logger.log(makeEntry());
20
+ // No error thrown
21
+ });
22
+
23
+ it('flush resolves immediately', async () => {
24
+ const logger = new NoopLogger();
25
+ await logger.flush();
26
+ });
27
+ });
28
+
29
+ describe('JsonFileLogger', () => {
30
+ let tempDir: string;
31
+
32
+ beforeEach(async () => {
33
+ tempDir = await mkdtemp(join(tmpdir(), 'dot-ai-logger-'));
34
+ });
35
+
36
+ afterEach(async () => {
37
+ await rm(tempDir, { recursive: true, force: true });
38
+ });
39
+
40
+ it('writes JSONL on flush', async () => {
41
+ const logFile = join(tempDir, 'test.jsonl');
42
+ const logger = new JsonFileLogger(logFile);
43
+
44
+ logger.log(makeEntry({ event: 'event_1' }));
45
+ logger.log(makeEntry({ event: 'event_2' }));
46
+ await logger.flush();
47
+
48
+ const content = await readFile(logFile, 'utf-8');
49
+ const lines = content.trim().split('\n');
50
+ expect(lines).toHaveLength(2);
51
+ expect(JSON.parse(lines[0]).event).toBe('event_1');
52
+ expect(JSON.parse(lines[1]).event).toBe('event_2');
53
+ });
54
+
55
+ it('does nothing on flush when buffer is empty', async () => {
56
+ const logFile = join(tempDir, 'empty.jsonl');
57
+ const logger = new JsonFileLogger(logFile);
58
+ await logger.flush();
59
+ // File should not exist (no write)
60
+ await expect(readFile(logFile, 'utf-8')).rejects.toThrow();
61
+ });
62
+
63
+ it('clears buffer after flush', async () => {
64
+ const logFile = join(tempDir, 'clear.jsonl');
65
+ const logger = new JsonFileLogger(logFile);
66
+
67
+ logger.log(makeEntry({ event: 'first' }));
68
+ await logger.flush();
69
+ logger.log(makeEntry({ event: 'second' }));
70
+ await logger.flush();
71
+
72
+ const content = await readFile(logFile, 'utf-8');
73
+ const lines = content.trim().split('\n');
74
+ expect(lines).toHaveLength(2);
75
+ expect(JSON.parse(lines[0]).event).toBe('first');
76
+ expect(JSON.parse(lines[1]).event).toBe('second');
77
+ });
78
+
79
+ it('includes all LogEntry fields', async () => {
80
+ const logFile = join(tempDir, 'fields.jsonl');
81
+ const logger = new JsonFileLogger(logFile);
82
+
83
+ logger.log(makeEntry({
84
+ level: 'warn',
85
+ phase: 'enrich',
86
+ event: 'labels_extracted',
87
+ data: { labels: ['git', 'commit'], count: 2 },
88
+ durationMs: 42,
89
+ }));
90
+ await logger.flush();
91
+
92
+ const content = await readFile(logFile, 'utf-8');
93
+ const entry = JSON.parse(content.trim());
94
+ expect(entry.level).toBe('warn');
95
+ expect(entry.phase).toBe('enrich');
96
+ expect(entry.event).toBe('labels_extracted');
97
+ expect(entry.data.labels).toEqual(['git', 'commit']);
98
+ expect(entry.durationMs).toBe(42);
99
+ });
100
+ });
101
+
102
+ describe('StderrLogger', () => {
103
+ it('writes to stderr', () => {
104
+ const logger = new StderrLogger();
105
+ // Just verify it doesn't throw
106
+ logger.log(makeEntry());
107
+ });
108
+
109
+ it('flush resolves immediately', async () => {
110
+ const logger = new StderrLogger();
111
+ await logger.flush();
112
+ });
113
+ });
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { discoverNodes, parseScanDirs } from '../nodes.js';
6
+
7
+ let root: string;
8
+
9
+ beforeEach(async () => {
10
+ root = await mkdtemp(join(tmpdir(), 'dot-ai-nodes-'));
11
+ });
12
+
13
+ describe('discoverNodes', () => {
14
+ it('returns root node even if .ai/ does not exist', () => {
15
+ const nodes = discoverNodes(root);
16
+ expect(nodes).toHaveLength(1);
17
+ expect(nodes[0].name).toBe('root');
18
+ expect(nodes[0].root).toBe(true);
19
+ });
20
+
21
+ it('discovers sub-nodes in projects/ directory', async () => {
22
+ await mkdir(join(root, '.ai'), { recursive: true });
23
+ await mkdir(join(root, 'projects', 'pro', '.ai'), { recursive: true });
24
+ await mkdir(join(root, 'projects', 'cockpit', '.ai'), { recursive: true });
25
+ // This project has no .ai/ — should be skipped
26
+ await mkdir(join(root, 'projects', 'no-ai'), { recursive: true });
27
+
28
+ const nodes = discoverNodes(root);
29
+ expect(nodes).toHaveLength(3); // root + pro + cockpit
30
+ expect(nodes.map(n => n.name).sort()).toEqual(['cockpit', 'pro', 'root']);
31
+ expect(nodes.find(n => n.name === 'pro')?.root).toBe(false);
32
+ });
33
+
34
+ it('supports custom scanDirs', async () => {
35
+ await mkdir(join(root, '.ai'), { recursive: true });
36
+ await mkdir(join(root, 'apps', 'web', '.ai'), { recursive: true });
37
+
38
+ const nodes = discoverNodes(root, ['apps']);
39
+ expect(nodes).toHaveLength(2);
40
+ expect(nodes[1].name).toBe('web');
41
+ });
42
+
43
+ it('handles multiple scanDirs', async () => {
44
+ await mkdir(join(root, '.ai'), { recursive: true });
45
+ await mkdir(join(root, 'projects', 'a', '.ai'), { recursive: true });
46
+ await mkdir(join(root, 'apps', 'b', '.ai'), { recursive: true });
47
+
48
+ const nodes = discoverNodes(root, ['projects', 'apps']);
49
+ expect(nodes).toHaveLength(3);
50
+ });
51
+
52
+ it('returns only root when scanDirs is empty', async () => {
53
+ await mkdir(join(root, '.ai'), { recursive: true });
54
+ await mkdir(join(root, 'projects', 'pro', '.ai'), { recursive: true });
55
+
56
+ const nodes = discoverNodes(root, []);
57
+ expect(nodes).toHaveLength(1);
58
+ });
59
+
60
+ it('ignores non-directory entries in scan path', async () => {
61
+ await mkdir(join(root, '.ai'), { recursive: true });
62
+ await mkdir(join(root, 'projects'), { recursive: true });
63
+ await writeFile(join(root, 'projects', 'not-a-dir'), 'hello');
64
+
65
+ const nodes = discoverNodes(root);
66
+ expect(nodes).toHaveLength(1); // only root
67
+ });
68
+
69
+ it('node paths point to .ai/ directory', async () => {
70
+ await mkdir(join(root, '.ai'), { recursive: true });
71
+ await mkdir(join(root, 'projects', 'pro', '.ai'), { recursive: true });
72
+
73
+ const nodes = discoverNodes(root);
74
+ const pro = nodes.find(n => n.name === 'pro');
75
+ expect(pro?.path).toBe(join(root, 'projects', 'pro', '.ai'));
76
+ });
77
+ });
78
+
79
+ describe('parseScanDirs', () => {
80
+ it('returns empty for undefined', () => {
81
+ expect(parseScanDirs(undefined)).toEqual([]);
82
+ });
83
+
84
+ it('returns empty for empty string', () => {
85
+ expect(parseScanDirs('')).toEqual([]);
86
+ });
87
+
88
+ it('parses single value', () => {
89
+ expect(parseScanDirs('projects')).toEqual(['projects']);
90
+ });
91
+
92
+ it('parses comma-separated values', () => {
93
+ expect(parseScanDirs('projects, apps')).toEqual(['projects', 'apps']);
94
+ });
95
+
96
+ it('trims whitespace', () => {
97
+ expect(parseScanDirs(' projects , apps ')).toEqual(['projects', 'apps']);
98
+ });
99
+
100
+ it('filters empty values', () => {
101
+ expect(parseScanDirs('projects,,apps')).toEqual(['projects', 'apps']);
102
+ });
103
+ });
package/src/config.ts ADDED
@@ -0,0 +1,178 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import type { DotAiConfig, ProviderConfig } from './types.js';
4
+ import { discoverNodes, parseScanDirs } from './nodes.js';
5
+
6
+ /**
7
+ * Inject the workspace root into all provider sections of a DotAiConfig.
8
+ * This ensures file-based providers resolve paths relative to the workspace.
9
+ */
10
+ export function injectRoot(config: DotAiConfig, root: string): DotAiConfig {
11
+ // Discover workspace nodes
12
+ const globalScanDirs = parseScanDirs(config.workspace?.scanDirs ?? 'projects');
13
+ const nodes = discoverNodes(root, globalScanDirs);
14
+
15
+ const result: DotAiConfig = {};
16
+ const providerKeys = ['memory', 'skills', 'identity', 'routing', 'tasks', 'tools'] as const;
17
+ for (const key of providerKeys) {
18
+ const section = config[key];
19
+ if (section && typeof section === 'object') {
20
+ result[key] = {
21
+ ...section,
22
+ with: { root, nodes, ...(section.with ?? {}) },
23
+ };
24
+ }
25
+ }
26
+ // Preserve non-provider sections
27
+ if (config.debug) {
28
+ result.debug = config.debug;
29
+ }
30
+ if (config.workspace) {
31
+ result.workspace = config.workspace;
32
+ }
33
+ return result;
34
+ }
35
+
36
+ /**
37
+ * Load and parse dot-ai.yml from a workspace root.
38
+ * Returns the config with defaults applied.
39
+ *
40
+ * Uses a minimal YAML parser (key: value pairs + nested objects).
41
+ * No dependency on yaml package.
42
+ */
43
+ export async function loadConfig(workspaceRoot: string): Promise<DotAiConfig> {
44
+ const configPath = join(workspaceRoot, '.ai', 'dot-ai.yml');
45
+
46
+ let raw: string;
47
+ try {
48
+ raw = await readFile(configPath, 'utf-8');
49
+ } catch {
50
+ // No config file — return empty config (all defaults)
51
+ return {};
52
+ }
53
+
54
+ return parseYaml(raw);
55
+ }
56
+
57
+ /**
58
+ * Resolve a config with defaults.
59
+ * Any missing provider gets the built-in file-based default.
60
+ */
61
+ export interface ResolvedConfig {
62
+ memory: ProviderConfig;
63
+ skills: ProviderConfig;
64
+ identity: ProviderConfig;
65
+ routing: ProviderConfig;
66
+ tasks: ProviderConfig;
67
+ tools: ProviderConfig;
68
+ debug?: import('./types.js').DebugConfig;
69
+ }
70
+
71
+ export function resolveConfig(config: DotAiConfig): ResolvedConfig {
72
+ return {
73
+ memory: config.memory ?? { use: '@dot-ai/provider-file-memory' },
74
+ skills: config.skills ?? { use: '@dot-ai/provider-file-skills' },
75
+ identity: config.identity ?? { use: '@dot-ai/provider-file-identity' },
76
+ routing: config.routing ?? { use: '@dot-ai/provider-rules-routing' },
77
+ tasks: config.tasks ?? { use: '@dot-ai/provider-file-tasks' },
78
+ tools: config.tools ?? { use: '@dot-ai/provider-file-tools' },
79
+ debug: config.debug,
80
+ };
81
+ }
82
+
83
+ // ── Minimal YAML parser ─────────────────────────────────────────────────────
84
+
85
+ interface YamlNode {
86
+ [key: string]: string | YamlNode;
87
+ }
88
+
89
+ function stripQuotes(s: string): string {
90
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
91
+ return s.slice(1, -1);
92
+ }
93
+ return s;
94
+ }
95
+
96
+ function parseYaml(raw: string): DotAiConfig {
97
+ const lines = raw.split('\n');
98
+ const result: YamlNode = {};
99
+ let currentSection: string | null = null;
100
+
101
+ for (const line of lines) {
102
+ // Skip comments and empty lines
103
+ if (line.trim().startsWith('#') || line.trim() === '') continue;
104
+
105
+ // Top-level key (no indent)
106
+ const topMatch = line.match(/^(\w+):$/);
107
+ if (topMatch) {
108
+ currentSection = topMatch[1];
109
+ result[currentSection] = {};
110
+ continue;
111
+ }
112
+
113
+ // Nested key: value (2-space indent)
114
+ const nestedMatch = line.match(/^ (\w+):\s*(.+)$/);
115
+ if (nestedMatch && currentSection) {
116
+ const section = result[currentSection] as YamlNode;
117
+ let value = stripQuotes(nestedMatch[2].trim());
118
+
119
+ // Resolve ${ENV_VAR} references
120
+ value = value.replace(/\$\{(\w+)\}/g, (_, name: string) => process.env[name] ?? '');
121
+
122
+ section[nestedMatch[1]] = value;
123
+ continue;
124
+ }
125
+
126
+ // Deeper nested key: value (4-space indent) for 'with' block
127
+ const deepMatch = line.match(/^ (\w+):\s*(.+)$/);
128
+ if (deepMatch && currentSection) {
129
+ const section = result[currentSection] as YamlNode;
130
+ if (!section['with'] || typeof section['with'] === 'string') {
131
+ section['with'] = {};
132
+ }
133
+ let value = stripQuotes(deepMatch[2].trim());
134
+ value = value.replace(/\$\{(\w+)\}/g, (_, name: string) => process.env[name] ?? '');
135
+ (section['with'] as YamlNode)[deepMatch[1]] = value;
136
+ }
137
+ }
138
+
139
+ // Convert YamlNode to DotAiConfig
140
+ const config: DotAiConfig = {};
141
+ const providerKeys = ['memory', 'skills', 'identity', 'routing', 'tasks', 'tools'] as const;
142
+
143
+ for (const key of providerKeys) {
144
+ const section = result[key];
145
+ if (section && typeof section === 'object') {
146
+ const node = section as YamlNode;
147
+ const providerConfig: ProviderConfig = {
148
+ use: typeof node['use'] === 'string' ? node['use'] : '',
149
+ };
150
+ if (node['with'] && typeof node['with'] === 'object') {
151
+ providerConfig.with = node['with'] as Record<string, unknown>;
152
+ }
153
+ config[key] = providerConfig;
154
+ }
155
+ }
156
+
157
+ // Parse debug section
158
+ const debugSection = result['debug'];
159
+ if (debugSection && typeof debugSection === 'object') {
160
+ const node = debugSection as YamlNode;
161
+ config.debug = {};
162
+ if (typeof node['logPath'] === 'string') {
163
+ config.debug.logPath = node['logPath'];
164
+ }
165
+ }
166
+
167
+ // Parse workspace section
168
+ const workspaceSection = result['workspace'];
169
+ if (workspaceSection && typeof workspaceSection === 'object') {
170
+ const node = workspaceSection as YamlNode;
171
+ config.workspace = {};
172
+ if (typeof node['scanDirs'] === 'string') {
173
+ config.workspace.scanDirs = node['scanDirs'];
174
+ }
175
+ }
176
+
177
+ return config;
178
+ }
@@ -0,0 +1,71 @@
1
+ import type {
2
+ MemoryEntry,
3
+ Skill,
4
+ Identity,
5
+ Tool,
6
+ Task,
7
+ TaskFilter,
8
+ RoutingResult,
9
+ Label,
10
+ } from './types.js';
11
+
12
+ /**
13
+ * Memory provider — search and store memories.
14
+ * Implementation decides WHERE and HOW (files, DB, API, etc.)
15
+ */
16
+ export interface MemoryProvider {
17
+ search(query: string, labels?: string[]): Promise<MemoryEntry[]>;
18
+ store(entry: Omit<MemoryEntry, 'source'>): Promise<void>;
19
+ }
20
+
21
+ /**
22
+ * Skill provider — discover and load skills.
23
+ * Implementation decides WHERE skills come from (files, registry, API, etc.)
24
+ */
25
+ export interface SkillProvider {
26
+ list(): Promise<Skill[]>;
27
+ match(labels: Label[]): Promise<Skill[]>;
28
+ load(name: string): Promise<string | null>; // returns content
29
+ }
30
+
31
+ /**
32
+ * Identity provider — load identity documents.
33
+ * Implementation decides format and source.
34
+ */
35
+ export interface IdentityProvider {
36
+ load(): Promise<Identity[]>;
37
+ }
38
+
39
+ /**
40
+ * Routing provider — decide which model to use.
41
+ * Implementation decides the logic (rules, LLM, etc.)
42
+ */
43
+ export interface RoutingProvider {
44
+ route(labels: Label[], context?: Record<string, unknown>): Promise<RoutingResult>;
45
+ }
46
+
47
+ /**
48
+ * Task provider — CRUD for tasks.
49
+ * Implementation decides storage (files, Cockpit API, Jira, etc.)
50
+ */
51
+ export interface TaskProvider {
52
+ list(filter?: TaskFilter): Promise<Task[]>;
53
+ get(id: string): Promise<Task | null>;
54
+ create(task: Omit<Task, 'id'>): Promise<Task>;
55
+ update(id: string, patch: Partial<Task>): Promise<Task>;
56
+ }
57
+
58
+ /**
59
+ * Tool provider — discover and match tools.
60
+ * Implementation decides source (MCP config, files, registry, etc.)
61
+ */
62
+ export interface ToolProvider {
63
+ list(): Promise<Tool[]>;
64
+ match(labels: Label[]): Promise<Tool[]>;
65
+ load(name: string): Promise<Tool | null>;
66
+ }
67
+
68
+ /**
69
+ * Factory function type for creating providers from config.
70
+ */
71
+ export type ProviderFactory<T> = (options: Record<string, unknown>) => T | Promise<T>;