@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
package/src/config.ts CHANGED
@@ -1,178 +1,176 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import type { DotAiConfig, ProviderConfig } from './types.js';
4
- import { discoverNodes, parseScanDirs } from './nodes.js';
3
+ import type { DotAiConfig, ExtensionsConfig } from './types.js';
5
4
 
6
5
  /**
7
- * Inject the workspace root into all provider sections of a DotAiConfig.
8
- * This ensures file-based providers resolve paths relative to the workspace.
6
+ * Load settings.json (Pi-compatible format).
7
+ *
8
+ * Format:
9
+ * {
10
+ * "packages": ["npm:@dot-ai/ext-cockpit@1.0.0"],
11
+ * "extensions": [".ai/extensions/custom.ts"],
12
+ * "debug": { "logPath": "..." },
13
+ * "workspace": { "scanDirs": "..." }
14
+ * }
15
+ */
16
+ async function loadSettingsJson(workspaceRoot: string): Promise<DotAiConfig | null> {
17
+ const settingsPath = join(workspaceRoot, '.ai', 'settings.json');
18
+ try {
19
+ const raw = await readFile(settingsPath, 'utf-8');
20
+ const json = JSON.parse(raw);
21
+ return settingsJsonToConfig(json);
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Convert Pi-compatible settings.json to DotAiConfig.
9
29
  */
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
- };
30
+ function settingsJsonToConfig(json: Record<string, unknown>): DotAiConfig {
31
+ const config: DotAiConfig = {};
32
+
33
+ // Extensions: string[] of local paths
34
+ const extensions = json['extensions'];
35
+ const packages = json['packages'];
36
+
37
+ if ((extensions && Array.isArray(extensions)) || (packages && Array.isArray(packages))) {
38
+ config.extensions = {};
39
+ if (extensions && Array.isArray(extensions)) {
40
+ config.extensions.paths = extensions.filter((e): e is string => typeof e === 'string');
41
+ }
42
+ if (packages && Array.isArray(packages)) {
43
+ config.extensions.packages = packages.filter((p): p is string => typeof p === 'string');
24
44
  }
25
45
  }
26
- // Preserve non-provider sections
27
- if (config.debug) {
28
- result.debug = config.debug;
46
+
47
+ // Debug section
48
+ if (json['debug'] && typeof json['debug'] === 'object') {
49
+ const debug = json['debug'] as Record<string, unknown>;
50
+ config.debug = {};
51
+ if (typeof debug['logPath'] === 'string') config.debug.logPath = debug['logPath'];
29
52
  }
30
- if (config.workspace) {
31
- result.workspace = config.workspace;
53
+
54
+ // Workspace section
55
+ if (json['workspace'] && typeof json['workspace'] === 'object') {
56
+ const ws = json['workspace'] as Record<string, unknown>;
57
+ config.workspace = {};
58
+ if (typeof ws['scanDirs'] === 'string') config.workspace.scanDirs = ws['scanDirs'];
32
59
  }
33
- return result;
60
+
61
+ return config;
34
62
  }
35
63
 
36
64
  /**
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.
65
+ * Load config from workspace root.
66
+ * Tries settings.json first, falls back to empty config.
42
67
  */
43
68
  export async function loadConfig(workspaceRoot: string): Promise<DotAiConfig> {
44
- const configPath = join(workspaceRoot, '.ai', 'dot-ai.yml');
69
+ const settingsConfig = await loadSettingsJson(workspaceRoot);
70
+ if (settingsConfig) return settingsConfig;
71
+ return {};
72
+ }
45
73
 
74
+ /**
75
+ * Migrate dot-ai.yml to settings.json.
76
+ * Reads the existing YAML config and writes a settings.json equivalent.
77
+ * Returns the path of the written file, or null if no YAML config exists.
78
+ */
79
+ export async function migrateConfig(workspaceRoot: string): Promise<string | null> {
80
+ const ymlPath = join(workspaceRoot, '.ai', 'dot-ai.yml');
46
81
  let raw: string;
47
82
  try {
48
- raw = await readFile(configPath, 'utf-8');
83
+ raw = await readFile(ymlPath, 'utf-8');
49
84
  } catch {
50
- // No config file — return empty config (all defaults)
51
- return {};
85
+ return null; // No YAML config to migrate
52
86
  }
53
87
 
54
- return parseYaml(raw);
88
+ // Extract extensions section from YAML if present
89
+ const config: DotAiConfig = {};
90
+ const extensions = parseExtensionsFromYaml(raw);
91
+ if (extensions) {
92
+ config.extensions = extensions;
93
+ }
94
+
95
+ const settings = configToSettingsJson(config);
96
+ const settingsPath = join(workspaceRoot, '.ai', 'settings.json');
97
+ const { writeFile: wf } = await import('node:fs/promises');
98
+ await wf(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
99
+ return settingsPath;
55
100
  }
56
101
 
57
102
  /**
58
- * Resolve a config with defaults.
59
- * Any missing provider gets the built-in file-based default.
103
+ * Convert DotAiConfig to Pi-compatible settings.json format.
60
104
  */
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 ─────────────────────────────────────────────────────
105
+ function configToSettingsJson(config: DotAiConfig): Record<string, unknown> {
106
+ const settings: Record<string, unknown> = {};
84
107
 
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);
108
+ if (config.extensions?.paths?.length) {
109
+ settings['extensions'] = config.extensions.paths;
92
110
  }
93
- return s;
111
+ if (config.extensions?.packages?.length) {
112
+ settings['packages'] = config.extensions.packages;
113
+ }
114
+ if (config.debug) settings['debug'] = config.debug;
115
+ if (config.workspace) settings['workspace'] = config.workspace;
116
+
117
+ return settings;
94
118
  }
95
119
 
96
- function parseYaml(raw: string): DotAiConfig {
120
+ /**
121
+ * Parse extensions section from legacy YAML config.
122
+ */
123
+ function parseExtensionsFromYaml(raw: string): ExtensionsConfig | null {
124
+ const extensions: ExtensionsConfig = {};
97
125
  const lines = raw.split('\n');
98
- const result: YamlNode = {};
99
- let currentSection: string | null = null;
126
+ let inExtensions = false;
127
+ let currentKey: 'paths' | 'packages' | null = null;
100
128
 
101
129
  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] = {};
130
+ if (line.match(/^extensions:$/)) {
131
+ inExtensions = true;
110
132
  continue;
111
133
  }
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;
134
+ if (inExtensions && line.match(/^\w+:/) && !line.match(/^extensions:/)) {
135
+ break;
136
+ }
137
+ if (!inExtensions) continue;
138
+
139
+ const inlineArrayMatch = line.match(/^\s{2}(\w+):\s*\[(.+)\]$/);
140
+ if (inlineArrayMatch) {
141
+ const key = inlineArrayMatch[1] as 'paths' | 'packages';
142
+ const items = inlineArrayMatch[2]
143
+ .split(',')
144
+ .map(s => stripQuotes(s.trim()))
145
+ .filter(s => s.length > 0);
146
+ extensions[key] = items;
147
+ currentKey = null;
123
148
  continue;
124
149
  }
125
150
 
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;
151
+ const keyMatch = line.match(/^\s{2}(\w+):$/);
152
+ if (keyMatch) {
153
+ currentKey = keyMatch[1] as 'paths' | 'packages';
154
+ extensions[currentKey] = [];
155
+ continue;
136
156
  }
137
- }
138
157
 
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;
158
+ const listItemMatch = line.match(/^\s{4}-\s*(.+)$/);
159
+ if (listItemMatch && currentKey) {
160
+ if (!extensions[currentKey]) extensions[currentKey] = [];
161
+ extensions[currentKey]!.push(stripQuotes(listItemMatch[1].trim()));
154
162
  }
155
163
  }
156
164
 
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
+ if (extensions.paths?.length || extensions.packages?.length) {
166
+ return extensions;
165
167
  }
168
+ return null;
169
+ }
166
170
 
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
- }
171
+ function stripQuotes(s: string): string {
172
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
173
+ return s.slice(1, -1);
175
174
  }
176
-
177
- return config;
175
+ return s;
178
176
  }
@@ -0,0 +1,99 @@
1
+ import type {
2
+ ContextInjectEvent, ContextInjectResult,
3
+ ContextModifyEvent, ContextModifyResult,
4
+ ToolCallEvent, ToolCallResult,
5
+ ToolResultEvent,
6
+ AgentEndEvent,
7
+ ToolDefinition,
8
+ ExtensionContext,
9
+ ResourcesDiscoverResult,
10
+ LabelExtractEvent,
11
+ ContextEnrichEvent, ContextEnrichResult,
12
+ RouteEvent, RouteResult,
13
+ InputEvent, InputResult,
14
+ CommandDefinition,
15
+ } from './extension-types.js';
16
+ import type { Label } from './types.js';
17
+
18
+ /**
19
+ * v6 Extension Context — passed as second argument to every event handler.
20
+ * Extends the base ExtensionContext with labels and optional agent capabilities.
21
+ */
22
+ export interface ExtensionContextV6 extends ExtensionContext {
23
+ /** Current prompt labels (available after label_extract) */
24
+ labels: Label[];
25
+
26
+ /** Agent capabilities (adapter-provided, may be undefined) */
27
+ agent?: {
28
+ abort(): void;
29
+ getContextUsage(): { tokens: number; percent: number } | undefined;
30
+ getSystemPrompt(): string;
31
+ [key: string]: unknown;
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Extension API — passed to extension factory functions.
37
+ * Pi-compatible: same on(event) + registerTool() + registerCommand() pattern.
38
+ */
39
+ export interface ExtensionAPI {
40
+ // ── Event subscription ──
41
+
42
+ /** Resource discovery: extensions declare resources and contribute labels */
43
+ on(event: 'resources_discover', handler: (e: undefined, ctx: ExtensionContextV6) => Promise<ResourcesDiscoverResult | void>): void;
44
+ /** Label extraction: extensions can add custom labels (chain-transform) */
45
+ on(event: 'label_extract', handler: (e: LabelExtractEvent, ctx: ExtensionContextV6) => Promise<Label[] | void>): void;
46
+ /** Context enrichment: extensions return sections for context injection */
47
+ on(event: 'context_enrich', handler: (e: ContextEnrichEvent, ctx: ExtensionContextV6) => Promise<ContextEnrichResult | void>): void;
48
+ /** Model routing: first result wins */
49
+ on(event: 'route', handler: (e: RouteEvent, ctx: ExtensionContextV6) => Promise<RouteResult | void>): void;
50
+ /** Input transformation: extensions can rewrite user input */
51
+ on(event: 'input', handler: (e: InputEvent, ctx: ExtensionContextV6) => Promise<InputResult | void>): void;
52
+ /** Tool call interception: fired before tool execution, can block */
53
+ on(event: 'tool_call', handler: (e: ToolCallEvent, ctx: ExtensionContextV6) => Promise<ToolCallResult | void>): void;
54
+ /** Tool result observation: fired after tool execution */
55
+ on(event: 'tool_result', handler: (e: ToolResultEvent, ctx: ExtensionContextV6) => Promise<void>): void;
56
+
57
+ // ── Lifecycle events ──
58
+
59
+ on(event: 'session_start', handler: (e: undefined, ctx: ExtensionContextV6) => Promise<void>): void;
60
+ on(event: 'session_end', handler: (e: undefined, ctx: ExtensionContextV6) => Promise<void>): void;
61
+ on(event: 'agent_start', handler: (e: undefined, ctx: ExtensionContextV6) => Promise<void>): void;
62
+ on(event: 'agent_end', handler: (e: AgentEndEvent, ctx: ExtensionContextV6) => Promise<void>): void;
63
+ on(event: 'turn_start', handler: (e: undefined, ctx: ExtensionContextV6) => Promise<void>): void;
64
+ on(event: 'turn_end', handler: (e: undefined, ctx: ExtensionContextV6) => Promise<void>): void;
65
+
66
+ // ── Legacy events (kept for transition) ──
67
+
68
+ /** @deprecated Use context_enrich instead */
69
+ on(event: 'context_inject', handler: (e: ContextInjectEvent, ctx: ExtensionContextV6) => Promise<ContextInjectResult | void>): void;
70
+ /** @deprecated Use context_enrich instead */
71
+ on(event: 'context_modify', handler: (e: ContextModifyEvent, ctx: ExtensionContextV6) => Promise<ContextModifyResult | void>): void;
72
+
73
+ // ── Catch-all for custom/Pi-specific events ──
74
+
75
+ on(event: string, handler: (e: any, ctx: ExtensionContextV6) => Promise<any>): void;
76
+
77
+ // ── Capability registration ──
78
+
79
+ /** Register a tool that the agent can invoke */
80
+ registerTool(tool: ToolDefinition): void;
81
+ /** Register a command (slash command, etc.) */
82
+ registerCommand(command: CommandDefinition): void;
83
+
84
+ // ── Inter-extension communication ──
85
+
86
+ events: {
87
+ on(event: string, handler: (...args: unknown[]) => void): void;
88
+ off(event: string, handler: (...args: unknown[]) => void): void;
89
+ emit(event: string, ...args: unknown[]): void;
90
+ };
91
+
92
+ // ── Extension config ──
93
+
94
+ /** Extension configuration (from extension-specific config files, env vars, etc.) */
95
+ config: Record<string, unknown>;
96
+
97
+ /** Workspace root directory (contains .ai/) */
98
+ workspaceRoot: string;
99
+ }
@@ -0,0 +1,127 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import type { ExtensionAPI } from './extension-api.js';
5
+ import type { LoadedExtension, ToolDefinition, CommandDefinition, ExtensionTier } from './extension-types.js';
6
+ import { EVENT_TIERS } from './extension-types.js';
7
+ import type { ExtensionsConfig } from './types.js';
8
+
9
+ /**
10
+ * Discover extension file paths from configured locations.
11
+ */
12
+ export async function discoverExtensions(
13
+ workspaceRoot: string,
14
+ config?: ExtensionsConfig,
15
+ ): Promise<string[]> {
16
+ const paths = new Set<string>();
17
+
18
+ // Default discovery paths
19
+ const searchDirs = [
20
+ join(workspaceRoot, '.ai', 'extensions'),
21
+ join(homedir(), '.ai', 'extensions'),
22
+ ...(config?.paths ?? []).map(p => resolve(workspaceRoot, p)),
23
+ ];
24
+
25
+ for (const dir of searchDirs) {
26
+ try {
27
+ const entries = await readdir(dir, { withFileTypes: true });
28
+ for (const entry of entries) {
29
+ const fullPath = join(dir, entry.name);
30
+ if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.js'))) {
31
+ paths.add(fullPath);
32
+ } else if (entry.isDirectory()) {
33
+ // Check for index.ts or index.js
34
+ for (const indexName of ['index.ts', 'index.js']) {
35
+ const indexPath = join(fullPath, indexName);
36
+ try {
37
+ await stat(indexPath);
38
+ paths.add(indexPath);
39
+ break;
40
+ } catch { /* not found */ }
41
+ }
42
+ // Check for package.json with dot-ai field
43
+ try {
44
+ const pkgPath = join(fullPath, 'package.json');
45
+ const pkgRaw = await readFile(pkgPath, 'utf-8');
46
+ const pkg = JSON.parse(pkgRaw) as Record<string, unknown>;
47
+ const dotAi = pkg['dot-ai'] as { extensions?: string[] } | undefined;
48
+ if (dotAi?.extensions && Array.isArray(dotAi.extensions)) {
49
+ for (const ext of dotAi.extensions) {
50
+ paths.add(resolve(fullPath, ext));
51
+ }
52
+ }
53
+ } catch { /* no package.json or no dot-ai field */ }
54
+ }
55
+ }
56
+ } catch { /* directory doesn't exist — skip */ }
57
+ }
58
+
59
+ // Also resolve npm packages from config
60
+ if (config?.packages) {
61
+ for (const pkg of config.packages) {
62
+ try {
63
+ const { createRequire } = await import('node:module');
64
+ const require = createRequire(join(workspaceRoot, 'package.json'));
65
+ const pkgJsonPath = require.resolve(`${pkg}/package.json`);
66
+ const pkgRaw = await readFile(pkgJsonPath, 'utf-8');
67
+ const pkgJson = JSON.parse(pkgRaw) as Record<string, unknown>;
68
+ const dotAi = pkgJson['dot-ai'] as { extensions?: string[] } | undefined;
69
+ if (dotAi?.extensions && Array.isArray(dotAi.extensions)) {
70
+ const pkgDir = join(pkgJsonPath, '..');
71
+ for (const ext of dotAi.extensions) {
72
+ paths.add(resolve(pkgDir, ext));
73
+ }
74
+ }
75
+ } catch { /* package not found */ }
76
+ }
77
+ }
78
+
79
+ return Array.from(paths);
80
+ }
81
+
82
+ /**
83
+ * Create a v6 ExtensionAPI instance that collects registrations into a LoadedExtension.
84
+ * Extensions communicate via events only — no provider access.
85
+ */
86
+ export function createV6CollectorAPI(
87
+ extensionPath: string,
88
+ config: Record<string, unknown>,
89
+ eventBus?: { on: (event: string, handler: (...args: unknown[]) => void) => void; emit: (event: string, ...args: unknown[]) => void },
90
+ workspaceRoot?: string,
91
+ ): { api: ExtensionAPI; extension: LoadedExtension } {
92
+ const extension: LoadedExtension = {
93
+ path: extensionPath,
94
+ handlers: new Map(),
95
+ tools: new Map(),
96
+ commands: new Map(),
97
+ tiers: new Set(),
98
+ };
99
+
100
+ const api: ExtensionAPI = {
101
+ on(event: string, handler: Function) {
102
+ if (!extension.handlers.has(event)) {
103
+ extension.handlers.set(event, []);
104
+ }
105
+ extension.handlers.get(event)!.push(handler);
106
+
107
+ const tier: ExtensionTier | undefined = EVENT_TIERS[event];
108
+ if (tier) {
109
+ extension.tiers.add(tier);
110
+ }
111
+ },
112
+ registerTool(tool: ToolDefinition) {
113
+ extension.tools.set(tool.name, tool);
114
+ },
115
+ registerCommand(command: CommandDefinition) {
116
+ extension.commands.set(command.name, command);
117
+ },
118
+ events: eventBus ?? {
119
+ on: () => {},
120
+ emit: () => {},
121
+ },
122
+ config,
123
+ workspaceRoot: workspaceRoot ?? process.cwd(),
124
+ } as unknown as ExtensionAPI;
125
+
126
+ return { api, extension };
127
+ }