jinzd-ai-cli 0.3.6 → 0.4.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.
@@ -0,0 +1,2531 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ CONFIG_DIR_NAME,
4
+ MEMORY_FILE_NAME,
5
+ SUBAGENT_ALLOWED_TOOLS,
6
+ SUBAGENT_DEFAULT_MAX_ROUNDS,
7
+ SUBAGENT_MAX_ROUNDS_LIMIT,
8
+ runTestsTool
9
+ } from "./chunk-SX52VL4D.js";
10
+
11
+ // src/tools/builtin/bash.ts
12
+ import { execSync } from "child_process";
13
+ import { existsSync as existsSync2, readdirSync, statSync } from "fs";
14
+ import { platform } from "os";
15
+ import { resolve } from "path";
16
+
17
+ // src/tools/undo-stack.ts
18
+ import { readFileSync, writeFileSync, unlinkSync, rmdirSync, existsSync } from "fs";
19
+ var MAX_UNDO_DEPTH = 20;
20
+ var UndoStack = class {
21
+ stack = [];
22
+ /**
23
+ * 在执行文件写入操作之前调用,保存当前状态。
24
+ * @param filePath 将要被写入的文件路径
25
+ * @param description 操作描述,如 "write_file: src/index.ts"
26
+ */
27
+ push(filePath, description) {
28
+ let previousContent = null;
29
+ if (existsSync(filePath)) {
30
+ try {
31
+ previousContent = readFileSync(filePath, "utf-8");
32
+ } catch {
33
+ return;
34
+ }
35
+ }
36
+ this.pushEntry({
37
+ filePath,
38
+ previousContent,
39
+ description,
40
+ timestamp: /* @__PURE__ */ new Date()
41
+ });
42
+ }
43
+ /**
44
+ * 推入一个新建文件的条目(previousContent=null),undo 时删除该文件。
45
+ * 用于 bash 工具执行后检测到的新建文件。
46
+ */
47
+ pushNewFile(filePath, description) {
48
+ this.pushEntry({
49
+ filePath,
50
+ previousContent: null,
51
+ description,
52
+ timestamp: /* @__PURE__ */ new Date()
53
+ });
54
+ }
55
+ /**
56
+ * 推入一个新建目录的条目(previousContent=null, isDirectory=true),
57
+ * undo 时尝试 rmdir(仅空目录可删)。
58
+ */
59
+ pushNewDir(dirPath, description) {
60
+ this.pushEntry({
61
+ filePath: dirPath,
62
+ previousContent: null,
63
+ description,
64
+ timestamp: /* @__PURE__ */ new Date(),
65
+ isDirectory: true
66
+ });
67
+ }
68
+ /** 内部统一入栈方法,含溢出裁剪 */
69
+ pushEntry(entry) {
70
+ this.stack.push(entry);
71
+ if (this.stack.length > MAX_UNDO_DEPTH) {
72
+ this.stack.shift();
73
+ }
74
+ }
75
+ /**
76
+ * 弹出并执行最近一次撤销操作。
77
+ * @returns 撤销结果描述,或 null(栈为空时)
78
+ */
79
+ undo() {
80
+ const entry = this.stack.pop();
81
+ if (!entry) return null;
82
+ try {
83
+ if (entry.previousContent === null) {
84
+ if (entry.isDirectory) {
85
+ if (existsSync(entry.filePath)) {
86
+ try {
87
+ rmdirSync(entry.filePath);
88
+ return { entry, result: `Removed newly created directory: ${entry.filePath}` };
89
+ } catch {
90
+ return { entry, result: `Cannot remove directory (not empty): ${entry.filePath}` };
91
+ }
92
+ }
93
+ return { entry, result: `Directory already removed: ${entry.filePath}` };
94
+ } else {
95
+ if (existsSync(entry.filePath)) {
96
+ unlinkSync(entry.filePath);
97
+ }
98
+ return { entry, result: `Deleted newly created file: ${entry.filePath}` };
99
+ }
100
+ } else {
101
+ writeFileSync(entry.filePath, entry.previousContent, "utf-8");
102
+ const lines = entry.previousContent.split("\n").length;
103
+ return {
104
+ entry,
105
+ result: `Restored ${entry.filePath} to previous state (${lines} lines)`
106
+ };
107
+ }
108
+ } catch (err) {
109
+ const msg = err instanceof Error ? err.message : String(err);
110
+ return { entry, result: `Undo failed: ${msg}` };
111
+ }
112
+ }
113
+ /** 查看栈顶条目(不弹出),用于 /undo 显示预览 */
114
+ peek() {
115
+ return this.stack[this.stack.length - 1] ?? null;
116
+ }
117
+ /** 当前栈深度 */
118
+ get depth() {
119
+ return this.stack.length;
120
+ }
121
+ /** 返回栈的拷贝(所有文件修改记录),供 /diff 命令使用 */
122
+ getHistory() {
123
+ return [...this.stack];
124
+ }
125
+ /** 清空撤销栈(新会话时调用) */
126
+ clear() {
127
+ this.stack = [];
128
+ }
129
+ };
130
+ var undoStack = new UndoStack();
131
+
132
+ // src/tools/builtin/bash.ts
133
+ var IS_WINDOWS = platform() === "win32";
134
+ var SHELL = IS_WINDOWS ? "powershell.exe" : process.env["SHELL"] ?? "/bin/bash";
135
+ var persistentCwd = process.cwd();
136
+ var bashTool = {
137
+ definition: {
138
+ name: "bash",
139
+ description: IS_WINDOWS ? `Execute commands in PowerShell. Supports mkdir, ls, cat, python, etc.
140
+ Important rules:
141
+ 1. Each bash call runs in an independent subprocess; cd commands do not persist. To run in a specific directory, use the cwd parameter, or combine commands: e.g. "cd mydir; ls" or "mkdir mydir; cd mydir; New-Item file.txt".
142
+ 2. If a command fails (returns an error or non-zero exit code), stop immediately, report the error to the user, and do not retry the same or similar commands.
143
+ 3. Multiple commands can be combined with semicolons in a single call to reduce rounds.
144
+ 4. To delete directories, use Remove-Item -Recurse (the system will automatically optimize to a more reliable method).
145
+ 5. IMPORTANT: On Windows, "curl" is an alias for Invoke-WebRequest and does NOT support curl flags like -s, -X, -H. Use Invoke-RestMethod instead for HTTP requests. Example: Invoke-RestMethod -Uri "http://localhost:3000/api/health" -Method Get
146
+ 6. For long-running server commands (node server.js, npm run dev, etc.), use Start-Process -NoNewWindow to run in background, otherwise the tool will block until timeout.` : `Execute commands in ${SHELL}.
147
+ Important rules:
148
+ 1. Each bash call runs in an independent subprocess; cd commands do not persist. To run in a specific directory, use the cwd parameter, or combine commands: e.g. "cd mydir && ls" or "mkdir -p mydir && touch mydir/file.txt".
149
+ 2. If a command fails (returns an error or non-zero exit code), stop immediately, report the error to the user, and do not retry the same or similar commands.
150
+ 3. Multiple commands can be combined with && in a single call to reduce rounds.
151
+ 4. For long-running server commands (node server.js, npm start, npm run dev, etc.), run in background with & or nohup, otherwise the tool will block until timeout.`,
152
+ parameters: {
153
+ command: {
154
+ type: "string",
155
+ description: IS_WINDOWS ? `PowerShell command to execute. Combine multiple commands with semicolons, e.g.: "mkdir mydir; Set-Content mydir/file.txt 'content'"` : `${SHELL} command to execute. Combine multiple commands with &&, e.g.: "mkdir -p mydir && echo 'content' > mydir/file.txt"`,
156
+ required: true
157
+ },
158
+ cwd: {
159
+ type: "string",
160
+ description: "Working directory for the command (absolute or relative path). Once set, subsequent commands will also use this directory.",
161
+ required: false
162
+ },
163
+ timeout: {
164
+ type: "number",
165
+ description: "Timeout in milliseconds, defaults to 30000",
166
+ required: false
167
+ }
168
+ },
169
+ dangerous: false
170
+ },
171
+ async execute(args) {
172
+ const command = String(args["command"] ?? "");
173
+ const MAX_TIMEOUT = 3e5;
174
+ const timeout = Math.min(Math.max(Number(args["timeout"] ?? 3e4), 1e3), MAX_TIMEOUT);
175
+ const cwdArg = args["cwd"] ? String(args["cwd"]) : void 0;
176
+ if (!command.trim()) {
177
+ throw new Error("command is required");
178
+ }
179
+ if (!existsSync2(persistentCwd)) {
180
+ const fallback = process.cwd();
181
+ process.stderr.write(
182
+ `[bash] Previous cwd "${persistentCwd}" no longer exists, reset to "${fallback}"
183
+ `
184
+ );
185
+ persistentCwd = fallback;
186
+ }
187
+ let effectiveCwd = persistentCwd;
188
+ if (cwdArg) {
189
+ const resolved = resolve(persistentCwd, cwdArg);
190
+ if (!existsSync2(resolved)) {
191
+ throw new Error(
192
+ `cwd directory does not exist: "${resolved}". Create it first (e.g. mkdir -p "${resolved}") before specifying it as cwd.`
193
+ );
194
+ }
195
+ effectiveCwd = resolved;
196
+ persistentCwd = resolved;
197
+ }
198
+ let actualCommand;
199
+ if (IS_WINDOWS) {
200
+ const fixedCommand = fixWindowsDeleteCommand(command);
201
+ actualCommand = `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $OutputEncoding = [System.Text.Encoding]::UTF8; ${fixedCommand}`;
202
+ } else {
203
+ actualCommand = command;
204
+ }
205
+ const beforeSnapshot = snapshotDir(effectiveCwd);
206
+ const parsedTargets = parseCreationTargets(command, effectiveCwd);
207
+ const parsedTargetsBefore = /* @__PURE__ */ new Map();
208
+ for (const t of parsedTargets) {
209
+ parsedTargetsBefore.set(t, existsSync2(t));
210
+ }
211
+ try {
212
+ const output = execSync(actualCommand, {
213
+ timeout,
214
+ encoding: IS_WINDOWS ? "buffer" : "utf-8",
215
+ stdio: ["pipe", "pipe", "pipe"],
216
+ cwd: effectiveCwd,
217
+ shell: SHELL,
218
+ env: {
219
+ ...process.env,
220
+ PYTHONUTF8: "1",
221
+ PYTHONIOENCODING: "utf-8"
222
+ }
223
+ });
224
+ updateCwdFromCommand(command, effectiveCwd);
225
+ pushBashUndoEntries(beforeSnapshot, parsedTargetsBefore, effectiveCwd);
226
+ const result = IS_WINDOWS && Buffer.isBuffer(output) ? output.toString("utf-8") : output;
227
+ return result || "(command completed with no output)";
228
+ } catch (err) {
229
+ pushBashUndoEntries(beforeSnapshot, parsedTargetsBefore, effectiveCwd);
230
+ if (err && typeof err === "object" && "status" in err) {
231
+ const execErr = err;
232
+ const stderr = IS_WINDOWS && Buffer.isBuffer(execErr.stderr) ? execErr.stderr.toString("utf-8").trim() : execErr.stderr?.toString().trim() ?? "";
233
+ const stdout = IS_WINDOWS && Buffer.isBuffer(execErr.stdout) ? execErr.stdout.toString("utf-8").trim() : execErr.stdout?.toString().trim() ?? "";
234
+ const combined = [stdout, stderr].filter(Boolean).join("\n");
235
+ throw new Error(
236
+ `Exit code ${execErr.status}:
237
+ ${combined || (execErr.message ?? "Unknown error")}
238
+
239
+ [Command failed. Report this error to the user. Do not retry with variant commands.]`
240
+ );
241
+ }
242
+ throw err;
243
+ }
244
+ }
245
+ };
246
+ function fixWindowsDeleteCommand(command) {
247
+ return command.replace(
248
+ /Remove-Item\b([^;\n]*)/gi,
249
+ (match, args) => {
250
+ if (!/recurse/i.test(args)) return match;
251
+ let pathValue = "";
252
+ const pathMatch = args.match(/-Path\s+(['"]?)([^'";\s]+)\1/i) ?? args.match(/(?:^|\s)(['"]?)([^'";\s-][^'";\s]*)\1/);
253
+ if (pathMatch) {
254
+ pathValue = pathMatch[2] ?? "";
255
+ }
256
+ if (!pathValue) return match;
257
+ const safePath = pathValue.replace(/"/g, '\\"');
258
+ return `cmd /c rmdir /s /q "${safePath}"`;
259
+ }
260
+ );
261
+ }
262
+ function snapshotDir(dir) {
263
+ try {
264
+ return new Set(readdirSync(dir).map((name) => resolve(dir, name)));
265
+ } catch {
266
+ return /* @__PURE__ */ new Set();
267
+ }
268
+ }
269
+ function parseCreationTargets(command, cwd) {
270
+ const targets = [];
271
+ for (const m of command.matchAll(/(?:echo|cat|printf)\s+[^>]*>\s*(['"]?)([^\s;&|'"]+)\1/g)) {
272
+ if (m[2]) targets.push(resolve(cwd, m[2]));
273
+ }
274
+ for (const m of command.matchAll(/\btouch\s+((?:['"]?[^\s;&|'"]+['"]?\s*)+)/g)) {
275
+ for (const f of m[1].trim().split(/\s+/)) {
276
+ const clean = f.replace(/^['"]|['"]$/g, "");
277
+ if (clean && !clean.startsWith("-")) targets.push(resolve(cwd, clean));
278
+ }
279
+ }
280
+ for (const m of command.matchAll(/\bmkdir\s+(?:-\w+\s+)*((?:['"]?[^\s;&|'"]+['"]?\s*)+)/g)) {
281
+ for (const d of m[1].trim().split(/\s+/)) {
282
+ const clean = d.replace(/^['"]|['"]$/g, "");
283
+ if (clean && !clean.startsWith("-")) targets.push(resolve(cwd, clean));
284
+ }
285
+ }
286
+ for (const m of command.matchAll(/\bcp\s+(?:-\w+\s+)*['"]?[^\s;&|'"]+['"]?\s+(['"]?)([^\s;&|'"]+)\1/g)) {
287
+ if (m[2]) targets.push(resolve(cwd, m[2]));
288
+ }
289
+ for (const m of command.matchAll(/\bNew-Item\s+(?:-(?:Path|ItemType)\s+\w+\s+)*['"]?([^\s;&|'"]+)['"]?/gi)) {
290
+ if (m[1] && !m[1].startsWith("-")) targets.push(resolve(cwd, m[1]));
291
+ }
292
+ return [...new Set(targets)];
293
+ }
294
+ function pushBashUndoEntries(beforeSnapshot, parsedTargetsBefore, cwd) {
295
+ const tracked = /* @__PURE__ */ new Set();
296
+ const afterSnapshot = snapshotDir(cwd);
297
+ for (const absPath of afterSnapshot) {
298
+ if (!beforeSnapshot.has(absPath)) {
299
+ try {
300
+ const st = statSync(absPath);
301
+ if (st.isDirectory()) {
302
+ undoStack.pushNewDir(absPath, `bash (new dir): ${absPath}`);
303
+ } else {
304
+ undoStack.pushNewFile(absPath, `bash (new file): ${absPath}`);
305
+ }
306
+ tracked.add(absPath);
307
+ } catch {
308
+ }
309
+ }
310
+ }
311
+ for (const [target, existedBefore] of parsedTargetsBefore) {
312
+ if (!existedBefore && !tracked.has(target) && existsSync2(target)) {
313
+ try {
314
+ const st = statSync(target);
315
+ if (st.isDirectory()) {
316
+ undoStack.pushNewDir(target, `bash (new dir): ${target}`);
317
+ } else {
318
+ undoStack.pushNewFile(target, `bash (new file): ${target}`);
319
+ }
320
+ } catch {
321
+ }
322
+ }
323
+ }
324
+ }
325
+ function updateCwdFromCommand(command, baseCwd) {
326
+ const cdMatches = [...command.matchAll(/(?:^|[;&|])\s*cd\s+(['"]?)([^\s;&|'"]+)\1/g)];
327
+ if (cdMatches.length === 0) return;
328
+ const lastMatch = cdMatches[cdMatches.length - 1];
329
+ const target = lastMatch?.[2];
330
+ if (!target || target.startsWith("$") || target === "~") return;
331
+ try {
332
+ const newDir = resolve(baseCwd, target);
333
+ if (existsSync2(newDir)) {
334
+ persistentCwd = newDir;
335
+ }
336
+ } catch {
337
+ }
338
+ }
339
+
340
+ // src/tools/builtin/read-file.ts
341
+ import { readFileSync as readFileSync2, existsSync as existsSync3, statSync as statSync2, readdirSync as readdirSync2 } from "fs";
342
+ import { execSync as execSync2 } from "child_process";
343
+ import { extname, resolve as resolve2, basename, sep, dirname } from "path";
344
+ import { homedir } from "os";
345
+ var MAX_FILE_BYTES = 10 * 1024 * 1024;
346
+ function getSensitiveWarning(normalizedPath) {
347
+ const home = homedir();
348
+ const p = normalizedPath.toLowerCase();
349
+ const base = basename(normalizedPath).toLowerCase();
350
+ if (normalizedPath.startsWith(home) && p.includes(".aicli") && base === "config.json") {
351
+ return "[\u26A0 Security Warning: This file contains API keys. Be careful not to share this content.]\n\n";
352
+ }
353
+ if (base === ".env" || base.startsWith(".env.") || base.endsWith(".env")) {
354
+ return "[\u26A0 Security Warning: .env files may contain secrets and credentials.]\n\n";
355
+ }
356
+ if (normalizedPath.includes(`${sep}.ssh${sep}`) && (base.startsWith("id_") || base === "identity")) {
357
+ return "[\u26A0 Security Warning: This may be an SSH private key.]\n\n";
358
+ }
359
+ if (base === "credentials" && p.includes(".aws")) {
360
+ return "[\u26A0 Security Warning: This file contains AWS credentials.]\n\n";
361
+ }
362
+ return "";
363
+ }
364
+ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
365
+ ".pdf",
366
+ ".doc",
367
+ ".docx",
368
+ ".xls",
369
+ ".xlsx",
370
+ ".ppt",
371
+ ".pptx",
372
+ ".zip",
373
+ ".tar",
374
+ ".gz",
375
+ ".7z",
376
+ ".rar",
377
+ ".bz2",
378
+ ".png",
379
+ ".jpg",
380
+ ".jpeg",
381
+ ".gif",
382
+ ".bmp",
383
+ ".ico",
384
+ ".webp",
385
+ ".tiff",
386
+ ".mp3",
387
+ ".mp4",
388
+ ".avi",
389
+ ".mov",
390
+ ".mkv",
391
+ ".wav",
392
+ ".flac",
393
+ ".exe",
394
+ ".dll",
395
+ ".so",
396
+ ".dylib",
397
+ ".bin",
398
+ ".dat",
399
+ ".wasm",
400
+ ".class",
401
+ ".pyc"
402
+ ]);
403
+ function isBinaryBuffer(buf) {
404
+ const sample = buf.subarray(0, 512);
405
+ let nullCount = 0;
406
+ for (let i = 0; i < sample.length; i++) {
407
+ if (sample[i] === 0) nullCount++;
408
+ }
409
+ return nullCount / sample.length > 0.1;
410
+ }
411
+ function findSimilarFiles(filePath) {
412
+ const targetName = basename(filePath).toLowerCase();
413
+ if (!targetName) return [];
414
+ const cwd = process.cwd();
415
+ const candidates = [];
416
+ try {
417
+ for (const entry of readdirSync2(cwd, { withFileTypes: true })) {
418
+ if (entry.isFile() && entry.name.toLowerCase() === targetName) {
419
+ candidates.push(entry.name);
420
+ }
421
+ if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
422
+ try {
423
+ const subDir = resolve2(cwd, entry.name);
424
+ for (const sub of readdirSync2(subDir, { withFileTypes: true })) {
425
+ if (sub.isFile() && sub.name.toLowerCase() === targetName) {
426
+ candidates.push(`${entry.name}/${sub.name}`);
427
+ }
428
+ }
429
+ } catch {
430
+ }
431
+ }
432
+ }
433
+ } catch {
434
+ }
435
+ return candidates.slice(0, 5);
436
+ }
437
+ function tryExtractPdfText(absPath) {
438
+ try {
439
+ const output = execSync2(`pdftotext "${absPath}" -`, {
440
+ timeout: 15e3,
441
+ encoding: "utf-8",
442
+ stdio: ["pipe", "pipe", "pipe"]
443
+ });
444
+ if (output.trim().length > 0) return output;
445
+ } catch {
446
+ }
447
+ try {
448
+ const pyScript = `import sys; exec("try:\\n from pdfminer.high_level import extract_text\\n print(extract_text(sys.argv[1]))\\nexcept: pass")`;
449
+ const output = execSync2(`python -c "${pyScript}" "${absPath}"`, {
450
+ timeout: 15e3,
451
+ encoding: "utf-8",
452
+ stdio: ["pipe", "pipe", "pipe"],
453
+ env: { ...process.env, PYTHONUTF8: "1", PYTHONIOENCODING: "utf-8" }
454
+ });
455
+ if (output.trim().length > 0) return output;
456
+ } catch {
457
+ }
458
+ return null;
459
+ }
460
+ var readFileTool = {
461
+ definition: {
462
+ name: "read_file",
463
+ description: "Read text file contents. Automatically detects binary files (PDF/images/etc) and returns a hint instead of garbled output.",
464
+ parameters: {
465
+ path: {
466
+ type: "string",
467
+ description: "File path (absolute or relative to current working directory)",
468
+ required: true
469
+ },
470
+ encoding: {
471
+ type: "string",
472
+ description: "Encoding format, defaults to utf-8",
473
+ enum: ["utf-8", "utf8", "ascii", "base64"],
474
+ required: false
475
+ }
476
+ },
477
+ dangerous: false
478
+ },
479
+ async execute(args) {
480
+ const filePath = String(args["path"] ?? "");
481
+ const encoding = args["encoding"] ?? "utf-8";
482
+ if (!filePath) throw new Error("path is required");
483
+ const normalizedPath = resolve2(filePath);
484
+ if (!existsSync3(normalizedPath)) {
485
+ const suggestions = findSimilarFiles(filePath);
486
+ if (suggestions.length > 0) {
487
+ throw new Error(
488
+ `File not found: ${filePath}
489
+ Current working directory: ${process.cwd()}
490
+ Found similar files, did you mean:
491
+ ` + suggestions.map((s) => ` \u2192 ${s}`).join("\n") + `
492
+ Please retry with the correct relative path.`
493
+ );
494
+ }
495
+ throw new Error(
496
+ `File not found: ${filePath}
497
+ Current working directory: ${process.cwd()}
498
+ Please use list_dir to verify the file path and retry.`
499
+ );
500
+ }
501
+ const { size } = statSync2(normalizedPath);
502
+ if (size > MAX_FILE_BYTES) {
503
+ const mb = (size / 1024 / 1024).toFixed(1);
504
+ return `[File too large: ${filePath} (${mb} MB)]
505
+ Exceeds single read limit of ${MAX_FILE_BYTES / 1024 / 1024} MB.
506
+ Use the bash tool to read in segments, e.g.:
507
+ head -n 100 "${normalizedPath}" # first 100 lines
508
+ tail -n 100 "${normalizedPath}" # last 100 lines
509
+ sed -n '200,300p' "${normalizedPath}" # lines 200-300`;
510
+ }
511
+ const sensitiveWarning = getSensitiveWarning(normalizedPath);
512
+ const ext = extname(normalizedPath).toLowerCase();
513
+ if (ext === ".pdf") {
514
+ const pdfText = tryExtractPdfText(normalizedPath);
515
+ if (pdfText) {
516
+ const lines2 = pdfText.split("\n").length;
517
+ return `[PDF extracted: ${filePath} | ${lines2} lines]
518
+
519
+ ${pdfText}`;
520
+ }
521
+ const dir = dirname(normalizedPath);
522
+ const nameNoExt = basename(normalizedPath, ext);
523
+ const textAlts = [".md", ".txt", ".html"].map((e) => resolve2(dir, nameNoExt + e)).filter(existsSync3);
524
+ if (textAlts.length > 0) {
525
+ return `[PDF file: ${filePath}]
526
+ Cannot extract text from this PDF, but found alternative text versions:
527
+ ` + textAlts.map((p) => ` \u2192 ${basename(p)}`).join("\n") + `
528
+ Please use read_file to read the above files.`;
529
+ }
530
+ return `[PDF file: ${filePath}]
531
+ Cannot extract text from this PDF (requires pdftotext or pdfminer.six).
532
+ Suggestion: read existing text versions (.md / .txt) in the project, or install extraction tools via bash and retry.`;
533
+ }
534
+ if (BINARY_EXTENSIONS.has(ext)) {
535
+ return `[Binary file: ${filePath} (${ext})]
536
+ This is a binary file and cannot be read as text. If there is a text version (.md / .txt) in the project, please read that instead.`;
537
+ }
538
+ const buf = readFileSync2(normalizedPath);
539
+ if (encoding === "base64") {
540
+ return `[File: ${filePath} | base64]
541
+
542
+ ${buf.toString("base64")}`;
543
+ }
544
+ if (isBinaryBuffer(buf)) {
545
+ return `[Binary file: ${filePath}]
546
+ This file contains binary data and cannot be read as text.
547
+ If needed, use the bash tool to run an appropriate conversion program.`;
548
+ }
549
+ const content = buf.toString(encoding);
550
+ const lines = content.split("\n").length;
551
+ return `${sensitiveWarning}[File: ${filePath} | ${lines} lines]
552
+
553
+ ${content}`;
554
+ }
555
+ };
556
+
557
+ // src/tools/builtin/write-file.ts
558
+ import { writeFileSync as writeFileSync2, appendFileSync, mkdirSync } from "fs";
559
+ import { dirname as dirname2 } from "path";
560
+ var writeFileTool = {
561
+ definition: {
562
+ name: "write_file",
563
+ description: `Write content to a file. Creates the file if it doesn't exist, overwrites (append=false) or appends (append=true). Automatically creates parent directories.
564
+ Important: For long content (over 500 lines or 3000 chars), you MUST split into multiple calls using append=true, each no more than 300 lines, to avoid truncation. First call uses append=false (overwrite), subsequent calls use append=true.`,
565
+ parameters: {
566
+ path: {
567
+ type: "string",
568
+ description: "File path",
569
+ required: true
570
+ },
571
+ content: {
572
+ type: "string",
573
+ description: "Content to write",
574
+ required: true
575
+ },
576
+ append: {
577
+ type: "string",
578
+ description: "Whether to append. true=append to end, false=overwrite (default). Long files must be written in segments with append.",
579
+ required: false
580
+ },
581
+ encoding: {
582
+ type: "string",
583
+ description: "Encoding format, defaults to utf-8",
584
+ required: false
585
+ }
586
+ },
587
+ dangerous: false
588
+ // executor 会将 write_file 标记为 'write' 级别
589
+ },
590
+ async execute(args) {
591
+ const filePath = String(args["path"] ?? "");
592
+ const content = String(args["content"] ?? "");
593
+ const encoding = args["encoding"] ?? "utf-8";
594
+ const appendMode = String(args["append"] ?? "false").toLowerCase() === "true";
595
+ if (!filePath) throw new Error("path is required");
596
+ undoStack.push(filePath, `write_file${appendMode ? " (append)" : ""}: ${filePath}`);
597
+ mkdirSync(dirname2(filePath), { recursive: true });
598
+ if (appendMode) {
599
+ appendFileSync(filePath, content, encoding);
600
+ } else {
601
+ writeFileSync2(filePath, content, encoding);
602
+ }
603
+ const lines = content.split("\n").length;
604
+ const mode = appendMode ? "appended" : "written";
605
+ return `File ${mode}: ${filePath} (${lines} lines, ${content.length} bytes)`;
606
+ }
607
+ };
608
+
609
+ // src/tools/builtin/edit-file.ts
610
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync4 } from "fs";
611
+ function similarityScore(a, b) {
612
+ if (a === b) return 1;
613
+ if (a.length < 2 || b.length < 2) return 0;
614
+ const getBigrams = (s) => {
615
+ const bigrams = /* @__PURE__ */ new Map();
616
+ for (let i = 0; i < s.length - 1; i++) {
617
+ const bigram = s.slice(i, i + 2);
618
+ bigrams.set(bigram, (bigrams.get(bigram) ?? 0) + 1);
619
+ }
620
+ return bigrams;
621
+ };
622
+ const aBigrams = getBigrams(a.toLowerCase());
623
+ const bBigrams = getBigrams(b.toLowerCase());
624
+ let intersection = 0;
625
+ for (const [bigram, count] of aBigrams) {
626
+ intersection += Math.min(count, bBigrams.get(bigram) ?? 0);
627
+ }
628
+ return 2 * intersection / (a.length - 1 + b.length - 1);
629
+ }
630
+ function findSimilarLines(fileContent, searchStr, maxResults = 3) {
631
+ if (fileContent.length > 5e5) return [];
632
+ const fileLines = fileContent.split("\n");
633
+ const searchLines = searchStr.split("\n");
634
+ const firstSearchLine = searchLines[0].trim();
635
+ if (!firstSearchLine || firstSearchLine.length < 3) return [];
636
+ const candidates = [];
637
+ for (let i = 0; i < fileLines.length; i++) {
638
+ const trimmed = fileLines[i].trim();
639
+ if (!trimmed || trimmed.length < 3) continue;
640
+ const score = similarityScore(trimmed, firstSearchLine);
641
+ if (score > 0.4) {
642
+ candidates.push({ line: i + 1, text: fileLines[i], score });
643
+ }
644
+ }
645
+ return candidates.sort((a, b) => b.score - a.score).slice(0, maxResults).map((c) => ` Line ${c.line}: ${c.text.slice(0, 120)}`);
646
+ }
647
+ function findWhitespaceTolerant(fileLines, searchLines) {
648
+ const trimmedSearch = searchLines.map((l) => l.trim());
649
+ let matchStart = -1;
650
+ let matchCount = 0;
651
+ for (let i = 0; i <= fileLines.length - trimmedSearch.length; i++) {
652
+ let allMatch = true;
653
+ for (let j = 0; j < trimmedSearch.length; j++) {
654
+ if (fileLines[i + j].trim() !== trimmedSearch[j]) {
655
+ allMatch = false;
656
+ break;
657
+ }
658
+ }
659
+ if (allMatch) {
660
+ matchCount++;
661
+ if (matchStart === -1) matchStart = i;
662
+ if (matchCount > 1) break;
663
+ }
664
+ }
665
+ return { matchStart, matchCount };
666
+ }
667
+ var editFileTool = {
668
+ definition: {
669
+ name: "edit_file",
670
+ description: `Precisely edit file contents. Supports three modes:
671
+ 1. String replace (most common): Provide old_str and new_str to replace an exact match. old_str must appear exactly once in the file (unless replace_all is true).
672
+ 2. Line insert: Provide insert_after_line (1-based line number) and insert_content to insert after that line.
673
+ 3. Line delete: Provide delete_from_line and delete_to_line (inclusive) to delete that range.
674
+ Optional ignore_whitespace: true to match ignoring indentation differences.
675
+ Optional replace_all: true to replace ALL occurrences of old_str in the file at once (saves tool rounds when renaming variables/functions).
676
+ Note: Path can be absolute or relative to the current working directory.`,
677
+ parameters: {
678
+ path: {
679
+ type: "string",
680
+ description: "File path to edit",
681
+ required: true
682
+ },
683
+ old_str: {
684
+ type: "string",
685
+ description: "[Replace mode] Original string to replace, must appear exactly once (include enough context for uniqueness)",
686
+ required: false
687
+ },
688
+ new_str: {
689
+ type: "string",
690
+ description: "[Replace mode] New replacement string, can be empty to delete old_str",
691
+ required: false
692
+ },
693
+ ignore_whitespace: {
694
+ type: "boolean",
695
+ description: "[Replace mode] Whether to ignore leading/trailing whitespace per line when matching, defaults to false",
696
+ required: false
697
+ },
698
+ replace_all: {
699
+ type: "boolean",
700
+ description: "[Replace mode] Replace ALL occurrences of old_str instead of requiring unique match. Useful for renaming variables/functions across the file in one call.",
701
+ required: false
702
+ },
703
+ insert_after_line: {
704
+ type: "number",
705
+ description: "[Insert mode] Insert after this line number (1-based), 0 means insert at the beginning",
706
+ required: false
707
+ },
708
+ insert_content: {
709
+ type: "string",
710
+ description: "[Insert mode] Content to insert (no need to add newlines manually)",
711
+ required: false
712
+ },
713
+ delete_from_line: {
714
+ type: "number",
715
+ description: "[Delete mode] Start deleting from this line (1-based)",
716
+ required: false
717
+ },
718
+ delete_to_line: {
719
+ type: "number",
720
+ description: "[Delete mode] Delete up to and including this line (1-based)",
721
+ required: false
722
+ },
723
+ encoding: {
724
+ type: "string",
725
+ description: "File encoding, defaults to utf-8",
726
+ required: false
727
+ }
728
+ },
729
+ dangerous: false
730
+ // executor 中 edit_file 按 write 级别处理
731
+ },
732
+ async execute(args) {
733
+ const filePath = String(args["path"] ?? "");
734
+ const encoding = args["encoding"] ?? "utf-8";
735
+ if (!filePath) throw new Error("path is required");
736
+ if (!existsSync4(filePath)) throw new Error(`File not found: ${filePath}`);
737
+ const original = readFileSync3(filePath, encoding);
738
+ if (args["old_str"] !== void 0) {
739
+ const oldStr = String(args["old_str"]);
740
+ const newStr = String(args["new_str"] ?? "");
741
+ const ignoreWs = Boolean(args["ignore_whitespace"]);
742
+ const replaceAll = Boolean(args["replace_all"]);
743
+ if (oldStr === "") throw new Error("old_str cannot be empty");
744
+ if (ignoreWs) {
745
+ const fileLines = original.split("\n");
746
+ const searchLines = oldStr.split("\n");
747
+ const { matchStart, matchCount } = findWhitespaceTolerant(fileLines, searchLines);
748
+ if (matchStart === -1) {
749
+ const similar = findSimilarLines(original, oldStr);
750
+ const hint = similar.length > 0 ? `
751
+ Similar lines found (did you mean?):
752
+ ${similar.join("\n")}` : "";
753
+ return `ERROR: old_str not found in file (even with whitespace ignored).
754
+ File has ${fileLines.length} lines.${hint}
755
+ Please read the file first and use exact text.`;
756
+ }
757
+ if (matchCount > 1) {
758
+ return `ERROR: old_str matches multiple locations with whitespace-tolerant matching. Please include more surrounding context to make it unique.`;
759
+ }
760
+ undoStack.push(filePath, `edit_file (ws-replace): ${filePath}`);
761
+ const before = fileLines.slice(0, matchStart);
762
+ const after = fileLines.slice(matchStart + searchLines.length);
763
+ const updated2 = [...before, newStr, ...after].join("\n");
764
+ writeFileSync3(filePath, updated2, encoding);
765
+ return `Successfully edited ${filePath} (whitespace-tolerant match)
766
+ Location: around line ${matchStart + 1}
767
+ Replaced: ${searchLines.length} line(s) \u2192 ${newStr.split("\n").length} line(s)
768
+ Old: ${truncatePreview(oldStr)}
769
+ New: ${truncatePreview(newStr)}`;
770
+ }
771
+ if (replaceAll) {
772
+ const occurrences = original.split(oldStr).length - 1;
773
+ if (occurrences === 0) {
774
+ const similar = findSimilarLines(original, oldStr);
775
+ const hint = similar.length > 0 ? `
776
+ Similar lines found (did you mean?):
777
+ ${similar.join("\n")}` : "";
778
+ return `ERROR: old_str not found in file.${hint}
779
+ Please read the file first and use exact text.`;
780
+ }
781
+ undoStack.push(filePath, `edit_file (replace_all): ${filePath}`);
782
+ const updated2 = original.split(oldStr).join(newStr);
783
+ writeFileSync3(filePath, updated2, encoding);
784
+ return `Successfully edited ${filePath} (replace_all)
785
+ Replaced: ${occurrences} occurrence(s) of ${truncatePreview(oldStr)}
786
+ With: ${truncatePreview(newStr)}`;
787
+ }
788
+ const firstIndex = original.indexOf(oldStr);
789
+ if (firstIndex === -1) {
790
+ const lines = original.split("\n");
791
+ const similar = findSimilarLines(original, oldStr);
792
+ const hint = similar.length > 0 ? `
793
+ Similar lines found (did you mean?):
794
+ ${similar.join("\n")}` : "";
795
+ return `ERROR: old_str not found in file.
796
+ File has ${lines.length} lines.${hint}
797
+ Please read the file first and use exact text including whitespace/indentation.
798
+ Tip: You can also try ignore_whitespace: true to match ignoring indentation differences.`;
799
+ }
800
+ const secondIndex = original.indexOf(oldStr, firstIndex + 1);
801
+ if (secondIndex !== -1) {
802
+ return `ERROR: old_str appears multiple times in file (at least at positions ${firstIndex} and ${secondIndex}). Please include more surrounding context to make it unique.`;
803
+ }
804
+ undoStack.push(filePath, `edit_file (replace): ${filePath}`);
805
+ const updated = original.slice(0, firstIndex) + newStr + original.slice(firstIndex + oldStr.length);
806
+ writeFileSync3(filePath, updated, encoding);
807
+ const oldLines = oldStr.split("\n").length;
808
+ const newLines = newStr.split("\n").length;
809
+ const linesBefore = original.slice(0, firstIndex).split("\n").length;
810
+ return `Successfully edited ${filePath}
811
+ Location: around line ${linesBefore}
812
+ Replaced: ${oldLines} line(s) \u2192 ${newLines} line(s)
813
+ Old: ${truncatePreview(oldStr)}
814
+ New: ${truncatePreview(newStr)}`;
815
+ }
816
+ if (args["insert_after_line"] !== void 0) {
817
+ const afterLine = Number(args["insert_after_line"]);
818
+ const content = String(args["insert_content"] ?? "");
819
+ const lines = original.split("\n");
820
+ if (afterLine < 0 || afterLine > lines.length) {
821
+ throw new Error(`insert_after_line ${afterLine} is out of range (file has ${lines.length} lines)`);
822
+ }
823
+ undoStack.push(filePath, `edit_file (insert): ${filePath}`);
824
+ lines.splice(afterLine, 0, content);
825
+ writeFileSync3(filePath, lines.join("\n"), encoding);
826
+ return `Successfully inserted ${content.split("\n").length} line(s) after line ${afterLine} in ${filePath}`;
827
+ }
828
+ if (args["delete_from_line"] !== void 0) {
829
+ const fromLine = Number(args["delete_from_line"]);
830
+ const toLine = Number(args["delete_to_line"] ?? args["delete_from_line"]);
831
+ const lines = original.split("\n");
832
+ if (fromLine < 1 || toLine < fromLine || toLine > lines.length) {
833
+ throw new Error(
834
+ `Invalid line range: ${fromLine}-${toLine} (file has ${lines.length} lines, lines are 1-indexed)`
835
+ );
836
+ }
837
+ undoStack.push(filePath, `edit_file (delete): ${filePath}`);
838
+ const deleted = lines.splice(fromLine - 1, toLine - fromLine + 1);
839
+ writeFileSync3(filePath, lines.join("\n"), encoding);
840
+ return `Successfully deleted lines ${fromLine}-${toLine} (${deleted.length} lines) from ${filePath}`;
841
+ }
842
+ throw new Error(
843
+ "No operation specified. Provide either: (old_str + new_str) for replace, (insert_after_line + insert_content) for insert, or (delete_from_line + delete_to_line) for delete."
844
+ );
845
+ }
846
+ };
847
+ function truncatePreview(str, maxLen = 80) {
848
+ const oneLine = str.replace(/\n/g, "\u21B5");
849
+ if (oneLine.length <= maxLen) return JSON.stringify(str);
850
+ return JSON.stringify(oneLine.slice(0, maxLen)) + "...";
851
+ }
852
+
853
+ // src/tools/builtin/list-dir.ts
854
+ import { readdirSync as readdirSync3, statSync as statSync3, existsSync as existsSync5 } from "fs";
855
+ import { join, basename as basename2 } from "path";
856
+ var listDirTool = {
857
+ definition: {
858
+ name: "list_dir",
859
+ description: "List directory contents showing file names, types and sizes.",
860
+ parameters: {
861
+ path: {
862
+ type: "string",
863
+ description: "Directory path, defaults to current working directory",
864
+ required: false
865
+ },
866
+ recursive: {
867
+ type: "boolean",
868
+ description: "Whether to recursively list subdirectories, defaults to false",
869
+ required: false
870
+ }
871
+ },
872
+ dangerous: false
873
+ },
874
+ async execute(args) {
875
+ const dirPath = String(args["path"] ?? process.cwd());
876
+ const recursive = Boolean(args["recursive"] ?? false);
877
+ if (!existsSync5(dirPath)) {
878
+ const targetName = basename2(dirPath).toLowerCase();
879
+ const cwd = process.cwd();
880
+ const suggestions = [];
881
+ try {
882
+ for (const entry of readdirSync3(cwd, { withFileTypes: true })) {
883
+ if (entry.isDirectory() && entry.name.toLowerCase() === targetName) {
884
+ suggestions.push(entry.name);
885
+ }
886
+ }
887
+ } catch {
888
+ }
889
+ if (suggestions.length > 0) {
890
+ throw new Error(
891
+ `Directory not found: ${dirPath}
892
+ Current working directory: ${cwd}
893
+ Found similar directories:
894
+ ` + suggestions.map((s) => ` \u2192 ${s}`).join("\n") + `
895
+ Please retry with the correct relative path.`
896
+ );
897
+ }
898
+ throw new Error(
899
+ `Directory not found: ${dirPath}
900
+ Current working directory: ${cwd}
901
+ Please use list_dir (without path) to see the current directory structure first.`
902
+ );
903
+ }
904
+ const lines = [`Directory: ${dirPath}
905
+ `];
906
+ listRecursive(dirPath, "", recursive, lines);
907
+ return lines.join("\n");
908
+ }
909
+ };
910
+ function listRecursive(basePath, indent, recursive, lines) {
911
+ let entries;
912
+ try {
913
+ entries = readdirSync3(basePath, { withFileTypes: true });
914
+ } catch {
915
+ lines.push(`${indent}(permission denied)`);
916
+ return;
917
+ }
918
+ const sorted = entries.sort((a, b) => {
919
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
920
+ return a.name.localeCompare(b.name);
921
+ });
922
+ for (const entry of sorted) {
923
+ const USEFUL_DOT_ENTRIES = /* @__PURE__ */ new Set([".github", ".vscode", ".idea", ".gitignore", ".gitattributes", ".editorconfig", ".eslintrc", ".prettierrc", ".npmrc"]);
924
+ if (entry.name === "node_modules" || entry.name.startsWith(".") && !USEFUL_DOT_ENTRIES.has(entry.name) && !entry.name.startsWith(".env")) {
925
+ if (entry.isDirectory()) {
926
+ lines.push(`${indent}\u{1F4C1} ${entry.name}/ (skipped)`);
927
+ }
928
+ continue;
929
+ }
930
+ if (entry.isDirectory()) {
931
+ lines.push(`${indent}\u{1F4C1} ${entry.name}/`);
932
+ if (recursive) {
933
+ listRecursive(join(basePath, entry.name), indent + " ", true, lines);
934
+ }
935
+ } else {
936
+ try {
937
+ const stat = statSync3(join(basePath, entry.name));
938
+ const size = formatSize(stat.size);
939
+ lines.push(`${indent}\u{1F4C4} ${entry.name} (${size})`);
940
+ } catch {
941
+ lines.push(`${indent}\u{1F4C4} ${entry.name}`);
942
+ }
943
+ }
944
+ }
945
+ }
946
+ function formatSize(bytes) {
947
+ if (bytes < 1024) return `${bytes}B`;
948
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
949
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
950
+ }
951
+
952
+ // src/tools/builtin/grep-files.ts
953
+ import { readdirSync as readdirSync4, readFileSync as readFileSync4, statSync as statSync4, existsSync as existsSync6 } from "fs";
954
+ import { join as join2, relative } from "path";
955
+ var grepFilesTool = {
956
+ definition: {
957
+ name: "grep_files",
958
+ description: `Search for matching text or code in files, returning matching lines with line numbers. Use cases:
959
+ - Find function/class definitions (e.g. "function handleChat" or "class ProviderRegistry")
960
+ - Find all usages of a variable or string
961
+ - Check impact scope before modifying code
962
+ Supports regex. Automatically skips node_modules, dist, .git directories.`,
963
+ parameters: {
964
+ pattern: {
965
+ type: "string",
966
+ description: 'Search pattern, supports regex (e.g. "function\\s+\\w+" or literal "import React")',
967
+ required: true
968
+ },
969
+ path: {
970
+ type: "string",
971
+ description: "Search root directory, defaults to current working directory",
972
+ required: false
973
+ },
974
+ file_pattern: {
975
+ type: "string",
976
+ description: 'Filename filter with simple wildcards (e.g. "*.ts", "*.py", "*.json"), defaults to all text files',
977
+ required: false
978
+ },
979
+ ignore_case: {
980
+ type: "boolean",
981
+ description: "Whether to ignore case, defaults to false",
982
+ required: false
983
+ },
984
+ context_lines: {
985
+ type: "number",
986
+ description: "Number of context lines before/after each match, defaults to 0 (match line only)",
987
+ required: false
988
+ },
989
+ max_results: {
990
+ type: "number",
991
+ description: "Maximum number of results, defaults to 50",
992
+ required: false
993
+ }
994
+ },
995
+ dangerous: false
996
+ },
997
+ async execute(args) {
998
+ const pattern = String(args["pattern"] ?? "");
999
+ const rootPath = String(args["path"] ?? process.cwd());
1000
+ const filePattern = args["file_pattern"] ? String(args["file_pattern"]) : void 0;
1001
+ const ignoreCase = Boolean(args["ignore_case"] ?? false);
1002
+ const contextLines = Math.max(0, Number(args["context_lines"] ?? 0));
1003
+ const maxResults = Math.max(1, Number(args["max_results"] ?? 50));
1004
+ if (!pattern) throw new Error("pattern is required");
1005
+ if (!existsSync6(rootPath)) throw new Error(`Path not found: ${rootPath}`);
1006
+ let regex;
1007
+ try {
1008
+ regex = new RegExp(pattern, ignoreCase ? "gi" : "g");
1009
+ } catch {
1010
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1011
+ regex = new RegExp(escaped, ignoreCase ? "gi" : "g");
1012
+ }
1013
+ const results = [];
1014
+ const stat = statSync4(rootPath);
1015
+ if (stat.isFile()) {
1016
+ searchInFile(rootPath, rootPath, regex, contextLines, maxResults, results);
1017
+ } else {
1018
+ collectFiles(rootPath, filePattern, results, regex, contextLines, maxResults, rootPath);
1019
+ }
1020
+ if (results.length === 0) {
1021
+ return `No matches found for pattern: ${pattern}
1022
+ Searched in: ${rootPath}${filePattern ? `
1023
+ File filter: ${filePattern}` : ""}`;
1024
+ }
1025
+ const lines = [
1026
+ `Found ${results.length} match(es) for: ${pattern}`,
1027
+ `Searched in: ${rootPath}`,
1028
+ ""
1029
+ ];
1030
+ let currentFile = "";
1031
+ for (const r of results) {
1032
+ if (r.file !== currentFile) {
1033
+ if (currentFile) lines.push("");
1034
+ lines.push(`\u2500\u2500 ${r.file} \u2500\u2500`);
1035
+ currentFile = r.file;
1036
+ }
1037
+ if (r.contextBefore) {
1038
+ for (const [ln, text] of r.contextBefore) {
1039
+ lines.push(` ${String(ln).padStart(4)}\u2502 ${text}`);
1040
+ }
1041
+ }
1042
+ lines.push(`\u25B6 ${String(r.lineNumber).padStart(4)}\u2502 ${r.lineText}`);
1043
+ if (r.contextAfter) {
1044
+ for (const [ln, text] of r.contextAfter) {
1045
+ lines.push(` ${String(ln).padStart(4)}\u2502 ${text}`);
1046
+ }
1047
+ }
1048
+ }
1049
+ if (results.length >= maxResults) {
1050
+ lines.push("");
1051
+ lines.push(`(Results truncated at ${maxResults}. Use max_results or narrow your search.)`);
1052
+ }
1053
+ return lines.join("\n");
1054
+ }
1055
+ };
1056
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "dist-cjs", "release", ".next", "__pycache__", ".cache", "coverage", ".nyc_output"]);
1057
+ var BINARY_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".otf", ".mp3", ".mp4", ".wav", ".zip", ".tar", ".gz", ".rar", ".7z", ".exe", ".dll", ".so", ".dylib", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"]);
1058
+ function matchesFilePattern(filename, pattern) {
1059
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1060
+ return new RegExp(`^${escaped}$`, "i").test(filename);
1061
+ }
1062
+ function isBinary(filename) {
1063
+ const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
1064
+ return BINARY_EXTS.has(ext);
1065
+ }
1066
+ function collectFiles(dirPath, filePattern, results, regex, contextLines, maxResults, rootPath) {
1067
+ if (results.length >= maxResults) return;
1068
+ let entries;
1069
+ try {
1070
+ entries = readdirSync4(dirPath, { withFileTypes: true });
1071
+ } catch {
1072
+ return;
1073
+ }
1074
+ for (const entry of entries) {
1075
+ if (results.length >= maxResults) return;
1076
+ if (entry.isDirectory()) {
1077
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
1078
+ collectFiles(join2(dirPath, entry.name), filePattern, results, regex, contextLines, maxResults, rootPath);
1079
+ } else if (entry.isFile()) {
1080
+ if (isBinary(entry.name)) continue;
1081
+ if (filePattern && !matchesFilePattern(entry.name, filePattern)) continue;
1082
+ const fullPath = join2(dirPath, entry.name);
1083
+ const relPath = relative(rootPath, fullPath);
1084
+ searchInFile(fullPath, relPath, regex, contextLines, maxResults, results);
1085
+ }
1086
+ }
1087
+ }
1088
+ function searchInFile(fullPath, displayPath, regex, contextLines, maxResults, results) {
1089
+ try {
1090
+ const stat = statSync4(fullPath);
1091
+ if (stat.size > 1e6) return;
1092
+ } catch {
1093
+ return;
1094
+ }
1095
+ let content;
1096
+ try {
1097
+ content = readFileSync4(fullPath, "utf-8");
1098
+ } catch {
1099
+ return;
1100
+ }
1101
+ const lines = content.split("\n");
1102
+ regex.lastIndex = 0;
1103
+ for (let i = 0; i < lines.length; i++) {
1104
+ if (results.length >= maxResults) return;
1105
+ regex.lastIndex = 0;
1106
+ if (regex.test(lines[i])) {
1107
+ const result = {
1108
+ file: displayPath,
1109
+ lineNumber: i + 1,
1110
+ lineText: lines[i].trimEnd()
1111
+ };
1112
+ if (contextLines > 0) {
1113
+ result.contextBefore = [];
1114
+ result.contextAfter = [];
1115
+ for (let c = Math.max(0, i - contextLines); c < i; c++) {
1116
+ result.contextBefore.push([c + 1, lines[c].trimEnd()]);
1117
+ }
1118
+ for (let c = i + 1; c <= Math.min(lines.length - 1, i + contextLines); c++) {
1119
+ result.contextAfter.push([c + 1, lines[c].trimEnd()]);
1120
+ }
1121
+ }
1122
+ results.push(result);
1123
+ }
1124
+ }
1125
+ }
1126
+
1127
+ // src/tools/builtin/glob-files.ts
1128
+ import { readdirSync as readdirSync5, statSync as statSync5, existsSync as existsSync7 } from "fs";
1129
+ import { join as join3, relative as relative2, basename as basename3 } from "path";
1130
+ var globFilesTool = {
1131
+ definition: {
1132
+ name: "glob_files",
1133
+ description: `Find files by name or path pattern, returning a list of matching file paths. Use cases:
1134
+ - Find all files of a type (e.g. "**/*.ts" for all TypeScript files)
1135
+ - Find files by name (e.g. "**/index.ts" or "package.json")
1136
+ - Find files in a directory (e.g. "src/components/**")
1137
+ Results sorted by most recent modification time. Automatically skips node_modules, dist, .git directories.`,
1138
+ parameters: {
1139
+ pattern: {
1140
+ type: "string",
1141
+ description: 'Glob pattern, e.g. "**/*.ts", "src/**/*.tsx", "**/package.json", "*.md"',
1142
+ required: true
1143
+ },
1144
+ path: {
1145
+ type: "string",
1146
+ description: "Search root directory, defaults to current working directory",
1147
+ required: false
1148
+ },
1149
+ max_results: {
1150
+ type: "number",
1151
+ description: "Maximum number of files to return, defaults to 100",
1152
+ required: false
1153
+ }
1154
+ },
1155
+ dangerous: false
1156
+ },
1157
+ async execute(args) {
1158
+ const pattern = String(args["pattern"] ?? "");
1159
+ const rootPath = String(args["path"] ?? process.cwd());
1160
+ const maxResults = Math.max(1, Number(args["max_results"] ?? 100));
1161
+ if (!pattern) throw new Error("pattern is required");
1162
+ if (!existsSync7(rootPath)) throw new Error(`Path not found: ${rootPath}`);
1163
+ const regex = globToRegex(pattern);
1164
+ const matches = [];
1165
+ collectMatchingFiles(rootPath, rootPath, regex, matches, maxResults);
1166
+ if (matches.length === 0) {
1167
+ return `No files matched pattern: ${pattern}
1168
+ Searched in: ${rootPath}`;
1169
+ }
1170
+ matches.sort((a, b) => b.mtime - a.mtime);
1171
+ const lines = [
1172
+ `Found ${matches.length} file(s) matching: ${pattern}`,
1173
+ `Searched in: ${rootPath}`,
1174
+ "",
1175
+ ...matches.map((m) => m.relPath)
1176
+ ];
1177
+ if (matches.length >= maxResults) {
1178
+ lines.push("");
1179
+ lines.push(`(Results truncated at ${maxResults}. Use max_results or narrow your pattern.)`);
1180
+ }
1181
+ return lines.join("\n");
1182
+ }
1183
+ };
1184
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set([
1185
+ "node_modules",
1186
+ ".git",
1187
+ "dist",
1188
+ "dist-cjs",
1189
+ "release",
1190
+ ".next",
1191
+ "__pycache__",
1192
+ ".cache",
1193
+ "coverage",
1194
+ ".nyc_output",
1195
+ ".turbo",
1196
+ ".vite",
1197
+ "build",
1198
+ "out"
1199
+ ]);
1200
+ function globToRegex(pattern) {
1201
+ const normalized = pattern.replace(/\\/g, "/");
1202
+ let regStr = "";
1203
+ let i = 0;
1204
+ while (i < normalized.length) {
1205
+ const ch = normalized[i];
1206
+ if (ch === "*" && normalized[i + 1] === "*") {
1207
+ regStr += ".*";
1208
+ i += 2;
1209
+ if (normalized[i] === "/") i++;
1210
+ } else if (ch === "*") {
1211
+ regStr += "[^/]*";
1212
+ i++;
1213
+ } else if (ch === "?") {
1214
+ regStr += "[^/]";
1215
+ i++;
1216
+ } else if (".+^${}()|[]\\".includes(ch)) {
1217
+ regStr += "\\" + ch;
1218
+ i++;
1219
+ } else {
1220
+ regStr += ch;
1221
+ i++;
1222
+ }
1223
+ }
1224
+ if (!normalized.includes("/")) {
1225
+ return new RegExp(`(^|/)${regStr}$`, "i");
1226
+ }
1227
+ return new RegExp(`(^|/)${regStr}$`, "i");
1228
+ }
1229
+ function collectMatchingFiles(dirPath, rootPath, regex, results, maxResults) {
1230
+ if (results.length >= maxResults) return;
1231
+ let entries;
1232
+ try {
1233
+ entries = readdirSync5(dirPath, { withFileTypes: true });
1234
+ } catch {
1235
+ return;
1236
+ }
1237
+ for (const entry of entries) {
1238
+ if (results.length >= maxResults) break;
1239
+ const fullPath = join3(dirPath, entry.name);
1240
+ if (entry.isDirectory()) {
1241
+ if (SKIP_DIRS2.has(entry.name) || entry.name.startsWith(".")) continue;
1242
+ collectMatchingFiles(fullPath, rootPath, regex, results, maxResults);
1243
+ } else if (entry.isFile()) {
1244
+ const relPath = relative2(rootPath, fullPath).replace(/\\/g, "/");
1245
+ if (regex.test(relPath) || regex.test(basename3(relPath))) {
1246
+ try {
1247
+ const stat = statSync5(fullPath);
1248
+ results.push({ relPath, absPath: fullPath, mtime: stat.mtimeMs });
1249
+ } catch {
1250
+ results.push({ relPath, absPath: fullPath, mtime: 0 });
1251
+ }
1252
+ }
1253
+ }
1254
+ }
1255
+ }
1256
+
1257
+ // src/tools/builtin/run-interactive.ts
1258
+ import { spawn } from "child_process";
1259
+ import { platform as platform2 } from "os";
1260
+ var IS_WINDOWS2 = platform2() === "win32";
1261
+ var runInteractiveTool = {
1262
+ definition: {
1263
+ name: "run_interactive",
1264
+ description: `Run a CLI program that requires stdin interaction (e.g. Python games, Q&A scripts, menu programs). Pre-provide all input lines via stdin_lines array; the program consumes them sequentially and returns full output. [Important] args must be a string array like ["workspace/guess.py"], not a string. [Guessing strategy] For 1-100 guessing: start at 50, use binary search \u2014 at most 7 guesses. Example stdin_lines: ["50","25","37","43","40","41","n"] ("n"=don't play again). Windows Python path: C:\\Users\\Jinzd\\anaconda3\\envs\\python312\\python.exe`,
1265
+ parameters: {
1266
+ executable: {
1267
+ type: "string",
1268
+ description: 'Full path or command name of the executable, e.g. "python", "C:\\\\Users\\\\Jinzd\\\\anaconda3\\\\envs\\\\python312\\\\python.exe", "node"',
1269
+ required: true
1270
+ },
1271
+ args: {
1272
+ type: "array",
1273
+ description: 'Arguments array for the executable, e.g. ["workspace/guess.py"] or ["-c", "print(1)"]',
1274
+ items: { type: "string" },
1275
+ required: true
1276
+ },
1277
+ stdin_lines: {
1278
+ type: "array",
1279
+ description: `Input lines to feed to the program's stdin in order. E.g. for a guessing game: ["50", "25", "37"]. Each line gets a newline appended automatically.`,
1280
+ items: { type: "string" },
1281
+ required: true
1282
+ },
1283
+ timeout: {
1284
+ type: "number",
1285
+ description: "Overall timeout in milliseconds, defaults to 20000 (20s)",
1286
+ required: false
1287
+ }
1288
+ },
1289
+ dangerous: false
1290
+ },
1291
+ async execute(args) {
1292
+ const executable = String(args["executable"] ?? "").trim();
1293
+ const rawArgs = args["args"];
1294
+ let argsTypeWarning = "";
1295
+ const cmdArgs = Array.isArray(rawArgs) ? rawArgs.map(String) : typeof rawArgs === "string" && rawArgs.trim() ? (() => {
1296
+ argsTypeWarning = `[Warning] "args" should be an array but got string: ${JSON.stringify(rawArgs)}. Applied fallback.
1297
+ `;
1298
+ process.stderr.write(argsTypeWarning);
1299
+ return [rawArgs.trim()];
1300
+ })() : [];
1301
+ const rawStdin = args["stdin_lines"];
1302
+ let stdinTypeWarning = "";
1303
+ const stdinLines = Array.isArray(rawStdin) ? rawStdin.map(String) : typeof rawStdin === "string" && rawStdin.trim() ? (() => {
1304
+ stdinTypeWarning = `[Warning] "stdin_lines" should be an array but got string: ${JSON.stringify(rawStdin.slice(0, 80))}. Applied fallback.
1305
+ `;
1306
+ process.stderr.write(stdinTypeWarning);
1307
+ return rawStdin.split(",").map((s) => s.trim()).filter(Boolean);
1308
+ })() : [];
1309
+ const timeout = Math.min(Math.max(Number(args["timeout"] ?? 2e4), 1e3), 3e5);
1310
+ if (!executable) {
1311
+ throw new Error("executable is required");
1312
+ }
1313
+ const env = {
1314
+ ...process.env,
1315
+ // 强制 Python UTF-8 模式,修复 Windows 中文乱码
1316
+ PYTHONUTF8: "1",
1317
+ PYTHONIOENCODING: "utf-8",
1318
+ PYTHONDONTWRITEBYTECODE: "1"
1319
+ };
1320
+ const prefixWarnings = [argsTypeWarning, stdinTypeWarning].filter(Boolean).join("");
1321
+ return new Promise((resolve4) => {
1322
+ const child = spawn(executable, cmdArgs.map(String), {
1323
+ cwd: process.cwd(),
1324
+ env,
1325
+ stdio: ["pipe", "pipe", "pipe"]
1326
+ });
1327
+ let stdout = "";
1328
+ let stderr = "";
1329
+ child.stdout.setEncoding("utf-8");
1330
+ child.stderr.setEncoding("utf-8");
1331
+ child.stdout.on("data", (chunk) => {
1332
+ stdout += chunk;
1333
+ });
1334
+ child.stderr.on("data", (chunk) => {
1335
+ stderr += chunk;
1336
+ });
1337
+ let lineIdx = 0;
1338
+ const writeNextLine = () => {
1339
+ if (lineIdx < stdinLines.length && !child.stdin.destroyed) {
1340
+ const line = stdinLines[lineIdx++] + (IS_WINDOWS2 ? "\r\n" : "\n");
1341
+ const canContinue = child.stdin.write(line);
1342
+ if (canContinue) {
1343
+ setTimeout(writeNextLine, 150);
1344
+ } else {
1345
+ child.stdin.once("drain", () => setTimeout(writeNextLine, 150));
1346
+ }
1347
+ } else if (!child.stdin.destroyed) {
1348
+ child.stdin.end();
1349
+ }
1350
+ };
1351
+ setTimeout(writeNextLine, 400);
1352
+ const timer = setTimeout(() => {
1353
+ child.kill();
1354
+ resolve4(`${prefixWarnings}[Timeout after ${timeout}ms]
1355
+ ${buildOutput(stdout, stderr)}`);
1356
+ }, timeout);
1357
+ child.on("close", (code) => {
1358
+ clearTimeout(timer);
1359
+ const output = buildOutput(stdout, stderr);
1360
+ if (code !== 0 && code !== null) {
1361
+ resolve4(`${prefixWarnings}Exit code ${code}:
1362
+ ${output}`);
1363
+ } else {
1364
+ resolve4(`${prefixWarnings}${output || "(no output)"}`);
1365
+ }
1366
+ });
1367
+ child.on("error", (err) => {
1368
+ clearTimeout(timer);
1369
+ resolve4(
1370
+ `${prefixWarnings}Failed to start process "${executable}": ${err.message}
1371
+ Hint: On Windows, use the full path to the executable, e.g.:
1372
+ C:\\Users\\Jinzd\\anaconda3\\envs\\python312\\python.exe`
1373
+ );
1374
+ });
1375
+ });
1376
+ }
1377
+ };
1378
+ function buildOutput(stdout, stderr) {
1379
+ const parts = [];
1380
+ if (stdout.trim()) parts.push(stdout);
1381
+ if (stderr.trim()) parts.push(`[stderr]
1382
+ ${stderr}`);
1383
+ return parts.join("\n") || "(no output)";
1384
+ }
1385
+
1386
+ // src/tools/builtin/web-fetch.ts
1387
+ import { promises as dnsPromises } from "dns";
1388
+ function htmlToText(html) {
1389
+ const HTML_REGEX_LIMIT = 2e5;
1390
+ if (html.length > HTML_REGEX_LIMIT) {
1391
+ html = html.slice(0, HTML_REGEX_LIMIT);
1392
+ }
1393
+ let text = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<noscript[\s\S]*?<\/noscript>/gi, "").replace(/<svg[\s\S]*?<\/svg>/gi, "");
1394
+ text = text.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_m, lvl, content) => {
1395
+ const prefix = "#".repeat(Number(lvl));
1396
+ return `
1397
+ ${prefix} ${stripTags(content).trim()}
1398
+ `;
1399
+ });
1400
+ text = text.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (_m, code) => {
1401
+ return "\n```\n" + stripTags(code) + "\n```\n";
1402
+ });
1403
+ text = text.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (_m, item) => {
1404
+ return `
1405
+ - ${stripTags(item).trim()}`;
1406
+ });
1407
+ text = text.replace(/<\/(p|div|section|article|blockquote|tr)>/gi, "\n");
1408
+ text = text.replace(/<br\s*\/?>/gi, "\n");
1409
+ text = text.replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (_m, href, label) => {
1410
+ const l = stripTags(label).trim();
1411
+ if (href.startsWith("http") && l && l !== href) return `[${l}](${href})`;
1412
+ return l || href;
1413
+ });
1414
+ text = stripTags(text);
1415
+ text = text.replace(/\n{3,}/g, "\n\n");
1416
+ text = text.split("\n").map((l) => l.trimEnd()).join("\n");
1417
+ return text.trim();
1418
+ }
1419
+ function stripTags(html) {
1420
+ return html.replace(/<[^>]+>/g, "");
1421
+ }
1422
+ function extractTitle(html) {
1423
+ const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
1424
+ return m ? stripTags(m[1]).trim() : "";
1425
+ }
1426
+ function extractDescription(html) {
1427
+ const m = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i) ?? html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
1428
+ return m ? m[1].trim() : "";
1429
+ }
1430
+ var MAX_OUTPUT = 16e3;
1431
+ function isPrivateHost(hostname) {
1432
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
1433
+ if (h === "localhost" || h === "0.0.0.0" || h === "::1") return true;
1434
+ if (h.startsWith("fe80:")) return true;
1435
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
1436
+ if (m) {
1437
+ const [o1, o2] = [Number(m[1]), Number(m[2])];
1438
+ if (o1 === 127) return true;
1439
+ if (o1 === 10) return true;
1440
+ if (o1 === 172 && o2 >= 16 && o2 <= 31) return true;
1441
+ if (o1 === 192 && o2 === 168) return true;
1442
+ if (o1 === 169 && o2 === 254) return true;
1443
+ if (o1 === 0) return true;
1444
+ }
1445
+ return false;
1446
+ }
1447
+ async function resolveAndCheck(hostname) {
1448
+ const h = hostname.replace(/^\[|\]$/g, "");
1449
+ if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h) || h.includes(":")) return;
1450
+ try {
1451
+ const { address } = await dnsPromises.lookup(h);
1452
+ if (isPrivateHost(address)) {
1453
+ throw new Error(`Blocked: "${hostname}" resolves to private address ${address}. web_fetch is restricted to public URLs.`);
1454
+ }
1455
+ } catch (e) {
1456
+ if (e.message.startsWith("Blocked:")) throw e;
1457
+ }
1458
+ }
1459
+ var webFetchTool = {
1460
+ definition: {
1461
+ name: "web_fetch",
1462
+ description: "Fetch a URL and return its content as plain text / Markdown. Use this to read documentation, API references, READMEs, articles, or any public web page. Follows redirects automatically. Returns the first ~16000 characters of extracted text.",
1463
+ parameters: {
1464
+ url: {
1465
+ type: "string",
1466
+ description: "The full URL to fetch (must start with http:// or https://)",
1467
+ required: true
1468
+ },
1469
+ selector: {
1470
+ type: "string",
1471
+ description: "Optional: keyword to search in the extracted text. If provided, returns only the paragraphs / sections that contain this keyword (case-insensitive).",
1472
+ required: false
1473
+ }
1474
+ }
1475
+ },
1476
+ async execute(args) {
1477
+ const url = String(args["url"] ?? "").trim();
1478
+ const selector = args["selector"] ? String(args["selector"]).trim() : "";
1479
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
1480
+ throw new Error(`Invalid URL: "${url}". URL must start with http:// or https://`);
1481
+ }
1482
+ try {
1483
+ const parsedUrl = new URL(url);
1484
+ if (isPrivateHost(parsedUrl.hostname)) {
1485
+ throw new Error(`Blocked: "${url}" resolves to a private/internal address. web_fetch is restricted to public URLs.`);
1486
+ }
1487
+ await resolveAndCheck(parsedUrl.hostname);
1488
+ } catch (e) {
1489
+ if (e.message.startsWith("Blocked:")) throw e;
1490
+ throw new Error(`Invalid URL: "${url}"`);
1491
+ }
1492
+ const controller = new AbortController();
1493
+ const timeoutId = setTimeout(() => controller.abort(), 2e4);
1494
+ let rawHtml;
1495
+ let finalUrl;
1496
+ let contentType;
1497
+ const MAX_REDIRECTS = 10;
1498
+ const FETCH_HEADERS = {
1499
+ "User-Agent": "Mozilla/5.0 (compatible; ai-cli/1.0; +https://github.com/ai-cli)",
1500
+ Accept: "text/html,application/xhtml+xml,text/plain,*/*",
1501
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8"
1502
+ };
1503
+ try {
1504
+ let currentUrl = url;
1505
+ let resp = null;
1506
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
1507
+ const parsedHop = new URL(currentUrl);
1508
+ if (isPrivateHost(parsedHop.hostname)) {
1509
+ throw new Error(`Blocked: redirect to private/internal address "${currentUrl}".`);
1510
+ }
1511
+ await resolveAndCheck(parsedHop.hostname);
1512
+ const r = await fetch(currentUrl, {
1513
+ signal: controller.signal,
1514
+ headers: FETCH_HEADERS,
1515
+ redirect: "manual"
1516
+ // 手动控制重定向
1517
+ });
1518
+ if (r.status >= 300 && r.status < 400) {
1519
+ if (hop >= MAX_REDIRECTS) {
1520
+ throw new Error(`Too many redirects (>${MAX_REDIRECTS}): ${url}`);
1521
+ }
1522
+ const location = r.headers.get("Location");
1523
+ if (!location) {
1524
+ resp = r;
1525
+ break;
1526
+ }
1527
+ currentUrl = new URL(location, currentUrl).href;
1528
+ continue;
1529
+ }
1530
+ resp = r;
1531
+ break;
1532
+ }
1533
+ clearTimeout(timeoutId);
1534
+ if (!resp) throw new Error(`Too many redirects (>${MAX_REDIRECTS}): ${url}`);
1535
+ finalUrl = currentUrl;
1536
+ contentType = resp.headers.get("content-type") ?? "";
1537
+ if (!resp.ok) {
1538
+ throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
1539
+ }
1540
+ const buf = await resp.arrayBuffer();
1541
+ rawHtml = new TextDecoder("utf-8", { fatal: false }).decode(buf.slice(0, 2e6));
1542
+ } catch (err) {
1543
+ clearTimeout(timeoutId);
1544
+ if (err.name === "AbortError") {
1545
+ throw new Error(`Request timed out after 20s: ${url}`);
1546
+ }
1547
+ throw err;
1548
+ }
1549
+ let text;
1550
+ if (contentType.includes("text/plain") || contentType.includes("application/json")) {
1551
+ text = rawHtml;
1552
+ } else {
1553
+ const title = extractTitle(rawHtml);
1554
+ const desc = extractDescription(rawHtml);
1555
+ let body = htmlToText(rawHtml);
1556
+ const header = [
1557
+ title ? `# ${title}` : "",
1558
+ desc ? `> ${desc}` : "",
1559
+ `Source: ${finalUrl}`,
1560
+ ""
1561
+ ].filter(Boolean).join("\n");
1562
+ text = header + "\n\n" + body;
1563
+ }
1564
+ if (selector) {
1565
+ const lower = selector.toLowerCase();
1566
+ const paragraphs = text.split("\n\n");
1567
+ const matched = paragraphs.filter((p) => p.toLowerCase().includes(lower));
1568
+ if (matched.length > 0) {
1569
+ text = `[Filtered by keyword: "${selector}"]
1570
+
1571
+ ` + matched.join("\n\n");
1572
+ } else {
1573
+ text = `[No paragraphs contain keyword: "${selector}"]
1574
+
1575
+ ` + text;
1576
+ }
1577
+ }
1578
+ if (text.length > MAX_OUTPUT) {
1579
+ const kept = text.slice(0, MAX_OUTPUT);
1580
+ text = kept + `
1581
+
1582
+ ... [Content truncated: ${text.length} chars total, returning first ${MAX_OUTPUT} chars. Use the selector parameter to narrow scope for more details] ...`;
1583
+ }
1584
+ return text;
1585
+ }
1586
+ };
1587
+
1588
+ // src/tools/builtin/save-last-response.ts
1589
+ import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync2 } from "fs";
1590
+ import { dirname as dirname3 } from "path";
1591
+ var lastResponseStore = { content: "" };
1592
+ var saveLastResponseTool = {
1593
+ definition: {
1594
+ name: "save_last_response",
1595
+ description: `Tool for generating and saving large documents (exams, reports, long articles) to a file.
1596
+
1597
+ [Usage] When the user requests generating and saving a large document, call this tool with the target path:
1598
+ - The system will automatically initiate a streaming generation request, outputting content to both terminal and disk (tee mode)
1599
+ - No need to output content first then call \u2014 the system handles "generate + save" in one step
1600
+ - Only the file path is passed as a parameter, content is NOT passed via arguments, avoiding API argument truncation
1601
+
1602
+ [Warning] Do NOT use write_file to save large content \u2014 write_file's content parameter gets truncated by the API for large documents, resulting in incomplete files.
1603
+
1604
+ [Use cases] Exam papers (600-700 lines, 15-25KB), technical reports, long articles \u2014 any content over 2KB.`,
1605
+ parameters: {
1606
+ path: {
1607
+ type: "string",
1608
+ description: "File path to save to (including filename), e.g. reports/2026-summary.md",
1609
+ required: true
1610
+ }
1611
+ },
1612
+ dangerous: false
1613
+ // getDangerLevel 中标记为 write
1614
+ },
1615
+ async execute(args) {
1616
+ const filePath = String(args["path"] ?? "");
1617
+ if (!filePath) throw new Error("path is required");
1618
+ const content = lastResponseStore.content;
1619
+ if (!content) {
1620
+ throw new Error("No content to save: AI has not produced any response yet, or the last response was empty.");
1621
+ }
1622
+ undoStack.push(filePath, `save_last_response: ${filePath}`);
1623
+ mkdirSync2(dirname3(filePath), { recursive: true });
1624
+ writeFileSync4(filePath, content, "utf-8");
1625
+ const lines = content.split("\n").length;
1626
+ return `File saved: ${filePath} (${lines} lines, ${content.length} bytes)`;
1627
+ }
1628
+ };
1629
+
1630
+ // src/tools/builtin/save-memory.ts
1631
+ import { existsSync as existsSync8, statSync as statSync6, appendFileSync as appendFileSync2, mkdirSync as mkdirSync3 } from "fs";
1632
+ import { join as join4 } from "path";
1633
+ import { homedir as homedir2 } from "os";
1634
+ function getMemoryFilePath() {
1635
+ return join4(homedir2(), CONFIG_DIR_NAME, MEMORY_FILE_NAME);
1636
+ }
1637
+ function formatTimestamp() {
1638
+ const now = /* @__PURE__ */ new Date();
1639
+ const pad = (n) => String(n).padStart(2, "0");
1640
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
1641
+ }
1642
+ var saveMemoryTool = {
1643
+ definition: {
1644
+ name: "save_memory",
1645
+ description: "Save important information to persistent memory that survives across sessions. Use this to remember: user preferences, coding style, project architecture decisions, recurring patterns, key findings, or any knowledge worth preserving. Keep each memory entry concise (1-3 sentences). The content will be automatically timestamped.",
1646
+ parameters: {
1647
+ content: {
1648
+ type: "string",
1649
+ description: "The information to save to persistent memory. Keep it concise and actionable.",
1650
+ required: true
1651
+ }
1652
+ },
1653
+ dangerous: false
1654
+ },
1655
+ async execute(args) {
1656
+ const content = String(args["content"] ?? "").trim();
1657
+ if (!content) throw new Error("content is required");
1658
+ const memoryPath = getMemoryFilePath();
1659
+ const configDir = join4(homedir2(), CONFIG_DIR_NAME);
1660
+ if (!existsSync8(configDir)) {
1661
+ mkdirSync3(configDir, { recursive: true });
1662
+ }
1663
+ const timestamp = formatTimestamp();
1664
+ const entry = `
1665
+ ## ${timestamp}
1666
+ ${content}
1667
+ `;
1668
+ appendFileSync2(memoryPath, entry, "utf-8");
1669
+ const byteSize = statSync6(memoryPath).size;
1670
+ return `Memory saved successfully. File size: ${byteSize} bytes in ${MEMORY_FILE_NAME}`;
1671
+ }
1672
+ };
1673
+
1674
+ // src/tools/builtin/ask-user.ts
1675
+ import chalk from "chalk";
1676
+ var askUserContext = {
1677
+ prompting: false
1678
+ };
1679
+ var askUserTool = {
1680
+ definition: {
1681
+ name: "ask_user",
1682
+ description: "Ask the user a question and wait for their text response. Use this when you need clarification, confirmation, or any information from the user before proceeding with a task. The user will see the question in the terminal and can type a response. Returns the user's answer as text.",
1683
+ parameters: {
1684
+ question: {
1685
+ type: "string",
1686
+ description: "The question to ask the user. Be clear and specific.",
1687
+ required: true
1688
+ }
1689
+ },
1690
+ dangerous: false
1691
+ },
1692
+ async execute(args) {
1693
+ const question = String(args["question"] ?? "").trim();
1694
+ if (!question) throw new Error("question parameter is required");
1695
+ if (!askUserContext.rl) {
1696
+ throw new Error("ask_user is not available in this context (readline not initialized)");
1697
+ }
1698
+ const answer = await promptUser(askUserContext.rl, question);
1699
+ if (answer === null) {
1700
+ return "User did not respond (cancelled).";
1701
+ }
1702
+ return `User response: ${answer}`;
1703
+ }
1704
+ };
1705
+ function promptUser(rl, question) {
1706
+ const rlAny = rl;
1707
+ const savedOutput = rlAny.output;
1708
+ rlAny.output = process.stdout;
1709
+ rl.resume();
1710
+ askUserContext.prompting = true;
1711
+ console.log();
1712
+ console.log(chalk.cyan("\u2753 ") + chalk.bold(question));
1713
+ process.stdout.write(chalk.cyan("> "));
1714
+ return new Promise((resolve4) => {
1715
+ let completed = false;
1716
+ const cleanup = (answer) => {
1717
+ if (completed) return;
1718
+ completed = true;
1719
+ rl.removeListener("line", onLine);
1720
+ askUserContext.cancelFn = void 0;
1721
+ rl.pause();
1722
+ rlAny.output = savedOutput;
1723
+ askUserContext.prompting = false;
1724
+ resolve4(answer);
1725
+ };
1726
+ const onLine = (line) => {
1727
+ cleanup(line);
1728
+ };
1729
+ askUserContext.cancelFn = () => {
1730
+ process.stdout.write(chalk.gray("\n(cancelled)\n"));
1731
+ cleanup(null);
1732
+ };
1733
+ rl.once("line", onLine);
1734
+ });
1735
+ }
1736
+
1737
+ // src/tools/builtin/write-todos.ts
1738
+ import chalk2 from "chalk";
1739
+ var VALID_STATUSES = /* @__PURE__ */ new Set(["pending", "in_progress", "completed"]);
1740
+ var currentTodos = [];
1741
+ var writeTodosTool = {
1742
+ definition: {
1743
+ name: "write_todos",
1744
+ description: `Create or update a task list to track progress on complex tasks. Pass the COMPLETE list of todos each time (full replacement, not incremental). The list will be rendered in the terminal so the user can see progress. Use this proactively when working on multi-step tasks to show the user what you're doing.
1745
+ Parameter format: JSON array string, e.g. [{"title":"Read config","status":"completed"},{"title":"Parse args","status":"in_progress"},{"title":"Run tests","status":"pending"}]
1746
+ Valid statuses: pending, in_progress, completed.`,
1747
+ parameters: {
1748
+ todos: {
1749
+ type: "string",
1750
+ description: 'JSON array of todo objects. Each object must have "title" (string) and "status" ("pending"|"in_progress"|"completed"). Example: [{"title":"Step 1","status":"completed"},{"title":"Step 2","status":"in_progress"}]',
1751
+ required: true
1752
+ }
1753
+ },
1754
+ dangerous: false
1755
+ },
1756
+ async execute(args) {
1757
+ const raw = args["todos"];
1758
+ let parsed;
1759
+ if (typeof raw === "string") {
1760
+ const trimmed = raw.trim();
1761
+ if (!trimmed) throw new Error("todos parameter is required");
1762
+ try {
1763
+ parsed = JSON.parse(trimmed);
1764
+ } catch (err) {
1765
+ throw new Error(`Invalid JSON in todos parameter: ${err.message}`);
1766
+ }
1767
+ } else if (Array.isArray(raw)) {
1768
+ parsed = raw;
1769
+ } else {
1770
+ throw new Error("todos parameter must be a JSON array string");
1771
+ }
1772
+ if (!Array.isArray(parsed)) {
1773
+ throw new Error("todos must be a JSON array");
1774
+ }
1775
+ const todos = parsed.map((item, i) => {
1776
+ if (typeof item !== "object" || item === null) {
1777
+ throw new Error(`todos[${i}] must be an object`);
1778
+ }
1779
+ const obj = item;
1780
+ const title = String(obj["title"] ?? "").trim();
1781
+ const status = String(obj["status"] ?? "").trim();
1782
+ if (!title) throw new Error(`todos[${i}].title is required`);
1783
+ if (!VALID_STATUSES.has(status)) {
1784
+ throw new Error(`todos[${i}].status must be one of: pending, in_progress, completed (got "${status}")`);
1785
+ }
1786
+ return { title, status };
1787
+ });
1788
+ currentTodos = todos;
1789
+ renderTodoList(todos);
1790
+ const completed = todos.filter((t) => t.status === "completed").length;
1791
+ const inProgress = todos.filter((t) => t.status === "in_progress").length;
1792
+ const pending = todos.filter((t) => t.status === "pending").length;
1793
+ return `Todo list updated: ${todos.length} items (${completed} completed, ${inProgress} in progress, ${pending} pending)`;
1794
+ }
1795
+ };
1796
+ function renderTodoList(todos) {
1797
+ const completed = todos.filter((t) => t.status === "completed").length;
1798
+ const total = todos.length;
1799
+ console.log();
1800
+ console.log(
1801
+ chalk2.bold.cyan("\u{1F4CB} Todo List") + chalk2.dim(` (${completed}/${total} completed)`)
1802
+ );
1803
+ console.log(chalk2.dim(" " + "\u2500".repeat(40)));
1804
+ for (const todo of todos) {
1805
+ let icon;
1806
+ let text;
1807
+ switch (todo.status) {
1808
+ case "completed":
1809
+ icon = chalk2.green(" \u2713 ");
1810
+ text = chalk2.strikethrough.gray(todo.title);
1811
+ break;
1812
+ case "in_progress":
1813
+ icon = chalk2.yellow(" \u2192 ");
1814
+ text = chalk2.white(todo.title);
1815
+ break;
1816
+ case "pending":
1817
+ default:
1818
+ icon = chalk2.gray(" \u25CB ");
1819
+ text = chalk2.gray(todo.title);
1820
+ break;
1821
+ }
1822
+ console.log(icon + text);
1823
+ }
1824
+ console.log();
1825
+ }
1826
+
1827
+ // src/config/env-loader.ts
1828
+ var ENV_KEY_MAP = {
1829
+ claude: "AICLI_API_KEY_CLAUDE",
1830
+ gemini: "AICLI_API_KEY_GEMINI",
1831
+ deepseek: "AICLI_API_KEY_DEEPSEEK",
1832
+ zhipu: "AICLI_API_KEY_ZHIPU",
1833
+ kimi: "AICLI_API_KEY_KIMI",
1834
+ openai: "AICLI_API_KEY_OPENAI",
1835
+ openrouter: "AICLI_API_KEY_OPENROUTER",
1836
+ "google-search": "AICLI_API_KEY_GOOGLESEARCH"
1837
+ };
1838
+ var EnvLoader = class {
1839
+ /**
1840
+ * 读取指定 provider 的 API Key 环境变量。
1841
+ * 优先级:固定映射(如 AICLI_API_KEY_CLAUDE)> 动态格式(AICLI_API_KEY_<ID大写>)
1842
+ * 自定义 provider 示例:id="siliconflow" → 读取 AICLI_API_KEY_SILICONFLOW
1843
+ */
1844
+ static getApiKey(providerId) {
1845
+ const fixedEnvVar = ENV_KEY_MAP[providerId];
1846
+ if (fixedEnvVar) {
1847
+ const val = process.env[fixedEnvVar];
1848
+ if (val) return val;
1849
+ }
1850
+ const dynamicEnvVar = `AICLI_API_KEY_${providerId.toUpperCase().replace(/-/g, "_")}`;
1851
+ return process.env[dynamicEnvVar] || void 0;
1852
+ }
1853
+ static getDefaultProvider() {
1854
+ return process.env["AICLI_PROVIDER"] || void 0;
1855
+ }
1856
+ static isStreamingDisabled() {
1857
+ return process.env["AICLI_NO_STREAM"] === "1";
1858
+ }
1859
+ /** Google Custom Search Engine ID (cx) 环境变量 */
1860
+ static getGoogleSearchEngineId() {
1861
+ return process.env["AICLI_GOOGLE_CX"] || void 0;
1862
+ }
1863
+ };
1864
+
1865
+ // src/tools/builtin/google-search.ts
1866
+ var GOOGLE_SEARCH_API = "https://www.googleapis.com/customsearch/v1";
1867
+ var REQUEST_TIMEOUT_MS = 15e3;
1868
+ var MAX_RESULTS = 10;
1869
+ var DEFAULT_RESULTS = 5;
1870
+ var googleSearchContext = {};
1871
+ var googleSearchTool = {
1872
+ definition: {
1873
+ name: "google_search",
1874
+ description: "Search the web using Google Custom Search API. Returns titles, URLs, and descriptions of search results. Use this to look up current information, verify facts, find documentation, or research topics that require up-to-date web data.",
1875
+ parameters: {
1876
+ query: {
1877
+ type: "string",
1878
+ description: "The search query string.",
1879
+ required: true
1880
+ },
1881
+ num_results: {
1882
+ type: "number",
1883
+ description: "Number of results to return (1-10, default 5).",
1884
+ required: false
1885
+ }
1886
+ },
1887
+ dangerous: false
1888
+ },
1889
+ async execute(args) {
1890
+ const query = String(args["query"] ?? "").trim();
1891
+ if (!query) throw new Error("query parameter is required");
1892
+ const numResults = Math.min(
1893
+ Math.max(Math.floor(Number(args["num_results"] ?? DEFAULT_RESULTS)), 1),
1894
+ MAX_RESULTS
1895
+ );
1896
+ const { apiKey, cx } = resolveConfig();
1897
+ const url = new URL(GOOGLE_SEARCH_API);
1898
+ url.searchParams.set("key", apiKey);
1899
+ url.searchParams.set("cx", cx);
1900
+ url.searchParams.set("q", query);
1901
+ url.searchParams.set("num", String(numResults));
1902
+ const controller = new AbortController();
1903
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
1904
+ try {
1905
+ const response = await fetch(url.toString(), {
1906
+ method: "GET",
1907
+ headers: {
1908
+ "Accept": "application/json"
1909
+ },
1910
+ signal: controller.signal
1911
+ });
1912
+ if (!response.ok) {
1913
+ const errorBody = await response.text().catch(() => "");
1914
+ if (response.status === 403) {
1915
+ throw new Error(
1916
+ `Google Search API 403 Forbidden \u2014 Invalid API Key or daily free quota exceeded (100/day).
1917
+ Please check your API Key and Search Engine ID configuration.`
1918
+ );
1919
+ }
1920
+ if (response.status === 429) {
1921
+ throw new Error("Google Search API 429 \u2014 Too many requests, please try again later.");
1922
+ }
1923
+ throw new Error(
1924
+ `Google Search API error: HTTP ${response.status} ${response.statusText}
1925
+ ${errorBody.slice(0, 500)}`
1926
+ );
1927
+ }
1928
+ const data = await response.json();
1929
+ return formatResults(query, data, numResults);
1930
+ } catch (err) {
1931
+ if (err instanceof Error && err.name === "AbortError") {
1932
+ throw new Error(`Google Search request timed out (${REQUEST_TIMEOUT_MS / 1e3}s). Please check your network or proxy configuration.`);
1933
+ }
1934
+ throw err;
1935
+ } finally {
1936
+ clearTimeout(timeout);
1937
+ }
1938
+ }
1939
+ };
1940
+ function resolveConfig() {
1941
+ let apiKey;
1942
+ let cx;
1943
+ if (googleSearchContext.configManager) {
1944
+ apiKey = googleSearchContext.configManager.getApiKey("google-search");
1945
+ cx = EnvLoader.getGoogleSearchEngineId() ?? googleSearchContext.configManager.get("googleSearchEngineId");
1946
+ } else {
1947
+ apiKey = EnvLoader.getApiKey("google-search");
1948
+ cx = EnvLoader.getGoogleSearchEngineId();
1949
+ }
1950
+ if (!apiKey) {
1951
+ throw new Error(
1952
+ 'Google Search API Key not configured.\nConfigure via one of:\n 1. Run /config \u2192 Configure Google Search\n 2. Set env var AICLI_API_KEY_GOOGLESEARCH\n 3. Add apiKeys["google-search"] to ~/.aicli/config.json'
1953
+ );
1954
+ }
1955
+ if (!cx) {
1956
+ throw new Error(
1957
+ "Google Search Engine ID (cx) not configured.\nConfigure via one of:\n 1. Run /config \u2192 Configure Google Search\n 2. Set env var AICLI_GOOGLE_CX\n 3. Add googleSearchEngineId to ~/.aicli/config.json\n\nGet one at: https://programmablesearchengine.google.com/ \u2192 Create search engine \u2192 Copy Search Engine ID"
1958
+ );
1959
+ }
1960
+ return { apiKey, cx };
1961
+ }
1962
+ function formatResults(query, data, requested) {
1963
+ const items = data.items ?? [];
1964
+ if (items.length === 0) {
1965
+ const info2 = data.searchInformation;
1966
+ return `No results found for: "${query}"` + (info2 ? ` (searched ${info2.formattedTotalResults ?? "0"} pages in ${info2.formattedSearchTime ?? "?"}s)` : "");
1967
+ }
1968
+ const info = data.searchInformation;
1969
+ const header = `Search results for "${query}" (${items.length} of ~${info?.formattedTotalResults ?? "?"} results):
1970
+ `;
1971
+ const results = items.map((item, i) => {
1972
+ const title = item.title ?? "Untitled";
1973
+ const link = item.link ?? "";
1974
+ const snippet = item.snippet ?? "";
1975
+ return `${i + 1}. **${title}**
1976
+ URL: ${link}
1977
+ ${snippet}`;
1978
+ });
1979
+ return header + "\n" + results.join("\n\n");
1980
+ }
1981
+
1982
+ // src/tools/types.ts
1983
+ function isFileWriteTool(name) {
1984
+ return name === "write_file" || name === "edit_file";
1985
+ }
1986
+ function getDangerLevel(toolName, args) {
1987
+ if (toolName.startsWith("mcp__")) return "safe";
1988
+ if (toolName === "bash") {
1989
+ const cmd = String(args["command"] ?? "");
1990
+ if (/\brm\s+[^\n]*(?:-\w*[rRfF]\w*|--recursive|--force)\b/.test(cmd)) return "destructive";
1991
+ if (/\brm\s+\S/.test(cmd)) return "destructive";
1992
+ if (/\brmdir\b|\bformat\b|\bmkfs\b/.test(cmd)) return "destructive";
1993
+ if (/\bRemove-Item\b.*(?:-Recurse|-Force)|\bri\s+.*-(?:Recurse|Force)\b|\brd\s+\/s\b|\brmdir\s+\/s\b/i.test(cmd)) return "destructive";
1994
+ if (/\bdel\s+\S/.test(cmd)) return "destructive";
1995
+ if (/\becho\b.*>>?|\btee\b|\bcp\b|\bmv\b/.test(cmd)) return "write";
1996
+ if (/\bSet-Content\b|\bOut-File\b|\bAdd-Content\b|\bCopy-Item\b|\bMove-Item\b/i.test(cmd)) return "write";
1997
+ return "safe";
1998
+ }
1999
+ if (toolName === "write_file") return "write";
2000
+ if (toolName === "edit_file") return "write";
2001
+ if (toolName === "save_last_response") return "write";
2002
+ if (toolName === "run_interactive") {
2003
+ const exe = String(args["executable"] ?? "").toLowerCase();
2004
+ if (/\b(rm|rmdir|del|format|mkfs|Remove-Item)\b/i.test(exe)) return "destructive";
2005
+ if (/\b(bash|sh|zsh|cmd|powershell|pwsh|python|node|ruby|perl)\b/i.test(exe)) return "write";
2006
+ return "write";
2007
+ }
2008
+ if (toolName === "read_file" || toolName === "list_dir" || toolName === "grep_files" || toolName === "glob_files" || toolName === "web_fetch" || toolName === "save_memory" || toolName === "ask_user" || toolName === "write_todos" || toolName === "google_search" || toolName === "spawn_agent" || toolName === "run_tests") return "safe";
2009
+ return "write";
2010
+ }
2011
+ function schemaToJsonSchema(schema) {
2012
+ const result = {
2013
+ type: schema.type,
2014
+ description: schema.description
2015
+ };
2016
+ if (schema.enum) result["enum"] = schema.enum;
2017
+ if (schema.items) result["items"] = schemaToJsonSchema(schema.items);
2018
+ if (schema.properties) {
2019
+ result["properties"] = Object.fromEntries(
2020
+ Object.entries(schema.properties).map(([k, v]) => [k, schemaToJsonSchema(v)])
2021
+ );
2022
+ }
2023
+ return result;
2024
+ }
2025
+
2026
+ // src/tools/truncate.ts
2027
+ var DEFAULT_MAX_TOOL_OUTPUT_CHARS = 12e3;
2028
+ function getMaxOutputChars(contextWindow) {
2029
+ if (!contextWindow || contextWindow <= 0) return DEFAULT_MAX_TOOL_OUTPUT_CHARS;
2030
+ return Math.max(DEFAULT_MAX_TOOL_OUTPUT_CHARS, Math.min(Math.floor(contextWindow / 4), 12e4));
2031
+ }
2032
+ var activeMaxChars = DEFAULT_MAX_TOOL_OUTPUT_CHARS;
2033
+ function setContextWindow(contextWindow) {
2034
+ activeMaxChars = getMaxOutputChars(contextWindow);
2035
+ }
2036
+ function getActiveMaxChars() {
2037
+ return activeMaxChars;
2038
+ }
2039
+ function truncateOutput(content, toolName, maxChars) {
2040
+ const limit = maxChars ?? activeMaxChars;
2041
+ if (content.length <= limit) return content;
2042
+ const keepHead = Math.floor(limit * 0.7);
2043
+ const keepTail = Math.floor(limit * 0.2);
2044
+ const omitted = content.length - keepHead - keepTail;
2045
+ const lines = content.split("\n").length;
2046
+ const head = content.slice(0, keepHead);
2047
+ const tail = content.slice(content.length - keepTail);
2048
+ return head + `
2049
+
2050
+ ... [Output truncated: ${content.length} chars / ${lines} lines total, ${omitted} chars omitted. Use read_file to view full content in segments` + (toolName === "bash" ? ", or narrow the command scope" : "") + `] ...
2051
+
2052
+ ` + tail;
2053
+ }
2054
+
2055
+ // src/repl/theme.ts
2056
+ import chalk3 from "chalk";
2057
+ var DARK_THEME = {
2058
+ prompt: chalk3.green,
2059
+ info: chalk3.cyan,
2060
+ warning: chalk3.yellow,
2061
+ error: chalk3.red,
2062
+ success: chalk3.green,
2063
+ dim: chalk3.dim,
2064
+ accent: chalk3.cyan,
2065
+ toolCall: chalk3.yellow,
2066
+ toolResult: chalk3.green,
2067
+ heading: chalk3.bold.cyan
2068
+ };
2069
+ var LIGHT_THEME = {
2070
+ prompt: chalk3.blue,
2071
+ info: chalk3.blueBright,
2072
+ warning: chalk3.yellow,
2073
+ error: chalk3.red,
2074
+ success: chalk3.green,
2075
+ dim: chalk3.gray,
2076
+ accent: chalk3.blueBright,
2077
+ toolCall: chalk3.magenta,
2078
+ toolResult: chalk3.green,
2079
+ heading: chalk3.bold.blue
2080
+ };
2081
+ function resolveColor(name) {
2082
+ if (name.startsWith("#")) return chalk3.hex(name);
2083
+ const parts = name.split(".");
2084
+ let result = chalk3;
2085
+ for (const part of parts) {
2086
+ const obj = result;
2087
+ if (obj && typeof obj[part] !== "undefined") {
2088
+ result = obj[part];
2089
+ }
2090
+ }
2091
+ if (typeof result !== "function") {
2092
+ process.stderr.write(`[theme] Warning: unrecognized color "${name}", using default.
2093
+ `);
2094
+ return chalk3;
2095
+ }
2096
+ return result;
2097
+ }
2098
+ function buildCustomTheme(base, overrides) {
2099
+ if (!overrides) return base;
2100
+ const result = { ...base };
2101
+ for (const [key, colorName] of Object.entries(overrides)) {
2102
+ if (key in result && colorName) {
2103
+ result[key] = resolveColor(colorName);
2104
+ }
2105
+ }
2106
+ return result;
2107
+ }
2108
+ var _currentTheme = DARK_THEME;
2109
+ function initTheme(themeId = "dark", customColors) {
2110
+ switch (themeId) {
2111
+ case "light":
2112
+ _currentTheme = LIGHT_THEME;
2113
+ break;
2114
+ case "custom":
2115
+ _currentTheme = buildCustomTheme(DARK_THEME, customColors);
2116
+ break;
2117
+ default:
2118
+ _currentTheme = DARK_THEME;
2119
+ }
2120
+ }
2121
+ var theme = new Proxy(DARK_THEME, {
2122
+ get(_target, prop) {
2123
+ return _currentTheme[prop];
2124
+ }
2125
+ });
2126
+
2127
+ // src/tools/builtin/spawn-agent.ts
2128
+ var spawnAgentContext = {
2129
+ provider: null,
2130
+ model: "",
2131
+ systemPrompt: void 0,
2132
+ modelParams: {},
2133
+ configManager: null
2134
+ };
2135
+ var PREFIX = theme.dim(" \u2503 ");
2136
+ var SubAgentExecutor = class {
2137
+ constructor(registry) {
2138
+ this.registry = registry;
2139
+ }
2140
+ round = 0;
2141
+ totalRounds = 0;
2142
+ setRoundInfo(current, total) {
2143
+ this.round = current;
2144
+ this.totalRounds = total;
2145
+ }
2146
+ async execute(call) {
2147
+ const tool = this.registry.get(call.name);
2148
+ if (!tool) {
2149
+ return {
2150
+ callId: call.id,
2151
+ content: `Unknown tool: ${call.name}`,
2152
+ isError: true
2153
+ };
2154
+ }
2155
+ const dangerLevel = getDangerLevel(call.name, call.arguments);
2156
+ if (dangerLevel === "destructive") {
2157
+ this.printPrefixed(
2158
+ theme.error("\u26A0 BLOCKED: ") + `Destructive operation ${call.name} not allowed in sub-agent`
2159
+ );
2160
+ return {
2161
+ callId: call.id,
2162
+ content: "Destructive operations are not allowed in sub-agents.",
2163
+ isError: true
2164
+ };
2165
+ }
2166
+ this.printToolCall(call, dangerLevel);
2167
+ try {
2168
+ const rawContent = await tool.execute(call.arguments);
2169
+ const content = truncateOutput(rawContent, call.name);
2170
+ const wasTruncated = content !== rawContent;
2171
+ this.printToolResult(call.name, rawContent, false, wasTruncated);
2172
+ return { callId: call.id, content, isError: false };
2173
+ } catch (err) {
2174
+ const message = err instanceof Error ? err.message : String(err);
2175
+ this.printToolResult(call.name, message, true, false);
2176
+ return { callId: call.id, content: message, isError: true };
2177
+ }
2178
+ }
2179
+ async executeAll(calls) {
2180
+ const results = [];
2181
+ for (const call of calls) {
2182
+ results.push(await this.execute(call));
2183
+ }
2184
+ return results;
2185
+ }
2186
+ // ── 带前缀的终端输出 ──
2187
+ printPrefixed(text) {
2188
+ for (const line of text.split("\n")) {
2189
+ console.log(PREFIX + line);
2190
+ }
2191
+ }
2192
+ printToolCall(call, dangerLevel) {
2193
+ console.log(PREFIX);
2194
+ const icon = dangerLevel === "write" ? theme.warning("\u270E Tool: ") : theme.toolCall("\u2699 Tool: ");
2195
+ const roundBadge = this.totalRounds > 0 ? theme.dim(` [${this.round}/${this.totalRounds}]`) : "";
2196
+ console.log(PREFIX + icon + call.name + roundBadge);
2197
+ for (const [key, val] of Object.entries(call.arguments)) {
2198
+ let valStr;
2199
+ if (Array.isArray(val)) {
2200
+ const json = JSON.stringify(val);
2201
+ valStr = json.length > 160 ? json.slice(0, 160) + "..." : json;
2202
+ } else if (typeof val === "string" && val.length > 120) {
2203
+ valStr = val.slice(0, 120) + "...";
2204
+ } else {
2205
+ valStr = String(val);
2206
+ }
2207
+ console.log(PREFIX + theme.dim(` ${key}: `) + valStr);
2208
+ }
2209
+ }
2210
+ printToolResult(name, content, isError, wasTruncated) {
2211
+ if (isError) {
2212
+ console.log(
2213
+ PREFIX + theme.error(`\u26A0 ${name} error: `) + theme.dim(content.slice(0, 300))
2214
+ );
2215
+ } else {
2216
+ const lines = content.split("\n");
2217
+ const maxLines = name === "run_interactive" ? 40 : 8;
2218
+ const preview = lines.slice(0, maxLines);
2219
+ const prefixedPreview = preview.map((l) => PREFIX + " " + theme.dim(l)).join("\n");
2220
+ const moreLines = lines.length > maxLines ? "\n" + PREFIX + theme.dim(` ... (${lines.length - maxLines} more lines)`) : "";
2221
+ const truncNote = wasTruncated ? "\n" + PREFIX + theme.warning(` \u26A1 Output truncated`) : "";
2222
+ console.log(PREFIX + theme.success("\u2713 Result:"));
2223
+ console.log(prefixedPreview + moreLines + truncNote);
2224
+ }
2225
+ }
2226
+ };
2227
+ function buildSubAgentSystemPrompt(task, parentSystemPrompt) {
2228
+ const parts = [];
2229
+ if (parentSystemPrompt) {
2230
+ parts.push(parentSystemPrompt);
2231
+ }
2232
+ parts.push(
2233
+ `# Sub-Agent Mode
2234
+
2235
+ You are a focused sub-agent delegated by the main agent to complete a specific task.
2236
+
2237
+ **Your Task**:
2238
+ ${task}
2239
+
2240
+ **Rules**:
2241
+ 1. Stay focused on your task, do not deviate
2242
+ 2. When done, output a concise summary: what was done, which files were modified, and the result
2243
+ 3. You cannot interact with the user (no ask_user tool), work independently using the task description
2244
+ 4. You cannot create sub-agents (no spawn_agent tool)
2245
+ 5. Destructive operations (rm -rf etc.) will be automatically blocked
2246
+ 6. Be efficient, minimize unnecessary tool call rounds`
2247
+ );
2248
+ return parts.join("\n\n---\n\n");
2249
+ }
2250
+ function printSubAgentHeader(task, maxRounds) {
2251
+ console.log();
2252
+ console.log(theme.dim(" \u250F\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501"));
2253
+ console.log(PREFIX + theme.toolCall("\u{1F916} Sub-Agent Spawned"));
2254
+ console.log(
2255
+ PREFIX + theme.dim("Task: ") + (task.slice(0, 120) + (task.length > 120 ? "..." : ""))
2256
+ );
2257
+ console.log(PREFIX + theme.dim(`Max rounds: ${maxRounds}`));
2258
+ console.log(theme.dim(" \u2503"));
2259
+ }
2260
+ function printSubAgentFooter(usage) {
2261
+ console.log(PREFIX);
2262
+ console.log(PREFIX + theme.toolCall("Sub-Agent Complete"));
2263
+ if (usage.inputTokens > 0 || usage.outputTokens > 0) {
2264
+ console.log(
2265
+ PREFIX + theme.dim(
2266
+ `Tokens: ${usage.inputTokens} in / ${usage.outputTokens} out`
2267
+ )
2268
+ );
2269
+ }
2270
+ console.log(theme.dim(" \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501"));
2271
+ console.log();
2272
+ }
2273
+ var spawnAgentTool = {
2274
+ definition: {
2275
+ name: "spawn_agent",
2276
+ description: "Delegate a specific subtask to an independent sub-agent. The sub-agent has its own conversation context and agentic tool-call loop, with access to bash, file read/write, edit, search, etc., but cannot interact with the user or spawn more sub-agents. Suitable for independently completable subtasks like: refactoring a module, writing tests, researching a technical approach, or implementing a specific feature. Returns a summary of the sub-agent execution result.",
2277
+ parameters: {
2278
+ task: {
2279
+ type: "string",
2280
+ description: "Task description for the sub-agent. Must include all necessary context: file paths, requirements, constraints, expected output. The sub-agent cannot access the parent conversation history; all info must be in this parameter.",
2281
+ required: true
2282
+ },
2283
+ max_rounds: {
2284
+ type: "number",
2285
+ description: "Max tool-call rounds for the sub-agent (1-15, default 10). Use fewer for simple tasks, more for complex ones.",
2286
+ required: false
2287
+ }
2288
+ },
2289
+ dangerous: false
2290
+ },
2291
+ async execute(args) {
2292
+ const task = String(args["task"] ?? "").trim();
2293
+ if (!task) throw new Error("task parameter is required");
2294
+ const rawMaxRounds = Number(args["max_rounds"] ?? SUBAGENT_DEFAULT_MAX_ROUNDS);
2295
+ const maxRounds = Math.min(
2296
+ Math.max(Math.round(rawMaxRounds), 1),
2297
+ SUBAGENT_MAX_ROUNDS_LIMIT
2298
+ );
2299
+ const ctx = spawnAgentContext;
2300
+ if (!ctx.provider) {
2301
+ throw new Error("spawn_agent: provider not initialized (context not injected)");
2302
+ }
2303
+ const subRegistry = new ToolRegistry();
2304
+ for (const tool of subRegistry.listAll()) {
2305
+ if (!SUBAGENT_ALLOWED_TOOLS.has(tool.definition.name)) {
2306
+ subRegistry.unregister(tool.definition.name);
2307
+ }
2308
+ }
2309
+ const subExecutor = new SubAgentExecutor(subRegistry);
2310
+ const subMessages = [
2311
+ { role: "user", content: task, timestamp: /* @__PURE__ */ new Date() }
2312
+ ];
2313
+ const subSystemPrompt = buildSubAgentSystemPrompt(task, ctx.systemPrompt);
2314
+ const toolDefs = subRegistry.getDefinitions();
2315
+ const extraMessages = [];
2316
+ const totalUsage = { inputTokens: 0, outputTokens: 0 };
2317
+ let finalContent = "";
2318
+ printSubAgentHeader(task, maxRounds);
2319
+ try {
2320
+ for (let round = 0; round < maxRounds; round++) {
2321
+ subExecutor.setRoundInfo(round + 1, maxRounds);
2322
+ const result = await ctx.provider.chatWithTools(
2323
+ {
2324
+ messages: subMessages,
2325
+ model: ctx.model,
2326
+ systemPrompt: subSystemPrompt,
2327
+ stream: false,
2328
+ temperature: ctx.modelParams.temperature,
2329
+ maxTokens: ctx.modelParams.maxTokens,
2330
+ timeout: ctx.modelParams.timeout,
2331
+ thinking: ctx.modelParams.thinking,
2332
+ ...extraMessages.length > 0 ? { _extraMessages: extraMessages } : {}
2333
+ },
2334
+ toolDefs
2335
+ );
2336
+ if (result.usage) {
2337
+ totalUsage.inputTokens += result.usage.inputTokens;
2338
+ totalUsage.outputTokens += result.usage.outputTokens;
2339
+ }
2340
+ if ("content" in result) {
2341
+ finalContent = result.content;
2342
+ break;
2343
+ }
2344
+ if (ctx.configManager) {
2345
+ googleSearchContext.configManager = ctx.configManager;
2346
+ }
2347
+ const toolResults = await subExecutor.executeAll(result.toolCalls);
2348
+ const reasoningContent = "reasoningContent" in result ? result.reasoningContent : void 0;
2349
+ const newMsgs = ctx.provider.buildToolResultMessages(
2350
+ result.toolCalls,
2351
+ toolResults,
2352
+ reasoningContent
2353
+ );
2354
+ extraMessages.push(...newMsgs);
2355
+ }
2356
+ if (!finalContent) {
2357
+ finalContent = `(Sub-agent reached maximum rounds (${maxRounds}) without producing a final response)`;
2358
+ }
2359
+ } catch (err) {
2360
+ const errMsg = err instanceof Error ? err.message : String(err);
2361
+ finalContent = `(Sub-agent error: ${errMsg})`;
2362
+ process.stderr.write(
2363
+ `
2364
+ [spawn_agent] Error in sub-agent loop: ${errMsg}
2365
+ `
2366
+ );
2367
+ }
2368
+ printSubAgentFooter(totalUsage);
2369
+ const lines = [
2370
+ "## Sub-Agent Result",
2371
+ "",
2372
+ finalContent,
2373
+ "",
2374
+ "---",
2375
+ `Token usage: ${totalUsage.inputTokens} input, ${totalUsage.outputTokens} output`
2376
+ ];
2377
+ return lines.join("\n");
2378
+ }
2379
+ };
2380
+
2381
+ // src/tools/registry.ts
2382
+ import { pathToFileURL } from "url";
2383
+ import { existsSync as existsSync9, mkdirSync as mkdirSync4, readdirSync as readdirSync6 } from "fs";
2384
+ import { join as join5 } from "path";
2385
+ var ToolRegistry = class {
2386
+ tools = /* @__PURE__ */ new Map();
2387
+ pluginToolNames = /* @__PURE__ */ new Set();
2388
+ mcpToolNames = /* @__PURE__ */ new Set();
2389
+ constructor() {
2390
+ this.register(bashTool);
2391
+ this.register(readFileTool);
2392
+ this.register(writeFileTool);
2393
+ this.register(editFileTool);
2394
+ this.register(listDirTool);
2395
+ this.register(grepFilesTool);
2396
+ this.register(globFilesTool);
2397
+ this.register(runInteractiveTool);
2398
+ this.register(webFetchTool);
2399
+ this.register(saveLastResponseTool);
2400
+ this.register(saveMemoryTool);
2401
+ this.register(askUserTool);
2402
+ this.register(writeTodosTool);
2403
+ this.register(googleSearchTool);
2404
+ this.register(spawnAgentTool);
2405
+ this.register(runTestsTool);
2406
+ }
2407
+ register(tool) {
2408
+ this.tools.set(tool.definition.name, tool);
2409
+ }
2410
+ get(name) {
2411
+ return this.tools.get(name);
2412
+ }
2413
+ /** 注销指定工具(子代理创建过滤后的工具集时使用) */
2414
+ unregister(name) {
2415
+ return this.tools.delete(name);
2416
+ }
2417
+ /** 返回所有工具的 schema,用于发送给 AI */
2418
+ getDefinitions() {
2419
+ return [...this.tools.values()].map((t) => t.definition);
2420
+ }
2421
+ listAll() {
2422
+ return [...this.tools.values()];
2423
+ }
2424
+ /** Returns only tools loaded from plugins (not built-in). */
2425
+ listPluginTools() {
2426
+ return [...this.tools.values()].filter((t) => this.pluginToolNames.has(t.definition.name));
2427
+ }
2428
+ /** 注册一个 MCP 工具(名称以 mcp__ 开头) */
2429
+ registerMcpTool(tool) {
2430
+ this.tools.set(tool.definition.name, tool);
2431
+ this.mcpToolNames.add(tool.definition.name);
2432
+ }
2433
+ /** 返回所有 MCP 工具 */
2434
+ listMcpTools() {
2435
+ return [...this.tools.values()].filter((t) => this.mcpToolNames.has(t.definition.name));
2436
+ }
2437
+ /** 清除所有已注册的 MCP 工具(重连时先清除再重新注册) */
2438
+ unregisterMcpTools() {
2439
+ for (const name of this.mcpToolNames) {
2440
+ this.tools.delete(name);
2441
+ }
2442
+ this.mcpToolNames.clear();
2443
+ }
2444
+ /**
2445
+ * Dynamically loads .js plugin files from pluginsDir.
2446
+ *
2447
+ * Security notes:
2448
+ * - Only loads when allowPlugins=true (must be explicitly enabled in config).
2449
+ * - Plugins run with FULL Node.js privileges in the main process.
2450
+ * - Prints a prominent warning listing every file before loading.
2451
+ * - Built-in tool names cannot be overridden by plugins.
2452
+ *
2453
+ * Creates the dir if missing. Skips invalid plugins with a warning.
2454
+ * Returns the number of successfully loaded plugins.
2455
+ */
2456
+ async loadPlugins(pluginsDir, allowPlugins = false) {
2457
+ if (!existsSync9(pluginsDir)) {
2458
+ try {
2459
+ mkdirSync4(pluginsDir, { recursive: true });
2460
+ } catch {
2461
+ }
2462
+ return 0;
2463
+ }
2464
+ let files;
2465
+ try {
2466
+ files = readdirSync6(pluginsDir).filter((f) => f.endsWith(".js"));
2467
+ } catch {
2468
+ return 0;
2469
+ }
2470
+ if (files.length === 0) return 0;
2471
+ if (!allowPlugins) {
2472
+ process.stderr.write(
2473
+ `[plugins] Found ${files.length} plugin(s) in ${pluginsDir} but loading is disabled.
2474
+ To enable, set "allowPlugins": true in ~/.aicli/config.json (or via /config).
2475
+ \u26A0 Plugins run with full system privileges. Only enable for trusted sources.
2476
+ `
2477
+ );
2478
+ return 0;
2479
+ }
2480
+ process.stderr.write(
2481
+ `
2482
+ [plugins] \u26A0 Loading ${files.length} plugin(s) with FULL system privileges:
2483
+ ` + files.map((f) => ` + ${join5(pluginsDir, f)}`).join("\n") + "\n\n"
2484
+ );
2485
+ let loaded = 0;
2486
+ for (const file of files) {
2487
+ try {
2488
+ const fileUrl = pathToFileURL(join5(pluginsDir, file)).href;
2489
+ const mod = await import(fileUrl);
2490
+ const tool = mod.tool ?? mod.default?.tool ?? mod.default;
2491
+ if (!tool || typeof tool.execute !== "function" || !tool.definition?.name) {
2492
+ process.stderr.write(`[plugins] Skipping ${file}: missing or invalid 'tool' export
2493
+ `);
2494
+ continue;
2495
+ }
2496
+ if (this.tools.has(tool.definition.name) && !this.pluginToolNames.has(tool.definition.name)) {
2497
+ process.stderr.write(`[plugins] Skipping ${file}: name '${tool.definition.name}' conflicts with built-in tool
2498
+ `);
2499
+ continue;
2500
+ }
2501
+ this.register(tool);
2502
+ this.pluginToolNames.add(tool.definition.name);
2503
+ loaded++;
2504
+ process.stderr.write(`[plugins] Loaded: ${tool.definition.name} (${file})
2505
+ `);
2506
+ } catch (err) {
2507
+ process.stderr.write(`[plugins] Failed to load ${file}: ${err.message}
2508
+ `);
2509
+ }
2510
+ }
2511
+ return loaded;
2512
+ }
2513
+ };
2514
+
2515
+ export {
2516
+ EnvLoader,
2517
+ isFileWriteTool,
2518
+ getDangerLevel,
2519
+ schemaToJsonSchema,
2520
+ initTheme,
2521
+ theme,
2522
+ undoStack,
2523
+ lastResponseStore,
2524
+ askUserContext,
2525
+ googleSearchContext,
2526
+ setContextWindow,
2527
+ getActiveMaxChars,
2528
+ truncateOutput,
2529
+ spawnAgentContext,
2530
+ ToolRegistry
2531
+ };