@ottocode/sdk 0.1.244 → 0.1.246

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 (39) hide show
  1. package/package.json +3 -2
  2. package/src/auth/src/index.ts +2 -2
  3. package/src/auth/src/wallet.ts +5 -5
  4. package/src/config/src/index.ts +7 -2
  5. package/src/config/src/manager.ts +106 -30
  6. package/src/core/src/index.ts +6 -1
  7. package/src/core/src/providers/resolver.ts +37 -9
  8. package/src/core/src/tools/builtin/bash.ts +2 -2
  9. package/src/core/src/tools/builtin/bash.txt +1 -1
  10. package/src/core/src/tools/builtin/fs/edit-shared.ts +1 -1
  11. package/src/core/src/tools/builtin/fs/edit.txt +2 -2
  12. package/src/core/src/tools/builtin/fs/write.txt +1 -1
  13. package/src/core/src/tools/loader.ts +133 -81
  14. package/src/core/src/utils/debug.ts +13 -0
  15. package/src/index.ts +47 -12
  16. package/src/prompts/src/agents/build.txt +3 -4
  17. package/src/prompts/src/providers/default.txt +1 -1
  18. package/src/prompts/src/providers/glm.txt +1 -1
  19. package/src/prompts/src/providers/google.txt +2 -2
  20. package/src/prompts/src/providers/moonshot.txt +1 -1
  21. package/src/prompts/src/providers/openai.txt +3 -3
  22. package/src/prompts/src/providers.ts +15 -0
  23. package/src/providers/src/authorization.ts +26 -1
  24. package/src/providers/src/catalog-manual.ts +41 -23
  25. package/src/providers/src/catalog-merged.ts +2 -2
  26. package/src/providers/src/catalog.ts +10284 -10283
  27. package/src/providers/src/env.ts +11 -6
  28. package/src/providers/src/index.ts +38 -12
  29. package/src/providers/src/ollama-discovery.ts +149 -0
  30. package/src/providers/src/{setu-client.ts → ottorouter-client.ts} +30 -30
  31. package/src/providers/src/pricing.ts +4 -1
  32. package/src/providers/src/registry.ts +258 -0
  33. package/src/providers/src/utils.ts +11 -4
  34. package/src/providers/src/validate.ts +63 -2
  35. package/src/skills/index.ts +3 -0
  36. package/src/skills/tool.ts +28 -36
  37. package/src/types/src/config.ts +34 -8
  38. package/src/types/src/index.ts +4 -0
  39. package/src/types/src/provider.ts +34 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ottocode/sdk",
3
- "version": "0.1.244",
3
+ "version": "0.1.246",
4
4
  "description": "AI agent SDK for building intelligent assistants - tree-shakable and comprehensive",
5
5
  "author": "nitishxyz",
6
6
  "license": "MIT",
@@ -97,9 +97,10 @@
97
97
  "@modelcontextprotocol/sdk": "^1.12",
98
98
  "@openauthjs/openauth": "^0.4.3",
99
99
  "@openrouter/ai-sdk-provider": "^1.2.0",
100
- "@ottocode/ai-sdk": "0.2.0",
100
+ "@ottorouter/ai-sdk": "0.2.1",
101
101
  "@solana/web3.js": "^1.98.0",
102
102
  "ai": "^6.0.0",
103
+ "ai-sdk-ollama": "^3.8.3",
103
104
  "bs58": "^6.0.0",
104
105
  "bun-pty": "^0.3.2",
105
106
  "diff": "^8.0.2",
@@ -86,8 +86,8 @@ export {
86
86
  generateWallet,
87
87
  importWallet,
88
88
  isValidPrivateKey,
89
- getSetuWallet,
90
- ensureSetuWallet,
89
+ getOttoRouterWallet,
90
+ ensureOttoRouterWallet,
91
91
  type WalletInfo,
92
92
  } from './wallet.ts';
93
93
 
@@ -34,25 +34,25 @@ export function isValidPrivateKey(privateKey: string): boolean {
34
34
  }
35
35
  }
36
36
 
37
- export async function getSetuWallet(
37
+ export async function getOttoRouterWallet(
38
38
  projectRoot?: string,
39
39
  ): Promise<WalletInfo | null> {
40
- const auth = await getAuth('setu', projectRoot);
40
+ const auth = await getAuth('ottorouter', projectRoot);
41
41
  if (auth?.type === 'wallet' && auth.secret) {
42
42
  return importWallet(auth.secret);
43
43
  }
44
44
  return null;
45
45
  }
46
46
 
47
- export async function ensureSetuWallet(
47
+ export async function ensureOttoRouterWallet(
48
48
  projectRoot?: string,
49
49
  ): Promise<WalletInfo> {
50
- const existing = await getSetuWallet(projectRoot);
50
+ const existing = await getOttoRouterWallet(projectRoot);
51
51
  if (existing) return existing;
52
52
 
53
53
  const wallet = generateWallet();
54
54
  await setAuth(
55
- 'setu',
55
+ 'ottorouter',
56
56
  { type: 'wallet', secret: wallet.privateKey },
57
57
  projectRoot,
58
58
  'global',
@@ -13,10 +13,11 @@ const DEFAULT_PROVIDER_SETTINGS: OttoConfig['providers'] = {
13
13
  openai: { enabled: false },
14
14
  anthropic: { enabled: false },
15
15
  google: { enabled: false },
16
+ 'ollama-cloud': { enabled: false, baseURL: 'https://ollama.com' },
16
17
  openrouter: { enabled: false },
17
18
  opencode: { enabled: false },
18
19
  copilot: { enabled: false },
19
- setu: { enabled: true },
20
+ ottorouter: { enabled: true },
20
21
  zai: { enabled: false },
21
22
  'zai-coding': { enabled: false },
22
23
  moonshot: { enabled: false },
@@ -29,7 +30,7 @@ const DEFAULTS: {
29
30
  } = {
30
31
  defaults: {
31
32
  agent: 'build',
32
- provider: 'setu',
33
+ provider: 'ottorouter',
33
34
  model: 'kimi-k2.5',
34
35
  toolApproval: 'auto',
35
36
  guidedMode: false,
@@ -64,6 +65,7 @@ export async function loadConfig(
64
65
  projectRoot,
65
66
  defaults: merged.defaults as OttoConfig['defaults'],
66
67
  providers: merged.providers as OttoConfig['providers'],
68
+ skills: merged.skills as OttoConfig['skills'],
67
69
  paths: {
68
70
  dataDir,
69
71
  dbPath,
@@ -129,6 +131,9 @@ export {
129
131
  isAuthorized,
130
132
  ensureEnv,
131
133
  writeDefaults,
134
+ writeProviderSettings,
135
+ removeProviderSettings,
136
+ writeSkillSettings,
132
137
  writeAuth,
133
138
  removeAuth,
134
139
  } from './manager.ts';
@@ -6,6 +6,10 @@ import {
6
6
  type ProviderId,
7
7
  type AuthInfo,
8
8
  } from '../../auth/src/index.ts';
9
+ import type {
10
+ ProviderSettingsEntry,
11
+ SkillSettings,
12
+ } from '../../types/src/index.ts';
9
13
  import {
10
14
  getGlobalConfigDir,
11
15
  getGlobalConfigPath,
@@ -83,30 +87,8 @@ export async function writeDefaults(
83
87
  }>,
84
88
  projectRoot?: string,
85
89
  ) {
86
- const root = projectRoot ? String(projectRoot) : process.cwd();
87
-
88
- if (scope === 'local') {
89
- const localDir = getLocalDataDir(root);
90
- const localPath = joinPath(localDir, 'config.json');
91
- const existing = await readJsonFile(localPath);
92
- const prevDefaults =
93
- existing && typeof existing.defaults === 'object'
94
- ? (existing.defaults as Record<string, unknown>)
95
- : {};
96
- const next = {
97
- ...existing,
98
- defaults: { ...prevDefaults, ...updates },
99
- };
100
- try {
101
- const { promises: fs } = await import('node:fs');
102
- await fs.mkdir(localDir, { recursive: true }).catch(() => {});
103
- } catch {}
104
- await Bun.write(localPath, JSON.stringify(next, null, 2));
105
- return;
106
- }
107
-
108
- const globalPath = getGlobalConfigPath();
109
- const existing = await readJsonFile(globalPath);
90
+ const filePath = getConfigFilePath(scope, projectRoot);
91
+ const existing = await readJsonFile(filePath);
110
92
  const prevDefaults =
111
93
  existing && typeof existing.defaults === 'object'
112
94
  ? (existing.defaults as Record<string, unknown>)
@@ -115,12 +97,82 @@ export async function writeDefaults(
115
97
  ...existing,
116
98
  defaults: { ...prevDefaults, ...updates },
117
99
  };
118
- const base = getGlobalConfigDir();
119
- try {
120
- const { promises: fs } = await import('node:fs');
121
- await fs.mkdir(base, { recursive: true }).catch(() => {});
122
- } catch {}
123
- await Bun.write(globalPath, JSON.stringify(next, null, 2));
100
+ await writeConfigFile(filePath, next);
101
+ }
102
+
103
+ /**
104
+ * Persist provider settings for a built-in or custom provider entry.
105
+ */
106
+ export async function writeProviderSettings(
107
+ scope: Scope,
108
+ provider: string,
109
+ updates: ProviderSettingsEntry,
110
+ projectRoot?: string,
111
+ ) {
112
+ const filePath = getConfigFilePath(scope, projectRoot);
113
+ const existing = await readJsonFile(filePath);
114
+ const prevProviders =
115
+ existing && typeof existing.providers === 'object'
116
+ ? (existing.providers as Record<string, unknown>)
117
+ : {};
118
+ const previousEntry =
119
+ prevProviders[provider] && typeof prevProviders[provider] === 'object'
120
+ ? (prevProviders[provider] as Record<string, unknown>)
121
+ : {};
122
+ const next = {
123
+ ...existing,
124
+ providers: {
125
+ ...prevProviders,
126
+ [provider]: { ...previousEntry, ...updates },
127
+ },
128
+ };
129
+ await writeConfigFile(filePath, next);
130
+ }
131
+
132
+ /**
133
+ * Remove a provider override or custom provider entry from config.
134
+ */
135
+ export async function removeProviderSettings(
136
+ scope: Scope,
137
+ provider: string,
138
+ projectRoot?: string,
139
+ ) {
140
+ const filePath = getConfigFilePath(scope, projectRoot);
141
+ const existing = await readJsonFile(filePath);
142
+ if (!existing || typeof existing.providers !== 'object') return;
143
+ const providers = { ...(existing.providers as Record<string, unknown>) };
144
+ delete providers[provider];
145
+ const next = { ...existing, providers };
146
+ await writeConfigFile(filePath, next);
147
+ }
148
+
149
+ export async function writeSkillSettings(
150
+ scope: Scope,
151
+ updates: SkillSettings,
152
+ projectRoot?: string,
153
+ ) {
154
+ const filePath = getConfigFilePath(scope, projectRoot);
155
+ const existing = await readJsonFile(filePath);
156
+ const prevSkills =
157
+ existing && typeof existing.skills === 'object'
158
+ ? (existing.skills as Record<string, unknown>)
159
+ : {};
160
+ const prevItems =
161
+ prevSkills.items && typeof prevSkills.items === 'object'
162
+ ? (prevSkills.items as Record<string, unknown>)
163
+ : {};
164
+ const next = {
165
+ ...existing,
166
+ skills: {
167
+ ...prevSkills,
168
+ ...updates,
169
+ items: {
170
+ ...prevItems,
171
+ ...(updates.items ?? {}),
172
+ },
173
+ },
174
+ };
175
+ await writeConfigFile(filePath, next);
124
176
  }
125
177
 
126
178
  export async function readDebugConfig(
@@ -183,6 +235,30 @@ async function readJsonFile(
183
235
  }
184
236
  }
185
237
 
238
+ function getConfigFilePath(scope: Scope, projectRoot?: string): string {
239
+ const root = projectRoot ? String(projectRoot) : process.cwd();
240
+ if (scope === 'local') {
241
+ const localDir = getLocalDataDir(root);
242
+ return joinPath(localDir, 'config.json');
243
+ }
244
+ return getGlobalConfigPath();
245
+ }
246
+
247
+ async function writeConfigFile(
248
+ filePath: string,
249
+ value: Record<string, unknown>,
250
+ ) {
251
+ const base =
252
+ filePath === getGlobalConfigPath()
253
+ ? getGlobalConfigDir()
254
+ : filePath.slice(0, Math.max(0, filePath.lastIndexOf('/')));
255
+ try {
256
+ const { promises: fs } = await import('node:fs');
257
+ await fs.mkdir(base, { recursive: true }).catch(() => {});
258
+ } catch {}
259
+ await Bun.write(filePath, JSON.stringify(value, null, 2));
260
+ }
261
+
186
262
  export async function writeAuth(
187
263
  provider: ProviderId,
188
264
  info: AuthInfo,
@@ -105,7 +105,12 @@ export {
105
105
  // Logging & Debug
106
106
  // =======================
107
107
  export { logger, debug, info, warn, error, time } from './utils/logger.ts';
108
- export { isDebugEnabled, isTraceEnabled } from './utils/debug.ts';
108
+ export {
109
+ isDebugEnabled,
110
+ isTraceEnabled,
111
+ setDebugEnabled,
112
+ setTraceEnabled,
113
+ } from './utils/debug.ts';
109
114
 
110
115
  // =======================
111
116
  // MCP (Model Context Protocol)
@@ -1,12 +1,14 @@
1
1
  import { openai, createOpenAI } from '@ai-sdk/openai';
2
2
  import { anthropic, createAnthropic } from '@ai-sdk/anthropic';
3
3
  import { google, createGoogleGenerativeAI } from '@ai-sdk/google';
4
+ import { createOllama } from 'ai-sdk-ollama';
4
5
  import { createOpenRouter } from '@openrouter/ai-sdk-provider';
5
6
  import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
6
7
  import {
7
8
  catalog,
8
- createSetuModel,
9
+ createOttoRouterModel,
9
10
  createOpenAIOAuthModel,
11
+ normalizeOllamaBaseURL,
10
12
  } from '../../../providers/src/index.ts';
11
13
  import { createCopilotModel } from '../../../providers/src/copilot-client.ts';
12
14
  import type { OAuth } from '../../../types/src/index.ts';
@@ -32,13 +34,15 @@ export type ProviderName =
32
34
  | 'openai'
33
35
  | 'anthropic'
34
36
  | 'google'
37
+ | 'ollama-cloud'
35
38
  | 'openrouter'
36
39
  | 'opencode'
37
40
  | 'copilot'
38
- | 'setu'
41
+ | 'ottorouter'
39
42
  | 'zai'
40
43
  | 'zai-coding'
41
- | 'moonshot';
44
+ | 'moonshot'
45
+ | 'minimax';
42
46
 
43
47
  export type ModelConfig = {
44
48
  apiKey?: string;
@@ -96,6 +100,20 @@ export async function resolveModel(
96
100
  return google(model);
97
101
  }
98
102
 
103
+ if (provider === 'ollama-cloud') {
104
+ const entry = catalog[provider];
105
+ const apiKey = config.apiKey || process.env.OLLAMA_API_KEY || '';
106
+ const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined;
107
+ const baseURL = normalizeOllamaBaseURL(
108
+ config.baseURL || entry?.api || 'https://ollama.com',
109
+ );
110
+ const instance = createOllama({
111
+ baseURL,
112
+ headers,
113
+ });
114
+ return instance(model);
115
+ }
116
+
99
117
  if (provider === 'openrouter') {
100
118
  const apiKey = config.apiKey || process.env.OPENROUTER_API_KEY || '';
101
119
  const openrouter = createOpenRouter({ apiKey });
@@ -163,16 +181,17 @@ export async function resolveModel(
163
181
  );
164
182
  }
165
183
 
166
- if (provider === 'setu') {
167
- const privateKey = config.apiKey || process.env.SETU_PRIVATE_KEY || '';
184
+ if (provider === 'ottorouter') {
185
+ const privateKey =
186
+ config.apiKey || process.env.OTTOROUTER_PRIVATE_KEY || '';
168
187
  if (!privateKey) {
169
188
  throw new Error(
170
- 'Setu provider requires SETU_PRIVATE_KEY (base58 Solana secret).',
189
+ 'OttoRouter provider requires OTTOROUTER_PRIVATE_KEY (base58 Solana secret).',
171
190
  );
172
191
  }
173
- const baseURL = config.baseURL || process.env.SETU_BASE_URL;
174
- const rpcURL = process.env.SETU_SOLANA_RPC_URL;
175
- return createSetuModel(
192
+ const baseURL = config.baseURL || process.env.OTTOROUTER_BASE_URL;
193
+ const rpcURL = process.env.OTTOROUTER_SOLANA_RPC_URL;
194
+ return createOttoRouterModel(
176
195
  model,
177
196
  { privateKey },
178
197
  {
@@ -232,6 +251,15 @@ export async function resolveModel(
232
251
  return instance(model);
233
252
  }
234
253
 
254
+ if (provider === 'minimax') {
255
+ const entry = catalog[provider];
256
+ const apiKey = config.apiKey || process.env.MINIMAX_API_KEY || '';
257
+ const baseURL =
258
+ config.baseURL || entry?.api || 'https://api.minimax.io/anthropic/v1';
259
+ const instance = createAnthropic({ apiKey, baseURL });
260
+ return instance(model);
261
+ }
262
+
235
263
  throw new Error(`Unsupported provider: ${provider}`);
236
264
  }
237
265
 
@@ -60,9 +60,9 @@ export function buildBashTool(projectRoot: string): {
60
60
  name: string;
61
61
  tool: Tool;
62
62
  } {
63
- // biome-ignore lint/suspicious/noExplicitAny: AI SDK tool typings do not model async-iterable execute results yet.
64
63
  const bash = tool({
65
64
  description: DESCRIPTION,
65
+
66
66
  inputSchema: z
67
67
  .object({
68
68
  cmd: z.string().describe('Shell command to run (bash -c <cmd>)'),
@@ -261,6 +261,6 @@ export function buildBashTool(projectRoot: string): {
261
261
 
262
262
  return stream();
263
263
  },
264
- } as any);
264
+ }) as unknown as Tool;
265
265
  return { name: 'bash', tool: bash };
266
266
  }
@@ -9,4 +9,4 @@
9
9
  - Chain commands with `&&` to fail-fast.
10
10
  - For long outputs, redirect to a file and `read` it back.
11
11
  - Batch independent checks (e.g. `git status && git diff`) in parallel tool calls rather than sequential bash chains when you need results separately.
12
- - Never use `bash` with `sed`/`awk` for programmatic file editing — use `edit`, `multiedit`, or `apply_patch` instead.
12
+ - Never use `bash` with `sed`/`awk` for programmatic file editing — use the dedicated file-editing tools instead.
@@ -34,7 +34,7 @@ export function applyStringEdit(
34
34
  ): { content: string; occurrences: number } {
35
35
  if (oldString.length === 0) {
36
36
  throw new Error(
37
- 'oldString must not be empty. Use write to create files or apply_patch for structural insertions.',
37
+ 'oldString must not be empty. Use write to create files or a structural editing tool for larger insertions.',
38
38
  );
39
39
  }
40
40
  if (oldString === newString) {
@@ -1,9 +1,9 @@
1
1
  Replace an exact text block in an existing file.
2
2
 
3
- Use this for targeted edits instead of apply_patch whenever possible.
3
+ Use this for targeted edits instead of structural patch-style editing whenever possible.
4
4
 
5
5
  Rules:
6
6
  - You must read the file first in the current session before editing it.
7
7
  - `oldString` must match the file exactly, including whitespace.
8
8
  - If `oldString` appears multiple times, provide more context or set `replaceAll: true`.
9
- - Use `write` for new files and `apply_patch` for structural multi-file diffs.
9
+ - Use `write` for new files and a structural editing tool for multi-file diffs when that capability exists.
@@ -6,6 +6,6 @@
6
6
  Usage tips:
7
7
  - Use for creating NEW files
8
8
  - Use when replacing >70% of a file's content (almost complete rewrite)
9
- - NEVER use for partial/targeted edits - use edit/multiedit first, or apply_patch for structural changes
9
+ - NEVER use for partial/targeted edits - use the dedicated exact-editing tools first, or a structural editing tool for larger changes when available
10
10
  - Using write for partial edits wastes output tokens and risks hallucinating unchanged parts
11
11
  - Prefer idempotent writes by providing the full intended content when you do use write
@@ -12,7 +12,11 @@ import { updateTodosTool } from './builtin/todos.ts';
12
12
  import { buildWebSearchTool } from './builtin/websearch.ts';
13
13
  import { buildTerminalTool } from './builtin/terminal.ts';
14
14
  import type { TerminalManager } from '../terminals/index.ts';
15
- import { initializeSkills, buildSkillTool } from '../../../skills/index.ts';
15
+ import {
16
+ initializeSkills,
17
+ buildSkillTool,
18
+ setSkillSettings,
19
+ } from '../../../skills/index.ts';
16
20
  import { getMCPManager } from '../mcp/index.ts';
17
21
  import {
18
22
  getMCPToolBriefs,
@@ -104,6 +108,7 @@ type FsHelpers = {
104
108
  const pluginPatterns = ['tools/*/tool.js', 'tools/*/tool.mjs'];
105
109
 
106
110
  let globalTerminalManager: TerminalManager | null = null;
111
+ const staticToolDiscoveryCache = new Map<string, Promise<DiscoveredTool[]>>();
107
112
 
108
113
  export function setTerminalManager(manager: TerminalManager): void {
109
114
  globalTerminalManager = manager;
@@ -113,42 +118,141 @@ export function getTerminalManager(): TerminalManager | null {
113
118
  return globalTerminalManager;
114
119
  }
115
120
 
121
+ function getStaticToolDiscoveryCacheKey(
122
+ projectRoot: string,
123
+ globalConfigDir?: string,
124
+ ): string {
125
+ return `${projectRoot}::${globalConfigDir ?? ''}`;
126
+ }
127
+
128
+ async function discoverStaticProjectTools(
129
+ projectRoot: string,
130
+ globalConfigDir?: string,
131
+ skillSettings?: {
132
+ enabled?: boolean;
133
+ items?: Record<string, { enabled?: boolean }>;
134
+ },
135
+ ): Promise<DiscoveredTool[]> {
136
+ setSkillSettings(skillSettings);
137
+ const cacheKey = getStaticToolDiscoveryCacheKey(projectRoot, globalConfigDir);
138
+ const cached = staticToolDiscoveryCache.get(cacheKey);
139
+ if (cached) return cached;
140
+
141
+ const discoveryPromise = (async () => {
142
+ const tools = new Map<string, Tool>();
143
+ for (const { name, tool } of buildFsTools(projectRoot))
144
+ tools.set(name, tool);
145
+ for (const { name, tool } of buildGitTools(projectRoot))
146
+ tools.set(name, tool);
147
+ // Built-ins
148
+ tools.set('finish', finishTool);
149
+ tools.set('progress_update', progressUpdateTool);
150
+ const bash = buildBashTool(projectRoot);
151
+ tools.set(bash.name, bash.tool);
152
+ // Search
153
+ const rg = buildRipgrepTool(projectRoot);
154
+ tools.set(rg.name, rg.tool);
155
+ const glob = buildGlobTool(projectRoot);
156
+ tools.set(glob.name, glob.tool);
157
+ // Patch/apply
158
+ const ap = buildApplyPatchTool(projectRoot);
159
+ tools.set(ap.name, ap.tool);
160
+ // Todo tracking
161
+ tools.set('update_todos', updateTodosTool);
162
+ // Web search
163
+ const ws = buildWebSearchTool();
164
+ tools.set(ws.name, ws.tool);
165
+ // Skills
166
+ await initializeSkills(projectRoot);
167
+ const skillTool = buildSkillTool();
168
+ tools.set(skillTool.name, skillTool.tool);
169
+
170
+ async function loadFromBase(base: string | null | undefined) {
171
+ if (!base) return;
172
+ try {
173
+ await fs.readdir(base);
174
+ } catch {
175
+ return;
176
+ }
177
+ for (const pattern of pluginPatterns) {
178
+ const files = await fg(pattern, { cwd: base, absolute: false });
179
+ for (const rel of files) {
180
+ const match = rel.match(/^tools\/([^/]+)\/tool\.(m?js)$/);
181
+ if (!match || !match[1]) continue;
182
+ const folder = match[1];
183
+ const absPath = join(base, rel).replace(/\\/g, '/');
184
+ try {
185
+ const plugin = await loadPlugin(absPath, folder, projectRoot);
186
+ if (plugin) tools.set(plugin.name, plugin.tool);
187
+ } catch {}
188
+ }
189
+ }
190
+ // Fallback: manual directory scan
191
+ try {
192
+ const toolsDir = join(base, 'tools');
193
+ const entries = await fs.readdir(toolsDir).catch(() => [] as string[]);
194
+ for (const folder of entries) {
195
+ const js = join(toolsDir, folder, 'tool.js');
196
+ const mjs = join(toolsDir, folder, 'tool.mjs');
197
+ const candidate = await fs
198
+ .stat(js)
199
+ .then(() => js)
200
+ .catch(
201
+ async () =>
202
+ await fs
203
+ .stat(mjs)
204
+ .then(() => mjs)
205
+ .catch(() => null),
206
+ );
207
+ if (!candidate) continue;
208
+ try {
209
+ const plugin = await loadPlugin(
210
+ candidate.replace(/\\/g, '/'),
211
+ folder,
212
+ projectRoot,
213
+ );
214
+ if (plugin) tools.set(plugin.name, plugin.tool);
215
+ } catch {}
216
+ }
217
+ } catch {}
218
+ }
219
+
220
+ await loadFromBase(globalConfigDir);
221
+ await loadFromBase(join(projectRoot, '.otto'));
222
+ return Array.from(tools.entries()).map(([name, tool]) => ({ name, tool }));
223
+ })();
224
+
225
+ staticToolDiscoveryCache.set(cacheKey, discoveryPromise);
226
+ try {
227
+ return await discoveryPromise;
228
+ } catch (error) {
229
+ staticToolDiscoveryCache.delete(cacheKey);
230
+ throw error;
231
+ }
232
+ }
233
+
116
234
  export async function discoverProjectTools(
117
235
  projectRoot: string,
118
236
  globalConfigDir?: string,
237
+ skillSettings?: {
238
+ enabled?: boolean;
239
+ items?: Record<string, { enabled?: boolean }>;
240
+ },
119
241
  ): Promise<DiscoverResult> {
120
- const tools = new Map<string, Tool>();
121
- for (const { name, tool } of buildFsTools(projectRoot)) tools.set(name, tool);
122
- for (const { name, tool } of buildGitTools(projectRoot))
123
- tools.set(name, tool);
124
- // Built-ins
125
- tools.set('finish', finishTool);
126
- tools.set('progress_update', progressUpdateTool);
127
- const bash = buildBashTool(projectRoot);
128
- tools.set(bash.name, bash.tool);
129
- // Search
130
- const rg = buildRipgrepTool(projectRoot);
131
- tools.set(rg.name, rg.tool);
132
- const glob = buildGlobTool(projectRoot);
133
- tools.set(glob.name, glob.tool);
134
- // Patch/apply
135
- const ap = buildApplyPatchTool(projectRoot);
136
- tools.set(ap.name, ap.tool);
137
- // Todo tracking
138
- tools.set('update_todos', updateTodosTool);
139
- // Web search
140
- const ws = buildWebSearchTool();
141
- tools.set(ws.name, ws.tool);
142
- // Terminal (if manager is available)
242
+ setSkillSettings(skillSettings);
243
+ const staticTools = await discoverStaticProjectTools(
244
+ projectRoot,
245
+ globalConfigDir,
246
+ skillSettings,
247
+ );
248
+ const tools = new Map<string, Tool>(
249
+ staticTools.map(({ name, tool }) => [name, tool]),
250
+ );
251
+
143
252
  if (globalTerminalManager) {
144
253
  const term = buildTerminalTool(projectRoot, globalTerminalManager);
145
254
  tools.set(term.name, term.tool);
146
255
  }
147
- // Skills
148
- // Always reinitialize to ensure skills are discovered for the current project
149
- await initializeSkills(projectRoot);
150
- const skillTool = buildSkillTool();
151
- tools.set(skillTool.name, skillTool.tool);
152
256
 
153
257
  const mcpManager = getMCPManager();
154
258
  let mcpToolsRecord: Record<string, Tool> = {};
@@ -162,58 +266,6 @@ export async function discoverProjectTools(
162
266
  }
163
267
  }
164
268
 
165
- async function loadFromBase(base: string | null | undefined) {
166
- if (!base) return;
167
- try {
168
- await fs.readdir(base);
169
- } catch {
170
- return;
171
- }
172
- for (const pattern of pluginPatterns) {
173
- const files = await fg(pattern, { cwd: base, absolute: false });
174
- for (const rel of files) {
175
- const match = rel.match(/^tools\/([^/]+)\/tool\.(m?js)$/);
176
- if (!match || !match[1]) continue;
177
- const folder = match[1];
178
- const absPath = join(base, rel).replace(/\\/g, '/');
179
- try {
180
- const plugin = await loadPlugin(absPath, folder, projectRoot);
181
- if (plugin) tools.set(plugin.name, plugin.tool);
182
- } catch {}
183
- }
184
- }
185
- // Fallback: manual directory scan
186
- try {
187
- const toolsDir = join(base, 'tools');
188
- const entries = await fs.readdir(toolsDir).catch(() => [] as string[]);
189
- for (const folder of entries) {
190
- const js = join(toolsDir, folder, 'tool.js');
191
- const mjs = join(toolsDir, folder, 'tool.mjs');
192
- const candidate = await fs
193
- .stat(js)
194
- .then(() => js)
195
- .catch(
196
- async () =>
197
- await fs
198
- .stat(mjs)
199
- .then(() => mjs)
200
- .catch(() => null),
201
- );
202
- if (!candidate) continue;
203
- try {
204
- const plugin = await loadPlugin(
205
- candidate.replace(/\\/g, '/'),
206
- folder,
207
- projectRoot,
208
- );
209
- if (plugin) tools.set(plugin.name, plugin.tool);
210
- } catch {}
211
- }
212
- } catch {}
213
- }
214
-
215
- await loadFromBase(globalConfigDir);
216
- await loadFromBase(join(projectRoot, '.otto'));
217
269
  return {
218
270
  tools: Array.from(tools.entries()).map(([name, tool]) => ({ name, tool })),
219
271
  mcpToolsRecord,