@krmxd/onegpt 2.0.0-beta.1

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.
package/src/tools.js ADDED
@@ -0,0 +1,1119 @@
1
+ "use strict";
2
+
3
+ // Tools registry and permission system - full parity with the Python build:
4
+ // alias resolution, argument repair/anchoring, schema validation with teaching
5
+ // errors, permission prompts only for genuinely dangerous tools, Google-first
6
+ // web search with TLS fallback.
7
+
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const os = require("os");
11
+ const http = require("http");
12
+ const https = require("https");
13
+ const { spawnSync } = require("child_process");
14
+
15
+ class ToolDef {
16
+ constructor(name, description, parameters, dangerous = false) {
17
+ this.name = name;
18
+ this.description = description;
19
+ this.parameters = parameters;
20
+ this.dangerous = dangerous;
21
+ this.run = async () => new ToolResult("", "Not implemented", false);
22
+ }
23
+ }
24
+
25
+ class ToolResult {
26
+ constructor(output = "", error = "", success = true) {
27
+ this.output = output;
28
+ this.error = error;
29
+ this.success = success;
30
+ this.toolCallId = "";
31
+ this.name = "";
32
+ }
33
+
34
+ get content() {
35
+ return this.success ? this.output : `Error: ${this.error}`;
36
+ }
37
+ }
38
+
39
+ const IGNORE = new Set([".git", "node_modules", "__pycache__", ".venv", "venv",
40
+ ".tox", "dist", "build", "target", "vendor"]);
41
+
42
+ function hsz(n) {
43
+ let s = n;
44
+ for (const u of ["B", "KB", "MB", "GB"]) {
45
+ if (s < 1024) return u === "B" ? `${Math.round(s)}${u}` : `${s.toFixed(1)}${u}`;
46
+ s /= 1024;
47
+ }
48
+ return `${s.toFixed(1)}TB`;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Small local models constantly invent tool names ("create_file", "mkdir",
53
+ // "run_command"...) or misname arguments ("filename", "text"). Hard-rejecting
54
+ // them breaks the whole flow with 'Unknown tool'. Instead we resolve aliases
55
+ // and repair arguments so the intended action still runs.
56
+ const TOOL_ALIASES = {
57
+ // folder creation
58
+ mkdir: "make_dir", makedir: "make_dir", createdir: "make_dir", create_dir: "make_dir",
59
+ create_directory: "make_dir", newdir: "make_dir", new_dir: "make_dir",
60
+ newfolder: "make_dir", new_folder: "make_dir", createfolder: "make_dir",
61
+ create_folder: "make_dir", folder: "make_dir", directory: "make_dir",
62
+ // file writing
63
+ writefile: "write_file", filewrite: "write_file", createfile: "write_file",
64
+ create_file: "write_file", newfile: "write_file", new_file: "write_file",
65
+ savefile: "write_file", save_file: "write_file", save: "write_file",
66
+ touch: "write_file", overwritefile: "write_file", overwrite_file: "write_file",
67
+ // file reading
68
+ readfile: "read_file", cat: "read_file", openfile: "read_file",
69
+ viewfile: "read_file", showfile: "read_file", view: "read_file",
70
+ // editing
71
+ editfile: "edit_file", edit: "edit_file", replace: "edit_file",
72
+ replacetext: "edit_file", replace_text: "edit_file", strreplace: "edit_file",
73
+ str_replace: "edit_file", patch: "edit_file", patchfile: "edit_file",
74
+ patch_file: "edit_file", updatefile: "edit_file", update_file: "edit_file",
75
+ modifyfile: "edit_file", modify_file: "edit_file",
76
+ // listing / searching
77
+ ls: "list_dir", dirlist: "list_dir", listdir: "list_dir",
78
+ listdirectory: "list_dir", list_directory: "list_dir", listfiles: "list_dir",
79
+ list_files: "list_dir", list: "list_dir", tree: "list_dir",
80
+ find: "search_files", findfiles: "search_files", find_files: "search_files",
81
+ glob: "search_files", grep: "search_content", ripgrep: "search_content",
82
+ searchtext: "search_content", search_text: "search_content",
83
+ searchcode: "search_content", search_code: "search_content",
84
+ contentsearch: "search_content", content_search: "search_content",
85
+ search: "search_content",
86
+ // existence
87
+ exists: "file_exists", stat: "file_exists", checkexists: "file_exists",
88
+ check_exists: "file_exists", checkfile: "file_exists", check_file: "file_exists",
89
+ // shell
90
+ exec: "terminal", execute: "terminal", executecommand: "terminal",
91
+ execute_command: "terminal", runcommand: "terminal", run_command: "terminal",
92
+ run: "bash", shell: "bash", cmd: "bash", command: "bash", system: "bash",
93
+ // code runners
94
+ runpython: "run_python", python_run: "run_python", py_run: "run_python",
95
+ execpython: "run_python", exec_python: "run_python", python: "run_python",
96
+ py: "run_python", runpy: "run_python",
97
+ runnode: "run_node", node_run: "run_node", js_run: "run_node",
98
+ runjs: "run_node", nodejs_run: "run_node", node: "run_node", js: "run_node",
99
+ // package installs
100
+ install: "install_packages", installpackage: "install_packages",
101
+ install_package: "install_packages", pip_install: "install_packages",
102
+ pipinstall: "install_packages", npm_install: "install_packages",
103
+ npminstall: "install_packages", addpackage: "install_packages",
104
+ add_package: "install_packages",
105
+ // http
106
+ request: "http_request", http: "http_request", fetchapi: "http_request",
107
+ fetch_api: "http_request", api_request: "http_request", apirequest: "http_request",
108
+ post: "http_request", getjson: "http_request",
109
+ // web / todo convenience
110
+ searchweb: "web_search", search_web: "web_search", websearch: "web_search",
111
+ fetchurl: "web_fetch", fetch_url: "web_fetch", httpget: "web_fetch",
112
+ http_get: "web_fetch", curl: "web_fetch",
113
+ todos: "todo_read", todolist: "todo_read", todo_list: "todo_read",
114
+ };
115
+
116
+ // Argument key repairs per canonical tool (wrong-key -> right-key).
117
+ const _ARG_GROUPS = {
118
+ path: ["path", "filepath", "file_path", "filename", "file_name", "name",
119
+ "file", "target", "location", "folder", "directory", "dir",
120
+ "dir_path", "dirpath"],
121
+ content: ["content", "contents", "text", "data", "body", "value",
122
+ "code", "file_content", "new_text"],
123
+ old_text: ["old_text", "old_string", "old", "original", "original_text",
124
+ "search", "find", "old_str"],
125
+ new_text: ["new_text", "new_string", "replacement", "replace_with", "new_str"],
126
+ command: ["command", "cmd", "shell", "line", "script", "instruction"],
127
+ };
128
+
129
+ const FILE_TOOLS = new Set(["read_file", "write_file", "make_dir", "list_dir",
130
+ "search_files", "search_content", "file_exists"]);
131
+
132
+ function normTool(name) {
133
+ return String(name || "").toLowerCase().replace(/[^a-z0-9]/g, "");
134
+ }
135
+
136
+ function shortJson(val, limit = 60) {
137
+ let s;
138
+ try { s = JSON.stringify(val); } catch { s = String(val); }
139
+ return s.length > limit ? s.slice(0, limit) + "…" : s;
140
+ }
141
+
142
+ // Keep model-invented paths inside the current workspace. Small models love
143
+ // writing to /home/<made-up-user>/... which usually does not exist here.
144
+ // Everything else - relative paths, /tmp, the real home - passes through.
145
+ function anchorPath(raw) {
146
+ if (typeof raw !== "string") return raw;
147
+ const p = raw.trim();
148
+ if (!p.startsWith("/home/") && !p.startsWith("/Users/")) return raw;
149
+ const segs = p.split("/").filter(Boolean);
150
+ if (segs.length < 3) return raw;
151
+ const userRoot = "/" + segs.slice(0, 2).join("/");
152
+ let realHome = false;
153
+ try {
154
+ realHome = fs.statSync(userRoot).isDirectory();
155
+ } catch {}
156
+ if (realHome) return raw; // a real user home
157
+ const rest = segs.slice(2);
158
+ if (!rest.length || rest.includes("..")) return raw;
159
+ return rest.join("/");
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Permissions: file tools never prompt - creating/writing files inside the
164
+ // workspace is the assistant's core job and y/n prompts inside a TUI race
165
+ // with its key reader (mangled answers -> bogus denials). Only genuinely
166
+ // dangerous actions (shell, git history changes, task rewrites) ask.
167
+ class PermissionManager {
168
+ constructor() {
169
+ this.auto_approve = false;
170
+ this.allowed = new Set();
171
+ this.prompting = false; // true while a y/n prompt owns the keyboard
172
+ // UI integration: when set, ask() routes the question here instead of
173
+ // stdin - callable(toolName, desc) -> "y" | "n" | "a" (async ok).
174
+ this.ui_hook = null;
175
+ }
176
+
177
+ needs_permission(toolName) {
178
+ if (this.auto_approve || this.allowed.has(toolName)) return false;
179
+ return ["terminal", "bash", "git_commit", "todo_write",
180
+ "run_python", "run_node", "install_packages"].includes(toolName);
181
+ }
182
+
183
+ async ask(toolName, args) {
184
+ if (!this.needs_permission(toolName)) return true;
185
+ const desc = args.command || args.path || toolName;
186
+ this.prompting = true;
187
+ let r = "";
188
+ try {
189
+ if (typeof this.ui_hook === "function") {
190
+ r = String((await this.ui_hook(toolName, desc)) || "").trim().toLowerCase();
191
+ } else if (process.stdin.isTTY !== true) {
192
+ r = "y"; // headless (pipes, CI): no keyboard to ask
193
+ } else {
194
+ process.stdout.write(`\n\x1b[33m Permission:\x1b[0m ${toolName} -> ${String(desc).slice(0, 80)}\n`);
195
+ r = await askLine(" Allow? (y/n/a=always): ");
196
+ }
197
+ } finally {
198
+ this.prompting = false;
199
+ }
200
+ if (["y", "yes", ""].includes(r)) return true;
201
+ if (["a", "always"].includes(r)) { this.allowed.add(toolName); return true; }
202
+ return false;
203
+ }
204
+ }
205
+
206
+ function askLine(prompt) {
207
+ return new Promise((resolve) => {
208
+ process.stdout.write(prompt);
209
+ const rl = require("readline").createInterface({ input: process.stdin, terminal: false });
210
+ rl.once("line", (line) => { rl.close(); resolve(String(line).trim().toLowerCase()); });
211
+ rl.once("close", () => resolve(""));
212
+ });
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // HTTP with browser-like defaults: search engines reject bot UAs at TLS/HTTP
217
+ // level, and Android/Termux Node builds can ship stale CA stores - so on TLS
218
+ // errors we retry once without verification as a last resort.
219
+ const BROWSER_HEADERS = {
220
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
221
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
222
+ "Accept-Language": "en-US,en;q=0.9",
223
+ };
224
+
225
+ function httpGet(url, opts = {}, redirects = 0) {
226
+ return new Promise((resolve, reject) => {
227
+ const mod = url.startsWith("https") ? https : http;
228
+ const req = mod.get(url, {
229
+ headers: { ...BROWSER_HEADERS, ...(opts.headers || {}) },
230
+ timeout: opts.timeout || 15000,
231
+ rejectUnauthorized: !opts.insecure,
232
+ }, (res) => {
233
+ if ([301, 302, 303, 307, 308].includes(res.statusCode || 0)
234
+ && res.headers.location && redirects < 4) {
235
+ res.resume();
236
+ httpGet(new URL(res.headers.location, url).toString(), opts, redirects + 1)
237
+ .then(resolve, reject);
238
+ return;
239
+ }
240
+ const chunks = [];
241
+ res.on("data", (c) => chunks.push(c));
242
+ res.on("end", () => resolve({
243
+ status: res.statusCode || 0,
244
+ text: Buffer.concat(chunks).toString("utf8"),
245
+ headers: res.headers,
246
+ }));
247
+ });
248
+ req.on("timeout", () => req.destroy(new Error("Timeout")));
249
+ req.on("error", (e) => {
250
+ if (!opts.insecure && /certificate|CERT|TLS|ssl/i.test(`${e.code || ""} ${e.message}`)) {
251
+ httpGet(url, { ...opts, insecure: true }, redirects).then(resolve, reject);
252
+ } else reject(e);
253
+ });
254
+ });
255
+ }
256
+
257
+ function stripTags(s) {
258
+ return s.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
259
+ }
260
+
261
+ function parseGoogle(html, n) {
262
+ const out = [], seen = new Set();
263
+ const rx = /<a[^>]+href="(?:(?:\/url\?q=)|(?:https?:\/\/[^\/]*google\.[^\/]*\/url\?q=))?([^"&]+)[^"]*"[^>]*>([\s\S]*?)<\/a>/g;
264
+ let m;
265
+ while ((m = rx.exec(html)) && out.length < n) {
266
+ let url = m[1];
267
+ try { url = decodeURIComponent(url); } catch {}
268
+ const title = stripTags(m[2]);
269
+ if (!title || !/^https?:\/\/|^\/url\?q=/.test(m[1]) || /^https?:$/.test(url)) continue;
270
+ if (!/^https?:\/\//.test(url)) continue;
271
+ if (/google\.|gstatic\.|googleads|doubleclick/i.test(url)) continue;
272
+ if (seen.has(url)) continue;
273
+ seen.add(url);
274
+ out.push(` ${title}\n ${url}`);
275
+ }
276
+ if (!out.length) {
277
+ for (const mm of html.matchAll(/href="(https?:\/\/[^"]+)"/g)) {
278
+ const u = mm[1];
279
+ if (/google\.|gstatic\./i.test(u) || seen.has(u)) continue;
280
+ seen.add(u);
281
+ out.push(` ${u}`);
282
+ if (out.length >= n) break;
283
+ }
284
+ }
285
+ return out;
286
+ }
287
+
288
+ function parseDdg(html, n) {
289
+ const out = [], seen = new Set();
290
+ const rx = /<a[^>]+class="result__a"[^>]*>/g;
291
+ let m;
292
+ while ((m = rx.exec(html)) && out.length < n) {
293
+ const tail = html.slice(m.index, m.index + 500);
294
+ const href = (tail.match(/href="([^"]+)"/) || [])[1] || "";
295
+ const titleM = tail.match(/>([\s\S]*?)<\/a>/);
296
+ let url = href.startsWith("//") ? "https:" + href : href;
297
+ const title = titleM ? stripTags(titleM[1]) : "";
298
+ if (!title || !/^https?:\/\//.test(url) || /duckduckgo\.com/.test(url)) continue;
299
+ if (seen.has(url)) continue;
300
+ seen.add(url);
301
+ out.push(` ${title}\n ${url}`);
302
+ }
303
+ if (!out.length) { // fallback: any external link
304
+ for (const mm of html.matchAll(/href="(https?:\/\/[^"]+)"/g)) {
305
+ const u = mm[1];
306
+ if (/duckduckgo\.com/.test(u) || seen.has(u)) continue;
307
+ seen.add(u);
308
+ out.push(` ${u}`);
309
+ if (out.length >= n) break;
310
+ }
311
+ }
312
+ return out;
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Tool implementations
317
+
318
+ function safeDecode(s) {
319
+ try { return decodeURIComponent(s); } catch { return s; }
320
+ }
321
+
322
+ function b64uDecode(s) {
323
+ try {
324
+ return Buffer.from(s, "base64url").toString("utf8");
325
+ } catch {
326
+ return "";
327
+ }
328
+ }
329
+
330
+ // Markdown pages from the r.jina.ai renderer: [title](url) where url is
331
+ // either a DDG redirect (?uddg=<enc>) or a Bing redirect (&u=a1<b64>).
332
+ function parseMdLinks(text, n) {
333
+ const out = [], seen = new Set();
334
+ for (const m of text.matchAll(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g)) {
335
+ let url = m[2];
336
+ const mu = url.match(/[?&]uddg=([^&\s)]+)/);
337
+ if (mu) {
338
+ url = safeDecode(mu[1]);
339
+ } else {
340
+ const mb = url.match(/[?&]u=a1([A-Za-z0-9_\-%]+)/);
341
+ if (!mb) continue;
342
+ url = b64uDecode(mb[1]);
343
+ }
344
+ const title = m[1].replace(/\s+/g, " ").trim()
345
+ .replace(/^\*+|\*+$/g, "").replace(/^`|`$/g, "");
346
+ if (!title || !/^https?:\/\//.test(url) || seen.has(url)) continue;
347
+ if (/duckduckgo\.com|bing\.com\/ck\/|\.bing\.com\/search|google\.|external-content/.test(url)) continue;
348
+ seen.add(url);
349
+ out.push(` ${title}\n ${url}`);
350
+ if (out.length >= n) break;
351
+ }
352
+ return out;
353
+ }
354
+
355
+ // Bing wraps result links in /ck/a?...&u=a1<base64url-of-real-url>.
356
+ function parseBing(html, n) {
357
+ const out = [], seen = new Set();
358
+ for (const m of html.matchAll(/<a[^>]+href="([^"]*ck\/a\?[^"]*)"[^>]*>([\s\S]*?)<\/a>/g)) {
359
+ const href = m[1].replace(/&amp;/g, "&");
360
+ const mb = href.match(/[?&]u=a1([A-Za-z0-9_\-]+)/);
361
+ if (!mb) continue;
362
+ const url = b64uDecode(mb[1]);
363
+ const title = stripTags(m[2]);
364
+ if (!title || !/^https?:\/\//.test(url) || /bing\.com/.test(url) || seen.has(url)) continue;
365
+ seen.add(url);
366
+ out.push(` ${title}\n ${url}`);
367
+ if (out.length >= n) break;
368
+ }
369
+ return out;
370
+ }
371
+
372
+ function parseGoogleApi(body, n) {
373
+ try {
374
+ const items = JSON.parse(body).items || [];
375
+ return items.slice(0, n)
376
+ .filter((it) => it.link)
377
+ .map((it) => ` ${it.title || ""}\n ${it.link}`);
378
+ } catch {
379
+ return [];
380
+ }
381
+ }
382
+
383
+ async function runShell(command, cwd = ".", timeout = 30000) {
384
+ const r = spawnSync("bash", ["-c", command], {
385
+ cwd: path.resolve(cwd), timeout,
386
+ encoding: "utf-8", maxBuffer: 16 * 1024 * 1024,
387
+ });
388
+ if (r.error && r.error.code === "ETIMEDOUT") {
389
+ return new ToolResult("", `Timeout after ${timeout}s`, false);
390
+ }
391
+ if (r.error) return new ToolResult("", r.error.message, false);
392
+ let out = r.stdout || "";
393
+ if (r.stderr) out += `\n[stderr]\n${r.stderr}`;
394
+ if (r.status !== 0) out += `\n[exit ${r.status}]`;
395
+ return new ToolResult(out.slice(0, 50000) || "Done (no output)");
396
+ }
397
+
398
+ // Run code with an interpreter directly (argv, never a shell) so user code
399
+ // can't be mangled by quoting and packages install safely.
400
+ function runInterpreter(bin, argv, cwd, timeout) {
401
+ const r = spawnSync(bin, argv, {
402
+ cwd: path.resolve(cwd || "."), timeout: timeout || 30000,
403
+ encoding: "utf-8", maxBuffer: 16 * 1024 * 1024,
404
+ env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" },
405
+ });
406
+ if (r.error && r.error.code === "ETIMEDOUT") {
407
+ return new ToolResult("", `Timeout after ${timeout || 30}s`, false);
408
+ }
409
+ if (r.error && r.error.code === "ENOENT") {
410
+ return new ToolResult("", `${bin} is not installed on this device`, false);
411
+ }
412
+ if (r.error) return new ToolResult("", r.error.message, false);
413
+ let out = r.stdout || "";
414
+ if (r.stderr) out += `\n[stderr]\n${r.stderr}`;
415
+ if (r.status !== 0) out += `\n[exit ${r.status}]`;
416
+ return new ToolResult(out.trim().slice(0, 20000) || "Done (no output)");
417
+ }
418
+
419
+ function httpRequest(url, { method = "GET", headers = {}, body = null, timeout = 20000 } = {}) {
420
+ return new Promise((resolve, reject) => {
421
+ let u;
422
+ try { u = new URL(url); } catch { return reject(new Error(`Invalid URL: ${url}`)); }
423
+ const mod = u.protocol === "http:" ? require("http") : require("https");
424
+ const req = mod.request(u, {
425
+ method, headers: { "User-Agent": BROWSER_HEADERS["User-Agent"], ...headers },
426
+ timeout,
427
+ }, (res) => {
428
+ let data = "";
429
+ res.setEncoding("utf-8");
430
+ res.on("data", (c) => { data += c; if (data.length > 2e6) req.destroy(); });
431
+ res.on("end", () => resolve({ status: res.statusCode, text: data }));
432
+ });
433
+ req.on("timeout", () => { req.destroy(new Error(`Timeout after ${timeout / 1000}s`)); });
434
+ req.on("error", reject);
435
+ if (body != null) req.write(body);
436
+ req.end();
437
+ });
438
+ }
439
+
440
+ const TOOLS = [
441
+ new ToolDef("read_file", "Read file contents with line numbers", {
442
+ type: "object",
443
+ properties: {
444
+ path: { type: "string" },
445
+ start_line: { type: "integer" },
446
+ end_line: { type: "integer" },
447
+ },
448
+ required: ["path"],
449
+ }),
450
+ new ToolDef("write_file",
451
+ "Create or overwrite a file (parent folders are created automatically)", {
452
+ type: "object",
453
+ properties: { path: { type: "string" }, content: { type: "string" } },
454
+ required: ["path", "content"],
455
+ }, true),
456
+ new ToolDef("make_dir",
457
+ "Create a folder/directory (nested parents are created automatically)", {
458
+ type: "object", properties: { path: { type: "string" } }, required: ["path"],
459
+ }),
460
+ new ToolDef("edit_file", "Replace exact text in file", {
461
+ type: "object",
462
+ properties: {
463
+ path: { type: "string" }, old_text: { type: "string" }, new_text: { type: "string" },
464
+ },
465
+ required: ["path", "old_text", "new_text"],
466
+ }, true),
467
+ new ToolDef("list_dir", "List directory contents", {
468
+ type: "object",
469
+ properties: { path: { type: "string" }, pattern: { type: "string" } },
470
+ }),
471
+ new ToolDef("search_files", "Find files by glob pattern", {
472
+ type: "object",
473
+ properties: { pattern: { type: "string" }, path: { type: "string" } },
474
+ required: ["pattern"],
475
+ }),
476
+ new ToolDef("search_content", "Search text inside files", {
477
+ type: "object",
478
+ properties: {
479
+ query: { type: "string" }, path: { type: "string" }, include: { type: "string" },
480
+ },
481
+ required: ["query"],
482
+ }),
483
+ new ToolDef("file_exists", "Check if file/dir exists", {
484
+ type: "object", properties: { path: { type: "string" } }, required: ["path"],
485
+ }),
486
+ new ToolDef("terminal", "Execute a shell command", {
487
+ type: "object",
488
+ properties: {
489
+ command: { type: "string" }, cwd: { type: "string" }, timeout: { type: "integer" },
490
+ },
491
+ required: ["command"],
492
+ }, true),
493
+ new ToolDef("bash", "Run a shell command", {
494
+ type: "object",
495
+ properties: {
496
+ command: { type: "string" }, cwd: { type: "string" }, timeout: { type: "integer" },
497
+ },
498
+ required: ["command"],
499
+ }, true),
500
+ new ToolDef("run_python",
501
+ "Execute Python 3 code (or a .py file) and see its output - use this to TEST scripts you wrote", {
502
+ type: "object",
503
+ properties: {
504
+ code: { type: "string" }, path: { type: "string" },
505
+ cwd: { type: "string" }, timeout: { type: "integer" },
506
+ },
507
+ }, true),
508
+ new ToolDef("run_node",
509
+ "Execute JavaScript with Node.js (or a .js file) and see its output - use this to TEST scripts you wrote", {
510
+ type: "object",
511
+ properties: {
512
+ code: { type: "string" }, path: { type: "string" },
513
+ cwd: { type: "string" }, timeout: { type: "integer" },
514
+ },
515
+ }, true),
516
+ new ToolDef("install_packages",
517
+ "Install packages with pip or npm, e.g. manager='pip', packages=['flask']", {
518
+ type: "object",
519
+ properties: {
520
+ manager: { type: "string", enum: ["pip", "pip3", "npm"] },
521
+ packages: { type: "array", items: { type: "string" } },
522
+ timeout: { type: "integer" },
523
+ },
524
+ required: ["packages"],
525
+ }, true),
526
+ new ToolDef("http_request",
527
+ "Call any HTTP API and get the response body (GET/POST/PUT/DELETE with headers and body)", {
528
+ type: "object",
529
+ properties: {
530
+ url: { type: "string" }, method: { type: "string" },
531
+ headers: { type: "object" }, body: { type: "string" },
532
+ timeout: { type: "integer" },
533
+ },
534
+ required: ["url"],
535
+ }),
536
+ new ToolDef("git_status", "Show working tree status", {
537
+ type: "object", properties: {}, required: [],
538
+ }),
539
+ new ToolDef("git_diff", "Show uncommitted changes", {
540
+ type: "object", properties: {}, required: [],
541
+ }),
542
+ new ToolDef("git_log", "Show recent commit history", {
543
+ type: "object",
544
+ properties: { count: { type: "integer", description: "How many commits (default 10)" } },
545
+ required: [],
546
+ }),
547
+ new ToolDef("git_commit", "Stage everything and commit", {
548
+ type: "object",
549
+ properties: { message: { type: "string", description: "Commit message" } },
550
+ required: ["message"],
551
+ }, true),
552
+ new ToolDef("git_branch", "List branches or show the current one", {
553
+ type: "object", properties: {}, required: [],
554
+ }),
555
+ new ToolDef("web_search", "Search the web (Google)", {
556
+ type: "object",
557
+ properties: {
558
+ query: { type: "string" },
559
+ num_results: { type: "integer", description: "How many results (default 5)" },
560
+ },
561
+ required: ["query"],
562
+ }),
563
+ new ToolDef("web_fetch", "Fetch URL content", {
564
+ type: "object",
565
+ properties: {
566
+ url: { type: "string" },
567
+ max_chars: { type: "integer", description: "Max characters to return (default 5000)" },
568
+ },
569
+ required: ["url"],
570
+ }),
571
+ new ToolDef("todo_read", "Read todo list", {
572
+ type: "object", properties: {}, required: [],
573
+ }),
574
+ new ToolDef("todo_write", "Write todo list", {
575
+ type: "object",
576
+ properties: {
577
+ todos: {
578
+ type: "array",
579
+ items: {
580
+ type: "object",
581
+ properties: { text: { type: "string" }, done: { type: "boolean" } },
582
+ },
583
+ },
584
+ },
585
+ required: ["todos"],
586
+ }, true),
587
+ new ToolDef("ogpt_command",
588
+ "Run an internal OGPT command. Available: /help, /status, /models, /catalog, " +
589
+ "/model <oGPT-name|id> (switch model), /memory add <text>, /task new <desc>, " +
590
+ "/index, /skills, /history, /ps, /warmup, /save. Use when the user asks to switch " +
591
+ "models, save sessions, store notes, or inspect OGPT itself.",
592
+ {
593
+ type: "object",
594
+ properties: {
595
+ command: {
596
+ type: "string",
597
+ description: "Full command line including leading slash, e.g. '/model oGPT-2a' or '/memory add prefers Python'",
598
+ },
599
+ },
600
+ required: ["command"],
601
+ }),
602
+ ];
603
+
604
+ function todoFile() {
605
+ return path.join(os.homedir(), ".config", "ogpt", "todos.json");
606
+ }
607
+
608
+ function globToRx(pattern) {
609
+ const esc = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&")
610
+ .replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*")
611
+ .replace(/\?/g, ".");
612
+ return new RegExp(`^${esc}$`, "i");
613
+ }
614
+
615
+ const IMPLEMENTATIONS = {
616
+ read_file(args) {
617
+ const p = path.resolve(anchorPath(String(args.path)).replace(/^~(?=$|\/)/, os.homedir()));
618
+ if (!fs.existsSync(p)) return new ToolResult("", `Not found: ${p}`, false);
619
+ const lines = fs.readFileSync(p, "utf-8").split("\n");
620
+ const s = Math.max(0, parseInt(args.start_line || 1, 10) - 1);
621
+ const e = Math.min(lines.length, parseInt(args.end_line || lines.length, 10));
622
+ const numbered = lines.slice(s, e)
623
+ .map((l, i) => `${String(i + s + 1).padStart(4)}: ${l}`).join("\n");
624
+ return new ToolResult(`File: ${p} (${lines.length} lines)\n${numbered}`);
625
+ },
626
+
627
+ write_file(args) {
628
+ const raw = String(args.path || "").trim();
629
+ if (!raw) return new ToolResult("", "Empty path", false);
630
+ const p = path.resolve(raw.replace(/^~(?=$|\/)/, os.homedir()));
631
+ const existed = fs.existsSync(p);
632
+ let content = String(args.content ?? "");
633
+ if (!content.trim()) {
634
+ // An empty .html file gets a professional HTML5 skeleton (page title
635
+ // from the file name) instead of a blank 0-byte file.
636
+ if (path.extname(p).toLowerCase() === ".html") {
637
+ let title = path.basename(p, ".html").replace(/[-_]/g, " ").trim();
638
+ title = title ? title[0].toUpperCase() + title.slice(1) : "Document";
639
+ content =
640
+ "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n" +
641
+ " <meta charset=\"UTF-8\">\n" +
642
+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n" +
643
+ ` <title>${title}</title>\n` +
644
+ "</head>\n<body>\n\n</body>\n</html>\n";
645
+ }
646
+ }
647
+ fs.mkdirSync(path.dirname(p), { recursive: true });
648
+ fs.writeFileSync(p, content, "utf-8");
649
+ const nLines = content.split("\n").length - (content.endsWith("\n") ? 1 : 0) || (content ? 1 : 0);
650
+ return new ToolResult(
651
+ `${existed ? "Updated" : "Created"} ${p} (${nLines} lines, ${hsz(Buffer.byteLength(content))})`);
652
+ },
653
+
654
+ make_dir(args) {
655
+ const raw = String(args.path || "").trim();
656
+ if (!raw) return new ToolResult("", "Empty path", false);
657
+ const p = path.resolve(raw.replace(/^~(?=$|\/)/, os.homedir()));
658
+ if (fs.existsSync(p) && fs.statSync(p).isDirectory()) {
659
+ return new ToolResult(`Directory already exists: ${p}`);
660
+ }
661
+ if (fs.existsSync(p)) {
662
+ return new ToolResult("", `'${p}' already exists as a FILE. Do not make_dir files - ` +
663
+ `call write_file directly instead (parent folders are created automatically). ` +
664
+ `Example: ${JSON.stringify({ name: "write_file", arguments: { path: raw, content: "...your code..." } })}`,
665
+ false);
666
+ }
667
+ fs.mkdirSync(p, { recursive: true });
668
+ return new ToolResult(`Created directory: ${p}`);
669
+ },
670
+
671
+ edit_file(args) {
672
+ const p = path.resolve(anchorPath(String(args.path)).replace(/^~(?=$|\/)/, os.homedir()));
673
+ if (!fs.existsSync(p)) return new ToolResult("", `Not found: ${p}`, false);
674
+ const content = fs.readFileSync(p, "utf-8");
675
+ const oldT = String(args.old_text ?? ""), newT = String(args.new_text ?? "");
676
+ if (!content.includes(oldT)) return new ToolResult("", `Text not found in ${path.basename(p)}`, false);
677
+ const count = content.split(oldT).length - 1;
678
+ fs.writeFileSync(p, content.replace(oldT, newT), "utf-8");
679
+ return new ToolResult(`Replaced 1 of ${count} in ${path.basename(p)}` +
680
+ (count > 1 ? ` (${count - 1} more)` : ""));
681
+ },
682
+
683
+ list_dir(args) {
684
+ const p = path.resolve(anchorPath(String(args.path || ".")).replace(/^~(?=$|\/)/, os.homedir()));
685
+ if (!fs.existsSync(p) || !fs.statSync(p).isDirectory()) {
686
+ return new ToolResult("", `Not dir: ${p}`, false);
687
+ }
688
+ let entries = fs.readdirSync(p, { withFileTypes: true })
689
+ .filter((e) => !IGNORE.has(e.name))
690
+ .sort((a, b) => (b.isDirectory() - a.isDirectory())
691
+ || a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
692
+ if (args.pattern) {
693
+ const rx = globToRx(String(args.pattern));
694
+ entries = entries.filter((e) => rx.test(e.name));
695
+ }
696
+ const lines = entries.slice(0, 80).map((e) => {
697
+ if (e.isDirectory()) return ` ${e.name}/`;
698
+ const st = fs.statSync(path.join(p, e.name));
699
+ return ` ${e.name} (${hsz(st.size)})`;
700
+ });
701
+ return new ToolResult(`Contents of ${p}:\n${lines.join("\n")}`);
702
+ },
703
+
704
+ search_files(args) {
705
+ const root = path.resolve(anchorPath(String(args.path || ".")));
706
+ const rx = globToRx(String(args.pattern));
707
+ const results = [];
708
+ const walk = (d) => {
709
+ if (results.length >= 200) return;
710
+ let items;
711
+ try { items = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
712
+ for (const e of items) {
713
+ if (results.length >= 200) return;
714
+ const full = path.join(d, e.name);
715
+ if (e.isDirectory()) {
716
+ if (!IGNORE.has(e.name)) walk(full);
717
+ } else if (rx.test(e.name)) {
718
+ results.push(path.relative(root, full));
719
+ }
720
+ }
721
+ };
722
+ walk(root);
723
+ return results.length
724
+ ? new ToolResult(`Found ${results.length}:\n${results.slice(0, 100).join("\n")}`)
725
+ : new ToolResult(`No matches for '${args.pattern}'`);
726
+ },
727
+
728
+ search_content(args) {
729
+ const q = String(args.query || "").toLowerCase();
730
+ const root = path.resolve(anchorPath(String(args.path || ".")).replace(/^~(?=$|\/)/, os.homedir()));
731
+ const inc = args.include ? globToRx(String(args.include)) : null;
732
+ const results = [];
733
+ const walk = (d) => {
734
+ let items;
735
+ try { items = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
736
+ for (const e of items) {
737
+ if (results.length >= 50) return;
738
+ const full = path.join(d, e.name);
739
+ if (e.isDirectory()) {
740
+ if (!IGNORE.has(e.name)) walk(full);
741
+ continue;
742
+ }
743
+ if (inc && !inc.test(e.name)) continue;
744
+ let st;
745
+ try { st = fs.statSync(full); } catch { continue; }
746
+ if (st.size > 2 * 1024 * 1024) continue;
747
+ try {
748
+ const lines = fs.readFileSync(full, "utf-8").split("\n");
749
+ for (let i = 0; i < lines.length; i++) {
750
+ if (lines[i].toLowerCase().includes(q)) {
751
+ results.push(`${path.relative(root, full)}:${i + 1}: ${lines[i].trimEnd().slice(0, 120)}`);
752
+ if (results.length >= 50) return;
753
+ }
754
+ }
755
+ } catch {}
756
+ }
757
+ };
758
+ walk(root);
759
+ return results.length
760
+ ? new ToolResult(`Matches:\n${results.join("\n")}`)
761
+ : new ToolResult(`No matches for '${args.query}'`);
762
+ },
763
+
764
+ file_exists(args) {
765
+ const p = path.resolve(anchorPath(String(args.path)).replace(/^~(?=$|\/)/, os.homedir()));
766
+ if (fs.existsSync(p)) {
767
+ const t = fs.statSync(p).isDirectory() ? "dir" : "file";
768
+ return new ToolResult(`Exists (${t}): ${p}`);
769
+ }
770
+ return new ToolResult(`Not found: ${p}`);
771
+ },
772
+
773
+ terminal(args) {
774
+ return runShell(String(args.command || ""), args.cwd || ".",
775
+ parseInt(args.timeout || 30, 10) * 1000);
776
+ },
777
+
778
+ bash(args) {
779
+ return runShell(String(args.command || ""), args.cwd || ".",
780
+ parseInt(args.timeout || 30, 10) * 1000);
781
+ },
782
+
783
+ run_python(args) {
784
+ const code = args.code != null ? String(args.code) : "";
785
+ const file = String(args.path || "").trim();
786
+ if (!code.trim() && !file) {
787
+ return new ToolResult("", "Provide 'code' (Python source to run) or 'path' (a .py file)", false);
788
+ }
789
+ const argv = file ? [file] : ["-c", code];
790
+ return runInterpreter("python3", argv, args.cwd || ".", parseInt(args.timeout || 60, 10) * 1000);
791
+ },
792
+
793
+ run_node(args) {
794
+ const code = args.code != null ? String(args.code) : "";
795
+ const file = String(args.path || "").trim();
796
+ if (!code.trim() && !file) {
797
+ return new ToolResult("", "Provide 'code' (JavaScript source to run) or 'path' (a .js file)", false);
798
+ }
799
+ const argv = file ? [file] : ["-e", code];
800
+ return runInterpreter("node", argv, args.cwd || ".", parseInt(args.timeout || 60, 10) * 1000);
801
+ },
802
+
803
+ install_packages(args) {
804
+ let pkgs = args.packages;
805
+ if (typeof pkgs === "string") pkgs = pkgs.split(/\s+/);
806
+ if (!Array.isArray(pkgs) || !pkgs.length || !pkgs.every((p) => /^[\w@/.+-]+$/.test(p))) {
807
+ return new ToolResult("", "'packages' must be a list of package names, e.g. "
808
+ + `{"name": "install_packages", "arguments": {"manager": "pip", "packages": ["flask"]}}`, false);
809
+ }
810
+ let mgr = String(args.manager || "pip").trim().toLowerCase();
811
+ if (mgr === "python" || mgr === "py") mgr = "pip";
812
+ if (mgr === "node" || mgr === "nodejs" || mgr === "javascript") mgr = "npm";
813
+ if (!["pip", "pip3", "npm"].includes(mgr)) {
814
+ return new ToolResult("", `'manager' must be one of pip, pip3 or npm - got '${mgr}'`, false);
815
+ }
816
+ if (mgr.startsWith("pip")) {
817
+ return runInterpreter(mgr, ["install", ...pkgs], ".",
818
+ parseInt(args.timeout || 300, 10) * 1000);
819
+ }
820
+ // npm: install into the workspace, not global
821
+ return runInterpreter("npm", ["install", ...pkgs], ".",
822
+ parseInt(args.timeout || 300, 10) * 1000);
823
+ },
824
+
825
+ async http_request(args) {
826
+ const url = String(args.url || "").trim();
827
+ if (!/^https?:\/\//i.test(url)) {
828
+ return new ToolResult("", "'url' must start with http:// or https://", false);
829
+ }
830
+ try {
831
+ const res = await httpRequest(url, {
832
+ method: String(args.method || "GET").toUpperCase(),
833
+ headers: args.headers && typeof args.headers === "object" ? args.headers : {},
834
+ body: args.body != null ? String(args.body) : null,
835
+ timeout: parseInt(args.timeout || 20, 10) * 1000,
836
+ });
837
+ return new ToolResult(`HTTP ${res.status} ${url}\n${res.text.slice(0, 8000)}`);
838
+ } catch (e) {
839
+ return new ToolResult("", e.message, false);
840
+ }
841
+ },
842
+
843
+ git_status() { return runShell("git status --short --branch"); },
844
+ git_diff() { return runShell("git diff HEAD"); },
845
+ git_log(args) { return runShell(`git log --oneline -n ${parseInt(args.count || 10, 10)}`); },
846
+ async git_commit(args) {
847
+ const msg = String(args.message || "").trim();
848
+ if (!msg) return new ToolResult("", "Missing required argument 'message' for git_commit. "
849
+ + `Send it with real values, e.g. ${JSON.stringify({ name: "git_commit", arguments: { message: "Fix login bug" } })}`, false);
850
+ await runShell('git add -A');
851
+ return runShell(`git commit -m ${JSON.stringify(msg)}`);
852
+ },
853
+ git_branch() { return runShell("git branch --show-current && git branch"); },
854
+
855
+ async web_search(args) {
856
+ const query = String(args.query || "");
857
+ const n = Math.max(1, parseInt(args.num_results || 5, 10));
858
+ const q = encodeURIComponent(query);
859
+ // Official Google Programmable Search when the user configured keys -
860
+ // the cleanest way to talk to Google.
861
+ let apiKey = "", cx = "";
862
+ try {
863
+ const { getConfig } = require("./config");
864
+ const cfg = getConfig();
865
+ apiKey = cfg.get("web.google_api_key", "") || "";
866
+ cx = cfg.get("web.google_cx", "") || "";
867
+ } catch {}
868
+ const endpoints = [];
869
+ if (apiKey && cx) {
870
+ endpoints.push({
871
+ url: `https://www.googleapis.com/customsearch/v1?key=${apiKey}&cx=${cx}&num=${Math.min(n, 10)}&q=${q}`,
872
+ parse: parseGoogleApi,
873
+ });
874
+ }
875
+ endpoints.push(
876
+ { url: `https://www.bing.com/search?q=${q}&count=10`, parse: parseBing },
877
+ { url: `https://html.duckduckgo.com/html/?q=${q}`, parse: parseDdg },
878
+ { url: `https://lite.duckduckgo.com/lite/?q=${q}`, parse: parseDdg },
879
+ // Server-rendered proxies rescue locked-down networks where carriers
880
+ // reset engine domains or Google demands JavaScript.
881
+ { url: `https://r.jina.ai/https://html.duckduckgo.com/html/?q=${q}`, parse: parseMdLinks },
882
+ { url: `https://r.jina.ai/https://www.bing.com/search?q=${q}`, parse: parseMdLinks },
883
+ {
884
+ url: `https://www.google.com/search?q=${q}&gbv=1&num=10`,
885
+ headers: { Cookie: "CONSENT=YES+cb.20240101-01-p0.en+FX+000" },
886
+ parse: parseGoogle,
887
+ },
888
+ );
889
+ let lastErr = "";
890
+ for (const ep of endpoints) {
891
+ try {
892
+ const res = await httpGet(ep.url, { headers: ep.headers });
893
+ if (res.status !== 200) { lastErr = `${ep.url} -> HTTP ${res.status}`; continue; }
894
+ const results = ep.parse(res.text, n);
895
+ if (results.length) {
896
+ return new ToolResult(`Results for '${query}':\n${results.join("\n")}`);
897
+ }
898
+ lastErr = `${ep.url} returned no parseable results`;
899
+ } catch (e) {
900
+ lastErr = `${ep.url} -> ${e.message}`;
901
+ }
902
+ }
903
+ return new ToolResult("",
904
+ `No search backend responded (${lastErr}). Try web_fetch on a known URL.`, false);
905
+ },
906
+
907
+ async web_fetch(args) {
908
+ const url = String(args.url || "");
909
+ const maxChars = parseInt(args.max_chars || 5000, 10);
910
+ const clean = (res) => {
911
+ let text = res.text.slice(0, 30000);
912
+ if (/html/i.test(res.headers["content-type"] || "") || /<html/i.test(text)) {
913
+ text = text.replace(/<script[\s\S]*?<\/script>/gi, " ")
914
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
915
+ .replace(/<[^>]+>/g, " ")
916
+ .replace(/\s+/g, " ").trim();
917
+ }
918
+ return text;
919
+ };
920
+ try {
921
+ const res = await httpGet(url, {});
922
+ if (res.status >= 400) throw new Error(`HTTP ${res.status} for ${url}`);
923
+ const text = clean(res);
924
+ if (!text.trim()) throw new Error("empty page (JS-only site?)");
925
+ return new ToolResult(`URL: ${url}\n${text.slice(0, maxChars)}`);
926
+ } catch (e) {
927
+ // Locked-down networks / JS-only sites: try the server-side renderer
928
+ // before giving up.
929
+ try {
930
+ const res = await httpGet(`https://r.jina.ai/${url}`, { timeout: 30000 });
931
+ if (res.status === 200 && res.text.trim()) {
932
+ return new ToolResult(`URL: ${url}\n${res.text.slice(0, maxChars)}`);
933
+ }
934
+ } catch {}
935
+ return new ToolResult("", e.message, false);
936
+ }
937
+ },
938
+
939
+ todo_read() {
940
+ try {
941
+ const data = JSON.parse(fs.readFileSync(todoFile(), "utf-8"));
942
+ const items = Array.isArray(data) ? data : data.todos || [];
943
+ if (!items.length) return new ToolResult("No todos.");
944
+ return new ToolResult(items.map((t, i) =>
945
+ ` ${i + 1}. [${t.done ? "x" : " "}] ${t.text}`).join("\n"));
946
+ } catch {
947
+ return new ToolResult("No todos.");
948
+ }
949
+ },
950
+
951
+ todo_write(args) {
952
+ const todos = Array.isArray(args.todos) ? args.todos : null;
953
+ if (!todos) return new ToolResult("", "'todos' must be an array of {text, done}", false);
954
+ fs.mkdirSync(path.dirname(todoFile()), { recursive: true });
955
+ fs.writeFileSync(todoFile(), JSON.stringify(todos.map((t) => ({
956
+ text: String(t.text || ""), done: !!t.done,
957
+ })), null, 2));
958
+ return new ToolResult(`Saved ${todos.length} todos`);
959
+ },
960
+ };
961
+
962
+ class ToolRegistry {
963
+ constructor() {
964
+ this._tools = {};
965
+ this._perm = new PermissionManager();
966
+ this._log = [];
967
+ // Set by the CLI: (commandLine) => output string
968
+ this.commandDispatcher = null;
969
+ for (const t of TOOLS) this._tools[t.name] = t;
970
+ }
971
+
972
+ // Backwards-compat for older callers that read registry._autoApprove.
973
+ get _autoApprove() { return this._perm.auto_approve; }
974
+
975
+ definitions() {
976
+ return Object.values(this._tools).map((t) => ({
977
+ name: t.name,
978
+ description: t.description,
979
+ parameters: t.parameters,
980
+ }));
981
+ }
982
+
983
+ listTools() { return Object.values(this._tools); }
984
+
985
+ names() { return Object.keys(this._tools); }
986
+
987
+ canonicalName(name) { return this.resolveName(name); }
988
+
989
+ resolveName(name) {
990
+ if (Object.prototype.hasOwnProperty.call(this._tools, name)) return name;
991
+ const n = normTool(name);
992
+ if (!n) return name;
993
+ const canon = TOOL_ALIASES[n];
994
+ if (canon && this._tools[canon]) return canon;
995
+ for (const known of Object.keys(this._tools)) {
996
+ if (normTool(known) === n) return known;
997
+ }
998
+ // last resort: unique suffix match ("write" -> "write_file")
999
+ const hits = Object.keys(this._tools)
1000
+ .filter((k) => normTool(k).endsWith(n) || n.endsWith(normTool(k)));
1001
+ if (hits.length === 1) return hits[0];
1002
+ return name;
1003
+ }
1004
+
1005
+ prepareCall(toolCall) {
1006
+ // Canonicalize name AND argument keys on a tool call (in place) so chips,
1007
+ // snapshots and code events all work with the canonical view.
1008
+ try {
1009
+ const resolved = this.resolveName(toolCall.name);
1010
+ if (resolved !== toolCall.name) toolCall.name = resolved;
1011
+ const tool = this._tools[resolved];
1012
+ if (tool) {
1013
+ toolCall.arguments = this.repairArgs(tool, toolCall.arguments || {});
1014
+ }
1015
+ } catch {}
1016
+ return toolCall;
1017
+ }
1018
+
1019
+ repairArgs(tool, args) {
1020
+ if (!args || typeof args !== "object" || Array.isArray(args)) return {};
1021
+ const props = (tool.parameters && tool.parameters.properties) || {};
1022
+ const fixed = { ...args };
1023
+ // already-valid keys stay untouched
1024
+ for (const [canonical, aliases] of Object.entries(_ARG_GROUPS)) {
1025
+ if (props[canonical] && !(canonical in fixed)) {
1026
+ for (const alias of aliases) {
1027
+ if (alias !== canonical && alias in args) { fixed[canonical] = args[alias]; break; }
1028
+ }
1029
+ }
1030
+ }
1031
+ if (FILE_TOOLS.has(tool.name) && typeof fixed.path === "string") {
1032
+ fixed.path = anchorPath(fixed.path);
1033
+ }
1034
+ return fixed;
1035
+ }
1036
+
1037
+ validateArgs(tool, args) {
1038
+ // Return a teaching error when args are malformed (schema parroting,
1039
+ // wrong types, missing required keys).
1040
+ const params = tool.parameters || {};
1041
+ const props = params.properties || {};
1042
+ for (const [key, spec] of Object.entries(props)) {
1043
+ if (!(key in args)) continue;
1044
+ const val = args[key];
1045
+ const vtype = spec && spec.type;
1046
+ if (vtype === "string" && val != null && typeof val !== "string"
1047
+ && typeof val !== "number" && typeof val !== "boolean") {
1048
+ const example = JSON.stringify({ name: tool.name, arguments: { [key]: "my_folder" } });
1049
+ return `Invalid arguments for ${tool.name}: '${key}' must be a plain string, `
1050
+ + `but you sent ${shortJson(val)}. Do not copy the parameter schema - `
1051
+ + `use real values. Correct shape: ${example}`;
1052
+ }
1053
+ }
1054
+ for (const key of params.required || []) {
1055
+ const v = args[key];
1056
+ // 'content'/'text' may legitimately be an empty string - write_file
1057
+ // fills in a starter template for known types (e.g. HTML boilerplate).
1058
+ const allowEmpty = key === "content" || key === "text" || key === "todos";
1059
+ if (!(key in args) || v == null ||
1060
+ (!allowEmpty && (v === "" ||
1061
+ (Array.isArray(v) && !v.length) ||
1062
+ (typeof v === "object" && !Array.isArray(v) && !Object.keys(v).length)))) {
1063
+ return `Missing required argument '${key}' for ${tool.name}. `
1064
+ + `Send it with real values, e.g. `
1065
+ + `${JSON.stringify({ name: tool.name, arguments: { [key]: "value" } })}`;
1066
+ }
1067
+ }
1068
+ return null;
1069
+ }
1070
+
1071
+ async execute(toolCall) {
1072
+ const id = toolCall.id || "";
1073
+ const requested = toolCall.name;
1074
+ const resolved = this.resolveName(requested);
1075
+ const tool = this._tools[resolved];
1076
+ if (!tool) {
1077
+ return new ToolResult("",
1078
+ `Unknown tool '${requested}'. You must call exactly one of these tools: `
1079
+ + `${this.names().sort().join(", ")}.`, false);
1080
+ }
1081
+ const args = this.repairArgs(tool, toolCall.arguments || {});
1082
+ const problem = this.validateArgs(tool, args);
1083
+ if (problem) return this._finish(id, resolved, problem, false);
1084
+
1085
+ if (!(await this._perm.ask(tool.name, args))) {
1086
+ return this._finish(id, resolved, "Permission denied", false);
1087
+ }
1088
+
1089
+ let result;
1090
+ try {
1091
+ result = await IMPLEMENTATIONS[resolved](args);
1092
+ } catch (e) {
1093
+ result = new ToolResult("", e.message, false);
1094
+ }
1095
+ this._log.push({ tool: resolved, success: result.success });
1096
+ return this._finish(id, resolved, result.content, result.success, result);
1097
+ }
1098
+
1099
+ _finish(id, name, content, success, base) {
1100
+ const r = base || new ToolResult();
1101
+ r.toolCallId = id;
1102
+ r.name = name;
1103
+ if (success) r.output = content;
1104
+ else r.error = content.replace(/^Error: /, "");
1105
+ r.success = success;
1106
+ return r;
1107
+ }
1108
+
1109
+ toggleApprove(toolName) {
1110
+ if (toolName) {
1111
+ this._perm.allowed.add(toolName);
1112
+ return `Auto-approved: ${toolName}`;
1113
+ }
1114
+ this._perm.auto_approve = !this._perm.auto_approve;
1115
+ return `Auto-approve all: ${this._perm.auto_approve ? "ON" : "OFF"}`;
1116
+ }
1117
+ }
1118
+
1119
+ module.exports = { ToolRegistry, ToolDef, ToolResult, TOOLS };