@dot-ai/core 0.10.0 → 0.11.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.
@@ -5,77 +5,198 @@ import type { ExtensionAPI } from './extension-api.js';
5
5
  import type { LoadedExtension, ToolDefinition, CommandDefinition } from './extension-types.js';
6
6
  import type { ExtensionsConfig, Skill, Identity } from './types.js';
7
7
 
8
+ function isExtensionFile(name: string): boolean {
9
+ return name.endsWith('.ts') || name.endsWith('.js');
10
+ }
11
+
12
+ /**
13
+ * Resolve extension entry points from a directory.
14
+ *
15
+ * Checks for:
16
+ * 1. package.json with "dot-ai.extensions" field -> returns declared paths
17
+ * 2. index.ts or index.js -> returns the index file
18
+ *
19
+ * Returns resolved paths or null if no entry points found.
20
+ */
21
+ async function resolveExtensionEntries(dir: string): Promise<string[] | null> {
22
+ // Check for package.json with "dot-ai" field first
23
+ try {
24
+ const pkgPath = join(dir, 'package.json');
25
+ const pkgRaw = await readFile(pkgPath, 'utf-8');
26
+ const pkg = JSON.parse(pkgRaw) as Record<string, unknown>;
27
+ const dotAi = pkg['dot-ai'] as { extensions?: string[] } | undefined;
28
+ if (dotAi?.extensions && Array.isArray(dotAi.extensions)) {
29
+ const entries: string[] = [];
30
+ for (const ext of dotAi.extensions) {
31
+ const resolvedPath = resolve(dir, ext);
32
+ try {
33
+ await stat(resolvedPath);
34
+ entries.push(resolvedPath);
35
+ } catch { /* entry doesn't exist */ }
36
+ }
37
+ if (entries.length > 0) return entries;
38
+ }
39
+ } catch { /* no package.json or invalid */ }
40
+
41
+ // Check for index.ts or index.js
42
+ for (const indexName of ['index.ts', 'index.js']) {
43
+ const indexPath = join(dir, indexName);
44
+ try {
45
+ await stat(indexPath);
46
+ return [indexPath];
47
+ } catch { /* not found */ }
48
+ }
49
+
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Scan a directory for extensions.
55
+ *
56
+ * Discovery rules:
57
+ * 1. Direct files: `dir/*.ts` or `*.js` -> load
58
+ * 2. Subdirectory with package.json: `dir/sub/package.json` with "dot-ai" field -> load what it declares
59
+ * 3. Subdirectory with index: `dir/sub/index.ts` or `index.js` -> load
60
+ *
61
+ * No recursion beyond one level. Complex packages must use package.json manifest.
62
+ */
63
+ async function discoverExtensionsInDir(dir: string): Promise<string[]> {
64
+ const discovered: string[] = [];
65
+
66
+ try {
67
+ const entries = await readdir(dir, { withFileTypes: true });
68
+
69
+ for (const entry of entries) {
70
+ const entryPath = join(dir, entry.name);
71
+
72
+ // 1. Direct files: *.ts or *.js
73
+ if (entry.isFile() && isExtensionFile(entry.name)) {
74
+ discovered.push(entryPath);
75
+ continue;
76
+ }
77
+
78
+ // 2 & 3. Subdirectories: package.json or index file
79
+ if (entry.isDirectory()) {
80
+ const resolved = await resolveExtensionEntries(entryPath);
81
+ if (resolved) {
82
+ discovered.push(...resolved);
83
+ }
84
+ }
85
+ }
86
+ } catch { /* directory doesn't exist — skip */ }
87
+
88
+ return discovered;
89
+ }
90
+
91
+ /**
92
+ * Resolve extensions from installed packages in .ai/packages/.
93
+ *
94
+ * Reads the package.json created by `npm --prefix .ai/packages/` and resolves
95
+ * extension entry points from each installed package's "dot-ai.extensions" field.
96
+ */
97
+ async function resolveInstalledPackages(workspaceRoot: string): Promise<string[]> {
98
+ const packagesDir = join(workspaceRoot, '.ai', 'packages');
99
+ const pkgJsonPath = join(packagesDir, 'package.json');
100
+
101
+ try {
102
+ const raw = await readFile(pkgJsonPath, 'utf-8');
103
+ const pkg = JSON.parse(raw) as Record<string, unknown>;
104
+ const deps = pkg.dependencies as Record<string, string> | undefined;
105
+ if (!deps) return [];
106
+
107
+ const paths: string[] = [];
108
+ for (const name of Object.keys(deps)) {
109
+ const pkgDir = join(packagesDir, 'node_modules', name);
110
+ const entries = await resolveExtensionEntries(pkgDir);
111
+ if (entries) {
112
+ paths.push(...entries);
113
+ }
114
+ }
115
+ return paths;
116
+ } catch {
117
+ return [];
118
+ }
119
+ }
120
+
8
121
  /**
9
- * Discover extension file paths from configured locations.
122
+ * Discover extension file paths from all sources.
123
+ *
124
+ * Sources (in order):
125
+ * 1. Auto-discovery: .ai/extensions/ (project-local)
126
+ * 2. Auto-discovery: ~/.ai/extensions/ (global)
127
+ * 3. Installed packages: .ai/packages/node_modules/ (installed via dot-ai install)
128
+ * 4. Configured paths: settings.json "extensions" array (explicit paths/dirs)
129
+ * 5. Configured packages: settings.json "packages" array (npm package names resolved from workspace node_modules)
10
130
  */
11
131
  export async function discoverExtensions(
12
132
  workspaceRoot: string,
13
133
  config?: ExtensionsConfig,
14
134
  ): Promise<string[]> {
15
- const paths = new Set<string>();
135
+ const seen = new Set<string>();
136
+ const allPaths: string[] = [];
137
+
138
+ const addPaths = (paths: string[]) => {
139
+ for (const p of paths) {
140
+ const resolved = resolve(p);
141
+ if (!seen.has(resolved)) {
142
+ seen.add(resolved);
143
+ allPaths.push(resolved);
144
+ }
145
+ }
146
+ };
16
147
 
17
- // Default discovery paths
18
- const searchDirs = [
19
- join(workspaceRoot, '.ai', 'extensions'),
20
- join(homedir(), '.ai', 'extensions'),
21
- ...(config?.paths ?? []).map(p => resolve(workspaceRoot, p)),
22
- ];
148
+ // 1. Project-local extensions: .ai/extensions/
149
+ addPaths(await discoverExtensionsInDir(join(workspaceRoot, '.ai', 'extensions')));
23
150
 
24
- for (const dir of searchDirs) {
25
- try {
26
- const entries = await readdir(dir, { withFileTypes: true });
27
- for (const entry of entries) {
28
- const fullPath = join(dir, entry.name);
29
- if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.js'))) {
30
- paths.add(fullPath);
31
- } else if (entry.isDirectory()) {
32
- // Check for index.ts or index.js
33
- for (const indexName of ['index.ts', 'index.js']) {
34
- const indexPath = join(fullPath, indexName);
35
- try {
36
- await stat(indexPath);
37
- paths.add(indexPath);
38
- break;
39
- } catch { /* not found */ }
151
+ // 2. Global extensions: ~/.ai/extensions/
152
+ addPaths(await discoverExtensionsInDir(join(homedir(), '.ai', 'extensions')));
153
+
154
+ // 3. Installed packages: .ai/packages/node_modules/
155
+ addPaths(await resolveInstalledPackages(workspaceRoot));
156
+
157
+ // 4. Explicitly configured paths from settings.json "extensions" array
158
+ if (config?.paths) {
159
+ for (const p of config.paths) {
160
+ const resolved = resolve(workspaceRoot, p);
161
+ try {
162
+ const s = await stat(resolved);
163
+ if (s.isDirectory()) {
164
+ // Check for package.json or index file first
165
+ const entries = await resolveExtensionEntries(resolved);
166
+ if (entries) {
167
+ addPaths(entries);
168
+ } else {
169
+ // Discover individual files in directory
170
+ addPaths(await discoverExtensionsInDir(resolved));
40
171
  }
41
- // Check for package.json with dot-ai field
42
- try {
43
- const pkgPath = join(fullPath, 'package.json');
44
- const pkgRaw = await readFile(pkgPath, 'utf-8');
45
- const pkg = JSON.parse(pkgRaw) as Record<string, unknown>;
46
- const dotAi = pkg['dot-ai'] as { extensions?: string[] } | undefined;
47
- if (dotAi?.extensions && Array.isArray(dotAi.extensions)) {
48
- for (const ext of dotAi.extensions) {
49
- paths.add(resolve(fullPath, ext));
50
- }
51
- }
52
- } catch { /* no package.json or no dot-ai field */ }
172
+ } else if (s.isFile()) {
173
+ addPaths([resolved]);
53
174
  }
175
+ } catch {
176
+ // Path doesn't exist — add anyway (will fail at load time with clear error)
177
+ addPaths([resolved]);
54
178
  }
55
- } catch { /* directory doesn't exist — skip */ }
179
+ }
56
180
  }
57
181
 
58
- // Also resolve npm packages from config
182
+ // 5. Configured npm packages from settings.json "packages" array
183
+ // These are resolved from the workspace's own node_modules
59
184
  if (config?.packages) {
60
185
  for (const pkg of config.packages) {
61
186
  try {
62
187
  const { createRequire } = await import('node:module');
63
188
  const require = createRequire(join(workspaceRoot, 'package.json'));
64
189
  const pkgJsonPath = require.resolve(`${pkg}/package.json`);
65
- const pkgRaw = await readFile(pkgJsonPath, 'utf-8');
66
- const pkgJson = JSON.parse(pkgRaw) as Record<string, unknown>;
67
- const dotAi = pkgJson['dot-ai'] as { extensions?: string[] } | undefined;
68
- if (dotAi?.extensions && Array.isArray(dotAi.extensions)) {
69
- const pkgDir = join(pkgJsonPath, '..');
70
- for (const ext of dotAi.extensions) {
71
- paths.add(resolve(pkgDir, ext));
72
- }
190
+ const pkgDir = join(pkgJsonPath, '..');
191
+ const entries = await resolveExtensionEntries(pkgDir);
192
+ if (entries) {
193
+ addPaths(entries);
73
194
  }
74
195
  } catch { /* package not found */ }
75
196
  }
76
197
  }
77
198
 
78
- return Array.from(paths);
199
+ return allPaths;
79
200
  }
80
201
 
81
202
  /**
package/src/index.ts CHANGED
@@ -85,8 +85,8 @@ export { NoopLogger, JsonFileLogger, StderrLogger } from './logger.js';
85
85
  export { loadConfig, migrateConfig } from './config.js';
86
86
 
87
87
  // ── Package Manager ──
88
- export { install, remove, listPackages, resolvePackages } from './package-manager.js';
89
- export type { PackageInfo } from './package-manager.js';
88
+ export { install, remove, listPackages, resolvePackages, ensurePackagesInstalled } from './package-manager.js';
89
+ export type { PackageInfo, MissingPackageAction } from './package-manager.js';
90
90
 
91
91
  // ── Boot Cache ──
92
92
  export { computeChecksum, loadBootCache, writeBootCache, clearBootCache } from './boot-cache.js';
@@ -1,6 +1,7 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { readFile, mkdir } from 'node:fs/promises';
3
- import { join } from 'node:path';
2
+ import { readFile, mkdir, writeFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
4
5
 
5
6
  export interface PackageInfo {
6
7
  name: string;
@@ -12,25 +13,83 @@ export interface PackageInfo {
12
13
  };
13
14
  }
14
15
 
16
+ export type MissingPackageAction = 'install' | 'skip' | 'error';
17
+
15
18
  /**
16
- * Install a dot-ai package from npm or git.
19
+ * Parse a package source string into name and spec.
20
+ *
21
+ * Supported formats:
22
+ * - "npm:@dot-ai/ext-memory@1.0.0" → { name: "@dot-ai/ext-memory", spec: "@dot-ai/ext-memory@1.0.0" }
23
+ * - "npm:@dot-ai/ext-memory" → { name: "@dot-ai/ext-memory", spec: "@dot-ai/ext-memory" }
24
+ * - "@dot-ai/ext-memory@1.0.0" → { name: "@dot-ai/ext-memory", spec: "@dot-ai/ext-memory@1.0.0" }
25
+ * - "@dot-ai/ext-memory" → { name: "@dot-ai/ext-memory", spec: "@dot-ai/ext-memory" }
17
26
  */
18
- export async function install(
19
- source: string,
20
- targetDir: string,
21
- ): Promise<PackageInfo> {
22
- const installDir = join(targetDir, '.ai', 'packages');
27
+ function parsePackageSource(source: string): { name: string; spec: string } {
28
+ // Strip npm: prefix
29
+ const raw = source.startsWith('npm:') ? source.slice(4) : source;
30
+
31
+ // Extract name (without version) for scoped packages
32
+ if (raw.startsWith('@')) {
33
+ // @scope/name@version → name = @scope/name
34
+ const slashIdx = raw.indexOf('/');
35
+ if (slashIdx === -1) return { name: raw, spec: raw };
36
+ const afterSlash = raw.slice(slashIdx + 1);
37
+ const atIdx = afterSlash.indexOf('@');
38
+ if (atIdx === -1) return { name: raw, spec: raw };
39
+ return { name: raw.slice(0, slashIdx + 1 + atIdx), spec: raw };
40
+ }
41
+
42
+ // name@version → name = name
43
+ const atIdx = raw.indexOf('@');
44
+ if (atIdx === -1) return { name: raw, spec: raw };
45
+ return { name: raw.slice(0, atIdx), spec: raw };
46
+ }
47
+
48
+ /**
49
+ * Get the install directory for packages.
50
+ */
51
+ function getInstallDir(workspaceRoot: string): string {
52
+ return join(workspaceRoot, '.ai', 'packages');
53
+ }
54
+
55
+ /**
56
+ * Check if a package is already installed in .ai/packages/.
57
+ */
58
+ function isPackageInstalled(installDir: string, name: string): boolean {
59
+ return existsSync(join(installDir, 'node_modules', name, 'package.json'));
60
+ }
61
+
62
+ /**
63
+ * Install a single npm package into .ai/packages/.
64
+ */
65
+ async function installNpm(spec: string, installDir: string): Promise<void> {
23
66
  await mkdir(installDir, { recursive: true });
24
67
 
25
- execSync(`npm install --prefix "${installDir}" "${source}"`, {
68
+ // Ensure package.json exists (npm --prefix needs it)
69
+ const pkgJsonPath = join(installDir, 'package.json');
70
+ if (!existsSync(pkgJsonPath)) {
71
+ await writeFile(pkgJsonPath, JSON.stringify({ private: true, dependencies: {} }, null, 2) + '\n');
72
+ }
73
+
74
+ execSync(`npm install --prefix "${installDir}" "${spec}"`, {
26
75
  stdio: 'pipe',
27
76
  timeout: 60000,
28
77
  });
78
+ }
79
+
80
+ /**
81
+ * Install a dot-ai package from npm.
82
+ *
83
+ * Installs into `.ai/packages/node_modules/`.
84
+ */
85
+ export async function install(
86
+ source: string,
87
+ workspaceRoot: string,
88
+ ): Promise<PackageInfo> {
89
+ const installDir = getInstallDir(workspaceRoot);
90
+ const { name, spec } = parsePackageSource(source);
29
91
 
30
- // Read installed package info
31
- const name = source.startsWith('@') || !source.includes('/')
32
- ? source.replace(/@[^/]*$/, '')
33
- : source;
92
+ await installNpm(spec, installDir);
34
93
  return readPackageInfo(installDir, name);
35
94
  }
36
95
 
@@ -39,9 +98,9 @@ export async function install(
39
98
  */
40
99
  export async function remove(
41
100
  name: string,
42
- targetDir: string,
101
+ workspaceRoot: string,
43
102
  ): Promise<void> {
44
- const installDir = join(targetDir, '.ai', 'packages');
103
+ const installDir = getInstallDir(workspaceRoot);
45
104
  execSync(`npm uninstall --prefix "${installDir}" "${name}"`, {
46
105
  stdio: 'pipe',
47
106
  timeout: 60000,
@@ -52,9 +111,9 @@ export async function remove(
52
111
  * List installed dot-ai packages.
53
112
  */
54
113
  export async function listPackages(
55
- targetDir: string,
114
+ workspaceRoot: string,
56
115
  ): Promise<PackageInfo[]> {
57
- const installDir = join(targetDir, '.ai', 'packages');
116
+ const installDir = getInstallDir(workspaceRoot);
58
117
  const pkgJsonPath = join(installDir, 'package.json');
59
118
 
60
119
  try {
@@ -82,24 +141,88 @@ export async function listPackages(
82
141
  * Resolve dot-ai manifest from installed packages.
83
142
  */
84
143
  export async function resolvePackages(
85
- targetDir: string,
144
+ workspaceRoot: string,
86
145
  ): Promise<{ extensions: string[]; skills: string[]; providers: string[] }> {
87
- const packages = await listPackages(targetDir);
146
+ const packages = await listPackages(workspaceRoot);
88
147
  const result = { extensions: [] as string[], skills: [] as string[], providers: [] as string[] };
89
148
 
90
- const installDir = join(targetDir, '.ai', 'packages');
149
+ const installDir = getInstallDir(workspaceRoot);
91
150
  for (const pkg of packages) {
92
151
  if (!pkg.dotAi) continue;
93
152
  const pkgDir = join(installDir, 'node_modules', pkg.name);
94
153
 
95
154
  if (pkg.dotAi.extensions) {
96
- result.extensions.push(...pkg.dotAi.extensions.map(e => join(pkgDir, e)));
155
+ result.extensions.push(...pkg.dotAi.extensions.map(e => resolve(pkgDir, e)));
97
156
  }
98
157
  if (pkg.dotAi.skills) {
99
- result.skills.push(...pkg.dotAi.skills.map(s => join(pkgDir, s)));
158
+ result.skills.push(...pkg.dotAi.skills.map(s => resolve(pkgDir, s)));
100
159
  }
101
160
  if (pkg.dotAi.providers) {
102
- result.providers.push(...pkg.dotAi.providers.map(p => join(pkgDir, p)));
161
+ result.providers.push(...pkg.dotAi.providers.map(p => resolve(pkgDir, p)));
162
+ }
163
+ }
164
+
165
+ return result;
166
+ }
167
+
168
+ /**
169
+ * Ensure all packages from settings.json are installed.
170
+ *
171
+ * Like Pi's `resolvePackageSources()` with auto-install:
172
+ * - Reads the `packages` array from settings.json config
173
+ * - For each package, checks if it's already installed in `.ai/packages/`
174
+ * - If not installed, auto-installs it via npm
175
+ * - The `onMissing` callback allows callers to control behavior (install/skip/error)
176
+ *
177
+ * This should be called BEFORE `discoverExtensions()` in the boot flow,
178
+ * so that installed packages are available for extension discovery.
179
+ */
180
+ export async function ensurePackagesInstalled(
181
+ workspaceRoot: string,
182
+ packages: string[],
183
+ onMissing?: (source: string) => Promise<MissingPackageAction>,
184
+ ): Promise<{ installed: string[]; skipped: string[]; errors: Array<{ source: string; error: string }> }> {
185
+ const installDir = getInstallDir(workspaceRoot);
186
+ const result = {
187
+ installed: [] as string[],
188
+ skipped: [] as string[],
189
+ errors: [] as Array<{ source: string; error: string }>,
190
+ };
191
+
192
+ for (const source of packages) {
193
+ const { name, spec } = parsePackageSource(source);
194
+
195
+ // Already installed? Skip.
196
+ if (isPackageInstalled(installDir, name)) {
197
+ result.skipped.push(source);
198
+ continue;
199
+ }
200
+
201
+ // Determine action for missing package
202
+ let action: MissingPackageAction = 'install';
203
+ if (onMissing) {
204
+ action = await onMissing(source);
205
+ }
206
+
207
+ if (action === 'skip') {
208
+ result.skipped.push(source);
209
+ continue;
210
+ }
211
+
212
+ if (action === 'error') {
213
+ result.errors.push({ source, error: `Missing package: ${source}` });
214
+ continue;
215
+ }
216
+
217
+ // Auto-install
218
+ try {
219
+ await installNpm(spec, installDir);
220
+ result.installed.push(source);
221
+ } catch (err) {
222
+ result.errors.push({
223
+ source,
224
+ error: err instanceof Error ? err.message : String(err),
225
+ });
103
226
  }
104
227
  }
105
228
 
package/src/runtime.ts CHANGED
@@ -2,6 +2,7 @@ import { loadConfig } from './config.js';
2
2
  import { toolDefinitionToCapability } from './capabilities.js';
3
3
  import type { Capability } from './capabilities.js';
4
4
  import { discoverExtensions, createV6CollectorAPI } from './extension-loader.js';
5
+ import { ensurePackagesInstalled } from './package-manager.js';
5
6
  import { ExtensionRunner, EventBus } from './extension-runner.js';
6
7
  import type {
7
8
  ToolCallEvent, ToolCallResult, ExtensionDiagnostic,
@@ -92,8 +93,29 @@ export class DotAiRuntime {
92
93
  // Create event bus
93
94
  this._eventBus = new EventBus();
94
95
 
95
- // Discover extensions
96
+ // Auto-install packages from settings.json before discovery
96
97
  const extConfig = this.options.extensions ?? rawConfig.extensions;
98
+ if (extConfig?.packages?.length) {
99
+ const installResult = await ensurePackagesInstalled(
100
+ this.options.workspaceRoot,
101
+ extConfig.packages,
102
+ );
103
+ if (installResult.installed.length > 0 || installResult.errors.length > 0) {
104
+ this.logger.log({
105
+ timestamp: new Date().toISOString(),
106
+ level: installResult.errors.length > 0 ? 'warn' : 'info',
107
+ phase: 'boot',
108
+ event: 'packages_installed',
109
+ data: {
110
+ installed: installResult.installed,
111
+ skipped: installResult.skipped,
112
+ errors: installResult.errors,
113
+ },
114
+ });
115
+ }
116
+ }
117
+
118
+ // Discover extensions
97
119
  const extPaths = await discoverExtensions(this.options.workspaceRoot, extConfig);
98
120
 
99
121
  // Load extensions via collector API