skyloom 1.14.6 → 1.15.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 (157) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/publish.yml +74 -0
  3. package/CONVERSION_PLAN.md +191 -191
  4. package/README.md +523 -220
  5. package/config/default.yaml +46 -43
  6. package/config/models.yaml +928 -155
  7. package/config/providers.yaml +109 -6
  8. package/dist/agents/snow.d.ts +2 -0
  9. package/dist/agents/snow.d.ts.map +1 -1
  10. package/dist/agents/snow.js +36 -5
  11. package/dist/agents/snow.js.map +1 -1
  12. package/dist/cli/loom_chat.d.ts.map +1 -1
  13. package/dist/cli/loom_chat.js +207 -1
  14. package/dist/cli/loom_chat.js.map +1 -1
  15. package/dist/cli/main.js +190 -40
  16. package/dist/cli/main.js.map +1 -1
  17. package/dist/cli/tui.d.ts.map +1 -1
  18. package/dist/cli/tui.js +6 -31
  19. package/dist/cli/tui.js.map +1 -1
  20. package/dist/core/agent.d.ts +6 -4
  21. package/dist/core/agent.d.ts.map +1 -1
  22. package/dist/core/agent.js +61 -20
  23. package/dist/core/agent.js.map +1 -1
  24. package/dist/core/catalog.d.ts.map +1 -1
  25. package/dist/core/catalog.js +30 -9
  26. package/dist/core/catalog.js.map +1 -1
  27. package/dist/core/commands.d.ts +110 -0
  28. package/dist/core/commands.d.ts.map +1 -0
  29. package/dist/core/commands.js +633 -0
  30. package/dist/core/commands.js.map +1 -0
  31. package/dist/core/concurrency.d.ts +38 -0
  32. package/dist/core/concurrency.d.ts.map +1 -0
  33. package/dist/core/concurrency.js +65 -0
  34. package/dist/core/concurrency.js.map +1 -0
  35. package/dist/core/factory.js +16 -16
  36. package/dist/core/file_checkpoint.d.ts +9 -0
  37. package/dist/core/file_checkpoint.d.ts.map +1 -1
  38. package/dist/core/file_checkpoint.js +33 -1
  39. package/dist/core/file_checkpoint.js.map +1 -1
  40. package/dist/core/llm.d.ts.map +1 -1
  41. package/dist/core/llm.js +66 -13
  42. package/dist/core/llm.js.map +1 -1
  43. package/dist/core/memory.js +51 -51
  44. package/dist/core/schemas.d.ts +16 -0
  45. package/dist/core/schemas.d.ts.map +1 -1
  46. package/dist/core/schemas.js +32 -0
  47. package/dist/core/schemas.js.map +1 -1
  48. package/dist/core/security.d.ts.map +1 -1
  49. package/dist/core/security.js +27 -0
  50. package/dist/core/security.js.map +1 -1
  51. package/dist/core/skymd.js +14 -14
  52. package/dist/core/trace.d.ts +105 -0
  53. package/dist/core/trace.d.ts.map +1 -0
  54. package/dist/core/trace.js +213 -0
  55. package/dist/core/trace.js.map +1 -0
  56. package/dist/tools/builtin.d.ts +2 -6
  57. package/dist/tools/builtin.d.ts.map +1 -1
  58. package/dist/tools/builtin.js +180 -125
  59. package/dist/tools/builtin.js.map +1 -1
  60. package/dist/tools/extra.d.ts +13 -0
  61. package/dist/tools/extra.d.ts.map +1 -0
  62. package/dist/tools/extra.js +827 -0
  63. package/dist/tools/extra.js.map +1 -0
  64. package/dist/tools/guards.d.ts +12 -0
  65. package/dist/tools/guards.d.ts.map +1 -0
  66. package/dist/tools/guards.js +143 -0
  67. package/dist/tools/guards.js.map +1 -0
  68. package/dist/tools/model_tool.d.ts.map +1 -1
  69. package/dist/tools/model_tool.js +24 -4
  70. package/dist/tools/model_tool.js.map +1 -1
  71. package/dist/web/markdown.d.ts +32 -0
  72. package/dist/web/markdown.d.ts.map +1 -0
  73. package/dist/web/markdown.js +202 -0
  74. package/dist/web/markdown.js.map +1 -0
  75. package/dist/web/server.d.ts +4 -0
  76. package/dist/web/server.d.ts.map +1 -1
  77. package/dist/web/server.js +14 -582
  78. package/dist/web/server.js.map +1 -1
  79. package/dist/web/ui.d.ts +31 -0
  80. package/dist/web/ui.d.ts.map +1 -0
  81. package/dist/web/ui.js +1009 -0
  82. package/dist/web/ui.js.map +1 -0
  83. package/docs/AESTHETIC_DESIGN.md +152 -152
  84. package/docs/OPTIMIZATION_PLAN.md +178 -178
  85. package/package.json +68 -68
  86. package/src/agents/snow.ts +38 -5
  87. package/src/cli/commands_md.ts +112 -112
  88. package/src/cli/input_macros.ts +83 -83
  89. package/src/cli/loom.ts +1041 -1041
  90. package/src/cli/loom_chat.ts +772 -603
  91. package/src/cli/main.ts +853 -723
  92. package/src/cli/tui.ts +264 -289
  93. package/src/core/agent/guard.ts +133 -133
  94. package/src/core/agent/task.ts +100 -100
  95. package/src/core/agent.ts +1630 -1590
  96. package/src/core/agent_helpers.ts +500 -500
  97. package/src/core/bus.ts +221 -221
  98. package/src/core/cache.ts +153 -153
  99. package/src/core/catalog.ts +199 -178
  100. package/src/core/circuit_breaker.ts +119 -119
  101. package/src/core/commands.ts +704 -0
  102. package/src/core/concurrency.ts +73 -0
  103. package/src/core/config.ts +365 -365
  104. package/src/core/constants.ts +95 -95
  105. package/src/core/factory.ts +656 -656
  106. package/src/core/file_checkpoint.ts +163 -136
  107. package/src/core/hooks.ts +126 -126
  108. package/src/core/llm.ts +972 -915
  109. package/src/core/logger.ts +143 -143
  110. package/src/core/mcp.ts +1001 -1001
  111. package/src/core/memory.ts +1201 -1201
  112. package/src/core/middleware.ts +350 -350
  113. package/src/core/model_config.ts +159 -159
  114. package/src/core/pipelines.ts +424 -424
  115. package/src/core/schemas.ts +319 -282
  116. package/src/core/security.ts +27 -0
  117. package/src/core/semantic.ts +211 -211
  118. package/src/core/skill.ts +384 -384
  119. package/src/core/skymd.ts +143 -143
  120. package/src/core/theme.ts +65 -65
  121. package/src/core/tool.ts +457 -457
  122. package/src/core/trace.ts +236 -0
  123. package/src/core/verify.ts +71 -71
  124. package/src/plugins/loader.ts +91 -91
  125. package/src/skills/loader.ts +75 -75
  126. package/src/tools/builtin.ts +571 -493
  127. package/src/tools/computer.ts +279 -279
  128. package/src/tools/extra.ts +662 -0
  129. package/src/tools/guards.ts +82 -0
  130. package/src/tools/model_tool.ts +93 -74
  131. package/src/tools/todo.ts +76 -76
  132. package/src/web/markdown.ts +193 -0
  133. package/src/web/server.ts +117 -693
  134. package/src/web/ui.ts +949 -0
  135. package/tests/agent.test.ts +211 -159
  136. package/tests/agent_helpers.test.ts +48 -48
  137. package/tests/catalog.test.ts +86 -86
  138. package/tests/checkpoint_commands.test.ts +124 -124
  139. package/tests/claude_compat.test.ts +110 -110
  140. package/tests/commands.test.ts +103 -0
  141. package/tests/concurrency.test.ts +102 -0
  142. package/tests/config.test.ts +41 -41
  143. package/tests/extra_tools.test.ts +212 -0
  144. package/tests/fence_plugin.test.ts +52 -52
  145. package/tests/guard.test.ts +75 -75
  146. package/tests/loom.test.ts +337 -337
  147. package/tests/memory.test.ts +170 -170
  148. package/tests/model_config.test.ts +109 -109
  149. package/tests/skymd.test.ts +146 -146
  150. package/tests/ssrf.test.ts +38 -38
  151. package/tests/structured_retry.test.ts +87 -0
  152. package/tests/task.test.ts +60 -60
  153. package/tests/todo_toolstats.test.ts +94 -94
  154. package/tests/trace.test.ts +128 -0
  155. package/tests/tui.test.ts +67 -67
  156. package/tests/web.test.ts +169 -0
  157. package/tsconfig.json +38 -38
@@ -1,493 +1,571 @@
1
- /**
2
- * Built-in tool registration — registers all default tools.
3
- */
4
-
5
- import * as fs from 'fs';
6
- import * as os from 'os';
7
- import * as path from 'path';
8
- import { lookup } from 'dns/promises';
9
- import type { ToolRegistry } from '../core/tool';
10
- import { getLogger } from '../core/logger';
11
- import { registerComputerTools } from './computer';
12
-
13
- const log = getLogger('builtin-tools');
14
-
15
- /* ── SSRF guard for outbound fetches ──────────────────────────────────────
16
- http_get is auto-approved (DangerLevel.LOW), so without this an agent or
17
- prompt-injected content could pivot to internal services / cloud metadata
18
- (169.254.169.254). We block private, loopback and link-local targets — both
19
- when the URL is an IP literal and after DNS resolution. Operators who need to
20
- reach internal hosts set SKYLOOM_ALLOW_PRIVATE_FETCH=1.
21
- ────────────────────────────────────────────────────────────────────────── */
22
- function isPrivateIPv4(ip: string): boolean {
23
- const p = ip.split('.').map(Number);
24
- if (p.length !== 4 || p.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return false;
25
- const [a, b] = p;
26
- if (a === 0 || a === 127) return true; // this-host / loopback
27
- if (a === 10) return true; // private
28
- if (a === 172 && b >= 16 && b <= 31) return true; // private
29
- if (a === 192 && b === 168) return true; // private
30
- if (a === 169 && b === 254) return true; // link-local + cloud metadata
31
- if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
32
- return false;
33
- }
34
-
35
- function isPrivateIp(ip: string): boolean {
36
- const v = ip.toLowerCase();
37
- if (v === '::1' || v === '::') return true;
38
- if (v.startsWith('::ffff:')) { // IPv4-mapped IPv6
39
- const mapped = v.slice(7);
40
- if (mapped.includes('.')) return isPrivateIPv4(mapped);
41
- }
42
- if (/^f[cd]/.test(v)) return true; // fc00::/7 unique-local
43
- if (/^fe[89ab]/.test(v)) return true; // fe80::/10 link-local
44
- if (v.includes('.') && !v.includes(':')) return isPrivateIPv4(v);
45
- return false;
46
- }
47
-
48
- export { isPrivateIp, assertFetchAllowed }; // exported for tests
49
-
50
- async function assertFetchAllowed(rawUrl: string): Promise<void> {
51
- let u: URL;
52
- try { u = new URL(rawUrl); } catch { throw new Error(`invalid URL: ${rawUrl}`); }
53
- if (u.protocol !== 'http:' && u.protocol !== 'https:') {
54
- throw new Error(`blocked URL scheme '${u.protocol}' — only http/https are allowed`);
55
- }
56
- if (process.env.SKYLOOM_ALLOW_PRIVATE_FETCH === '1') return;
57
- const host = u.hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
58
- if (isPrivateIp(host)) {
59
- throw new Error(`blocked request to private/loopback address ${host} (set SKYLOOM_ALLOW_PRIVATE_FETCH=1 to allow)`);
60
- }
61
- let addrs: Array<{ address: string }> = [];
62
- try { addrs = await lookup(host, { all: true }); } catch { return; /* let fetch surface DNS errors */ }
63
- for (const a of addrs) {
64
- if (isPrivateIp(a.address)) {
65
- throw new Error(`blocked request: ${host} resolves to private address ${a.address} (set SKYLOOM_ALLOW_PRIVATE_FETCH=1 to allow)`);
66
- }
67
- }
68
- }
69
-
70
- /* ── Optional workspace fence for file tools ──────────────────────────────
71
- Off by default (the agent is a Claude-Code-style assistant that legitimately
72
- works across a repo). Set SKYLOOM_WORKSPACE_FENCE=1 to confine read/write/
73
- edit/delete/list/search to a root directory (SKYLOOM_WORKSPACE_ROOT, or the
74
- process cwd), blocking traversal to ~/.ssh, /etc, etc.
75
- ────────────────────────────────────────────────────────────────────────── */
76
- export function fenceRoot(): string | null {
77
- if (process.env.SKYLOOM_WORKSPACE_FENCE !== '1') return null;
78
- const raw = process.env.SKYLOOM_WORKSPACE_ROOT;
79
- return raw ? path.resolve(raw.replace(/^~(?=$|\/|\\)/, os.homedir())) : process.cwd();
80
- }
81
-
82
- /** Returns an error string if `resolvedPath` is outside the fence, else null. */
83
- export function fenceCheck(resolvedPath: string): string | null {
84
- const root = fenceRoot();
85
- if (!root) return null;
86
- const rel = path.relative(root, resolvedPath);
87
- if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) return null;
88
- return `Error: 路径越界 — 工作区围栏已启用 (SKYLOOM_WORKSPACE_FENCE=1),'${resolvedPath}' 在根目录 '${root}' 之外。`;
89
- }
90
-
91
- /**
92
- * Register all built-in tools into the given registry.
93
- */
94
- export function registerBuiltinTools(registry: ToolRegistry): void {
95
- // Register computer tools
96
- registerComputerTools(registry);
97
- // ── File Tools ──
98
-
99
- registry.register({
100
- name: 'read_file',
101
- description: 'Read the contents of a file. Large files are paged: pass offset (1-based start line) and limit (line count) to read further sections; use grep to locate the right offset first.',
102
- parameters: [
103
- { name: 'path', type: 'string', description: 'Absolute or relative path to the file', required: true },
104
- { name: 'offset', type: 'number', description: '1-based line number to start from (default 1)', required: false },
105
- { name: 'limit', type: 'number', description: 'Max lines to return (default 800)', required: false },
106
- ],
107
- handler: async (params) => {
108
- const filePath = path.resolve(params.path as string);
109
- const fenced = fenceCheck(filePath); if (fenced) return fenced;
110
- if (!fs.existsSync(filePath)) return `Error: File not found: ${filePath}`;
111
- try {
112
- const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
113
- const offset = Math.max(1, Number(params.offset) || 1);
114
- const limit = Math.max(1, Math.min(Number(params.limit) || 800, 4000));
115
- const slice = lines.slice(offset - 1, offset - 1 + limit);
116
- const remaining = lines.length - (offset - 1 + slice.length);
117
- const tail = remaining > 0
118
- ? `\n…[还有 ${remaining} 行 — read_file(path, offset=${offset + slice.length}) 继续]`
119
- : '';
120
- const range = offset > 1 || remaining > 0 ? ` lines ${offset}-${offset + slice.length - 1}/${lines.length}` : '';
121
- return `Successfully read ${filePath}${range}:\n${slice.join('\n')}${tail}`;
122
- } catch (e) {
123
- return `Error reading file: ${e}`;
124
- }
125
- },
126
- });
127
-
128
- registry.register({
129
- name: 'write_file',
130
- description: 'Write content to a file at the given path. Creates directories if needed.',
131
- parameters: [
132
- { name: 'path', type: 'string', description: 'Absolute or relative path to write to', required: true },
133
- { name: 'content', type: 'string', description: 'Content to write to the file', required: true },
134
- ],
135
- handler: async (params) => {
136
- const filePath = path.resolve(params.path as string);
137
- const fenced = fenceCheck(filePath); if (fenced) return fenced;
138
- try {
139
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
140
- fs.writeFileSync(filePath, params.content as string, 'utf-8');
141
- return `Successfully wrote ${Buffer.byteLength(params.content as string, 'utf-8')} bytes to ${filePath}`;
142
- } catch (e) {
143
- return `Error writing file: ${e}`;
144
- }
145
- },
146
- });
147
-
148
- registry.register({
149
- name: 'edit_file',
150
- description: 'Edit a file by replacing old_text with new_text. Use this for targeted edits.',
151
- parameters: [
152
- { name: 'path', type: 'string', description: 'Path to the file to edit', required: true },
153
- { name: 'old_text', type: 'string', description: 'Text to search for and replace', required: true },
154
- { name: 'new_text', type: 'string', description: 'Text to replace with', required: true },
155
- ],
156
- handler: async (params) => {
157
- const filePath = path.resolve(params.path as string);
158
- const fenced = fenceCheck(filePath); if (fenced) return fenced;
159
- if (!fs.existsSync(filePath)) return `Error: File not found: ${filePath}`;
160
- try {
161
- let content = fs.readFileSync(filePath, 'utf-8');
162
- const oldText = params.old_text as string;
163
- const newText = params.new_text as string;
164
- if (!content.includes(oldText)) {
165
- return `Error: old_text not found in file. Searched for: ${oldText.slice(0, 50)}...`;
166
- }
167
- content = content.replace(oldText, newText);
168
- fs.writeFileSync(filePath, content, 'utf-8');
169
- return `Successfully edited ${filePath}`;
170
- } catch (e) {
171
- return `Error editing file: ${e}`;
172
- }
173
- },
174
- });
175
-
176
- registry.register({
177
- name: 'delete_file',
178
- description: 'Delete a file at the given path.',
179
- parameters: [
180
- { name: 'path', type: 'string', description: 'Path to the file to delete', required: true },
181
- ],
182
- handler: async (params) => {
183
- const filePath = path.resolve(params.path as string);
184
- const fenced = fenceCheck(filePath); if (fenced) return fenced;
185
- if (!fs.existsSync(filePath)) return `Error: File not found: ${filePath}`;
186
- try {
187
- fs.unlinkSync(filePath);
188
- return `Successfully deleted ${filePath}`;
189
- } catch (e) {
190
- return `Error deleting file: ${e}`;
191
- }
192
- },
193
- });
194
-
195
- registry.register({
196
- name: 'list_directory',
197
- description: 'List files and directories at the given path.',
198
- parameters: [
199
- { name: 'path', type: 'string', description: 'Path to list', required: true },
200
- ],
201
- handler: async (params) => {
202
- const dirPath = path.resolve(params.path as string);
203
- const fenced = fenceCheck(dirPath); if (fenced) return fenced;
204
- if (!fs.existsSync(dirPath)) return `Error: Directory not found: ${dirPath}`;
205
- try {
206
- const entries = fs.readdirSync(dirPath);
207
- return entries.map(e => {
208
- const stat = fs.statSync(path.join(dirPath, e));
209
- return `${stat.isDirectory() ? '[DIR]' : '[FILE]'} ${e}`;
210
- }).join('\n');
211
- } catch (e) {
212
- return `Error listing directory: ${e}`;
213
- }
214
- },
215
- });
216
-
217
- registry.register({
218
- name: 'file_search',
219
- description: 'Search for files matching a glob pattern.',
220
- parameters: [
221
- { name: 'pattern', type: 'string', description: 'Glob pattern to match (e.g. "**/*.ts")', required: true },
222
- { name: 'directory', type: 'string', description: 'Directory to search in (default: cwd)', required: false },
223
- ],
224
- handler: async (params) => {
225
- const dir = params.directory ? path.resolve(params.directory as string) : process.cwd();
226
- const fenced = fenceCheck(dir); if (fenced) return fenced;
227
- const pattern = params.pattern as string;
228
- try {
229
- const { globSync } = require('glob');
230
- const results = globSync(pattern, { cwd: dir, nodir: true });
231
- if (results.length === 0) return 'No files found matching the pattern.';
232
- return results.slice(0, 200).join('\n') + (results.length > 200 ? `\n... and ${results.length - 200} more` : '');
233
- } catch (e) {
234
- return `Error searching files: ${e}`;
235
- }
236
- },
237
- });
238
-
239
- // ── Shell Tool ──
240
-
241
- registry.register({
242
- name: 'run_bash',
243
- description: 'Execute a shell command and return its output.',
244
- parameters: [
245
- { name: 'command', type: 'string', description: 'Command to execute', required: true },
246
- { name: 'timeout', type: 'number', description: 'Timeout in milliseconds (default: 30000)', required: false },
247
- ],
248
- handler: async (params) => {
249
- const cmd = params.command as string;
250
- const timeout = (params.timeout as number) || 30000;
251
- try {
252
- const { runInSandbox, formatSandboxResult } = require('../core/sandbox');
253
- const result = runInSandbox(cmd, { timeoutMs: timeout });
254
- return formatSandboxResult(result);
255
- } catch (e: any) { return `Error: ${e.message || e}`; }
256
- },
257
- dangerous: true,
258
- });
259
-
260
- // ── HTTP Tools ──
261
-
262
- registry.register({
263
- name: 'http_get',
264
- description: 'Make an HTTP GET request to a URL.',
265
- parameters: [
266
- { name: 'url', type: 'string', description: 'URL to fetch', required: true },
267
- ],
268
- handler: async (params) => {
269
- try {
270
- await assertFetchAllowed(params.url as string);
271
- const response = await fetch(params.url as string);
272
- const text = await response.text();
273
- return `Status: ${response.status}\n\n${text.slice(0, 10000)}${text.length > 10000 ? '\n...[truncated]' : ''}`;
274
- } catch (e) {
275
- return `Error fetching URL: ${e instanceof Error ? e.message : e}`;
276
- }
277
- },
278
- });
279
-
280
- // ── Task Management ──
281
-
282
- registry.register({
283
- name: 'task_done',
284
- description: 'Signal that the current task is complete and provide a summary. Call this when you have finished the work.',
285
- parameters: [
286
- { name: 'summary', type: 'string', description: 'Summary of what was accomplished', required: false },
287
- ],
288
- handler: async (_params) => {
289
- return '__TASK_DONE__';
290
- },
291
- cacheable: false,
292
- });
293
-
294
- // ── Search Tool ──
295
-
296
- registry.register({
297
- name: 'web_search',
298
- description: 'Search the web for information. Returns search results with titles and snippets.',
299
- parameters: [
300
- { name: 'query', type: 'string', description: 'Search query', required: true },
301
- ],
302
- handler: async (params) => {
303
- // Simplified web search using a basic approach
304
- try {
305
- const query = encodeURIComponent(params.query as string);
306
- const url = `https://api.duckduckgo.com/?q=${query}&format=json`;
307
- const response = await fetch(url);
308
- const data = await response.json() as Record<string, any>;
309
- const results: string[] = [];
310
- if (data.AbstractText) results.push(`Abstract: ${data.AbstractText}`);
311
- if (data.RelatedTopics) {
312
- for (const topic of data.RelatedTopics.slice(0, 10)) {
313
- if (topic.Text) results.push(`- ${topic.Text}`);
314
- else if (topic.Topics) {
315
- for (const sub of topic.Topics.slice(0, 5)) {
316
- if (sub.Text) results.push(`- ${sub.Text}`);
317
- }
318
- }
319
- }
320
- }
321
- return results.length > 0 ? results.join('\n') : 'No search results found.';
322
- } catch (e) {
323
- return `Search error: ${e}`;
324
- }
325
- },
326
- });
327
-
328
- // ── Memory Tools ──
329
-
330
- registry.register({
331
- name: 'remember_fact',
332
- description: 'Store a fact about the user or project in long-term memory.',
333
- parameters: [
334
- { name: 'key', type: 'string', description: 'Fact key (snake_case)', required: true },
335
- { name: 'value', type: 'string', description: 'Fact value', required: true },
336
- { name: 'category', type: 'string', description: 'Category (e.g. user_pref, project_info)', required: false },
337
- ],
338
- handler: async (_params) => {
339
- return 'Fact stored. (Memory integration at agent level.)';
340
- },
341
- });
342
-
343
- registry.register({
344
- name: 'recall_facts',
345
- description: 'Recall stored facts from long-term memory.',
346
- parameters: [
347
- { name: 'query', type: 'string', description: 'Search query to match against stored facts', required: true },
348
- ],
349
- handler: async (_params) => {
350
- return 'Recall handled at agent level.';
351
- },
352
- });
353
-
354
- // ── Git Tools ──
355
-
356
- registry.register({
357
- name: 'git_status',
358
- description: 'Show the working tree status.',
359
- parameters: [],
360
- handler: async () => {
361
- const { execSync } = require('child_process');
362
- try {
363
- return execSync('git status', { encoding: 'utf-8' });
364
- } catch (e: any) {
365
- return `Error: ${e.message || e}`;
366
- }
367
- },
368
- });
369
-
370
- registry.register({
371
- name: 'git_diff',
372
- description: 'Show changes between commits, commit and working tree, etc.',
373
- parameters: [
374
- { name: 'staged', type: 'boolean', description: 'Show staged changes only', required: false },
375
- ],
376
- handler: async (params) => {
377
- const { execSync } = require('child_process');
378
- try {
379
- const args = params.staged ? '--staged' : '';
380
- return execSync(`git diff ${args}`, { encoding: 'utf-8' });
381
- } catch (e: any) {
382
- return `Error: ${e.message || e}`;
383
- }
384
- },
385
- });
386
-
387
- registry.register({
388
- name: 'git_log',
389
- description: 'Show commit logs.',
390
- parameters: [
391
- { name: 'max_count', type: 'number', description: 'Number of commits to show (default: 10)', required: false },
392
- ],
393
- handler: async (params) => {
394
- const { execFileSync } = require('child_process');
395
- try {
396
- const n = Math.max(1, Math.min(1000, Math.floor(Number(params.max_count) || 10)));
397
- return execFileSync('git', ['log', '--oneline', `-${n}`], { encoding: 'utf-8' });
398
- } catch (e: any) {
399
- return `Error: ${e.message || e}`;
400
- }
401
- },
402
- });
403
-
404
- registry.register({
405
- name: 'git_commit',
406
- description: 'Create a new commit with the given message.',
407
- parameters: [
408
- { name: 'message', type: 'string', description: 'Commit message', required: true },
409
- ],
410
- handler: async (params) => {
411
- const { execFileSync } = require('child_process');
412
- try {
413
- const msg = String(params.message ?? '');
414
- // execFileSync passes the message as a single argv entry no shell, so
415
- // backticks / $() / ; in the message cannot be interpreted.
416
- execFileSync('git', ['commit', '-m', msg], { encoding: 'utf-8' });
417
- return 'Commit created successfully.';
418
- } catch (e: any) {
419
- return `Error: ${e.message || e}`;
420
- }
421
- },
422
- dangerous: true,
423
- });
424
-
425
- // ── Utility Tools ──
426
-
427
- registry.register({
428
- name: 'grep',
429
- description: 'Search for a pattern in files using ripgrep or grep.',
430
- parameters: [
431
- { name: 'pattern', type: 'string', description: 'Regex pattern to search for', required: true },
432
- { name: 'path', type: 'string', description: 'Directory to search in', required: false },
433
- ],
434
- handler: async (params) => {
435
- const { execFileSync } = require('child_process');
436
- const searchDir = params.path ? path.resolve(params.path as string) : process.cwd();
437
- const fenced = fenceCheck(searchDir); if (fenced) return fenced;
438
- const pat = String(params.pattern || '');
439
- // No shell: pattern and directory are passed as argv entries, and `--`
440
- // stops a leading `-` in the pattern from being read as a flag. This is a
441
- // non-dangerous (auto-approved) tool, so shell injection here would have
442
- // bypassed the tool-approval gate entirely.
443
- const variants: [string, string[]][] = [
444
- ['rg', ['-n', '--', pat, searchDir]],
445
- ['grep', ['-rn', '--', pat, searchDir]],
446
- ];
447
- for (const [bin, args] of variants) {
448
- try {
449
- const out = execFileSync(bin, args, { encoding: 'utf-8', maxBuffer: 1024 * 1024 });
450
- return out || 'No matches found.';
451
- } catch (e: any) {
452
- // exit status 1 = ran successfully, zero matches; anything else
453
- // (e.g. binary not installed) falls through to the next variant.
454
- if (e?.status === 1) return 'No matches found.';
455
- }
456
- }
457
- return 'No matches found.';
458
- },
459
- });
460
-
461
- registry.register({
462
- name: 'tree',
463
- description: 'Display directory tree structure.',
464
- parameters: [
465
- { name: 'directory', type: 'string', description: 'Directory to show tree for', required: false },
466
- { name: 'depth', type: 'number', description: 'Maximum depth (default: 3)', required: false },
467
- ],
468
- handler: async (params) => {
469
- const { execFileSync } = require('child_process');
470
- const treeDir = params.directory ? path.resolve(params.directory as string) : process.cwd();
471
- const fenced = fenceCheck(treeDir); if (fenced) return fenced;
472
- const depth = Math.max(1, Math.min(20, Math.floor(Number(params.depth) || 3)));
473
- try {
474
- // No shell: directory passed as an argv entry, depth clamped to an int.
475
- const out = execFileSync('tree', [treeDir, '-L', String(depth), '--charset=utf-8'], { encoding: 'utf-8' });
476
- return out;
477
- } catch {
478
- return 'Directory tree unavailable.';
479
- }
480
- },
481
- });
482
-
483
- log.info('builtin_tools_registered', { count: registry.listNames().length });
484
- }
485
-
486
- let _httpClient: any = null;
487
-
488
- export async function closeHttpClient(): Promise<void> {
489
- if (_httpClient) {
490
- try { await _httpClient.close?.(); } catch { /* ignore */ }
491
- _httpClient = null;
492
- }
493
- }
1
+ /**
2
+ * Built-in tool registration — registers all default tools.
3
+ */
4
+
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import type { ToolRegistry } from '../core/tool';
8
+ import { getLogger } from '../core/logger';
9
+ import { registerComputerTools } from './computer';
10
+ import { registerExtraTools } from './extra';
11
+ import { isPrivateIp, assertFetchAllowed, fenceRoot, fenceCheck } from './guards';
12
+
13
+ // Re-exported so existing importers/tests keep resolving these from builtin.
14
+ export { isPrivateIp, assertFetchAllowed, fenceRoot, fenceCheck };
15
+
16
+ const log = getLogger('builtin-tools');
17
+
18
+
19
+ /* ── Web search helpers ───────────────────────────────────────────────────
20
+ Multi-engine fallback. DuckDuckGo's Instant Answer JSON API only returns
21
+ "abstracts" and is blank for ~90% of real queries; HTML scraping is what
22
+ actually works. In CN networks, DDG/Bing may be unreachable — Baidu/Sogou
23
+ serve as fallbacks. Each parser is intentionally tolerant: HTML changes
24
+ over time, so we extract loosely and let the engine list provide redundancy.
25
+ ────────────────────────────────────────────────────────────────────────── */
26
+ interface SearchResult { title: string; url: string; snippet: string }
27
+
28
+ const SEARCH_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
29
+
30
+ async function fetchHtml(url: string, timeoutMs = 12000): Promise<string> {
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
33
+ try {
34
+ const res = await fetch(url, {
35
+ headers: {
36
+ 'User-Agent': SEARCH_UA,
37
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
38
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
39
+ },
40
+ signal: controller.signal,
41
+ });
42
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
43
+ return await res.text();
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
48
+
49
+ function decodeHtmlEntities(s: string): string {
50
+ return s
51
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
52
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, ' ')
53
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
54
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16)));
55
+ }
56
+
57
+ function stripTags(s: string): string {
58
+ return decodeHtmlEntities(s.replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
59
+ }
60
+
61
+ function unwrapDdgRedirect(href: string): string {
62
+ // DuckDuckGo HTML wraps results in /l/?uddg=<encoded-url>
63
+ const m = href.match(/[?&]uddg=([^&]+)/);
64
+ if (m) { try { return decodeURIComponent(m[1]); } catch { /* fall through */ } }
65
+ if (href.startsWith('//')) return 'https:' + href;
66
+ return href;
67
+ }
68
+
69
+ function unwrapBaiduRedirect(href: string): string {
70
+ // Baidu uses opaque /link?url=... redirects; we can't resolve without another request.
71
+ // Return as-is; consumer can still click through.
72
+ return href;
73
+ }
74
+
75
+ async function searchDuckDuckGo(query: string, max: number): Promise<SearchResult[]> {
76
+ const html = await fetchHtml(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`);
77
+ const results: SearchResult[] = [];
78
+ const re = /<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<a[^>]+class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
79
+ let m: RegExpExecArray | null;
80
+ while ((m = re.exec(html)) && results.length < max) {
81
+ results.push({ url: unwrapDdgRedirect(m[1]), title: stripTags(m[2]), snippet: stripTags(m[3]) });
82
+ }
83
+ return results;
84
+ }
85
+
86
+ async function searchBing(query: string, max: number): Promise<SearchResult[]> {
87
+ const html = await fetchHtml(`https://www.bing.com/search?q=${encodeURIComponent(query)}&setlang=zh-cn`);
88
+ const results: SearchResult[] = [];
89
+ const liRe = /<li class="b_algo"[\s\S]*?<\/li>/gi;
90
+ const items = html.match(liRe) || [];
91
+ for (const item of items) {
92
+ if (results.length >= max) break;
93
+ const a = item.match(/<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
94
+ if (!a) continue;
95
+ const snipMatch =
96
+ item.match(/<p class="b_lineclamp[^"]*"[^>]*>([\s\S]*?)<\/p>/i) ||
97
+ item.match(/<div class="b_caption"[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>/i) ||
98
+ item.match(/<p[^>]*>([\s\S]*?)<\/p>/i);
99
+ results.push({ url: a[1], title: stripTags(a[2]), snippet: snipMatch ? stripTags(snipMatch[1]) : '' });
100
+ }
101
+ return results;
102
+ }
103
+
104
+ async function searchBaidu(query: string, max: number): Promise<SearchResult[]> {
105
+ const html = await fetchHtml(`https://www.baidu.com/s?wd=${encodeURIComponent(query)}`);
106
+ const results: SearchResult[] = [];
107
+ // Baidu nests divs aggressively; anchor on <h3> ... <a href>...</a> and look
108
+ // for the nearest abstract block following.
109
+ const re = /<h3[^>]*>[\s\S]{0,500}?<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
110
+ let m: RegExpExecArray | null;
111
+ while ((m = re.exec(html)) && results.length < max) {
112
+ const url = unwrapBaiduRedirect(m[1]);
113
+ const title = stripTags(m[2]);
114
+ if (!title || !/^https?:\/\//.test(url)) continue;
115
+ const after = html.slice(re.lastIndex, re.lastIndex + 4000);
116
+ const snipMatch =
117
+ after.match(/<span class="content-right[^"]*"[^>]*>([\s\S]*?)<\/span>/i) ||
118
+ after.match(/<div class="c-abstract[^"]*"[^>]*>([\s\S]*?)<\/div>/i) ||
119
+ after.match(/<span[^>]*content[^"]*"[^>]*>([\s\S]{20,400}?)<\/span>/i) ||
120
+ after.match(/<p[^>]*>([\s\S]{20,400}?)<\/p>/i);
121
+ results.push({ url, title, snippet: snipMatch ? stripTags(snipMatch[1]) : '' });
122
+ }
123
+ return results;
124
+ }
125
+
126
+ async function searchSogou(query: string, max: number): Promise<SearchResult[]> {
127
+ const html = await fetchHtml(`https://www.sogou.com/web?query=${encodeURIComponent(query)}`);
128
+ const results: SearchResult[] = [];
129
+ const divRe = /<div[^>]+class="vrwrap"[\s\S]*?(?=<div[^>]+class="vrwrap"|$)/gi;
130
+ const items = html.match(divRe) || [];
131
+ for (const item of items) {
132
+ if (results.length >= max) break;
133
+ const a = item.match(/<h3[^>]*>[\s\S]*?<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
134
+ if (!a) continue;
135
+ let url = a[1];
136
+ if (url.startsWith('/link?')) url = 'https://www.sogou.com' + url;
137
+ const snipMatch =
138
+ item.match(/<div[^>]+class="(?:str_info|fz-mid|space-txt)[^"]*"[^>]*>([\s\S]*?)<\/div>/i) ||
139
+ item.match(/<p[^>]*>([\s\S]{20,400}?)<\/p>/i);
140
+ results.push({ url, title: stripTags(a[2]), snippet: snipMatch ? stripTags(snipMatch[1]) : '' });
141
+ }
142
+ return results;
143
+ }
144
+
145
+ async function runSearchEngine(engine: string, query: string, max: number): Promise<SearchResult[]> {
146
+ let results: SearchResult[];
147
+ switch (engine) {
148
+ case 'duckduckgo': case 'ddg': results = await searchDuckDuckGo(query, max); break;
149
+ case 'bing': results = await searchBing(query, max); break;
150
+ case 'baidu': results = await searchBaidu(query, max); break;
151
+ case 'sogou': results = await searchSogou(query, max); break;
152
+ default: throw new Error(`unknown search engine: ${engine}`);
153
+ }
154
+ // Drop placeholder/JS-anchor entries from inline answer cards.
155
+ return results.filter((r) => r.title && /^https?:\/\//i.test(r.url));
156
+ }
157
+
158
+ /**
159
+ * Register all built-in tools into the given registry.
160
+ */
161
+ export function registerBuiltinTools(registry: ToolRegistry): void {
162
+ // Register computer-operation + extended-capability tools
163
+ registerComputerTools(registry);
164
+ registerExtraTools(registry);
165
+ // ── File Tools ──
166
+
167
+ registry.register({
168
+ name: 'read_file',
169
+ description: 'Read the contents of a file. Large files are paged: pass offset (1-based start line) and limit (line count) to read further sections; use grep to locate the right offset first.',
170
+ parameters: [
171
+ { name: 'path', type: 'string', description: 'Absolute or relative path to the file', required: true },
172
+ { name: 'offset', type: 'number', description: '1-based line number to start from (default 1)', required: false },
173
+ { name: 'limit', type: 'number', description: 'Max lines to return (default 800)', required: false },
174
+ ],
175
+ handler: async (params) => {
176
+ const filePath = path.resolve(params.path as string);
177
+ const fenced = fenceCheck(filePath); if (fenced) return fenced;
178
+ if (!fs.existsSync(filePath)) return `Error: File not found: ${filePath}`;
179
+ try {
180
+ const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
181
+ const offset = Math.max(1, Number(params.offset) || 1);
182
+ const limit = Math.max(1, Math.min(Number(params.limit) || 800, 4000));
183
+ const slice = lines.slice(offset - 1, offset - 1 + limit);
184
+ const remaining = lines.length - (offset - 1 + slice.length);
185
+ const tail = remaining > 0
186
+ ? `\n…[还有 ${remaining} 行 — read_file(path, offset=${offset + slice.length}) 继续]`
187
+ : '';
188
+ const range = offset > 1 || remaining > 0 ? ` lines ${offset}-${offset + slice.length - 1}/${lines.length}` : '';
189
+ return `Successfully read ${filePath}${range}:\n${slice.join('\n')}${tail}`;
190
+ } catch (e) {
191
+ return `Error reading file: ${e}`;
192
+ }
193
+ },
194
+ });
195
+
196
+ registry.register({
197
+ name: 'write_file',
198
+ description: 'Write content to a file at the given path. Creates directories if needed.',
199
+ parameters: [
200
+ { name: 'path', type: 'string', description: 'Absolute or relative path to write to', required: true },
201
+ { name: 'content', type: 'string', description: 'Content to write to the file', required: true },
202
+ ],
203
+ handler: async (params) => {
204
+ const filePath = path.resolve(params.path as string);
205
+ const fenced = fenceCheck(filePath); if (fenced) return fenced;
206
+ try {
207
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
208
+ fs.writeFileSync(filePath, params.content as string, 'utf-8');
209
+ return `Successfully wrote ${Buffer.byteLength(params.content as string, 'utf-8')} bytes to ${filePath}`;
210
+ } catch (e) {
211
+ return `Error writing file: ${e}`;
212
+ }
213
+ },
214
+ });
215
+
216
+ registry.register({
217
+ name: 'edit_file',
218
+ description: 'Edit a file by replacing old_text with new_text. Use this for targeted edits.',
219
+ parameters: [
220
+ { name: 'path', type: 'string', description: 'Path to the file to edit', required: true },
221
+ { name: 'old_text', type: 'string', description: 'Text to search for and replace', required: true },
222
+ { name: 'new_text', type: 'string', description: 'Text to replace with', required: true },
223
+ ],
224
+ handler: async (params) => {
225
+ const filePath = path.resolve(params.path as string);
226
+ const fenced = fenceCheck(filePath); if (fenced) return fenced;
227
+ if (!fs.existsSync(filePath)) return `Error: File not found: ${filePath}`;
228
+ try {
229
+ let content = fs.readFileSync(filePath, 'utf-8');
230
+ const oldText = params.old_text as string;
231
+ const newText = params.new_text as string;
232
+ if (!content.includes(oldText)) {
233
+ return `Error: old_text not found in file. Searched for: ${oldText.slice(0, 50)}...`;
234
+ }
235
+ content = content.replace(oldText, newText);
236
+ fs.writeFileSync(filePath, content, 'utf-8');
237
+ return `Successfully edited ${filePath}`;
238
+ } catch (e) {
239
+ return `Error editing file: ${e}`;
240
+ }
241
+ },
242
+ });
243
+
244
+ registry.register({
245
+ name: 'delete_file',
246
+ description: 'Delete a file at the given path.',
247
+ parameters: [
248
+ { name: 'path', type: 'string', description: 'Path to the file to delete', required: true },
249
+ ],
250
+ handler: async (params) => {
251
+ const filePath = path.resolve(params.path as string);
252
+ const fenced = fenceCheck(filePath); if (fenced) return fenced;
253
+ if (!fs.existsSync(filePath)) return `Error: File not found: ${filePath}`;
254
+ try {
255
+ fs.unlinkSync(filePath);
256
+ return `Successfully deleted ${filePath}`;
257
+ } catch (e) {
258
+ return `Error deleting file: ${e}`;
259
+ }
260
+ },
261
+ });
262
+
263
+ registry.register({
264
+ name: 'list_directory',
265
+ description: 'List files and directories at the given path.',
266
+ parameters: [
267
+ { name: 'path', type: 'string', description: 'Path to list', required: true },
268
+ ],
269
+ handler: async (params) => {
270
+ const dirPath = path.resolve(params.path as string);
271
+ const fenced = fenceCheck(dirPath); if (fenced) return fenced;
272
+ if (!fs.existsSync(dirPath)) return `Error: Directory not found: ${dirPath}`;
273
+ try {
274
+ const entries = fs.readdirSync(dirPath);
275
+ return entries.map(e => {
276
+ const stat = fs.statSync(path.join(dirPath, e));
277
+ return `${stat.isDirectory() ? '[DIR]' : '[FILE]'} ${e}`;
278
+ }).join('\n');
279
+ } catch (e) {
280
+ return `Error listing directory: ${e}`;
281
+ }
282
+ },
283
+ });
284
+
285
+ registry.register({
286
+ name: 'file_search',
287
+ description: 'Search for files matching a glob pattern.',
288
+ parameters: [
289
+ { name: 'pattern', type: 'string', description: 'Glob pattern to match (e.g. "**/*.ts")', required: true },
290
+ { name: 'directory', type: 'string', description: 'Directory to search in (default: cwd)', required: false },
291
+ ],
292
+ handler: async (params) => {
293
+ const dir = params.directory ? path.resolve(params.directory as string) : process.cwd();
294
+ const fenced = fenceCheck(dir); if (fenced) return fenced;
295
+ const pattern = params.pattern as string;
296
+ try {
297
+ const { globSync } = require('glob');
298
+ const results = globSync(pattern, { cwd: dir, nodir: true });
299
+ if (results.length === 0) return 'No files found matching the pattern.';
300
+ return results.slice(0, 200).join('\n') + (results.length > 200 ? `\n... and ${results.length - 200} more` : '');
301
+ } catch (e) {
302
+ return `Error searching files: ${e}`;
303
+ }
304
+ },
305
+ });
306
+
307
+ // ── Shell Tool ──
308
+
309
+ registry.register({
310
+ name: 'run_bash',
311
+ description: 'Execute a shell command and return its output.',
312
+ parameters: [
313
+ { name: 'command', type: 'string', description: 'Command to execute', required: true },
314
+ { name: 'timeout', type: 'number', description: 'Timeout in milliseconds (default: 30000)', required: false },
315
+ ],
316
+ handler: async (params) => {
317
+ const cmd = params.command as string;
318
+ const timeout = (params.timeout as number) || 30000;
319
+ try {
320
+ const { runInSandbox, formatSandboxResult } = require('../core/sandbox');
321
+ const result = runInSandbox(cmd, { timeoutMs: timeout });
322
+ return formatSandboxResult(result);
323
+ } catch (e: any) { return `Error: ${e.message || e}`; }
324
+ },
325
+ dangerous: true,
326
+ });
327
+
328
+ // ── HTTP Tools ──
329
+
330
+ registry.register({
331
+ name: 'http_get',
332
+ description: 'Make an HTTP GET request to a URL.',
333
+ parameters: [
334
+ { name: 'url', type: 'string', description: 'URL to fetch', required: true },
335
+ ],
336
+ handler: async (params) => {
337
+ try {
338
+ await assertFetchAllowed(params.url as string);
339
+ const response = await fetch(params.url as string);
340
+ const text = await response.text();
341
+ return `Status: ${response.status}\n\n${text.slice(0, 10000)}${text.length > 10000 ? '\n...[truncated]' : ''}`;
342
+ } catch (e) {
343
+ return `Error fetching URL: ${e instanceof Error ? e.message : e}`;
344
+ }
345
+ },
346
+ });
347
+
348
+ // ── Task Management ──
349
+
350
+ registry.register({
351
+ name: 'task_done',
352
+ description: 'Signal that the current task is complete and provide a summary. Call this when you have finished the work.',
353
+ parameters: [
354
+ { name: 'summary', type: 'string', description: 'Summary of what was accomplished', required: false },
355
+ ],
356
+ handler: async (_params) => {
357
+ return '__TASK_DONE__';
358
+ },
359
+ cacheable: false,
360
+ });
361
+
362
+ // ── Search Tool ──
363
+
364
+ registry.register({
365
+ name: 'web_search',
366
+ description: 'Search the web for information. Returns search results with titles, URLs and snippets.',
367
+ parameters: [
368
+ { name: 'query', type: 'string', description: 'Search query', required: true },
369
+ { name: 'engine', type: 'string', description: 'Optional engine: duckduckgo|bing|baidu|sogou. Default: auto (tries each until one returns results)', required: false },
370
+ { name: 'max_results', type: 'number', description: 'Max results to return (default 8, capped at 20)', required: false },
371
+ ],
372
+ handler: async (params) => {
373
+ const query = String(params.query || '').trim();
374
+ if (!query) return 'Error: query is required';
375
+ const max = Math.max(1, Math.min(20, Math.floor(Number(params.max_results) || 8)));
376
+ const explicit = String(params.engine || '').trim().toLowerCase();
377
+ const envEngine = String(process.env.SKYLOOM_SEARCH_ENGINE || '').trim().toLowerCase();
378
+ const order = explicit
379
+ ? [explicit]
380
+ : envEngine
381
+ ? [envEngine, 'duckduckgo', 'bing', 'baidu', 'sogou']
382
+ : ['duckduckgo', 'bing', 'baidu', 'sogou'];
383
+ const seen = new Set<string>();
384
+ const tried: string[] = [];
385
+ for (const eng of order) {
386
+ if (seen.has(eng)) continue;
387
+ seen.add(eng);
388
+ tried.push(eng);
389
+ try {
390
+ const results = await runSearchEngine(eng, query, max);
391
+ if (results && results.length > 0) {
392
+ const head = `Search results (${eng}, ${results.length}):`;
393
+ const body = results
394
+ .map((r, i) => `${i + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ''}`)
395
+ .join('\n');
396
+ return `${head}\n${body}`;
397
+ }
398
+ } catch (e: any) {
399
+ log.warn('web_search_engine_failed', { engine: eng, error: String(e?.message || e) });
400
+ }
401
+ }
402
+ return `No search results found (tried: ${tried.join(', ')}). Set SKYLOOM_SEARCH_ENGINE to pin an engine, or try a different query.`;
403
+ },
404
+ });
405
+
406
+ // ── Memory Tools ──
407
+
408
+ registry.register({
409
+ name: 'remember_fact',
410
+ description: 'Store a fact about the user or project in long-term memory.',
411
+ parameters: [
412
+ { name: 'key', type: 'string', description: 'Fact key (snake_case)', required: true },
413
+ { name: 'value', type: 'string', description: 'Fact value', required: true },
414
+ { name: 'category', type: 'string', description: 'Category (e.g. user_pref, project_info)', required: false },
415
+ ],
416
+ handler: async (_params) => {
417
+ return 'Fact stored. (Memory integration at agent level.)';
418
+ },
419
+ });
420
+
421
+ registry.register({
422
+ name: 'recall_facts',
423
+ description: 'Recall stored facts from long-term memory.',
424
+ parameters: [
425
+ { name: 'query', type: 'string', description: 'Search query to match against stored facts', required: true },
426
+ ],
427
+ handler: async (_params) => {
428
+ return 'Recall handled at agent level.';
429
+ },
430
+ });
431
+
432
+ // ── Git Tools ──
433
+
434
+ registry.register({
435
+ name: 'git_status',
436
+ description: 'Show the working tree status.',
437
+ parameters: [],
438
+ handler: async () => {
439
+ const { execSync } = require('child_process');
440
+ try {
441
+ return execSync('git status', { encoding: 'utf-8' });
442
+ } catch (e: any) {
443
+ return `Error: ${e.message || e}`;
444
+ }
445
+ },
446
+ });
447
+
448
+ registry.register({
449
+ name: 'git_diff',
450
+ description: 'Show changes between commits, commit and working tree, etc.',
451
+ parameters: [
452
+ { name: 'staged', type: 'boolean', description: 'Show staged changes only', required: false },
453
+ ],
454
+ handler: async (params) => {
455
+ const { execSync } = require('child_process');
456
+ try {
457
+ const args = params.staged ? '--staged' : '';
458
+ return execSync(`git diff ${args}`, { encoding: 'utf-8' });
459
+ } catch (e: any) {
460
+ return `Error: ${e.message || e}`;
461
+ }
462
+ },
463
+ });
464
+
465
+ registry.register({
466
+ name: 'git_log',
467
+ description: 'Show commit logs.',
468
+ parameters: [
469
+ { name: 'max_count', type: 'number', description: 'Number of commits to show (default: 10)', required: false },
470
+ ],
471
+ handler: async (params) => {
472
+ const { execFileSync } = require('child_process');
473
+ try {
474
+ const n = Math.max(1, Math.min(1000, Math.floor(Number(params.max_count) || 10)));
475
+ return execFileSync('git', ['log', '--oneline', `-${n}`], { encoding: 'utf-8' });
476
+ } catch (e: any) {
477
+ return `Error: ${e.message || e}`;
478
+ }
479
+ },
480
+ });
481
+
482
+ registry.register({
483
+ name: 'git_commit',
484
+ description: 'Create a new commit with the given message.',
485
+ parameters: [
486
+ { name: 'message', type: 'string', description: 'Commit message', required: true },
487
+ ],
488
+ handler: async (params) => {
489
+ const { execFileSync } = require('child_process');
490
+ try {
491
+ const msg = String(params.message ?? '');
492
+ // execFileSync passes the message as a single argv entry — no shell, so
493
+ // backticks / $() / ; in the message cannot be interpreted.
494
+ execFileSync('git', ['commit', '-m', msg], { encoding: 'utf-8' });
495
+ return 'Commit created successfully.';
496
+ } catch (e: any) {
497
+ return `Error: ${e.message || e}`;
498
+ }
499
+ },
500
+ dangerous: true,
501
+ });
502
+
503
+ // ── Utility Tools ──
504
+
505
+ registry.register({
506
+ name: 'grep',
507
+ description: 'Search for a pattern in files using ripgrep or grep.',
508
+ parameters: [
509
+ { name: 'pattern', type: 'string', description: 'Regex pattern to search for', required: true },
510
+ { name: 'path', type: 'string', description: 'Directory to search in', required: false },
511
+ ],
512
+ handler: async (params) => {
513
+ const { execFileSync } = require('child_process');
514
+ const searchDir = params.path ? path.resolve(params.path as string) : process.cwd();
515
+ const fenced = fenceCheck(searchDir); if (fenced) return fenced;
516
+ const pat = String(params.pattern || '');
517
+ // No shell: pattern and directory are passed as argv entries, and `--`
518
+ // stops a leading `-` in the pattern from being read as a flag. This is a
519
+ // non-dangerous (auto-approved) tool, so shell injection here would have
520
+ // bypassed the tool-approval gate entirely.
521
+ const variants: [string, string[]][] = [
522
+ ['rg', ['-n', '--', pat, searchDir]],
523
+ ['grep', ['-rn', '--', pat, searchDir]],
524
+ ];
525
+ for (const [bin, args] of variants) {
526
+ try {
527
+ const out = execFileSync(bin, args, { encoding: 'utf-8', maxBuffer: 1024 * 1024 });
528
+ return out || 'No matches found.';
529
+ } catch (e: any) {
530
+ // exit status 1 = ran successfully, zero matches; anything else
531
+ // (e.g. binary not installed) falls through to the next variant.
532
+ if (e?.status === 1) return 'No matches found.';
533
+ }
534
+ }
535
+ return 'No matches found.';
536
+ },
537
+ });
538
+
539
+ registry.register({
540
+ name: 'tree',
541
+ description: 'Display directory tree structure.',
542
+ parameters: [
543
+ { name: 'directory', type: 'string', description: 'Directory to show tree for', required: false },
544
+ { name: 'depth', type: 'number', description: 'Maximum depth (default: 3)', required: false },
545
+ ],
546
+ handler: async (params) => {
547
+ const { execFileSync } = require('child_process');
548
+ const treeDir = params.directory ? path.resolve(params.directory as string) : process.cwd();
549
+ const fenced = fenceCheck(treeDir); if (fenced) return fenced;
550
+ const depth = Math.max(1, Math.min(20, Math.floor(Number(params.depth) || 3)));
551
+ try {
552
+ // No shell: directory passed as an argv entry, depth clamped to an int.
553
+ const out = execFileSync('tree', [treeDir, '-L', String(depth), '--charset=utf-8'], { encoding: 'utf-8' });
554
+ return out;
555
+ } catch {
556
+ return 'Directory tree unavailable.';
557
+ }
558
+ },
559
+ });
560
+
561
+ log.info('builtin_tools_registered', { count: registry.listNames().length });
562
+ }
563
+
564
+ let _httpClient: any = null;
565
+
566
+ export async function closeHttpClient(): Promise<void> {
567
+ if (_httpClient) {
568
+ try { await _httpClient.close?.(); } catch { /* ignore */ }
569
+ _httpClient = null;
570
+ }
571
+ }