jinzd-ai-cli 0.3.5 → 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.
- package/dist/{chunk-GTQRHY5Q.js → chunk-AE7S6GOW.js} +200 -2718
- package/dist/chunk-MXMU4PSC.js +2531 -0
- package/dist/{chunk-SGYQBV2O.js → chunk-ODUVYMY7.js} +1 -1
- package/dist/{chunk-GU5NVLYL.js → chunk-SX52VL4D.js} +1 -1
- package/dist/{hub-Y4UP3NAQ.js → hub-F5PM4DUQ.js} +45 -7
- package/dist/index.js +23 -18
- package/dist/{run-tests-XCNLK66A.js → run-tests-LX4GMVCW.js} +1 -1
- package/dist/{run-tests-WO3LYFX7.js → run-tests-PVELOOI3.js} +1 -1
- package/dist/{server-I7FDITW7.js → server-4I75C6R7.js} +14 -12
- package/dist/task-orchestrator-NS7XXP5N.js +500 -0
- package/package.json +2 -1
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
EnvLoader,
|
|
4
|
+
schemaToJsonSchema
|
|
5
|
+
} from "./chunk-MXMU4PSC.js";
|
|
2
6
|
import {
|
|
3
7
|
APP_NAME,
|
|
4
8
|
CONFIG_DIR_NAME,
|
|
@@ -9,14 +13,9 @@ import {
|
|
|
9
13
|
MCP_CONNECT_TIMEOUT,
|
|
10
14
|
MCP_PROTOCOL_VERSION,
|
|
11
15
|
MCP_TOOL_PREFIX,
|
|
12
|
-
MEMORY_FILE_NAME,
|
|
13
16
|
PLUGINS_DIR_NAME,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
SUBAGENT_MAX_ROUNDS_LIMIT,
|
|
17
|
-
VERSION,
|
|
18
|
-
runTestsTool
|
|
19
|
-
} from "./chunk-GU5NVLYL.js";
|
|
17
|
+
VERSION
|
|
18
|
+
} from "./chunk-SX52VL4D.js";
|
|
20
19
|
|
|
21
20
|
// src/config/config-manager.ts
|
|
22
21
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
@@ -153,44 +152,6 @@ var ConfigSchema = z.object({
|
|
|
153
152
|
allowPlugins: z.boolean().default(false)
|
|
154
153
|
});
|
|
155
154
|
|
|
156
|
-
// src/config/env-loader.ts
|
|
157
|
-
var ENV_KEY_MAP = {
|
|
158
|
-
claude: "AICLI_API_KEY_CLAUDE",
|
|
159
|
-
gemini: "AICLI_API_KEY_GEMINI",
|
|
160
|
-
deepseek: "AICLI_API_KEY_DEEPSEEK",
|
|
161
|
-
zhipu: "AICLI_API_KEY_ZHIPU",
|
|
162
|
-
kimi: "AICLI_API_KEY_KIMI",
|
|
163
|
-
openai: "AICLI_API_KEY_OPENAI",
|
|
164
|
-
openrouter: "AICLI_API_KEY_OPENROUTER",
|
|
165
|
-
"google-search": "AICLI_API_KEY_GOOGLESEARCH"
|
|
166
|
-
};
|
|
167
|
-
var EnvLoader = class {
|
|
168
|
-
/**
|
|
169
|
-
* 读取指定 provider 的 API Key 环境变量。
|
|
170
|
-
* 优先级:固定映射(如 AICLI_API_KEY_CLAUDE)> 动态格式(AICLI_API_KEY_<ID大写>)
|
|
171
|
-
* 自定义 provider 示例:id="siliconflow" → 读取 AICLI_API_KEY_SILICONFLOW
|
|
172
|
-
*/
|
|
173
|
-
static getApiKey(providerId) {
|
|
174
|
-
const fixedEnvVar = ENV_KEY_MAP[providerId];
|
|
175
|
-
if (fixedEnvVar) {
|
|
176
|
-
const val = process.env[fixedEnvVar];
|
|
177
|
-
if (val) return val;
|
|
178
|
-
}
|
|
179
|
-
const dynamicEnvVar = `AICLI_API_KEY_${providerId.toUpperCase().replace(/-/g, "_")}`;
|
|
180
|
-
return process.env[dynamicEnvVar] || void 0;
|
|
181
|
-
}
|
|
182
|
-
static getDefaultProvider() {
|
|
183
|
-
return process.env["AICLI_PROVIDER"] || void 0;
|
|
184
|
-
}
|
|
185
|
-
static isStreamingDisabled() {
|
|
186
|
-
return process.env["AICLI_NO_STREAM"] === "1";
|
|
187
|
-
}
|
|
188
|
-
/** Google Custom Search Engine ID (cx) 环境变量 */
|
|
189
|
-
static getGoogleSearchEngineId() {
|
|
190
|
-
return process.env["AICLI_GOOGLE_CX"] || void 0;
|
|
191
|
-
}
|
|
192
|
-
};
|
|
193
|
-
|
|
194
155
|
// src/core/errors.ts
|
|
195
156
|
var AiCliError = class extends Error {
|
|
196
157
|
constructor(message, options) {
|
|
@@ -351,50 +312,6 @@ var BaseProvider = class {
|
|
|
351
312
|
}
|
|
352
313
|
};
|
|
353
314
|
|
|
354
|
-
// src/tools/types.ts
|
|
355
|
-
function isFileWriteTool(name) {
|
|
356
|
-
return name === "write_file" || name === "edit_file";
|
|
357
|
-
}
|
|
358
|
-
function getDangerLevel(toolName, args) {
|
|
359
|
-
if (toolName.startsWith("mcp__")) return "safe";
|
|
360
|
-
if (toolName === "bash") {
|
|
361
|
-
const cmd = String(args["command"] ?? "");
|
|
362
|
-
if (/\brm\s+[^\n]*(?:-\w*[rRfF]\w*|--recursive|--force)\b/.test(cmd)) return "destructive";
|
|
363
|
-
if (/\brm\s+\S/.test(cmd)) return "destructive";
|
|
364
|
-
if (/\brmdir\b|\bformat\b|\bmkfs\b/.test(cmd)) return "destructive";
|
|
365
|
-
if (/\bRemove-Item\b.*(?:-Recurse|-Force)|\bri\s+.*-(?:Recurse|Force)\b|\brd\s+\/s\b|\brmdir\s+\/s\b/i.test(cmd)) return "destructive";
|
|
366
|
-
if (/\bdel\s+\S/.test(cmd)) return "destructive";
|
|
367
|
-
if (/\becho\b.*>>?|\btee\b|\bcp\b|\bmv\b/.test(cmd)) return "write";
|
|
368
|
-
if (/\bSet-Content\b|\bOut-File\b|\bAdd-Content\b|\bCopy-Item\b|\bMove-Item\b/i.test(cmd)) return "write";
|
|
369
|
-
return "safe";
|
|
370
|
-
}
|
|
371
|
-
if (toolName === "write_file") return "write";
|
|
372
|
-
if (toolName === "edit_file") return "write";
|
|
373
|
-
if (toolName === "save_last_response") return "write";
|
|
374
|
-
if (toolName === "run_interactive") {
|
|
375
|
-
const exe = String(args["executable"] ?? "").toLowerCase();
|
|
376
|
-
if (/\b(rm|rmdir|del|format|mkfs|Remove-Item)\b/i.test(exe)) return "destructive";
|
|
377
|
-
if (/\b(bash|sh|zsh|cmd|powershell|pwsh|python|node|ruby|perl)\b/i.test(exe)) return "write";
|
|
378
|
-
return "write";
|
|
379
|
-
}
|
|
380
|
-
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";
|
|
381
|
-
return "write";
|
|
382
|
-
}
|
|
383
|
-
function schemaToJsonSchema(schema) {
|
|
384
|
-
const result = {
|
|
385
|
-
type: schema.type,
|
|
386
|
-
description: schema.description
|
|
387
|
-
};
|
|
388
|
-
if (schema.enum) result["enum"] = schema.enum;
|
|
389
|
-
if (schema.items) result["items"] = schemaToJsonSchema(schema.items);
|
|
390
|
-
if (schema.properties) {
|
|
391
|
-
result["properties"] = Object.fromEntries(
|
|
392
|
-
Object.entries(schema.properties).map(([k, v]) => [k, schemaToJsonSchema(v)])
|
|
393
|
-
);
|
|
394
|
-
}
|
|
395
|
-
return result;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
315
|
// src/providers/claude.ts
|
|
399
316
|
var ClaudeProvider = class extends BaseProvider {
|
|
400
317
|
client;
|
|
@@ -2606,2601 +2523,179 @@ function formatGitContextForPrompt(ctx) {
|
|
|
2606
2523
|
return lines.join("\n");
|
|
2607
2524
|
}
|
|
2608
2525
|
|
|
2609
|
-
// src/
|
|
2610
|
-
import {
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
/**
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
return;
|
|
2632
|
-
}
|
|
2633
|
-
}
|
|
2634
|
-
this.pushEntry({
|
|
2635
|
-
filePath,
|
|
2636
|
-
previousContent,
|
|
2637
|
-
description,
|
|
2638
|
-
timestamp: /* @__PURE__ */ new Date()
|
|
2639
|
-
});
|
|
2640
|
-
}
|
|
2641
|
-
/**
|
|
2642
|
-
* 推入一个新建文件的条目(previousContent=null),undo 时删除该文件。
|
|
2643
|
-
* 用于 bash 工具执行后检测到的新建文件。
|
|
2644
|
-
*/
|
|
2645
|
-
pushNewFile(filePath, description) {
|
|
2646
|
-
this.pushEntry({
|
|
2647
|
-
filePath,
|
|
2648
|
-
previousContent: null,
|
|
2649
|
-
description,
|
|
2650
|
-
timestamp: /* @__PURE__ */ new Date()
|
|
2651
|
-
});
|
|
2652
|
-
}
|
|
2653
|
-
/**
|
|
2654
|
-
* 推入一个新建目录的条目(previousContent=null, isDirectory=true),
|
|
2655
|
-
* undo 时尝试 rmdir(仅空目录可删)。
|
|
2656
|
-
*/
|
|
2657
|
-
pushNewDir(dirPath, description) {
|
|
2658
|
-
this.pushEntry({
|
|
2659
|
-
filePath: dirPath,
|
|
2660
|
-
previousContent: null,
|
|
2661
|
-
description,
|
|
2662
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
2663
|
-
isDirectory: true
|
|
2664
|
-
});
|
|
2665
|
-
}
|
|
2666
|
-
/** 内部统一入栈方法,含溢出裁剪 */
|
|
2667
|
-
pushEntry(entry) {
|
|
2668
|
-
this.stack.push(entry);
|
|
2669
|
-
if (this.stack.length > MAX_UNDO_DEPTH) {
|
|
2670
|
-
this.stack.shift();
|
|
2671
|
-
}
|
|
2672
|
-
}
|
|
2673
|
-
/**
|
|
2674
|
-
* 弹出并执行最近一次撤销操作。
|
|
2675
|
-
* @returns 撤销结果描述,或 null(栈为空时)
|
|
2676
|
-
*/
|
|
2677
|
-
undo() {
|
|
2678
|
-
const entry = this.stack.pop();
|
|
2679
|
-
if (!entry) return null;
|
|
2680
|
-
try {
|
|
2681
|
-
if (entry.previousContent === null) {
|
|
2682
|
-
if (entry.isDirectory) {
|
|
2683
|
-
if (existsSync4(entry.filePath)) {
|
|
2684
|
-
try {
|
|
2685
|
-
rmdirSync(entry.filePath);
|
|
2686
|
-
return { entry, result: `Removed newly created directory: ${entry.filePath}` };
|
|
2687
|
-
} catch {
|
|
2688
|
-
return { entry, result: `Cannot remove directory (not empty): ${entry.filePath}` };
|
|
2689
|
-
}
|
|
2690
|
-
}
|
|
2691
|
-
return { entry, result: `Directory already removed: ${entry.filePath}` };
|
|
2692
|
-
} else {
|
|
2693
|
-
if (existsSync4(entry.filePath)) {
|
|
2694
|
-
unlinkSync2(entry.filePath);
|
|
2695
|
-
}
|
|
2696
|
-
return { entry, result: `Deleted newly created file: ${entry.filePath}` };
|
|
2697
|
-
}
|
|
2698
|
-
} else {
|
|
2699
|
-
writeFileSync3(entry.filePath, entry.previousContent, "utf-8");
|
|
2700
|
-
const lines = entry.previousContent.split("\n").length;
|
|
2701
|
-
return {
|
|
2702
|
-
entry,
|
|
2703
|
-
result: `Restored ${entry.filePath} to previous state (${lines} lines)`
|
|
2704
|
-
};
|
|
2705
|
-
}
|
|
2706
|
-
} catch (err) {
|
|
2707
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2708
|
-
return { entry, result: `Undo failed: ${msg}` };
|
|
2709
|
-
}
|
|
2710
|
-
}
|
|
2711
|
-
/** 查看栈顶条目(不弹出),用于 /undo 显示预览 */
|
|
2712
|
-
peek() {
|
|
2713
|
-
return this.stack[this.stack.length - 1] ?? null;
|
|
2526
|
+
// src/mcp/client.ts
|
|
2527
|
+
import { spawn } from "child_process";
|
|
2528
|
+
var McpClient = class {
|
|
2529
|
+
serverId;
|
|
2530
|
+
config;
|
|
2531
|
+
process = null;
|
|
2532
|
+
nextId = 1;
|
|
2533
|
+
connected = false;
|
|
2534
|
+
serverInfo = null;
|
|
2535
|
+
/** stderr 收集(最多保留最后 2KB,用于错误报告) */
|
|
2536
|
+
stderrBuffer = "";
|
|
2537
|
+
/** 缓存已发现的工具列表 */
|
|
2538
|
+
cachedTools = [];
|
|
2539
|
+
/** 错误信息(连接失败时设置) */
|
|
2540
|
+
errorMessage = null;
|
|
2541
|
+
// ── JSON-RPC 请求/响应匹配 ──────────────────────────────────────
|
|
2542
|
+
pendingRequests = /* @__PURE__ */ new Map();
|
|
2543
|
+
/** stdout 残余缓冲区(处理不完整的 JSON 行) */
|
|
2544
|
+
stdoutBuffer = "";
|
|
2545
|
+
constructor(serverId, config) {
|
|
2546
|
+
this.serverId = serverId;
|
|
2547
|
+
this.config = config;
|
|
2714
2548
|
}
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
return this.stack.length;
|
|
2549
|
+
get isConnected() {
|
|
2550
|
+
return this.connected;
|
|
2718
2551
|
}
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
return [...this.stack];
|
|
2552
|
+
get serverName() {
|
|
2553
|
+
return this.serverInfo?.name ?? this.serverId;
|
|
2722
2554
|
}
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
this.stack = [];
|
|
2555
|
+
get tools() {
|
|
2556
|
+
return this.cachedTools;
|
|
2726
2557
|
}
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
var SHELL = IS_WINDOWS ? "powershell.exe" : process.env["SHELL"] ?? "/bin/bash";
|
|
2733
|
-
var persistentCwd = process.cwd();
|
|
2734
|
-
var bashTool = {
|
|
2735
|
-
definition: {
|
|
2736
|
-
name: "bash",
|
|
2737
|
-
description: IS_WINDOWS ? `Execute commands in PowerShell. Supports mkdir, ls, cat, python, etc.
|
|
2738
|
-
Important rules:
|
|
2739
|
-
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".
|
|
2740
|
-
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.
|
|
2741
|
-
3. Multiple commands can be combined with semicolons in a single call to reduce rounds.
|
|
2742
|
-
4. To delete directories, use Remove-Item -Recurse (the system will automatically optimize to a more reliable method).
|
|
2743
|
-
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
|
|
2744
|
-
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}.
|
|
2745
|
-
Important rules:
|
|
2746
|
-
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".
|
|
2747
|
-
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.
|
|
2748
|
-
3. Multiple commands can be combined with && in a single call to reduce rounds.
|
|
2749
|
-
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.`,
|
|
2750
|
-
parameters: {
|
|
2751
|
-
command: {
|
|
2752
|
-
type: "string",
|
|
2753
|
-
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"`,
|
|
2754
|
-
required: true
|
|
2755
|
-
},
|
|
2756
|
-
cwd: {
|
|
2757
|
-
type: "string",
|
|
2758
|
-
description: "Working directory for the command (absolute or relative path). Once set, subsequent commands will also use this directory.",
|
|
2759
|
-
required: false
|
|
2760
|
-
},
|
|
2761
|
-
timeout: {
|
|
2762
|
-
type: "number",
|
|
2763
|
-
description: "Timeout in milliseconds, defaults to 30000",
|
|
2764
|
-
required: false
|
|
2765
|
-
}
|
|
2766
|
-
},
|
|
2767
|
-
dangerous: false
|
|
2768
|
-
},
|
|
2769
|
-
async execute(args) {
|
|
2770
|
-
const command = String(args["command"] ?? "");
|
|
2771
|
-
const MAX_TIMEOUT = 3e5;
|
|
2772
|
-
const timeout = Math.min(Math.max(Number(args["timeout"] ?? 3e4), 1e3), MAX_TIMEOUT);
|
|
2773
|
-
const cwdArg = args["cwd"] ? String(args["cwd"]) : void 0;
|
|
2774
|
-
if (!command.trim()) {
|
|
2775
|
-
throw new Error("command is required");
|
|
2776
|
-
}
|
|
2777
|
-
if (!existsSync5(persistentCwd)) {
|
|
2778
|
-
const fallback = process.cwd();
|
|
2779
|
-
process.stderr.write(
|
|
2780
|
-
`[bash] Previous cwd "${persistentCwd}" no longer exists, reset to "${fallback}"
|
|
2781
|
-
`
|
|
2782
|
-
);
|
|
2783
|
-
persistentCwd = fallback;
|
|
2784
|
-
}
|
|
2785
|
-
let effectiveCwd = persistentCwd;
|
|
2786
|
-
if (cwdArg) {
|
|
2787
|
-
const resolved = resolve(persistentCwd, cwdArg);
|
|
2788
|
-
if (!existsSync5(resolved)) {
|
|
2789
|
-
throw new Error(
|
|
2790
|
-
`cwd directory does not exist: "${resolved}". Create it first (e.g. mkdir -p "${resolved}") before specifying it as cwd.`
|
|
2791
|
-
);
|
|
2792
|
-
}
|
|
2793
|
-
effectiveCwd = resolved;
|
|
2794
|
-
persistentCwd = resolved;
|
|
2795
|
-
}
|
|
2796
|
-
let actualCommand;
|
|
2797
|
-
if (IS_WINDOWS) {
|
|
2798
|
-
const fixedCommand = fixWindowsDeleteCommand(command);
|
|
2799
|
-
actualCommand = `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $OutputEncoding = [System.Text.Encoding]::UTF8; ${fixedCommand}`;
|
|
2800
|
-
} else {
|
|
2801
|
-
actualCommand = command;
|
|
2802
|
-
}
|
|
2803
|
-
const beforeSnapshot = snapshotDir(effectiveCwd);
|
|
2804
|
-
const parsedTargets = parseCreationTargets(command, effectiveCwd);
|
|
2805
|
-
const parsedTargetsBefore = /* @__PURE__ */ new Map();
|
|
2806
|
-
for (const t of parsedTargets) {
|
|
2807
|
-
parsedTargetsBefore.set(t, existsSync5(t));
|
|
2808
|
-
}
|
|
2558
|
+
// ══════════════════════════════════════════════════════════════════
|
|
2559
|
+
// 连接与初始化
|
|
2560
|
+
// ══════════════════════════════════════════════════════════════════
|
|
2561
|
+
async connect() {
|
|
2562
|
+
const timeout = this.config.timeout ?? MCP_CONNECT_TIMEOUT;
|
|
2809
2563
|
try {
|
|
2810
|
-
|
|
2811
|
-
timeout,
|
|
2812
|
-
encoding: IS_WINDOWS ? "buffer" : "utf-8",
|
|
2564
|
+
this.process = spawn(this.config.command, this.config.args ?? [], {
|
|
2813
2565
|
stdio: ["pipe", "pipe", "pipe"],
|
|
2814
|
-
|
|
2815
|
-
shell
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2566
|
+
env: { ...process.env, ...this.config.env },
|
|
2567
|
+
// Windows 上 npx 等是 .cmd 脚本,需要 shell 模式
|
|
2568
|
+
shell: process.platform === "win32",
|
|
2569
|
+
// 不让子进程阻止父进程退出
|
|
2570
|
+
detached: false
|
|
2571
|
+
});
|
|
2572
|
+
this.process.on("error", (err) => {
|
|
2573
|
+
this.errorMessage = err.message;
|
|
2574
|
+
this.connected = false;
|
|
2575
|
+
this.rejectAllPending(new Error(`MCP server [${this.serverId}] process error: ${err.message}`));
|
|
2576
|
+
});
|
|
2577
|
+
this.process.on("exit", (code, signal) => {
|
|
2578
|
+
this.connected = false;
|
|
2579
|
+
const reason = signal ? `signal ${signal}` : `code ${code}`;
|
|
2580
|
+
this.rejectAllPending(new Error(`MCP server [${this.serverId}] exited: ${reason}`));
|
|
2581
|
+
});
|
|
2582
|
+
this.process.stdout.setEncoding("utf-8");
|
|
2583
|
+
this.process.stdout.on("data", (chunk) => this.handleStdoutData(chunk));
|
|
2584
|
+
this.process.stderr.setEncoding("utf-8");
|
|
2585
|
+
this.process.stderr.on("data", (chunk) => {
|
|
2586
|
+
this.stderrBuffer += chunk;
|
|
2587
|
+
if (this.stderrBuffer.length > 2048) {
|
|
2588
|
+
this.stderrBuffer = this.stderrBuffer.slice(-2048);
|
|
2820
2589
|
}
|
|
2821
2590
|
});
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2591
|
+
const initResult = await this.withTimeout(
|
|
2592
|
+
this.sendRequest("initialize", {
|
|
2593
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
2594
|
+
capabilities: {},
|
|
2595
|
+
clientInfo: { name: APP_NAME, version: VERSION }
|
|
2596
|
+
}),
|
|
2597
|
+
timeout,
|
|
2598
|
+
"initialize handshake"
|
|
2599
|
+
);
|
|
2600
|
+
this.serverInfo = initResult.serverInfo;
|
|
2601
|
+
this.sendNotification("notifications/initialized");
|
|
2602
|
+
this.connected = true;
|
|
2603
|
+
await this.refreshTools();
|
|
2826
2604
|
} catch (err) {
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
const stderr = IS_WINDOWS && Buffer.isBuffer(execErr.stderr) ? execErr.stderr.toString("utf-8").trim() : execErr.stderr?.toString().trim() ?? "";
|
|
2831
|
-
const stdout = IS_WINDOWS && Buffer.isBuffer(execErr.stdout) ? execErr.stdout.toString("utf-8").trim() : execErr.stdout?.toString().trim() ?? "";
|
|
2832
|
-
const combined = [stdout, stderr].filter(Boolean).join("\n");
|
|
2833
|
-
throw new Error(
|
|
2834
|
-
`Exit code ${execErr.status}:
|
|
2835
|
-
${combined || (execErr.message ?? "Unknown error")}
|
|
2836
|
-
|
|
2837
|
-
[Command failed. Report this error to the user. Do not retry with variant commands.]`
|
|
2838
|
-
);
|
|
2839
|
-
}
|
|
2605
|
+
this.errorMessage = err instanceof Error ? err.message : String(err);
|
|
2606
|
+
this.connected = false;
|
|
2607
|
+
this.killProcess();
|
|
2840
2608
|
throw err;
|
|
2841
2609
|
}
|
|
2842
2610
|
}
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
return `cmd /c rmdir /s /q "${safePath}"`;
|
|
2857
|
-
}
|
|
2858
|
-
);
|
|
2859
|
-
}
|
|
2860
|
-
function snapshotDir(dir) {
|
|
2861
|
-
try {
|
|
2862
|
-
return new Set(readdirSync2(dir).map((name) => resolve(dir, name)));
|
|
2863
|
-
} catch {
|
|
2864
|
-
return /* @__PURE__ */ new Set();
|
|
2865
|
-
}
|
|
2866
|
-
}
|
|
2867
|
-
function parseCreationTargets(command, cwd) {
|
|
2868
|
-
const targets = [];
|
|
2869
|
-
for (const m of command.matchAll(/(?:echo|cat|printf)\s+[^>]*>\s*(['"]?)([^\s;&|'"]+)\1/g)) {
|
|
2870
|
-
if (m[2]) targets.push(resolve(cwd, m[2]));
|
|
2871
|
-
}
|
|
2872
|
-
for (const m of command.matchAll(/\btouch\s+((?:['"]?[^\s;&|'"]+['"]?\s*)+)/g)) {
|
|
2873
|
-
for (const f of m[1].trim().split(/\s+/)) {
|
|
2874
|
-
const clean = f.replace(/^['"]|['"]$/g, "");
|
|
2875
|
-
if (clean && !clean.startsWith("-")) targets.push(resolve(cwd, clean));
|
|
2876
|
-
}
|
|
2611
|
+
// ══════════════════════════════════════════════════════════════════
|
|
2612
|
+
// 工具操作
|
|
2613
|
+
// ══════════════════════════════════════════════════════════════════
|
|
2614
|
+
/** 刷新工具列表(tools/list) */
|
|
2615
|
+
async refreshTools() {
|
|
2616
|
+
this.ensureConnected();
|
|
2617
|
+
const result = await this.withTimeout(
|
|
2618
|
+
this.sendRequest("tools/list", {}),
|
|
2619
|
+
MCP_CALL_TIMEOUT,
|
|
2620
|
+
"tools/list"
|
|
2621
|
+
);
|
|
2622
|
+
this.cachedTools = result.tools ?? [];
|
|
2623
|
+
return this.cachedTools;
|
|
2877
2624
|
}
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2625
|
+
/** 调用工具(tools/call) */
|
|
2626
|
+
async callTool(name, args) {
|
|
2627
|
+
this.ensureConnected();
|
|
2628
|
+
return this.withTimeout(
|
|
2629
|
+
this.sendRequest("tools/call", { name, arguments: args }),
|
|
2630
|
+
MCP_CALL_TIMEOUT,
|
|
2631
|
+
`tools/call(${name})`
|
|
2632
|
+
);
|
|
2883
2633
|
}
|
|
2884
|
-
|
|
2885
|
-
|
|
2634
|
+
// ══════════════════════════════════════════════════════════════════
|
|
2635
|
+
// 关闭连接
|
|
2636
|
+
// ══════════════════════════════════════════════════════════════════
|
|
2637
|
+
async close() {
|
|
2638
|
+
this.connected = false;
|
|
2639
|
+
this.rejectAllPending(new Error("Client closing"));
|
|
2640
|
+
this.killProcess();
|
|
2886
2641
|
}
|
|
2887
|
-
|
|
2888
|
-
|
|
2642
|
+
/**
|
|
2643
|
+
* 断线重连:清理旧状态后重新执行完整的 connect() 流程。
|
|
2644
|
+
* 用于 MCP 服务器子进程意外退出后的自动或手动恢复。
|
|
2645
|
+
*/
|
|
2646
|
+
async reconnect() {
|
|
2647
|
+
this.connected = false;
|
|
2648
|
+
this.rejectAllPending(new Error("Reconnecting"));
|
|
2649
|
+
this.killProcess();
|
|
2650
|
+
this.errorMessage = null;
|
|
2651
|
+
this.stderrBuffer = "";
|
|
2652
|
+
this.stdoutBuffer = "";
|
|
2653
|
+
this.cachedTools = [];
|
|
2654
|
+
await this.connect();
|
|
2889
2655
|
}
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
try {
|
|
2898
|
-
const st = statSync(absPath);
|
|
2899
|
-
if (st.isDirectory()) {
|
|
2900
|
-
undoStack.pushNewDir(absPath, `bash (new dir): ${absPath}`);
|
|
2901
|
-
} else {
|
|
2902
|
-
undoStack.pushNewFile(absPath, `bash (new file): ${absPath}`);
|
|
2903
|
-
}
|
|
2904
|
-
tracked.add(absPath);
|
|
2905
|
-
} catch {
|
|
2656
|
+
// ══════════════════════════════════════════════════════════════════
|
|
2657
|
+
// 内部方法:JSON-RPC 通信
|
|
2658
|
+
// ══════════════════════════════════════════════════════════════════
|
|
2659
|
+
sendRequest(method, params) {
|
|
2660
|
+
return new Promise((resolve, reject) => {
|
|
2661
|
+
if (!this.process?.stdin?.writable) {
|
|
2662
|
+
return reject(new Error(`MCP server [${this.serverId}] stdin not writable`));
|
|
2906
2663
|
}
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2664
|
+
const id = this.nextId++;
|
|
2665
|
+
const request = {
|
|
2666
|
+
jsonrpc: "2.0",
|
|
2667
|
+
id,
|
|
2668
|
+
method,
|
|
2669
|
+
...params !== void 0 ? { params } : {}
|
|
2670
|
+
};
|
|
2671
|
+
let timer;
|
|
2672
|
+
const cleanup = () => {
|
|
2673
|
+
this.pendingRequests.delete(id);
|
|
2674
|
+
clearTimeout(timer);
|
|
2675
|
+
};
|
|
2676
|
+
timer = setTimeout(() => {
|
|
2677
|
+
cleanup();
|
|
2678
|
+
reject(new Error(`MCP request [${method}] timed out (internal)`));
|
|
2679
|
+
}, MCP_CALL_TIMEOUT * 2);
|
|
2680
|
+
this.pendingRequests.set(id, {
|
|
2681
|
+
resolve: (result) => {
|
|
2682
|
+
cleanup();
|
|
2683
|
+
resolve(result);
|
|
2684
|
+
},
|
|
2685
|
+
reject: (error) => {
|
|
2686
|
+
cleanup();
|
|
2687
|
+
reject(error);
|
|
2688
|
+
},
|
|
2689
|
+
timer
|
|
2690
|
+
});
|
|
2691
|
+
const json = JSON.stringify(request) + "\n";
|
|
2692
|
+
this.process.stdin.write(json, (err) => {
|
|
2693
|
+
if (err) {
|
|
2694
|
+
cleanup();
|
|
2695
|
+
reject(new Error(`MCP write error: ${err.message}`));
|
|
2917
2696
|
}
|
|
2918
|
-
}
|
|
2919
|
-
|
|
2920
|
-
}
|
|
2921
|
-
}
|
|
2922
|
-
}
|
|
2923
|
-
function updateCwdFromCommand(command, baseCwd) {
|
|
2924
|
-
const cdMatches = [...command.matchAll(/(?:^|[;&|])\s*cd\s+(['"]?)([^\s;&|'"]+)\1/g)];
|
|
2925
|
-
if (cdMatches.length === 0) return;
|
|
2926
|
-
const lastMatch = cdMatches[cdMatches.length - 1];
|
|
2927
|
-
const target = lastMatch?.[2];
|
|
2928
|
-
if (!target || target.startsWith("$") || target === "~") return;
|
|
2929
|
-
try {
|
|
2930
|
-
const newDir = resolve(baseCwd, target);
|
|
2931
|
-
if (existsSync5(newDir)) {
|
|
2932
|
-
persistentCwd = newDir;
|
|
2933
|
-
}
|
|
2934
|
-
} catch {
|
|
2935
|
-
}
|
|
2936
|
-
}
|
|
2937
|
-
|
|
2938
|
-
// src/tools/builtin/read-file.ts
|
|
2939
|
-
import { readFileSync as readFileSync4, existsSync as existsSync6, statSync as statSync2, readdirSync as readdirSync3 } from "fs";
|
|
2940
|
-
import { execSync as execSync3 } from "child_process";
|
|
2941
|
-
import { extname, resolve as resolve2, basename, sep, dirname } from "path";
|
|
2942
|
-
import { homedir as homedir2 } from "os";
|
|
2943
|
-
var MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
2944
|
-
function getSensitiveWarning(normalizedPath) {
|
|
2945
|
-
const home = homedir2();
|
|
2946
|
-
const p = normalizedPath.toLowerCase();
|
|
2947
|
-
const base = basename(normalizedPath).toLowerCase();
|
|
2948
|
-
if (normalizedPath.startsWith(home) && p.includes(".aicli") && base === "config.json") {
|
|
2949
|
-
return "[\u26A0 Security Warning: This file contains API keys. Be careful not to share this content.]\n\n";
|
|
2950
|
-
}
|
|
2951
|
-
if (base === ".env" || base.startsWith(".env.") || base.endsWith(".env")) {
|
|
2952
|
-
return "[\u26A0 Security Warning: .env files may contain secrets and credentials.]\n\n";
|
|
2953
|
-
}
|
|
2954
|
-
if (normalizedPath.includes(`${sep}.ssh${sep}`) && (base.startsWith("id_") || base === "identity")) {
|
|
2955
|
-
return "[\u26A0 Security Warning: This may be an SSH private key.]\n\n";
|
|
2956
|
-
}
|
|
2957
|
-
if (base === "credentials" && p.includes(".aws")) {
|
|
2958
|
-
return "[\u26A0 Security Warning: This file contains AWS credentials.]\n\n";
|
|
2959
|
-
}
|
|
2960
|
-
return "";
|
|
2961
|
-
}
|
|
2962
|
-
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
2963
|
-
".pdf",
|
|
2964
|
-
".doc",
|
|
2965
|
-
".docx",
|
|
2966
|
-
".xls",
|
|
2967
|
-
".xlsx",
|
|
2968
|
-
".ppt",
|
|
2969
|
-
".pptx",
|
|
2970
|
-
".zip",
|
|
2971
|
-
".tar",
|
|
2972
|
-
".gz",
|
|
2973
|
-
".7z",
|
|
2974
|
-
".rar",
|
|
2975
|
-
".bz2",
|
|
2976
|
-
".png",
|
|
2977
|
-
".jpg",
|
|
2978
|
-
".jpeg",
|
|
2979
|
-
".gif",
|
|
2980
|
-
".bmp",
|
|
2981
|
-
".ico",
|
|
2982
|
-
".webp",
|
|
2983
|
-
".tiff",
|
|
2984
|
-
".mp3",
|
|
2985
|
-
".mp4",
|
|
2986
|
-
".avi",
|
|
2987
|
-
".mov",
|
|
2988
|
-
".mkv",
|
|
2989
|
-
".wav",
|
|
2990
|
-
".flac",
|
|
2991
|
-
".exe",
|
|
2992
|
-
".dll",
|
|
2993
|
-
".so",
|
|
2994
|
-
".dylib",
|
|
2995
|
-
".bin",
|
|
2996
|
-
".dat",
|
|
2997
|
-
".wasm",
|
|
2998
|
-
".class",
|
|
2999
|
-
".pyc"
|
|
3000
|
-
]);
|
|
3001
|
-
function isBinaryBuffer(buf) {
|
|
3002
|
-
const sample = buf.subarray(0, 512);
|
|
3003
|
-
let nullCount = 0;
|
|
3004
|
-
for (let i = 0; i < sample.length; i++) {
|
|
3005
|
-
if (sample[i] === 0) nullCount++;
|
|
3006
|
-
}
|
|
3007
|
-
return nullCount / sample.length > 0.1;
|
|
3008
|
-
}
|
|
3009
|
-
function findSimilarFiles(filePath) {
|
|
3010
|
-
const targetName = basename(filePath).toLowerCase();
|
|
3011
|
-
if (!targetName) return [];
|
|
3012
|
-
const cwd = process.cwd();
|
|
3013
|
-
const candidates = [];
|
|
3014
|
-
try {
|
|
3015
|
-
for (const entry of readdirSync3(cwd, { withFileTypes: true })) {
|
|
3016
|
-
if (entry.isFile() && entry.name.toLowerCase() === targetName) {
|
|
3017
|
-
candidates.push(entry.name);
|
|
3018
|
-
}
|
|
3019
|
-
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
|
3020
|
-
try {
|
|
3021
|
-
const subDir = resolve2(cwd, entry.name);
|
|
3022
|
-
for (const sub of readdirSync3(subDir, { withFileTypes: true })) {
|
|
3023
|
-
if (sub.isFile() && sub.name.toLowerCase() === targetName) {
|
|
3024
|
-
candidates.push(`${entry.name}/${sub.name}`);
|
|
3025
|
-
}
|
|
3026
|
-
}
|
|
3027
|
-
} catch {
|
|
3028
|
-
}
|
|
3029
|
-
}
|
|
3030
|
-
}
|
|
3031
|
-
} catch {
|
|
3032
|
-
}
|
|
3033
|
-
return candidates.slice(0, 5);
|
|
3034
|
-
}
|
|
3035
|
-
function tryExtractPdfText(absPath) {
|
|
3036
|
-
try {
|
|
3037
|
-
const output = execSync3(`pdftotext "${absPath}" -`, {
|
|
3038
|
-
timeout: 15e3,
|
|
3039
|
-
encoding: "utf-8",
|
|
3040
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
3041
|
-
});
|
|
3042
|
-
if (output.trim().length > 0) return output;
|
|
3043
|
-
} catch {
|
|
3044
|
-
}
|
|
3045
|
-
try {
|
|
3046
|
-
const pyScript = `import sys; exec("try:\\n from pdfminer.high_level import extract_text\\n print(extract_text(sys.argv[1]))\\nexcept: pass")`;
|
|
3047
|
-
const output = execSync3(`python -c "${pyScript}" "${absPath}"`, {
|
|
3048
|
-
timeout: 15e3,
|
|
3049
|
-
encoding: "utf-8",
|
|
3050
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
3051
|
-
env: { ...process.env, PYTHONUTF8: "1", PYTHONIOENCODING: "utf-8" }
|
|
3052
|
-
});
|
|
3053
|
-
if (output.trim().length > 0) return output;
|
|
3054
|
-
} catch {
|
|
3055
|
-
}
|
|
3056
|
-
return null;
|
|
3057
|
-
}
|
|
3058
|
-
var readFileTool = {
|
|
3059
|
-
definition: {
|
|
3060
|
-
name: "read_file",
|
|
3061
|
-
description: "Read text file contents. Automatically detects binary files (PDF/images/etc) and returns a hint instead of garbled output.",
|
|
3062
|
-
parameters: {
|
|
3063
|
-
path: {
|
|
3064
|
-
type: "string",
|
|
3065
|
-
description: "File path (absolute or relative to current working directory)",
|
|
3066
|
-
required: true
|
|
3067
|
-
},
|
|
3068
|
-
encoding: {
|
|
3069
|
-
type: "string",
|
|
3070
|
-
description: "Encoding format, defaults to utf-8",
|
|
3071
|
-
enum: ["utf-8", "utf8", "ascii", "base64"],
|
|
3072
|
-
required: false
|
|
3073
|
-
}
|
|
3074
|
-
},
|
|
3075
|
-
dangerous: false
|
|
3076
|
-
},
|
|
3077
|
-
async execute(args) {
|
|
3078
|
-
const filePath = String(args["path"] ?? "");
|
|
3079
|
-
const encoding = args["encoding"] ?? "utf-8";
|
|
3080
|
-
if (!filePath) throw new Error("path is required");
|
|
3081
|
-
const normalizedPath = resolve2(filePath);
|
|
3082
|
-
if (!existsSync6(normalizedPath)) {
|
|
3083
|
-
const suggestions = findSimilarFiles(filePath);
|
|
3084
|
-
if (suggestions.length > 0) {
|
|
3085
|
-
throw new Error(
|
|
3086
|
-
`File not found: ${filePath}
|
|
3087
|
-
Current working directory: ${process.cwd()}
|
|
3088
|
-
Found similar files, did you mean:
|
|
3089
|
-
` + suggestions.map((s) => ` \u2192 ${s}`).join("\n") + `
|
|
3090
|
-
Please retry with the correct relative path.`
|
|
3091
|
-
);
|
|
3092
|
-
}
|
|
3093
|
-
throw new Error(
|
|
3094
|
-
`File not found: ${filePath}
|
|
3095
|
-
Current working directory: ${process.cwd()}
|
|
3096
|
-
Please use list_dir to verify the file path and retry.`
|
|
3097
|
-
);
|
|
3098
|
-
}
|
|
3099
|
-
const { size } = statSync2(normalizedPath);
|
|
3100
|
-
if (size > MAX_FILE_BYTES) {
|
|
3101
|
-
const mb = (size / 1024 / 1024).toFixed(1);
|
|
3102
|
-
return `[File too large: ${filePath} (${mb} MB)]
|
|
3103
|
-
Exceeds single read limit of ${MAX_FILE_BYTES / 1024 / 1024} MB.
|
|
3104
|
-
Use the bash tool to read in segments, e.g.:
|
|
3105
|
-
head -n 100 "${normalizedPath}" # first 100 lines
|
|
3106
|
-
tail -n 100 "${normalizedPath}" # last 100 lines
|
|
3107
|
-
sed -n '200,300p' "${normalizedPath}" # lines 200-300`;
|
|
3108
|
-
}
|
|
3109
|
-
const sensitiveWarning = getSensitiveWarning(normalizedPath);
|
|
3110
|
-
const ext = extname(normalizedPath).toLowerCase();
|
|
3111
|
-
if (ext === ".pdf") {
|
|
3112
|
-
const pdfText = tryExtractPdfText(normalizedPath);
|
|
3113
|
-
if (pdfText) {
|
|
3114
|
-
const lines2 = pdfText.split("\n").length;
|
|
3115
|
-
return `[PDF extracted: ${filePath} | ${lines2} lines]
|
|
3116
|
-
|
|
3117
|
-
${pdfText}`;
|
|
3118
|
-
}
|
|
3119
|
-
const dir = dirname(normalizedPath);
|
|
3120
|
-
const nameNoExt = basename(normalizedPath, ext);
|
|
3121
|
-
const textAlts = [".md", ".txt", ".html"].map((e) => resolve2(dir, nameNoExt + e)).filter(existsSync6);
|
|
3122
|
-
if (textAlts.length > 0) {
|
|
3123
|
-
return `[PDF file: ${filePath}]
|
|
3124
|
-
Cannot extract text from this PDF, but found alternative text versions:
|
|
3125
|
-
` + textAlts.map((p) => ` \u2192 ${basename(p)}`).join("\n") + `
|
|
3126
|
-
Please use read_file to read the above files.`;
|
|
3127
|
-
}
|
|
3128
|
-
return `[PDF file: ${filePath}]
|
|
3129
|
-
Cannot extract text from this PDF (requires pdftotext or pdfminer.six).
|
|
3130
|
-
Suggestion: read existing text versions (.md / .txt) in the project, or install extraction tools via bash and retry.`;
|
|
3131
|
-
}
|
|
3132
|
-
if (BINARY_EXTENSIONS.has(ext)) {
|
|
3133
|
-
return `[Binary file: ${filePath} (${ext})]
|
|
3134
|
-
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.`;
|
|
3135
|
-
}
|
|
3136
|
-
const buf = readFileSync4(normalizedPath);
|
|
3137
|
-
if (encoding === "base64") {
|
|
3138
|
-
return `[File: ${filePath} | base64]
|
|
3139
|
-
|
|
3140
|
-
${buf.toString("base64")}`;
|
|
3141
|
-
}
|
|
3142
|
-
if (isBinaryBuffer(buf)) {
|
|
3143
|
-
return `[Binary file: ${filePath}]
|
|
3144
|
-
This file contains binary data and cannot be read as text.
|
|
3145
|
-
If needed, use the bash tool to run an appropriate conversion program.`;
|
|
3146
|
-
}
|
|
3147
|
-
const content = buf.toString(encoding);
|
|
3148
|
-
const lines = content.split("\n").length;
|
|
3149
|
-
return `${sensitiveWarning}[File: ${filePath} | ${lines} lines]
|
|
3150
|
-
|
|
3151
|
-
${content}`;
|
|
3152
|
-
}
|
|
3153
|
-
};
|
|
3154
|
-
|
|
3155
|
-
// src/tools/builtin/write-file.ts
|
|
3156
|
-
import { writeFileSync as writeFileSync4, appendFileSync, mkdirSync as mkdirSync3 } from "fs";
|
|
3157
|
-
import { dirname as dirname2 } from "path";
|
|
3158
|
-
var writeFileTool = {
|
|
3159
|
-
definition: {
|
|
3160
|
-
name: "write_file",
|
|
3161
|
-
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.
|
|
3162
|
-
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.`,
|
|
3163
|
-
parameters: {
|
|
3164
|
-
path: {
|
|
3165
|
-
type: "string",
|
|
3166
|
-
description: "File path",
|
|
3167
|
-
required: true
|
|
3168
|
-
},
|
|
3169
|
-
content: {
|
|
3170
|
-
type: "string",
|
|
3171
|
-
description: "Content to write",
|
|
3172
|
-
required: true
|
|
3173
|
-
},
|
|
3174
|
-
append: {
|
|
3175
|
-
type: "string",
|
|
3176
|
-
description: "Whether to append. true=append to end, false=overwrite (default). Long files must be written in segments with append.",
|
|
3177
|
-
required: false
|
|
3178
|
-
},
|
|
3179
|
-
encoding: {
|
|
3180
|
-
type: "string",
|
|
3181
|
-
description: "Encoding format, defaults to utf-8",
|
|
3182
|
-
required: false
|
|
3183
|
-
}
|
|
3184
|
-
},
|
|
3185
|
-
dangerous: false
|
|
3186
|
-
// executor 会将 write_file 标记为 'write' 级别
|
|
3187
|
-
},
|
|
3188
|
-
async execute(args) {
|
|
3189
|
-
const filePath = String(args["path"] ?? "");
|
|
3190
|
-
const content = String(args["content"] ?? "");
|
|
3191
|
-
const encoding = args["encoding"] ?? "utf-8";
|
|
3192
|
-
const appendMode = String(args["append"] ?? "false").toLowerCase() === "true";
|
|
3193
|
-
if (!filePath) throw new Error("path is required");
|
|
3194
|
-
undoStack.push(filePath, `write_file${appendMode ? " (append)" : ""}: ${filePath}`);
|
|
3195
|
-
mkdirSync3(dirname2(filePath), { recursive: true });
|
|
3196
|
-
if (appendMode) {
|
|
3197
|
-
appendFileSync(filePath, content, encoding);
|
|
3198
|
-
} else {
|
|
3199
|
-
writeFileSync4(filePath, content, encoding);
|
|
3200
|
-
}
|
|
3201
|
-
const lines = content.split("\n").length;
|
|
3202
|
-
const mode = appendMode ? "appended" : "written";
|
|
3203
|
-
return `File ${mode}: ${filePath} (${lines} lines, ${content.length} bytes)`;
|
|
3204
|
-
}
|
|
3205
|
-
};
|
|
3206
|
-
|
|
3207
|
-
// src/tools/builtin/edit-file.ts
|
|
3208
|
-
import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
|
|
3209
|
-
function similarityScore(a, b) {
|
|
3210
|
-
if (a === b) return 1;
|
|
3211
|
-
if (a.length < 2 || b.length < 2) return 0;
|
|
3212
|
-
const getBigrams = (s) => {
|
|
3213
|
-
const bigrams = /* @__PURE__ */ new Map();
|
|
3214
|
-
for (let i = 0; i < s.length - 1; i++) {
|
|
3215
|
-
const bigram = s.slice(i, i + 2);
|
|
3216
|
-
bigrams.set(bigram, (bigrams.get(bigram) ?? 0) + 1);
|
|
3217
|
-
}
|
|
3218
|
-
return bigrams;
|
|
3219
|
-
};
|
|
3220
|
-
const aBigrams = getBigrams(a.toLowerCase());
|
|
3221
|
-
const bBigrams = getBigrams(b.toLowerCase());
|
|
3222
|
-
let intersection = 0;
|
|
3223
|
-
for (const [bigram, count] of aBigrams) {
|
|
3224
|
-
intersection += Math.min(count, bBigrams.get(bigram) ?? 0);
|
|
3225
|
-
}
|
|
3226
|
-
return 2 * intersection / (a.length - 1 + b.length - 1);
|
|
3227
|
-
}
|
|
3228
|
-
function findSimilarLines(fileContent, searchStr, maxResults = 3) {
|
|
3229
|
-
if (fileContent.length > 5e5) return [];
|
|
3230
|
-
const fileLines = fileContent.split("\n");
|
|
3231
|
-
const searchLines = searchStr.split("\n");
|
|
3232
|
-
const firstSearchLine = searchLines[0].trim();
|
|
3233
|
-
if (!firstSearchLine || firstSearchLine.length < 3) return [];
|
|
3234
|
-
const candidates = [];
|
|
3235
|
-
for (let i = 0; i < fileLines.length; i++) {
|
|
3236
|
-
const trimmed = fileLines[i].trim();
|
|
3237
|
-
if (!trimmed || trimmed.length < 3) continue;
|
|
3238
|
-
const score = similarityScore(trimmed, firstSearchLine);
|
|
3239
|
-
if (score > 0.4) {
|
|
3240
|
-
candidates.push({ line: i + 1, text: fileLines[i], score });
|
|
3241
|
-
}
|
|
3242
|
-
}
|
|
3243
|
-
return candidates.sort((a, b) => b.score - a.score).slice(0, maxResults).map((c) => ` Line ${c.line}: ${c.text.slice(0, 120)}`);
|
|
3244
|
-
}
|
|
3245
|
-
function findWhitespaceTolerant(fileLines, searchLines) {
|
|
3246
|
-
const trimmedSearch = searchLines.map((l) => l.trim());
|
|
3247
|
-
let matchStart = -1;
|
|
3248
|
-
let matchCount = 0;
|
|
3249
|
-
for (let i = 0; i <= fileLines.length - trimmedSearch.length; i++) {
|
|
3250
|
-
let allMatch = true;
|
|
3251
|
-
for (let j = 0; j < trimmedSearch.length; j++) {
|
|
3252
|
-
if (fileLines[i + j].trim() !== trimmedSearch[j]) {
|
|
3253
|
-
allMatch = false;
|
|
3254
|
-
break;
|
|
3255
|
-
}
|
|
3256
|
-
}
|
|
3257
|
-
if (allMatch) {
|
|
3258
|
-
matchCount++;
|
|
3259
|
-
if (matchStart === -1) matchStart = i;
|
|
3260
|
-
if (matchCount > 1) break;
|
|
3261
|
-
}
|
|
3262
|
-
}
|
|
3263
|
-
return { matchStart, matchCount };
|
|
3264
|
-
}
|
|
3265
|
-
var editFileTool = {
|
|
3266
|
-
definition: {
|
|
3267
|
-
name: "edit_file",
|
|
3268
|
-
description: `Precisely edit file contents. Supports three modes:
|
|
3269
|
-
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).
|
|
3270
|
-
2. Line insert: Provide insert_after_line (1-based line number) and insert_content to insert after that line.
|
|
3271
|
-
3. Line delete: Provide delete_from_line and delete_to_line (inclusive) to delete that range.
|
|
3272
|
-
Optional ignore_whitespace: true to match ignoring indentation differences.
|
|
3273
|
-
Optional replace_all: true to replace ALL occurrences of old_str in the file at once (saves tool rounds when renaming variables/functions).
|
|
3274
|
-
Note: Path can be absolute or relative to the current working directory.`,
|
|
3275
|
-
parameters: {
|
|
3276
|
-
path: {
|
|
3277
|
-
type: "string",
|
|
3278
|
-
description: "File path to edit",
|
|
3279
|
-
required: true
|
|
3280
|
-
},
|
|
3281
|
-
old_str: {
|
|
3282
|
-
type: "string",
|
|
3283
|
-
description: "[Replace mode] Original string to replace, must appear exactly once (include enough context for uniqueness)",
|
|
3284
|
-
required: false
|
|
3285
|
-
},
|
|
3286
|
-
new_str: {
|
|
3287
|
-
type: "string",
|
|
3288
|
-
description: "[Replace mode] New replacement string, can be empty to delete old_str",
|
|
3289
|
-
required: false
|
|
3290
|
-
},
|
|
3291
|
-
ignore_whitespace: {
|
|
3292
|
-
type: "boolean",
|
|
3293
|
-
description: "[Replace mode] Whether to ignore leading/trailing whitespace per line when matching, defaults to false",
|
|
3294
|
-
required: false
|
|
3295
|
-
},
|
|
3296
|
-
replace_all: {
|
|
3297
|
-
type: "boolean",
|
|
3298
|
-
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.",
|
|
3299
|
-
required: false
|
|
3300
|
-
},
|
|
3301
|
-
insert_after_line: {
|
|
3302
|
-
type: "number",
|
|
3303
|
-
description: "[Insert mode] Insert after this line number (1-based), 0 means insert at the beginning",
|
|
3304
|
-
required: false
|
|
3305
|
-
},
|
|
3306
|
-
insert_content: {
|
|
3307
|
-
type: "string",
|
|
3308
|
-
description: "[Insert mode] Content to insert (no need to add newlines manually)",
|
|
3309
|
-
required: false
|
|
3310
|
-
},
|
|
3311
|
-
delete_from_line: {
|
|
3312
|
-
type: "number",
|
|
3313
|
-
description: "[Delete mode] Start deleting from this line (1-based)",
|
|
3314
|
-
required: false
|
|
3315
|
-
},
|
|
3316
|
-
delete_to_line: {
|
|
3317
|
-
type: "number",
|
|
3318
|
-
description: "[Delete mode] Delete up to and including this line (1-based)",
|
|
3319
|
-
required: false
|
|
3320
|
-
},
|
|
3321
|
-
encoding: {
|
|
3322
|
-
type: "string",
|
|
3323
|
-
description: "File encoding, defaults to utf-8",
|
|
3324
|
-
required: false
|
|
3325
|
-
}
|
|
3326
|
-
},
|
|
3327
|
-
dangerous: false
|
|
3328
|
-
// executor 中 edit_file 按 write 级别处理
|
|
3329
|
-
},
|
|
3330
|
-
async execute(args) {
|
|
3331
|
-
const filePath = String(args["path"] ?? "");
|
|
3332
|
-
const encoding = args["encoding"] ?? "utf-8";
|
|
3333
|
-
if (!filePath) throw new Error("path is required");
|
|
3334
|
-
if (!existsSync7(filePath)) throw new Error(`File not found: ${filePath}`);
|
|
3335
|
-
const original = readFileSync5(filePath, encoding);
|
|
3336
|
-
if (args["old_str"] !== void 0) {
|
|
3337
|
-
const oldStr = String(args["old_str"]);
|
|
3338
|
-
const newStr = String(args["new_str"] ?? "");
|
|
3339
|
-
const ignoreWs = Boolean(args["ignore_whitespace"]);
|
|
3340
|
-
const replaceAll = Boolean(args["replace_all"]);
|
|
3341
|
-
if (oldStr === "") throw new Error("old_str cannot be empty");
|
|
3342
|
-
if (ignoreWs) {
|
|
3343
|
-
const fileLines = original.split("\n");
|
|
3344
|
-
const searchLines = oldStr.split("\n");
|
|
3345
|
-
const { matchStart, matchCount } = findWhitespaceTolerant(fileLines, searchLines);
|
|
3346
|
-
if (matchStart === -1) {
|
|
3347
|
-
const similar = findSimilarLines(original, oldStr);
|
|
3348
|
-
const hint = similar.length > 0 ? `
|
|
3349
|
-
Similar lines found (did you mean?):
|
|
3350
|
-
${similar.join("\n")}` : "";
|
|
3351
|
-
return `ERROR: old_str not found in file (even with whitespace ignored).
|
|
3352
|
-
File has ${fileLines.length} lines.${hint}
|
|
3353
|
-
Please read the file first and use exact text.`;
|
|
3354
|
-
}
|
|
3355
|
-
if (matchCount > 1) {
|
|
3356
|
-
return `ERROR: old_str matches multiple locations with whitespace-tolerant matching. Please include more surrounding context to make it unique.`;
|
|
3357
|
-
}
|
|
3358
|
-
undoStack.push(filePath, `edit_file (ws-replace): ${filePath}`);
|
|
3359
|
-
const before = fileLines.slice(0, matchStart);
|
|
3360
|
-
const after = fileLines.slice(matchStart + searchLines.length);
|
|
3361
|
-
const updated2 = [...before, newStr, ...after].join("\n");
|
|
3362
|
-
writeFileSync5(filePath, updated2, encoding);
|
|
3363
|
-
return `Successfully edited ${filePath} (whitespace-tolerant match)
|
|
3364
|
-
Location: around line ${matchStart + 1}
|
|
3365
|
-
Replaced: ${searchLines.length} line(s) \u2192 ${newStr.split("\n").length} line(s)
|
|
3366
|
-
Old: ${truncatePreview(oldStr)}
|
|
3367
|
-
New: ${truncatePreview(newStr)}`;
|
|
3368
|
-
}
|
|
3369
|
-
if (replaceAll) {
|
|
3370
|
-
const occurrences = original.split(oldStr).length - 1;
|
|
3371
|
-
if (occurrences === 0) {
|
|
3372
|
-
const similar = findSimilarLines(original, oldStr);
|
|
3373
|
-
const hint = similar.length > 0 ? `
|
|
3374
|
-
Similar lines found (did you mean?):
|
|
3375
|
-
${similar.join("\n")}` : "";
|
|
3376
|
-
return `ERROR: old_str not found in file.${hint}
|
|
3377
|
-
Please read the file first and use exact text.`;
|
|
3378
|
-
}
|
|
3379
|
-
undoStack.push(filePath, `edit_file (replace_all): ${filePath}`);
|
|
3380
|
-
const updated2 = original.split(oldStr).join(newStr);
|
|
3381
|
-
writeFileSync5(filePath, updated2, encoding);
|
|
3382
|
-
return `Successfully edited ${filePath} (replace_all)
|
|
3383
|
-
Replaced: ${occurrences} occurrence(s) of ${truncatePreview(oldStr)}
|
|
3384
|
-
With: ${truncatePreview(newStr)}`;
|
|
3385
|
-
}
|
|
3386
|
-
const firstIndex = original.indexOf(oldStr);
|
|
3387
|
-
if (firstIndex === -1) {
|
|
3388
|
-
const lines = original.split("\n");
|
|
3389
|
-
const similar = findSimilarLines(original, oldStr);
|
|
3390
|
-
const hint = similar.length > 0 ? `
|
|
3391
|
-
Similar lines found (did you mean?):
|
|
3392
|
-
${similar.join("\n")}` : "";
|
|
3393
|
-
return `ERROR: old_str not found in file.
|
|
3394
|
-
File has ${lines.length} lines.${hint}
|
|
3395
|
-
Please read the file first and use exact text including whitespace/indentation.
|
|
3396
|
-
Tip: You can also try ignore_whitespace: true to match ignoring indentation differences.`;
|
|
3397
|
-
}
|
|
3398
|
-
const secondIndex = original.indexOf(oldStr, firstIndex + 1);
|
|
3399
|
-
if (secondIndex !== -1) {
|
|
3400
|
-
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.`;
|
|
3401
|
-
}
|
|
3402
|
-
undoStack.push(filePath, `edit_file (replace): ${filePath}`);
|
|
3403
|
-
const updated = original.slice(0, firstIndex) + newStr + original.slice(firstIndex + oldStr.length);
|
|
3404
|
-
writeFileSync5(filePath, updated, encoding);
|
|
3405
|
-
const oldLines = oldStr.split("\n").length;
|
|
3406
|
-
const newLines = newStr.split("\n").length;
|
|
3407
|
-
const linesBefore = original.slice(0, firstIndex).split("\n").length;
|
|
3408
|
-
return `Successfully edited ${filePath}
|
|
3409
|
-
Location: around line ${linesBefore}
|
|
3410
|
-
Replaced: ${oldLines} line(s) \u2192 ${newLines} line(s)
|
|
3411
|
-
Old: ${truncatePreview(oldStr)}
|
|
3412
|
-
New: ${truncatePreview(newStr)}`;
|
|
3413
|
-
}
|
|
3414
|
-
if (args["insert_after_line"] !== void 0) {
|
|
3415
|
-
const afterLine = Number(args["insert_after_line"]);
|
|
3416
|
-
const content = String(args["insert_content"] ?? "");
|
|
3417
|
-
const lines = original.split("\n");
|
|
3418
|
-
if (afterLine < 0 || afterLine > lines.length) {
|
|
3419
|
-
throw new Error(`insert_after_line ${afterLine} is out of range (file has ${lines.length} lines)`);
|
|
3420
|
-
}
|
|
3421
|
-
undoStack.push(filePath, `edit_file (insert): ${filePath}`);
|
|
3422
|
-
lines.splice(afterLine, 0, content);
|
|
3423
|
-
writeFileSync5(filePath, lines.join("\n"), encoding);
|
|
3424
|
-
return `Successfully inserted ${content.split("\n").length} line(s) after line ${afterLine} in ${filePath}`;
|
|
3425
|
-
}
|
|
3426
|
-
if (args["delete_from_line"] !== void 0) {
|
|
3427
|
-
const fromLine = Number(args["delete_from_line"]);
|
|
3428
|
-
const toLine = Number(args["delete_to_line"] ?? args["delete_from_line"]);
|
|
3429
|
-
const lines = original.split("\n");
|
|
3430
|
-
if (fromLine < 1 || toLine < fromLine || toLine > lines.length) {
|
|
3431
|
-
throw new Error(
|
|
3432
|
-
`Invalid line range: ${fromLine}-${toLine} (file has ${lines.length} lines, lines are 1-indexed)`
|
|
3433
|
-
);
|
|
3434
|
-
}
|
|
3435
|
-
undoStack.push(filePath, `edit_file (delete): ${filePath}`);
|
|
3436
|
-
const deleted = lines.splice(fromLine - 1, toLine - fromLine + 1);
|
|
3437
|
-
writeFileSync5(filePath, lines.join("\n"), encoding);
|
|
3438
|
-
return `Successfully deleted lines ${fromLine}-${toLine} (${deleted.length} lines) from ${filePath}`;
|
|
3439
|
-
}
|
|
3440
|
-
throw new Error(
|
|
3441
|
-
"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."
|
|
3442
|
-
);
|
|
3443
|
-
}
|
|
3444
|
-
};
|
|
3445
|
-
function truncatePreview(str, maxLen = 80) {
|
|
3446
|
-
const oneLine = str.replace(/\n/g, "\u21B5");
|
|
3447
|
-
if (oneLine.length <= maxLen) return JSON.stringify(str);
|
|
3448
|
-
return JSON.stringify(oneLine.slice(0, maxLen)) + "...";
|
|
3449
|
-
}
|
|
3450
|
-
|
|
3451
|
-
// src/tools/builtin/list-dir.ts
|
|
3452
|
-
import { readdirSync as readdirSync4, statSync as statSync3, existsSync as existsSync8 } from "fs";
|
|
3453
|
-
import { join as join4, basename as basename2 } from "path";
|
|
3454
|
-
var listDirTool = {
|
|
3455
|
-
definition: {
|
|
3456
|
-
name: "list_dir",
|
|
3457
|
-
description: "List directory contents showing file names, types and sizes.",
|
|
3458
|
-
parameters: {
|
|
3459
|
-
path: {
|
|
3460
|
-
type: "string",
|
|
3461
|
-
description: "Directory path, defaults to current working directory",
|
|
3462
|
-
required: false
|
|
3463
|
-
},
|
|
3464
|
-
recursive: {
|
|
3465
|
-
type: "boolean",
|
|
3466
|
-
description: "Whether to recursively list subdirectories, defaults to false",
|
|
3467
|
-
required: false
|
|
3468
|
-
}
|
|
3469
|
-
},
|
|
3470
|
-
dangerous: false
|
|
3471
|
-
},
|
|
3472
|
-
async execute(args) {
|
|
3473
|
-
const dirPath = String(args["path"] ?? process.cwd());
|
|
3474
|
-
const recursive = Boolean(args["recursive"] ?? false);
|
|
3475
|
-
if (!existsSync8(dirPath)) {
|
|
3476
|
-
const targetName = basename2(dirPath).toLowerCase();
|
|
3477
|
-
const cwd = process.cwd();
|
|
3478
|
-
const suggestions = [];
|
|
3479
|
-
try {
|
|
3480
|
-
for (const entry of readdirSync4(cwd, { withFileTypes: true })) {
|
|
3481
|
-
if (entry.isDirectory() && entry.name.toLowerCase() === targetName) {
|
|
3482
|
-
suggestions.push(entry.name);
|
|
3483
|
-
}
|
|
3484
|
-
}
|
|
3485
|
-
} catch {
|
|
3486
|
-
}
|
|
3487
|
-
if (suggestions.length > 0) {
|
|
3488
|
-
throw new Error(
|
|
3489
|
-
`Directory not found: ${dirPath}
|
|
3490
|
-
Current working directory: ${cwd}
|
|
3491
|
-
Found similar directories:
|
|
3492
|
-
` + suggestions.map((s) => ` \u2192 ${s}`).join("\n") + `
|
|
3493
|
-
Please retry with the correct relative path.`
|
|
3494
|
-
);
|
|
3495
|
-
}
|
|
3496
|
-
throw new Error(
|
|
3497
|
-
`Directory not found: ${dirPath}
|
|
3498
|
-
Current working directory: ${cwd}
|
|
3499
|
-
Please use list_dir (without path) to see the current directory structure first.`
|
|
3500
|
-
);
|
|
3501
|
-
}
|
|
3502
|
-
const lines = [`Directory: ${dirPath}
|
|
3503
|
-
`];
|
|
3504
|
-
listRecursive(dirPath, "", recursive, lines);
|
|
3505
|
-
return lines.join("\n");
|
|
3506
|
-
}
|
|
3507
|
-
};
|
|
3508
|
-
function listRecursive(basePath, indent, recursive, lines) {
|
|
3509
|
-
let entries;
|
|
3510
|
-
try {
|
|
3511
|
-
entries = readdirSync4(basePath, { withFileTypes: true });
|
|
3512
|
-
} catch {
|
|
3513
|
-
lines.push(`${indent}(permission denied)`);
|
|
3514
|
-
return;
|
|
3515
|
-
}
|
|
3516
|
-
const sorted = entries.sort((a, b) => {
|
|
3517
|
-
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
|
|
3518
|
-
return a.name.localeCompare(b.name);
|
|
3519
|
-
});
|
|
3520
|
-
for (const entry of sorted) {
|
|
3521
|
-
const USEFUL_DOT_ENTRIES = /* @__PURE__ */ new Set([".github", ".vscode", ".idea", ".gitignore", ".gitattributes", ".editorconfig", ".eslintrc", ".prettierrc", ".npmrc"]);
|
|
3522
|
-
if (entry.name === "node_modules" || entry.name.startsWith(".") && !USEFUL_DOT_ENTRIES.has(entry.name) && !entry.name.startsWith(".env")) {
|
|
3523
|
-
if (entry.isDirectory()) {
|
|
3524
|
-
lines.push(`${indent}\u{1F4C1} ${entry.name}/ (skipped)`);
|
|
3525
|
-
}
|
|
3526
|
-
continue;
|
|
3527
|
-
}
|
|
3528
|
-
if (entry.isDirectory()) {
|
|
3529
|
-
lines.push(`${indent}\u{1F4C1} ${entry.name}/`);
|
|
3530
|
-
if (recursive) {
|
|
3531
|
-
listRecursive(join4(basePath, entry.name), indent + " ", true, lines);
|
|
3532
|
-
}
|
|
3533
|
-
} else {
|
|
3534
|
-
try {
|
|
3535
|
-
const stat = statSync3(join4(basePath, entry.name));
|
|
3536
|
-
const size = formatSize(stat.size);
|
|
3537
|
-
lines.push(`${indent}\u{1F4C4} ${entry.name} (${size})`);
|
|
3538
|
-
} catch {
|
|
3539
|
-
lines.push(`${indent}\u{1F4C4} ${entry.name}`);
|
|
3540
|
-
}
|
|
3541
|
-
}
|
|
3542
|
-
}
|
|
3543
|
-
}
|
|
3544
|
-
function formatSize(bytes) {
|
|
3545
|
-
if (bytes < 1024) return `${bytes}B`;
|
|
3546
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
3547
|
-
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
3548
|
-
}
|
|
3549
|
-
|
|
3550
|
-
// src/tools/builtin/grep-files.ts
|
|
3551
|
-
import { readdirSync as readdirSync5, readFileSync as readFileSync6, statSync as statSync4, existsSync as existsSync9 } from "fs";
|
|
3552
|
-
import { join as join5, relative } from "path";
|
|
3553
|
-
var grepFilesTool = {
|
|
3554
|
-
definition: {
|
|
3555
|
-
name: "grep_files",
|
|
3556
|
-
description: `Search for matching text or code in files, returning matching lines with line numbers. Use cases:
|
|
3557
|
-
- Find function/class definitions (e.g. "function handleChat" or "class ProviderRegistry")
|
|
3558
|
-
- Find all usages of a variable or string
|
|
3559
|
-
- Check impact scope before modifying code
|
|
3560
|
-
Supports regex. Automatically skips node_modules, dist, .git directories.`,
|
|
3561
|
-
parameters: {
|
|
3562
|
-
pattern: {
|
|
3563
|
-
type: "string",
|
|
3564
|
-
description: 'Search pattern, supports regex (e.g. "function\\s+\\w+" or literal "import React")',
|
|
3565
|
-
required: true
|
|
3566
|
-
},
|
|
3567
|
-
path: {
|
|
3568
|
-
type: "string",
|
|
3569
|
-
description: "Search root directory, defaults to current working directory",
|
|
3570
|
-
required: false
|
|
3571
|
-
},
|
|
3572
|
-
file_pattern: {
|
|
3573
|
-
type: "string",
|
|
3574
|
-
description: 'Filename filter with simple wildcards (e.g. "*.ts", "*.py", "*.json"), defaults to all text files',
|
|
3575
|
-
required: false
|
|
3576
|
-
},
|
|
3577
|
-
ignore_case: {
|
|
3578
|
-
type: "boolean",
|
|
3579
|
-
description: "Whether to ignore case, defaults to false",
|
|
3580
|
-
required: false
|
|
3581
|
-
},
|
|
3582
|
-
context_lines: {
|
|
3583
|
-
type: "number",
|
|
3584
|
-
description: "Number of context lines before/after each match, defaults to 0 (match line only)",
|
|
3585
|
-
required: false
|
|
3586
|
-
},
|
|
3587
|
-
max_results: {
|
|
3588
|
-
type: "number",
|
|
3589
|
-
description: "Maximum number of results, defaults to 50",
|
|
3590
|
-
required: false
|
|
3591
|
-
}
|
|
3592
|
-
},
|
|
3593
|
-
dangerous: false
|
|
3594
|
-
},
|
|
3595
|
-
async execute(args) {
|
|
3596
|
-
const pattern = String(args["pattern"] ?? "");
|
|
3597
|
-
const rootPath = String(args["path"] ?? process.cwd());
|
|
3598
|
-
const filePattern = args["file_pattern"] ? String(args["file_pattern"]) : void 0;
|
|
3599
|
-
const ignoreCase = Boolean(args["ignore_case"] ?? false);
|
|
3600
|
-
const contextLines = Math.max(0, Number(args["context_lines"] ?? 0));
|
|
3601
|
-
const maxResults = Math.max(1, Number(args["max_results"] ?? 50));
|
|
3602
|
-
if (!pattern) throw new Error("pattern is required");
|
|
3603
|
-
if (!existsSync9(rootPath)) throw new Error(`Path not found: ${rootPath}`);
|
|
3604
|
-
let regex;
|
|
3605
|
-
try {
|
|
3606
|
-
regex = new RegExp(pattern, ignoreCase ? "gi" : "g");
|
|
3607
|
-
} catch {
|
|
3608
|
-
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3609
|
-
regex = new RegExp(escaped, ignoreCase ? "gi" : "g");
|
|
3610
|
-
}
|
|
3611
|
-
const results = [];
|
|
3612
|
-
const stat = statSync4(rootPath);
|
|
3613
|
-
if (stat.isFile()) {
|
|
3614
|
-
searchInFile(rootPath, rootPath, regex, contextLines, maxResults, results);
|
|
3615
|
-
} else {
|
|
3616
|
-
collectFiles(rootPath, filePattern, results, regex, contextLines, maxResults, rootPath);
|
|
3617
|
-
}
|
|
3618
|
-
if (results.length === 0) {
|
|
3619
|
-
return `No matches found for pattern: ${pattern}
|
|
3620
|
-
Searched in: ${rootPath}${filePattern ? `
|
|
3621
|
-
File filter: ${filePattern}` : ""}`;
|
|
3622
|
-
}
|
|
3623
|
-
const lines = [
|
|
3624
|
-
`Found ${results.length} match(es) for: ${pattern}`,
|
|
3625
|
-
`Searched in: ${rootPath}`,
|
|
3626
|
-
""
|
|
3627
|
-
];
|
|
3628
|
-
let currentFile = "";
|
|
3629
|
-
for (const r of results) {
|
|
3630
|
-
if (r.file !== currentFile) {
|
|
3631
|
-
if (currentFile) lines.push("");
|
|
3632
|
-
lines.push(`\u2500\u2500 ${r.file} \u2500\u2500`);
|
|
3633
|
-
currentFile = r.file;
|
|
3634
|
-
}
|
|
3635
|
-
if (r.contextBefore) {
|
|
3636
|
-
for (const [ln, text] of r.contextBefore) {
|
|
3637
|
-
lines.push(` ${String(ln).padStart(4)}\u2502 ${text}`);
|
|
3638
|
-
}
|
|
3639
|
-
}
|
|
3640
|
-
lines.push(`\u25B6 ${String(r.lineNumber).padStart(4)}\u2502 ${r.lineText}`);
|
|
3641
|
-
if (r.contextAfter) {
|
|
3642
|
-
for (const [ln, text] of r.contextAfter) {
|
|
3643
|
-
lines.push(` ${String(ln).padStart(4)}\u2502 ${text}`);
|
|
3644
|
-
}
|
|
3645
|
-
}
|
|
3646
|
-
}
|
|
3647
|
-
if (results.length >= maxResults) {
|
|
3648
|
-
lines.push("");
|
|
3649
|
-
lines.push(`(Results truncated at ${maxResults}. Use max_results or narrow your search.)`);
|
|
3650
|
-
}
|
|
3651
|
-
return lines.join("\n");
|
|
3652
|
-
}
|
|
3653
|
-
};
|
|
3654
|
-
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "dist-cjs", "release", ".next", "__pycache__", ".cache", "coverage", ".nyc_output"]);
|
|
3655
|
-
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"]);
|
|
3656
|
-
function matchesFilePattern(filename, pattern) {
|
|
3657
|
-
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
3658
|
-
return new RegExp(`^${escaped}$`, "i").test(filename);
|
|
3659
|
-
}
|
|
3660
|
-
function isBinary(filename) {
|
|
3661
|
-
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
|
|
3662
|
-
return BINARY_EXTS.has(ext);
|
|
3663
|
-
}
|
|
3664
|
-
function collectFiles(dirPath, filePattern, results, regex, contextLines, maxResults, rootPath) {
|
|
3665
|
-
if (results.length >= maxResults) return;
|
|
3666
|
-
let entries;
|
|
3667
|
-
try {
|
|
3668
|
-
entries = readdirSync5(dirPath, { withFileTypes: true });
|
|
3669
|
-
} catch {
|
|
3670
|
-
return;
|
|
3671
|
-
}
|
|
3672
|
-
for (const entry of entries) {
|
|
3673
|
-
if (results.length >= maxResults) return;
|
|
3674
|
-
if (entry.isDirectory()) {
|
|
3675
|
-
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
3676
|
-
collectFiles(join5(dirPath, entry.name), filePattern, results, regex, contextLines, maxResults, rootPath);
|
|
3677
|
-
} else if (entry.isFile()) {
|
|
3678
|
-
if (isBinary(entry.name)) continue;
|
|
3679
|
-
if (filePattern && !matchesFilePattern(entry.name, filePattern)) continue;
|
|
3680
|
-
const fullPath = join5(dirPath, entry.name);
|
|
3681
|
-
const relPath = relative(rootPath, fullPath);
|
|
3682
|
-
searchInFile(fullPath, relPath, regex, contextLines, maxResults, results);
|
|
3683
|
-
}
|
|
3684
|
-
}
|
|
3685
|
-
}
|
|
3686
|
-
function searchInFile(fullPath, displayPath, regex, contextLines, maxResults, results) {
|
|
3687
|
-
try {
|
|
3688
|
-
const stat = statSync4(fullPath);
|
|
3689
|
-
if (stat.size > 1e6) return;
|
|
3690
|
-
} catch {
|
|
3691
|
-
return;
|
|
3692
|
-
}
|
|
3693
|
-
let content;
|
|
3694
|
-
try {
|
|
3695
|
-
content = readFileSync6(fullPath, "utf-8");
|
|
3696
|
-
} catch {
|
|
3697
|
-
return;
|
|
3698
|
-
}
|
|
3699
|
-
const lines = content.split("\n");
|
|
3700
|
-
regex.lastIndex = 0;
|
|
3701
|
-
for (let i = 0; i < lines.length; i++) {
|
|
3702
|
-
if (results.length >= maxResults) return;
|
|
3703
|
-
regex.lastIndex = 0;
|
|
3704
|
-
if (regex.test(lines[i])) {
|
|
3705
|
-
const result = {
|
|
3706
|
-
file: displayPath,
|
|
3707
|
-
lineNumber: i + 1,
|
|
3708
|
-
lineText: lines[i].trimEnd()
|
|
3709
|
-
};
|
|
3710
|
-
if (contextLines > 0) {
|
|
3711
|
-
result.contextBefore = [];
|
|
3712
|
-
result.contextAfter = [];
|
|
3713
|
-
for (let c = Math.max(0, i - contextLines); c < i; c++) {
|
|
3714
|
-
result.contextBefore.push([c + 1, lines[c].trimEnd()]);
|
|
3715
|
-
}
|
|
3716
|
-
for (let c = i + 1; c <= Math.min(lines.length - 1, i + contextLines); c++) {
|
|
3717
|
-
result.contextAfter.push([c + 1, lines[c].trimEnd()]);
|
|
3718
|
-
}
|
|
3719
|
-
}
|
|
3720
|
-
results.push(result);
|
|
3721
|
-
}
|
|
3722
|
-
}
|
|
3723
|
-
}
|
|
3724
|
-
|
|
3725
|
-
// src/tools/builtin/glob-files.ts
|
|
3726
|
-
import { readdirSync as readdirSync6, statSync as statSync5, existsSync as existsSync10 } from "fs";
|
|
3727
|
-
import { join as join6, relative as relative2, basename as basename3 } from "path";
|
|
3728
|
-
var globFilesTool = {
|
|
3729
|
-
definition: {
|
|
3730
|
-
name: "glob_files",
|
|
3731
|
-
description: `Find files by name or path pattern, returning a list of matching file paths. Use cases:
|
|
3732
|
-
- Find all files of a type (e.g. "**/*.ts" for all TypeScript files)
|
|
3733
|
-
- Find files by name (e.g. "**/index.ts" or "package.json")
|
|
3734
|
-
- Find files in a directory (e.g. "src/components/**")
|
|
3735
|
-
Results sorted by most recent modification time. Automatically skips node_modules, dist, .git directories.`,
|
|
3736
|
-
parameters: {
|
|
3737
|
-
pattern: {
|
|
3738
|
-
type: "string",
|
|
3739
|
-
description: 'Glob pattern, e.g. "**/*.ts", "src/**/*.tsx", "**/package.json", "*.md"',
|
|
3740
|
-
required: true
|
|
3741
|
-
},
|
|
3742
|
-
path: {
|
|
3743
|
-
type: "string",
|
|
3744
|
-
description: "Search root directory, defaults to current working directory",
|
|
3745
|
-
required: false
|
|
3746
|
-
},
|
|
3747
|
-
max_results: {
|
|
3748
|
-
type: "number",
|
|
3749
|
-
description: "Maximum number of files to return, defaults to 100",
|
|
3750
|
-
required: false
|
|
3751
|
-
}
|
|
3752
|
-
},
|
|
3753
|
-
dangerous: false
|
|
3754
|
-
},
|
|
3755
|
-
async execute(args) {
|
|
3756
|
-
const pattern = String(args["pattern"] ?? "");
|
|
3757
|
-
const rootPath = String(args["path"] ?? process.cwd());
|
|
3758
|
-
const maxResults = Math.max(1, Number(args["max_results"] ?? 100));
|
|
3759
|
-
if (!pattern) throw new Error("pattern is required");
|
|
3760
|
-
if (!existsSync10(rootPath)) throw new Error(`Path not found: ${rootPath}`);
|
|
3761
|
-
const regex = globToRegex(pattern);
|
|
3762
|
-
const matches = [];
|
|
3763
|
-
collectMatchingFiles(rootPath, rootPath, regex, matches, maxResults);
|
|
3764
|
-
if (matches.length === 0) {
|
|
3765
|
-
return `No files matched pattern: ${pattern}
|
|
3766
|
-
Searched in: ${rootPath}`;
|
|
3767
|
-
}
|
|
3768
|
-
matches.sort((a, b) => b.mtime - a.mtime);
|
|
3769
|
-
const lines = [
|
|
3770
|
-
`Found ${matches.length} file(s) matching: ${pattern}`,
|
|
3771
|
-
`Searched in: ${rootPath}`,
|
|
3772
|
-
"",
|
|
3773
|
-
...matches.map((m) => m.relPath)
|
|
3774
|
-
];
|
|
3775
|
-
if (matches.length >= maxResults) {
|
|
3776
|
-
lines.push("");
|
|
3777
|
-
lines.push(`(Results truncated at ${maxResults}. Use max_results or narrow your pattern.)`);
|
|
3778
|
-
}
|
|
3779
|
-
return lines.join("\n");
|
|
3780
|
-
}
|
|
3781
|
-
};
|
|
3782
|
-
var SKIP_DIRS2 = /* @__PURE__ */ new Set([
|
|
3783
|
-
"node_modules",
|
|
3784
|
-
".git",
|
|
3785
|
-
"dist",
|
|
3786
|
-
"dist-cjs",
|
|
3787
|
-
"release",
|
|
3788
|
-
".next",
|
|
3789
|
-
"__pycache__",
|
|
3790
|
-
".cache",
|
|
3791
|
-
"coverage",
|
|
3792
|
-
".nyc_output",
|
|
3793
|
-
".turbo",
|
|
3794
|
-
".vite",
|
|
3795
|
-
"build",
|
|
3796
|
-
"out"
|
|
3797
|
-
]);
|
|
3798
|
-
function globToRegex(pattern) {
|
|
3799
|
-
const normalized = pattern.replace(/\\/g, "/");
|
|
3800
|
-
let regStr = "";
|
|
3801
|
-
let i = 0;
|
|
3802
|
-
while (i < normalized.length) {
|
|
3803
|
-
const ch = normalized[i];
|
|
3804
|
-
if (ch === "*" && normalized[i + 1] === "*") {
|
|
3805
|
-
regStr += ".*";
|
|
3806
|
-
i += 2;
|
|
3807
|
-
if (normalized[i] === "/") i++;
|
|
3808
|
-
} else if (ch === "*") {
|
|
3809
|
-
regStr += "[^/]*";
|
|
3810
|
-
i++;
|
|
3811
|
-
} else if (ch === "?") {
|
|
3812
|
-
regStr += "[^/]";
|
|
3813
|
-
i++;
|
|
3814
|
-
} else if (".+^${}()|[]\\".includes(ch)) {
|
|
3815
|
-
regStr += "\\" + ch;
|
|
3816
|
-
i++;
|
|
3817
|
-
} else {
|
|
3818
|
-
regStr += ch;
|
|
3819
|
-
i++;
|
|
3820
|
-
}
|
|
3821
|
-
}
|
|
3822
|
-
if (!normalized.includes("/")) {
|
|
3823
|
-
return new RegExp(`(^|/)${regStr}$`, "i");
|
|
3824
|
-
}
|
|
3825
|
-
return new RegExp(`(^|/)${regStr}$`, "i");
|
|
3826
|
-
}
|
|
3827
|
-
function collectMatchingFiles(dirPath, rootPath, regex, results, maxResults) {
|
|
3828
|
-
if (results.length >= maxResults) return;
|
|
3829
|
-
let entries;
|
|
3830
|
-
try {
|
|
3831
|
-
entries = readdirSync6(dirPath, { withFileTypes: true });
|
|
3832
|
-
} catch {
|
|
3833
|
-
return;
|
|
3834
|
-
}
|
|
3835
|
-
for (const entry of entries) {
|
|
3836
|
-
if (results.length >= maxResults) break;
|
|
3837
|
-
const fullPath = join6(dirPath, entry.name);
|
|
3838
|
-
if (entry.isDirectory()) {
|
|
3839
|
-
if (SKIP_DIRS2.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
3840
|
-
collectMatchingFiles(fullPath, rootPath, regex, results, maxResults);
|
|
3841
|
-
} else if (entry.isFile()) {
|
|
3842
|
-
const relPath = relative2(rootPath, fullPath).replace(/\\/g, "/");
|
|
3843
|
-
if (regex.test(relPath) || regex.test(basename3(relPath))) {
|
|
3844
|
-
try {
|
|
3845
|
-
const stat = statSync5(fullPath);
|
|
3846
|
-
results.push({ relPath, absPath: fullPath, mtime: stat.mtimeMs });
|
|
3847
|
-
} catch {
|
|
3848
|
-
results.push({ relPath, absPath: fullPath, mtime: 0 });
|
|
3849
|
-
}
|
|
3850
|
-
}
|
|
3851
|
-
}
|
|
3852
|
-
}
|
|
3853
|
-
}
|
|
3854
|
-
|
|
3855
|
-
// src/tools/builtin/run-interactive.ts
|
|
3856
|
-
import { spawn } from "child_process";
|
|
3857
|
-
import { platform as platform2 } from "os";
|
|
3858
|
-
var IS_WINDOWS2 = platform2() === "win32";
|
|
3859
|
-
var runInteractiveTool = {
|
|
3860
|
-
definition: {
|
|
3861
|
-
name: "run_interactive",
|
|
3862
|
-
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`,
|
|
3863
|
-
parameters: {
|
|
3864
|
-
executable: {
|
|
3865
|
-
type: "string",
|
|
3866
|
-
description: 'Full path or command name of the executable, e.g. "python", "C:\\\\Users\\\\Jinzd\\\\anaconda3\\\\envs\\\\python312\\\\python.exe", "node"',
|
|
3867
|
-
required: true
|
|
3868
|
-
},
|
|
3869
|
-
args: {
|
|
3870
|
-
type: "array",
|
|
3871
|
-
description: 'Arguments array for the executable, e.g. ["workspace/guess.py"] or ["-c", "print(1)"]',
|
|
3872
|
-
items: { type: "string" },
|
|
3873
|
-
required: true
|
|
3874
|
-
},
|
|
3875
|
-
stdin_lines: {
|
|
3876
|
-
type: "array",
|
|
3877
|
-
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.`,
|
|
3878
|
-
items: { type: "string" },
|
|
3879
|
-
required: true
|
|
3880
|
-
},
|
|
3881
|
-
timeout: {
|
|
3882
|
-
type: "number",
|
|
3883
|
-
description: "Overall timeout in milliseconds, defaults to 20000 (20s)",
|
|
3884
|
-
required: false
|
|
3885
|
-
}
|
|
3886
|
-
},
|
|
3887
|
-
dangerous: false
|
|
3888
|
-
},
|
|
3889
|
-
async execute(args) {
|
|
3890
|
-
const executable = String(args["executable"] ?? "").trim();
|
|
3891
|
-
const rawArgs = args["args"];
|
|
3892
|
-
let argsTypeWarning = "";
|
|
3893
|
-
const cmdArgs = Array.isArray(rawArgs) ? rawArgs.map(String) : typeof rawArgs === "string" && rawArgs.trim() ? (() => {
|
|
3894
|
-
argsTypeWarning = `[Warning] "args" should be an array but got string: ${JSON.stringify(rawArgs)}. Applied fallback.
|
|
3895
|
-
`;
|
|
3896
|
-
process.stderr.write(argsTypeWarning);
|
|
3897
|
-
return [rawArgs.trim()];
|
|
3898
|
-
})() : [];
|
|
3899
|
-
const rawStdin = args["stdin_lines"];
|
|
3900
|
-
let stdinTypeWarning = "";
|
|
3901
|
-
const stdinLines = Array.isArray(rawStdin) ? rawStdin.map(String) : typeof rawStdin === "string" && rawStdin.trim() ? (() => {
|
|
3902
|
-
stdinTypeWarning = `[Warning] "stdin_lines" should be an array but got string: ${JSON.stringify(rawStdin.slice(0, 80))}. Applied fallback.
|
|
3903
|
-
`;
|
|
3904
|
-
process.stderr.write(stdinTypeWarning);
|
|
3905
|
-
return rawStdin.split(",").map((s) => s.trim()).filter(Boolean);
|
|
3906
|
-
})() : [];
|
|
3907
|
-
const timeout = Math.min(Math.max(Number(args["timeout"] ?? 2e4), 1e3), 3e5);
|
|
3908
|
-
if (!executable) {
|
|
3909
|
-
throw new Error("executable is required");
|
|
3910
|
-
}
|
|
3911
|
-
const env = {
|
|
3912
|
-
...process.env,
|
|
3913
|
-
// 强制 Python UTF-8 模式,修复 Windows 中文乱码
|
|
3914
|
-
PYTHONUTF8: "1",
|
|
3915
|
-
PYTHONIOENCODING: "utf-8",
|
|
3916
|
-
PYTHONDONTWRITEBYTECODE: "1"
|
|
3917
|
-
};
|
|
3918
|
-
const prefixWarnings = [argsTypeWarning, stdinTypeWarning].filter(Boolean).join("");
|
|
3919
|
-
return new Promise((resolve4) => {
|
|
3920
|
-
const child = spawn(executable, cmdArgs.map(String), {
|
|
3921
|
-
cwd: process.cwd(),
|
|
3922
|
-
env,
|
|
3923
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
3924
|
-
});
|
|
3925
|
-
let stdout = "";
|
|
3926
|
-
let stderr = "";
|
|
3927
|
-
child.stdout.setEncoding("utf-8");
|
|
3928
|
-
child.stderr.setEncoding("utf-8");
|
|
3929
|
-
child.stdout.on("data", (chunk) => {
|
|
3930
|
-
stdout += chunk;
|
|
3931
|
-
});
|
|
3932
|
-
child.stderr.on("data", (chunk) => {
|
|
3933
|
-
stderr += chunk;
|
|
3934
|
-
});
|
|
3935
|
-
let lineIdx = 0;
|
|
3936
|
-
const writeNextLine = () => {
|
|
3937
|
-
if (lineIdx < stdinLines.length && !child.stdin.destroyed) {
|
|
3938
|
-
const line = stdinLines[lineIdx++] + (IS_WINDOWS2 ? "\r\n" : "\n");
|
|
3939
|
-
const canContinue = child.stdin.write(line);
|
|
3940
|
-
if (canContinue) {
|
|
3941
|
-
setTimeout(writeNextLine, 150);
|
|
3942
|
-
} else {
|
|
3943
|
-
child.stdin.once("drain", () => setTimeout(writeNextLine, 150));
|
|
3944
|
-
}
|
|
3945
|
-
} else if (!child.stdin.destroyed) {
|
|
3946
|
-
child.stdin.end();
|
|
3947
|
-
}
|
|
3948
|
-
};
|
|
3949
|
-
setTimeout(writeNextLine, 400);
|
|
3950
|
-
const timer = setTimeout(() => {
|
|
3951
|
-
child.kill();
|
|
3952
|
-
resolve4(`${prefixWarnings}[Timeout after ${timeout}ms]
|
|
3953
|
-
${buildOutput(stdout, stderr)}`);
|
|
3954
|
-
}, timeout);
|
|
3955
|
-
child.on("close", (code) => {
|
|
3956
|
-
clearTimeout(timer);
|
|
3957
|
-
const output = buildOutput(stdout, stderr);
|
|
3958
|
-
if (code !== 0 && code !== null) {
|
|
3959
|
-
resolve4(`${prefixWarnings}Exit code ${code}:
|
|
3960
|
-
${output}`);
|
|
3961
|
-
} else {
|
|
3962
|
-
resolve4(`${prefixWarnings}${output || "(no output)"}`);
|
|
3963
|
-
}
|
|
3964
|
-
});
|
|
3965
|
-
child.on("error", (err) => {
|
|
3966
|
-
clearTimeout(timer);
|
|
3967
|
-
resolve4(
|
|
3968
|
-
`${prefixWarnings}Failed to start process "${executable}": ${err.message}
|
|
3969
|
-
Hint: On Windows, use the full path to the executable, e.g.:
|
|
3970
|
-
C:\\Users\\Jinzd\\anaconda3\\envs\\python312\\python.exe`
|
|
3971
|
-
);
|
|
3972
|
-
});
|
|
3973
|
-
});
|
|
3974
|
-
}
|
|
3975
|
-
};
|
|
3976
|
-
function buildOutput(stdout, stderr) {
|
|
3977
|
-
const parts = [];
|
|
3978
|
-
if (stdout.trim()) parts.push(stdout);
|
|
3979
|
-
if (stderr.trim()) parts.push(`[stderr]
|
|
3980
|
-
${stderr}`);
|
|
3981
|
-
return parts.join("\n") || "(no output)";
|
|
3982
|
-
}
|
|
3983
|
-
|
|
3984
|
-
// src/tools/builtin/web-fetch.ts
|
|
3985
|
-
import { promises as dnsPromises } from "dns";
|
|
3986
|
-
function htmlToText(html) {
|
|
3987
|
-
const HTML_REGEX_LIMIT = 2e5;
|
|
3988
|
-
if (html.length > HTML_REGEX_LIMIT) {
|
|
3989
|
-
html = html.slice(0, HTML_REGEX_LIMIT);
|
|
3990
|
-
}
|
|
3991
|
-
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, "");
|
|
3992
|
-
text = text.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_m, lvl, content) => {
|
|
3993
|
-
const prefix = "#".repeat(Number(lvl));
|
|
3994
|
-
return `
|
|
3995
|
-
${prefix} ${stripTags(content).trim()}
|
|
3996
|
-
`;
|
|
3997
|
-
});
|
|
3998
|
-
text = text.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (_m, code) => {
|
|
3999
|
-
return "\n```\n" + stripTags(code) + "\n```\n";
|
|
4000
|
-
});
|
|
4001
|
-
text = text.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (_m, item) => {
|
|
4002
|
-
return `
|
|
4003
|
-
- ${stripTags(item).trim()}`;
|
|
4004
|
-
});
|
|
4005
|
-
text = text.replace(/<\/(p|div|section|article|blockquote|tr)>/gi, "\n");
|
|
4006
|
-
text = text.replace(/<br\s*\/?>/gi, "\n");
|
|
4007
|
-
text = text.replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (_m, href, label) => {
|
|
4008
|
-
const l = stripTags(label).trim();
|
|
4009
|
-
if (href.startsWith("http") && l && l !== href) return `[${l}](${href})`;
|
|
4010
|
-
return l || href;
|
|
4011
|
-
});
|
|
4012
|
-
text = stripTags(text);
|
|
4013
|
-
text = text.replace(/\n{3,}/g, "\n\n");
|
|
4014
|
-
text = text.split("\n").map((l) => l.trimEnd()).join("\n");
|
|
4015
|
-
return text.trim();
|
|
4016
|
-
}
|
|
4017
|
-
function stripTags(html) {
|
|
4018
|
-
return html.replace(/<[^>]+>/g, "");
|
|
4019
|
-
}
|
|
4020
|
-
function extractTitle(html) {
|
|
4021
|
-
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
4022
|
-
return m ? stripTags(m[1]).trim() : "";
|
|
4023
|
-
}
|
|
4024
|
-
function extractDescription(html) {
|
|
4025
|
-
const m = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i) ?? html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
|
|
4026
|
-
return m ? m[1].trim() : "";
|
|
4027
|
-
}
|
|
4028
|
-
var MAX_OUTPUT = 16e3;
|
|
4029
|
-
function isPrivateHost(hostname) {
|
|
4030
|
-
const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
4031
|
-
if (h === "localhost" || h === "0.0.0.0" || h === "::1") return true;
|
|
4032
|
-
if (h.startsWith("fe80:")) return true;
|
|
4033
|
-
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
4034
|
-
if (m) {
|
|
4035
|
-
const [o1, o2] = [Number(m[1]), Number(m[2])];
|
|
4036
|
-
if (o1 === 127) return true;
|
|
4037
|
-
if (o1 === 10) return true;
|
|
4038
|
-
if (o1 === 172 && o2 >= 16 && o2 <= 31) return true;
|
|
4039
|
-
if (o1 === 192 && o2 === 168) return true;
|
|
4040
|
-
if (o1 === 169 && o2 === 254) return true;
|
|
4041
|
-
if (o1 === 0) return true;
|
|
4042
|
-
}
|
|
4043
|
-
return false;
|
|
4044
|
-
}
|
|
4045
|
-
async function resolveAndCheck(hostname) {
|
|
4046
|
-
const h = hostname.replace(/^\[|\]$/g, "");
|
|
4047
|
-
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h) || h.includes(":")) return;
|
|
4048
|
-
try {
|
|
4049
|
-
const { address } = await dnsPromises.lookup(h);
|
|
4050
|
-
if (isPrivateHost(address)) {
|
|
4051
|
-
throw new Error(`Blocked: "${hostname}" resolves to private address ${address}. web_fetch is restricted to public URLs.`);
|
|
4052
|
-
}
|
|
4053
|
-
} catch (e) {
|
|
4054
|
-
if (e.message.startsWith("Blocked:")) throw e;
|
|
4055
|
-
}
|
|
4056
|
-
}
|
|
4057
|
-
var webFetchTool = {
|
|
4058
|
-
definition: {
|
|
4059
|
-
name: "web_fetch",
|
|
4060
|
-
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.",
|
|
4061
|
-
parameters: {
|
|
4062
|
-
url: {
|
|
4063
|
-
type: "string",
|
|
4064
|
-
description: "The full URL to fetch (must start with http:// or https://)",
|
|
4065
|
-
required: true
|
|
4066
|
-
},
|
|
4067
|
-
selector: {
|
|
4068
|
-
type: "string",
|
|
4069
|
-
description: "Optional: keyword to search in the extracted text. If provided, returns only the paragraphs / sections that contain this keyword (case-insensitive).",
|
|
4070
|
-
required: false
|
|
4071
|
-
}
|
|
4072
|
-
}
|
|
4073
|
-
},
|
|
4074
|
-
async execute(args) {
|
|
4075
|
-
const url = String(args["url"] ?? "").trim();
|
|
4076
|
-
const selector = args["selector"] ? String(args["selector"]).trim() : "";
|
|
4077
|
-
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
4078
|
-
throw new Error(`Invalid URL: "${url}". URL must start with http:// or https://`);
|
|
4079
|
-
}
|
|
4080
|
-
try {
|
|
4081
|
-
const parsedUrl = new URL(url);
|
|
4082
|
-
if (isPrivateHost(parsedUrl.hostname)) {
|
|
4083
|
-
throw new Error(`Blocked: "${url}" resolves to a private/internal address. web_fetch is restricted to public URLs.`);
|
|
4084
|
-
}
|
|
4085
|
-
await resolveAndCheck(parsedUrl.hostname);
|
|
4086
|
-
} catch (e) {
|
|
4087
|
-
if (e.message.startsWith("Blocked:")) throw e;
|
|
4088
|
-
throw new Error(`Invalid URL: "${url}"`);
|
|
4089
|
-
}
|
|
4090
|
-
const controller = new AbortController();
|
|
4091
|
-
const timeoutId = setTimeout(() => controller.abort(), 2e4);
|
|
4092
|
-
let rawHtml;
|
|
4093
|
-
let finalUrl;
|
|
4094
|
-
let contentType;
|
|
4095
|
-
const MAX_REDIRECTS = 10;
|
|
4096
|
-
const FETCH_HEADERS = {
|
|
4097
|
-
"User-Agent": "Mozilla/5.0 (compatible; ai-cli/1.0; +https://github.com/ai-cli)",
|
|
4098
|
-
Accept: "text/html,application/xhtml+xml,text/plain,*/*",
|
|
4099
|
-
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8"
|
|
4100
|
-
};
|
|
4101
|
-
try {
|
|
4102
|
-
let currentUrl = url;
|
|
4103
|
-
let resp = null;
|
|
4104
|
-
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
4105
|
-
const parsedHop = new URL(currentUrl);
|
|
4106
|
-
if (isPrivateHost(parsedHop.hostname)) {
|
|
4107
|
-
throw new Error(`Blocked: redirect to private/internal address "${currentUrl}".`);
|
|
4108
|
-
}
|
|
4109
|
-
await resolveAndCheck(parsedHop.hostname);
|
|
4110
|
-
const r = await fetch(currentUrl, {
|
|
4111
|
-
signal: controller.signal,
|
|
4112
|
-
headers: FETCH_HEADERS,
|
|
4113
|
-
redirect: "manual"
|
|
4114
|
-
// 手动控制重定向
|
|
4115
|
-
});
|
|
4116
|
-
if (r.status >= 300 && r.status < 400) {
|
|
4117
|
-
if (hop >= MAX_REDIRECTS) {
|
|
4118
|
-
throw new Error(`Too many redirects (>${MAX_REDIRECTS}): ${url}`);
|
|
4119
|
-
}
|
|
4120
|
-
const location = r.headers.get("Location");
|
|
4121
|
-
if (!location) {
|
|
4122
|
-
resp = r;
|
|
4123
|
-
break;
|
|
4124
|
-
}
|
|
4125
|
-
currentUrl = new URL(location, currentUrl).href;
|
|
4126
|
-
continue;
|
|
4127
|
-
}
|
|
4128
|
-
resp = r;
|
|
4129
|
-
break;
|
|
4130
|
-
}
|
|
4131
|
-
clearTimeout(timeoutId);
|
|
4132
|
-
if (!resp) throw new Error(`Too many redirects (>${MAX_REDIRECTS}): ${url}`);
|
|
4133
|
-
finalUrl = currentUrl;
|
|
4134
|
-
contentType = resp.headers.get("content-type") ?? "";
|
|
4135
|
-
if (!resp.ok) {
|
|
4136
|
-
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
|
4137
|
-
}
|
|
4138
|
-
const buf = await resp.arrayBuffer();
|
|
4139
|
-
rawHtml = new TextDecoder("utf-8", { fatal: false }).decode(buf.slice(0, 2e6));
|
|
4140
|
-
} catch (err) {
|
|
4141
|
-
clearTimeout(timeoutId);
|
|
4142
|
-
if (err.name === "AbortError") {
|
|
4143
|
-
throw new Error(`Request timed out after 20s: ${url}`);
|
|
4144
|
-
}
|
|
4145
|
-
throw err;
|
|
4146
|
-
}
|
|
4147
|
-
let text;
|
|
4148
|
-
if (contentType.includes("text/plain") || contentType.includes("application/json")) {
|
|
4149
|
-
text = rawHtml;
|
|
4150
|
-
} else {
|
|
4151
|
-
const title = extractTitle(rawHtml);
|
|
4152
|
-
const desc = extractDescription(rawHtml);
|
|
4153
|
-
let body = htmlToText(rawHtml);
|
|
4154
|
-
const header = [
|
|
4155
|
-
title ? `# ${title}` : "",
|
|
4156
|
-
desc ? `> ${desc}` : "",
|
|
4157
|
-
`Source: ${finalUrl}`,
|
|
4158
|
-
""
|
|
4159
|
-
].filter(Boolean).join("\n");
|
|
4160
|
-
text = header + "\n\n" + body;
|
|
4161
|
-
}
|
|
4162
|
-
if (selector) {
|
|
4163
|
-
const lower = selector.toLowerCase();
|
|
4164
|
-
const paragraphs = text.split("\n\n");
|
|
4165
|
-
const matched = paragraphs.filter((p) => p.toLowerCase().includes(lower));
|
|
4166
|
-
if (matched.length > 0) {
|
|
4167
|
-
text = `[Filtered by keyword: "${selector}"]
|
|
4168
|
-
|
|
4169
|
-
` + matched.join("\n\n");
|
|
4170
|
-
} else {
|
|
4171
|
-
text = `[No paragraphs contain keyword: "${selector}"]
|
|
4172
|
-
|
|
4173
|
-
` + text;
|
|
4174
|
-
}
|
|
4175
|
-
}
|
|
4176
|
-
if (text.length > MAX_OUTPUT) {
|
|
4177
|
-
const kept = text.slice(0, MAX_OUTPUT);
|
|
4178
|
-
text = kept + `
|
|
4179
|
-
|
|
4180
|
-
... [Content truncated: ${text.length} chars total, returning first ${MAX_OUTPUT} chars. Use the selector parameter to narrow scope for more details] ...`;
|
|
4181
|
-
}
|
|
4182
|
-
return text;
|
|
4183
|
-
}
|
|
4184
|
-
};
|
|
4185
|
-
|
|
4186
|
-
// src/tools/builtin/save-last-response.ts
|
|
4187
|
-
import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync4 } from "fs";
|
|
4188
|
-
import { dirname as dirname3 } from "path";
|
|
4189
|
-
var lastResponseStore = { content: "" };
|
|
4190
|
-
var saveLastResponseTool = {
|
|
4191
|
-
definition: {
|
|
4192
|
-
name: "save_last_response",
|
|
4193
|
-
description: `Tool for generating and saving large documents (exams, reports, long articles) to a file.
|
|
4194
|
-
|
|
4195
|
-
[Usage] When the user requests generating and saving a large document, call this tool with the target path:
|
|
4196
|
-
- The system will automatically initiate a streaming generation request, outputting content to both terminal and disk (tee mode)
|
|
4197
|
-
- No need to output content first then call \u2014 the system handles "generate + save" in one step
|
|
4198
|
-
- Only the file path is passed as a parameter, content is NOT passed via arguments, avoiding API argument truncation
|
|
4199
|
-
|
|
4200
|
-
[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.
|
|
4201
|
-
|
|
4202
|
-
[Use cases] Exam papers (600-700 lines, 15-25KB), technical reports, long articles \u2014 any content over 2KB.`,
|
|
4203
|
-
parameters: {
|
|
4204
|
-
path: {
|
|
4205
|
-
type: "string",
|
|
4206
|
-
description: "File path to save to (including filename), e.g. reports/2026-summary.md",
|
|
4207
|
-
required: true
|
|
4208
|
-
}
|
|
4209
|
-
},
|
|
4210
|
-
dangerous: false
|
|
4211
|
-
// getDangerLevel 中标记为 write
|
|
4212
|
-
},
|
|
4213
|
-
async execute(args) {
|
|
4214
|
-
const filePath = String(args["path"] ?? "");
|
|
4215
|
-
if (!filePath) throw new Error("path is required");
|
|
4216
|
-
const content = lastResponseStore.content;
|
|
4217
|
-
if (!content) {
|
|
4218
|
-
throw new Error("No content to save: AI has not produced any response yet, or the last response was empty.");
|
|
4219
|
-
}
|
|
4220
|
-
undoStack.push(filePath, `save_last_response: ${filePath}`);
|
|
4221
|
-
mkdirSync4(dirname3(filePath), { recursive: true });
|
|
4222
|
-
writeFileSync6(filePath, content, "utf-8");
|
|
4223
|
-
const lines = content.split("\n").length;
|
|
4224
|
-
return `File saved: ${filePath} (${lines} lines, ${content.length} bytes)`;
|
|
4225
|
-
}
|
|
4226
|
-
};
|
|
4227
|
-
|
|
4228
|
-
// src/tools/builtin/save-memory.ts
|
|
4229
|
-
import { existsSync as existsSync11, statSync as statSync6, appendFileSync as appendFileSync2, mkdirSync as mkdirSync5 } from "fs";
|
|
4230
|
-
import { join as join7 } from "path";
|
|
4231
|
-
import { homedir as homedir3 } from "os";
|
|
4232
|
-
function getMemoryFilePath() {
|
|
4233
|
-
return join7(homedir3(), CONFIG_DIR_NAME, MEMORY_FILE_NAME);
|
|
4234
|
-
}
|
|
4235
|
-
function formatTimestamp() {
|
|
4236
|
-
const now = /* @__PURE__ */ new Date();
|
|
4237
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
4238
|
-
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
4239
|
-
}
|
|
4240
|
-
var saveMemoryTool = {
|
|
4241
|
-
definition: {
|
|
4242
|
-
name: "save_memory",
|
|
4243
|
-
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.",
|
|
4244
|
-
parameters: {
|
|
4245
|
-
content: {
|
|
4246
|
-
type: "string",
|
|
4247
|
-
description: "The information to save to persistent memory. Keep it concise and actionable.",
|
|
4248
|
-
required: true
|
|
4249
|
-
}
|
|
4250
|
-
},
|
|
4251
|
-
dangerous: false
|
|
4252
|
-
},
|
|
4253
|
-
async execute(args) {
|
|
4254
|
-
const content = String(args["content"] ?? "").trim();
|
|
4255
|
-
if (!content) throw new Error("content is required");
|
|
4256
|
-
const memoryPath = getMemoryFilePath();
|
|
4257
|
-
const configDir = join7(homedir3(), CONFIG_DIR_NAME);
|
|
4258
|
-
if (!existsSync11(configDir)) {
|
|
4259
|
-
mkdirSync5(configDir, { recursive: true });
|
|
4260
|
-
}
|
|
4261
|
-
const timestamp = formatTimestamp();
|
|
4262
|
-
const entry = `
|
|
4263
|
-
## ${timestamp}
|
|
4264
|
-
${content}
|
|
4265
|
-
`;
|
|
4266
|
-
appendFileSync2(memoryPath, entry, "utf-8");
|
|
4267
|
-
const byteSize = statSync6(memoryPath).size;
|
|
4268
|
-
return `Memory saved successfully. File size: ${byteSize} bytes in ${MEMORY_FILE_NAME}`;
|
|
4269
|
-
}
|
|
4270
|
-
};
|
|
4271
|
-
|
|
4272
|
-
// src/tools/builtin/ask-user.ts
|
|
4273
|
-
import chalk from "chalk";
|
|
4274
|
-
var askUserContext = {
|
|
4275
|
-
prompting: false
|
|
4276
|
-
};
|
|
4277
|
-
var askUserTool = {
|
|
4278
|
-
definition: {
|
|
4279
|
-
name: "ask_user",
|
|
4280
|
-
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.",
|
|
4281
|
-
parameters: {
|
|
4282
|
-
question: {
|
|
4283
|
-
type: "string",
|
|
4284
|
-
description: "The question to ask the user. Be clear and specific.",
|
|
4285
|
-
required: true
|
|
4286
|
-
}
|
|
4287
|
-
},
|
|
4288
|
-
dangerous: false
|
|
4289
|
-
},
|
|
4290
|
-
async execute(args) {
|
|
4291
|
-
const question = String(args["question"] ?? "").trim();
|
|
4292
|
-
if (!question) throw new Error("question parameter is required");
|
|
4293
|
-
if (!askUserContext.rl) {
|
|
4294
|
-
throw new Error("ask_user is not available in this context (readline not initialized)");
|
|
4295
|
-
}
|
|
4296
|
-
const answer = await promptUser(askUserContext.rl, question);
|
|
4297
|
-
if (answer === null) {
|
|
4298
|
-
return "User did not respond (cancelled).";
|
|
4299
|
-
}
|
|
4300
|
-
return `User response: ${answer}`;
|
|
4301
|
-
}
|
|
4302
|
-
};
|
|
4303
|
-
function promptUser(rl, question) {
|
|
4304
|
-
const rlAny = rl;
|
|
4305
|
-
const savedOutput = rlAny.output;
|
|
4306
|
-
rlAny.output = process.stdout;
|
|
4307
|
-
rl.resume();
|
|
4308
|
-
askUserContext.prompting = true;
|
|
4309
|
-
console.log();
|
|
4310
|
-
console.log(chalk.cyan("\u2753 ") + chalk.bold(question));
|
|
4311
|
-
process.stdout.write(chalk.cyan("> "));
|
|
4312
|
-
return new Promise((resolve4) => {
|
|
4313
|
-
let completed = false;
|
|
4314
|
-
const cleanup = (answer) => {
|
|
4315
|
-
if (completed) return;
|
|
4316
|
-
completed = true;
|
|
4317
|
-
rl.removeListener("line", onLine);
|
|
4318
|
-
askUserContext.cancelFn = void 0;
|
|
4319
|
-
rl.pause();
|
|
4320
|
-
rlAny.output = savedOutput;
|
|
4321
|
-
askUserContext.prompting = false;
|
|
4322
|
-
resolve4(answer);
|
|
4323
|
-
};
|
|
4324
|
-
const onLine = (line) => {
|
|
4325
|
-
cleanup(line);
|
|
4326
|
-
};
|
|
4327
|
-
askUserContext.cancelFn = () => {
|
|
4328
|
-
process.stdout.write(chalk.gray("\n(cancelled)\n"));
|
|
4329
|
-
cleanup(null);
|
|
4330
|
-
};
|
|
4331
|
-
rl.once("line", onLine);
|
|
4332
|
-
});
|
|
4333
|
-
}
|
|
4334
|
-
|
|
4335
|
-
// src/tools/builtin/write-todos.ts
|
|
4336
|
-
import chalk2 from "chalk";
|
|
4337
|
-
var VALID_STATUSES = /* @__PURE__ */ new Set(["pending", "in_progress", "completed"]);
|
|
4338
|
-
var currentTodos = [];
|
|
4339
|
-
var writeTodosTool = {
|
|
4340
|
-
definition: {
|
|
4341
|
-
name: "write_todos",
|
|
4342
|
-
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.
|
|
4343
|
-
Parameter format: JSON array string, e.g. [{"title":"Read config","status":"completed"},{"title":"Parse args","status":"in_progress"},{"title":"Run tests","status":"pending"}]
|
|
4344
|
-
Valid statuses: pending, in_progress, completed.`,
|
|
4345
|
-
parameters: {
|
|
4346
|
-
todos: {
|
|
4347
|
-
type: "string",
|
|
4348
|
-
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"}]',
|
|
4349
|
-
required: true
|
|
4350
|
-
}
|
|
4351
|
-
},
|
|
4352
|
-
dangerous: false
|
|
4353
|
-
},
|
|
4354
|
-
async execute(args) {
|
|
4355
|
-
const raw = args["todos"];
|
|
4356
|
-
let parsed;
|
|
4357
|
-
if (typeof raw === "string") {
|
|
4358
|
-
const trimmed = raw.trim();
|
|
4359
|
-
if (!trimmed) throw new Error("todos parameter is required");
|
|
4360
|
-
try {
|
|
4361
|
-
parsed = JSON.parse(trimmed);
|
|
4362
|
-
} catch (err) {
|
|
4363
|
-
throw new Error(`Invalid JSON in todos parameter: ${err.message}`);
|
|
4364
|
-
}
|
|
4365
|
-
} else if (Array.isArray(raw)) {
|
|
4366
|
-
parsed = raw;
|
|
4367
|
-
} else {
|
|
4368
|
-
throw new Error("todos parameter must be a JSON array string");
|
|
4369
|
-
}
|
|
4370
|
-
if (!Array.isArray(parsed)) {
|
|
4371
|
-
throw new Error("todos must be a JSON array");
|
|
4372
|
-
}
|
|
4373
|
-
const todos = parsed.map((item, i) => {
|
|
4374
|
-
if (typeof item !== "object" || item === null) {
|
|
4375
|
-
throw new Error(`todos[${i}] must be an object`);
|
|
4376
|
-
}
|
|
4377
|
-
const obj = item;
|
|
4378
|
-
const title = String(obj["title"] ?? "").trim();
|
|
4379
|
-
const status = String(obj["status"] ?? "").trim();
|
|
4380
|
-
if (!title) throw new Error(`todos[${i}].title is required`);
|
|
4381
|
-
if (!VALID_STATUSES.has(status)) {
|
|
4382
|
-
throw new Error(`todos[${i}].status must be one of: pending, in_progress, completed (got "${status}")`);
|
|
4383
|
-
}
|
|
4384
|
-
return { title, status };
|
|
4385
|
-
});
|
|
4386
|
-
currentTodos = todos;
|
|
4387
|
-
renderTodoList(todos);
|
|
4388
|
-
const completed = todos.filter((t) => t.status === "completed").length;
|
|
4389
|
-
const inProgress = todos.filter((t) => t.status === "in_progress").length;
|
|
4390
|
-
const pending = todos.filter((t) => t.status === "pending").length;
|
|
4391
|
-
return `Todo list updated: ${todos.length} items (${completed} completed, ${inProgress} in progress, ${pending} pending)`;
|
|
4392
|
-
}
|
|
4393
|
-
};
|
|
4394
|
-
function renderTodoList(todos) {
|
|
4395
|
-
const completed = todos.filter((t) => t.status === "completed").length;
|
|
4396
|
-
const total = todos.length;
|
|
4397
|
-
console.log();
|
|
4398
|
-
console.log(
|
|
4399
|
-
chalk2.bold.cyan("\u{1F4CB} Todo List") + chalk2.dim(` (${completed}/${total} completed)`)
|
|
4400
|
-
);
|
|
4401
|
-
console.log(chalk2.dim(" " + "\u2500".repeat(40)));
|
|
4402
|
-
for (const todo of todos) {
|
|
4403
|
-
let icon;
|
|
4404
|
-
let text;
|
|
4405
|
-
switch (todo.status) {
|
|
4406
|
-
case "completed":
|
|
4407
|
-
icon = chalk2.green(" \u2713 ");
|
|
4408
|
-
text = chalk2.strikethrough.gray(todo.title);
|
|
4409
|
-
break;
|
|
4410
|
-
case "in_progress":
|
|
4411
|
-
icon = chalk2.yellow(" \u2192 ");
|
|
4412
|
-
text = chalk2.white(todo.title);
|
|
4413
|
-
break;
|
|
4414
|
-
case "pending":
|
|
4415
|
-
default:
|
|
4416
|
-
icon = chalk2.gray(" \u25CB ");
|
|
4417
|
-
text = chalk2.gray(todo.title);
|
|
4418
|
-
break;
|
|
4419
|
-
}
|
|
4420
|
-
console.log(icon + text);
|
|
4421
|
-
}
|
|
4422
|
-
console.log();
|
|
4423
|
-
}
|
|
4424
|
-
|
|
4425
|
-
// src/tools/builtin/google-search.ts
|
|
4426
|
-
var GOOGLE_SEARCH_API = "https://www.googleapis.com/customsearch/v1";
|
|
4427
|
-
var REQUEST_TIMEOUT_MS = 15e3;
|
|
4428
|
-
var MAX_RESULTS = 10;
|
|
4429
|
-
var DEFAULT_RESULTS = 5;
|
|
4430
|
-
var googleSearchContext = {};
|
|
4431
|
-
var googleSearchTool = {
|
|
4432
|
-
definition: {
|
|
4433
|
-
name: "google_search",
|
|
4434
|
-
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.",
|
|
4435
|
-
parameters: {
|
|
4436
|
-
query: {
|
|
4437
|
-
type: "string",
|
|
4438
|
-
description: "The search query string.",
|
|
4439
|
-
required: true
|
|
4440
|
-
},
|
|
4441
|
-
num_results: {
|
|
4442
|
-
type: "number",
|
|
4443
|
-
description: "Number of results to return (1-10, default 5).",
|
|
4444
|
-
required: false
|
|
4445
|
-
}
|
|
4446
|
-
},
|
|
4447
|
-
dangerous: false
|
|
4448
|
-
},
|
|
4449
|
-
async execute(args) {
|
|
4450
|
-
const query = String(args["query"] ?? "").trim();
|
|
4451
|
-
if (!query) throw new Error("query parameter is required");
|
|
4452
|
-
const numResults = Math.min(
|
|
4453
|
-
Math.max(Math.floor(Number(args["num_results"] ?? DEFAULT_RESULTS)), 1),
|
|
4454
|
-
MAX_RESULTS
|
|
4455
|
-
);
|
|
4456
|
-
const { apiKey, cx } = resolveConfig();
|
|
4457
|
-
const url = new URL(GOOGLE_SEARCH_API);
|
|
4458
|
-
url.searchParams.set("key", apiKey);
|
|
4459
|
-
url.searchParams.set("cx", cx);
|
|
4460
|
-
url.searchParams.set("q", query);
|
|
4461
|
-
url.searchParams.set("num", String(numResults));
|
|
4462
|
-
const controller = new AbortController();
|
|
4463
|
-
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
4464
|
-
try {
|
|
4465
|
-
const response = await fetch(url.toString(), {
|
|
4466
|
-
method: "GET",
|
|
4467
|
-
headers: {
|
|
4468
|
-
"Accept": "application/json"
|
|
4469
|
-
},
|
|
4470
|
-
signal: controller.signal
|
|
4471
|
-
});
|
|
4472
|
-
if (!response.ok) {
|
|
4473
|
-
const errorBody = await response.text().catch(() => "");
|
|
4474
|
-
if (response.status === 403) {
|
|
4475
|
-
throw new Error(
|
|
4476
|
-
`Google Search API 403 Forbidden \u2014 Invalid API Key or daily free quota exceeded (100/day).
|
|
4477
|
-
Please check your API Key and Search Engine ID configuration.`
|
|
4478
|
-
);
|
|
4479
|
-
}
|
|
4480
|
-
if (response.status === 429) {
|
|
4481
|
-
throw new Error("Google Search API 429 \u2014 Too many requests, please try again later.");
|
|
4482
|
-
}
|
|
4483
|
-
throw new Error(
|
|
4484
|
-
`Google Search API error: HTTP ${response.status} ${response.statusText}
|
|
4485
|
-
${errorBody.slice(0, 500)}`
|
|
4486
|
-
);
|
|
4487
|
-
}
|
|
4488
|
-
const data = await response.json();
|
|
4489
|
-
return formatResults(query, data, numResults);
|
|
4490
|
-
} catch (err) {
|
|
4491
|
-
if (err instanceof Error && err.name === "AbortError") {
|
|
4492
|
-
throw new Error(`Google Search request timed out (${REQUEST_TIMEOUT_MS / 1e3}s). Please check your network or proxy configuration.`);
|
|
4493
|
-
}
|
|
4494
|
-
throw err;
|
|
4495
|
-
} finally {
|
|
4496
|
-
clearTimeout(timeout);
|
|
4497
|
-
}
|
|
4498
|
-
}
|
|
4499
|
-
};
|
|
4500
|
-
function resolveConfig() {
|
|
4501
|
-
let apiKey;
|
|
4502
|
-
let cx;
|
|
4503
|
-
if (googleSearchContext.configManager) {
|
|
4504
|
-
apiKey = googleSearchContext.configManager.getApiKey("google-search");
|
|
4505
|
-
cx = EnvLoader.getGoogleSearchEngineId() ?? googleSearchContext.configManager.get("googleSearchEngineId");
|
|
4506
|
-
} else {
|
|
4507
|
-
apiKey = EnvLoader.getApiKey("google-search");
|
|
4508
|
-
cx = EnvLoader.getGoogleSearchEngineId();
|
|
4509
|
-
}
|
|
4510
|
-
if (!apiKey) {
|
|
4511
|
-
throw new Error(
|
|
4512
|
-
'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'
|
|
4513
|
-
);
|
|
4514
|
-
}
|
|
4515
|
-
if (!cx) {
|
|
4516
|
-
throw new Error(
|
|
4517
|
-
"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"
|
|
4518
|
-
);
|
|
4519
|
-
}
|
|
4520
|
-
return { apiKey, cx };
|
|
4521
|
-
}
|
|
4522
|
-
function formatResults(query, data, requested) {
|
|
4523
|
-
const items = data.items ?? [];
|
|
4524
|
-
if (items.length === 0) {
|
|
4525
|
-
const info2 = data.searchInformation;
|
|
4526
|
-
return `No results found for: "${query}"` + (info2 ? ` (searched ${info2.formattedTotalResults ?? "0"} pages in ${info2.formattedSearchTime ?? "?"}s)` : "");
|
|
4527
|
-
}
|
|
4528
|
-
const info = data.searchInformation;
|
|
4529
|
-
const header = `Search results for "${query}" (${items.length} of ~${info?.formattedTotalResults ?? "?"} results):
|
|
4530
|
-
`;
|
|
4531
|
-
const results = items.map((item, i) => {
|
|
4532
|
-
const title = item.title ?? "Untitled";
|
|
4533
|
-
const link = item.link ?? "";
|
|
4534
|
-
const snippet = item.snippet ?? "";
|
|
4535
|
-
return `${i + 1}. **${title}**
|
|
4536
|
-
URL: ${link}
|
|
4537
|
-
${snippet}`;
|
|
4538
|
-
});
|
|
4539
|
-
return header + "\n" + results.join("\n\n");
|
|
4540
|
-
}
|
|
4541
|
-
|
|
4542
|
-
// src/tools/truncate.ts
|
|
4543
|
-
var DEFAULT_MAX_TOOL_OUTPUT_CHARS = 12e3;
|
|
4544
|
-
function getMaxOutputChars(contextWindow) {
|
|
4545
|
-
if (!contextWindow || contextWindow <= 0) return DEFAULT_MAX_TOOL_OUTPUT_CHARS;
|
|
4546
|
-
return Math.max(DEFAULT_MAX_TOOL_OUTPUT_CHARS, Math.min(Math.floor(contextWindow / 4), 12e4));
|
|
4547
|
-
}
|
|
4548
|
-
var activeMaxChars = DEFAULT_MAX_TOOL_OUTPUT_CHARS;
|
|
4549
|
-
function setContextWindow(contextWindow) {
|
|
4550
|
-
activeMaxChars = getMaxOutputChars(contextWindow);
|
|
4551
|
-
}
|
|
4552
|
-
function getActiveMaxChars() {
|
|
4553
|
-
return activeMaxChars;
|
|
4554
|
-
}
|
|
4555
|
-
function truncateOutput(content, toolName, maxChars) {
|
|
4556
|
-
const limit = maxChars ?? activeMaxChars;
|
|
4557
|
-
if (content.length <= limit) return content;
|
|
4558
|
-
const keepHead = Math.floor(limit * 0.7);
|
|
4559
|
-
const keepTail = Math.floor(limit * 0.2);
|
|
4560
|
-
const omitted = content.length - keepHead - keepTail;
|
|
4561
|
-
const lines = content.split("\n").length;
|
|
4562
|
-
const head = content.slice(0, keepHead);
|
|
4563
|
-
const tail = content.slice(content.length - keepTail);
|
|
4564
|
-
return head + `
|
|
4565
|
-
|
|
4566
|
-
... [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" : "") + `] ...
|
|
4567
|
-
|
|
4568
|
-
` + tail;
|
|
4569
|
-
}
|
|
4570
|
-
|
|
4571
|
-
// src/repl/theme.ts
|
|
4572
|
-
import chalk3 from "chalk";
|
|
4573
|
-
var DARK_THEME = {
|
|
4574
|
-
prompt: chalk3.green,
|
|
4575
|
-
info: chalk3.cyan,
|
|
4576
|
-
warning: chalk3.yellow,
|
|
4577
|
-
error: chalk3.red,
|
|
4578
|
-
success: chalk3.green,
|
|
4579
|
-
dim: chalk3.dim,
|
|
4580
|
-
accent: chalk3.cyan,
|
|
4581
|
-
toolCall: chalk3.yellow,
|
|
4582
|
-
toolResult: chalk3.green,
|
|
4583
|
-
heading: chalk3.bold.cyan
|
|
4584
|
-
};
|
|
4585
|
-
var LIGHT_THEME = {
|
|
4586
|
-
prompt: chalk3.blue,
|
|
4587
|
-
info: chalk3.blueBright,
|
|
4588
|
-
warning: chalk3.yellow,
|
|
4589
|
-
error: chalk3.red,
|
|
4590
|
-
success: chalk3.green,
|
|
4591
|
-
dim: chalk3.gray,
|
|
4592
|
-
accent: chalk3.blueBright,
|
|
4593
|
-
toolCall: chalk3.magenta,
|
|
4594
|
-
toolResult: chalk3.green,
|
|
4595
|
-
heading: chalk3.bold.blue
|
|
4596
|
-
};
|
|
4597
|
-
function resolveColor(name) {
|
|
4598
|
-
if (name.startsWith("#")) return chalk3.hex(name);
|
|
4599
|
-
const parts = name.split(".");
|
|
4600
|
-
let result = chalk3;
|
|
4601
|
-
for (const part of parts) {
|
|
4602
|
-
const obj = result;
|
|
4603
|
-
if (obj && typeof obj[part] !== "undefined") {
|
|
4604
|
-
result = obj[part];
|
|
4605
|
-
}
|
|
4606
|
-
}
|
|
4607
|
-
if (typeof result !== "function") {
|
|
4608
|
-
process.stderr.write(`[theme] Warning: unrecognized color "${name}", using default.
|
|
4609
|
-
`);
|
|
4610
|
-
return chalk3;
|
|
4611
|
-
}
|
|
4612
|
-
return result;
|
|
4613
|
-
}
|
|
4614
|
-
function buildCustomTheme(base, overrides) {
|
|
4615
|
-
if (!overrides) return base;
|
|
4616
|
-
const result = { ...base };
|
|
4617
|
-
for (const [key, colorName] of Object.entries(overrides)) {
|
|
4618
|
-
if (key in result && colorName) {
|
|
4619
|
-
result[key] = resolveColor(colorName);
|
|
4620
|
-
}
|
|
4621
|
-
}
|
|
4622
|
-
return result;
|
|
4623
|
-
}
|
|
4624
|
-
var _currentTheme = DARK_THEME;
|
|
4625
|
-
function initTheme(themeId = "dark", customColors) {
|
|
4626
|
-
switch (themeId) {
|
|
4627
|
-
case "light":
|
|
4628
|
-
_currentTheme = LIGHT_THEME;
|
|
4629
|
-
break;
|
|
4630
|
-
case "custom":
|
|
4631
|
-
_currentTheme = buildCustomTheme(DARK_THEME, customColors);
|
|
4632
|
-
break;
|
|
4633
|
-
default:
|
|
4634
|
-
_currentTheme = DARK_THEME;
|
|
4635
|
-
}
|
|
4636
|
-
}
|
|
4637
|
-
var theme = new Proxy(DARK_THEME, {
|
|
4638
|
-
get(_target, prop) {
|
|
4639
|
-
return _currentTheme[prop];
|
|
4640
|
-
}
|
|
4641
|
-
});
|
|
4642
|
-
|
|
4643
|
-
// src/tools/builtin/spawn-agent.ts
|
|
4644
|
-
var spawnAgentContext = {
|
|
4645
|
-
provider: null,
|
|
4646
|
-
model: "",
|
|
4647
|
-
systemPrompt: void 0,
|
|
4648
|
-
modelParams: {},
|
|
4649
|
-
configManager: null
|
|
4650
|
-
};
|
|
4651
|
-
var PREFIX = theme.dim(" \u2503 ");
|
|
4652
|
-
var SubAgentExecutor = class {
|
|
4653
|
-
constructor(registry) {
|
|
4654
|
-
this.registry = registry;
|
|
4655
|
-
}
|
|
4656
|
-
round = 0;
|
|
4657
|
-
totalRounds = 0;
|
|
4658
|
-
setRoundInfo(current, total) {
|
|
4659
|
-
this.round = current;
|
|
4660
|
-
this.totalRounds = total;
|
|
4661
|
-
}
|
|
4662
|
-
async execute(call) {
|
|
4663
|
-
const tool = this.registry.get(call.name);
|
|
4664
|
-
if (!tool) {
|
|
4665
|
-
return {
|
|
4666
|
-
callId: call.id,
|
|
4667
|
-
content: `Unknown tool: ${call.name}`,
|
|
4668
|
-
isError: true
|
|
4669
|
-
};
|
|
4670
|
-
}
|
|
4671
|
-
const dangerLevel = getDangerLevel(call.name, call.arguments);
|
|
4672
|
-
if (dangerLevel === "destructive") {
|
|
4673
|
-
this.printPrefixed(
|
|
4674
|
-
theme.error("\u26A0 BLOCKED: ") + `Destructive operation ${call.name} not allowed in sub-agent`
|
|
4675
|
-
);
|
|
4676
|
-
return {
|
|
4677
|
-
callId: call.id,
|
|
4678
|
-
content: "Destructive operations are not allowed in sub-agents.",
|
|
4679
|
-
isError: true
|
|
4680
|
-
};
|
|
4681
|
-
}
|
|
4682
|
-
this.printToolCall(call, dangerLevel);
|
|
4683
|
-
try {
|
|
4684
|
-
const rawContent = await tool.execute(call.arguments);
|
|
4685
|
-
const content = truncateOutput(rawContent, call.name);
|
|
4686
|
-
const wasTruncated = content !== rawContent;
|
|
4687
|
-
this.printToolResult(call.name, rawContent, false, wasTruncated);
|
|
4688
|
-
return { callId: call.id, content, isError: false };
|
|
4689
|
-
} catch (err) {
|
|
4690
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4691
|
-
this.printToolResult(call.name, message, true, false);
|
|
4692
|
-
return { callId: call.id, content: message, isError: true };
|
|
4693
|
-
}
|
|
4694
|
-
}
|
|
4695
|
-
async executeAll(calls) {
|
|
4696
|
-
const results = [];
|
|
4697
|
-
for (const call of calls) {
|
|
4698
|
-
results.push(await this.execute(call));
|
|
4699
|
-
}
|
|
4700
|
-
return results;
|
|
4701
|
-
}
|
|
4702
|
-
// ── 带前缀的终端输出 ──
|
|
4703
|
-
printPrefixed(text) {
|
|
4704
|
-
for (const line of text.split("\n")) {
|
|
4705
|
-
console.log(PREFIX + line);
|
|
4706
|
-
}
|
|
4707
|
-
}
|
|
4708
|
-
printToolCall(call, dangerLevel) {
|
|
4709
|
-
console.log(PREFIX);
|
|
4710
|
-
const icon = dangerLevel === "write" ? theme.warning("\u270E Tool: ") : theme.toolCall("\u2699 Tool: ");
|
|
4711
|
-
const roundBadge = this.totalRounds > 0 ? theme.dim(` [${this.round}/${this.totalRounds}]`) : "";
|
|
4712
|
-
console.log(PREFIX + icon + call.name + roundBadge);
|
|
4713
|
-
for (const [key, val] of Object.entries(call.arguments)) {
|
|
4714
|
-
let valStr;
|
|
4715
|
-
if (Array.isArray(val)) {
|
|
4716
|
-
const json = JSON.stringify(val);
|
|
4717
|
-
valStr = json.length > 160 ? json.slice(0, 160) + "..." : json;
|
|
4718
|
-
} else if (typeof val === "string" && val.length > 120) {
|
|
4719
|
-
valStr = val.slice(0, 120) + "...";
|
|
4720
|
-
} else {
|
|
4721
|
-
valStr = String(val);
|
|
4722
|
-
}
|
|
4723
|
-
console.log(PREFIX + theme.dim(` ${key}: `) + valStr);
|
|
4724
|
-
}
|
|
4725
|
-
}
|
|
4726
|
-
printToolResult(name, content, isError, wasTruncated) {
|
|
4727
|
-
if (isError) {
|
|
4728
|
-
console.log(
|
|
4729
|
-
PREFIX + theme.error(`\u26A0 ${name} error: `) + theme.dim(content.slice(0, 300))
|
|
4730
|
-
);
|
|
4731
|
-
} else {
|
|
4732
|
-
const lines = content.split("\n");
|
|
4733
|
-
const maxLines = name === "run_interactive" ? 40 : 8;
|
|
4734
|
-
const preview = lines.slice(0, maxLines);
|
|
4735
|
-
const prefixedPreview = preview.map((l) => PREFIX + " " + theme.dim(l)).join("\n");
|
|
4736
|
-
const moreLines = lines.length > maxLines ? "\n" + PREFIX + theme.dim(` ... (${lines.length - maxLines} more lines)`) : "";
|
|
4737
|
-
const truncNote = wasTruncated ? "\n" + PREFIX + theme.warning(` \u26A1 Output truncated`) : "";
|
|
4738
|
-
console.log(PREFIX + theme.success("\u2713 Result:"));
|
|
4739
|
-
console.log(prefixedPreview + moreLines + truncNote);
|
|
4740
|
-
}
|
|
4741
|
-
}
|
|
4742
|
-
};
|
|
4743
|
-
function buildSubAgentSystemPrompt(task, parentSystemPrompt) {
|
|
4744
|
-
const parts = [];
|
|
4745
|
-
if (parentSystemPrompt) {
|
|
4746
|
-
parts.push(parentSystemPrompt);
|
|
4747
|
-
}
|
|
4748
|
-
parts.push(
|
|
4749
|
-
`# Sub-Agent Mode
|
|
4750
|
-
|
|
4751
|
-
You are a focused sub-agent delegated by the main agent to complete a specific task.
|
|
4752
|
-
|
|
4753
|
-
**Your Task**:
|
|
4754
|
-
${task}
|
|
4755
|
-
|
|
4756
|
-
**Rules**:
|
|
4757
|
-
1. Stay focused on your task, do not deviate
|
|
4758
|
-
2. When done, output a concise summary: what was done, which files were modified, and the result
|
|
4759
|
-
3. You cannot interact with the user (no ask_user tool), work independently using the task description
|
|
4760
|
-
4. You cannot create sub-agents (no spawn_agent tool)
|
|
4761
|
-
5. Destructive operations (rm -rf etc.) will be automatically blocked
|
|
4762
|
-
6. Be efficient, minimize unnecessary tool call rounds`
|
|
4763
|
-
);
|
|
4764
|
-
return parts.join("\n\n---\n\n");
|
|
4765
|
-
}
|
|
4766
|
-
function printSubAgentHeader(task, maxRounds) {
|
|
4767
|
-
console.log();
|
|
4768
|
-
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"));
|
|
4769
|
-
console.log(PREFIX + theme.toolCall("\u{1F916} Sub-Agent Spawned"));
|
|
4770
|
-
console.log(
|
|
4771
|
-
PREFIX + theme.dim("Task: ") + (task.slice(0, 120) + (task.length > 120 ? "..." : ""))
|
|
4772
|
-
);
|
|
4773
|
-
console.log(PREFIX + theme.dim(`Max rounds: ${maxRounds}`));
|
|
4774
|
-
console.log(theme.dim(" \u2503"));
|
|
4775
|
-
}
|
|
4776
|
-
function printSubAgentFooter(usage) {
|
|
4777
|
-
console.log(PREFIX);
|
|
4778
|
-
console.log(PREFIX + theme.toolCall("Sub-Agent Complete"));
|
|
4779
|
-
if (usage.inputTokens > 0 || usage.outputTokens > 0) {
|
|
4780
|
-
console.log(
|
|
4781
|
-
PREFIX + theme.dim(
|
|
4782
|
-
`Tokens: ${usage.inputTokens} in / ${usage.outputTokens} out`
|
|
4783
|
-
)
|
|
4784
|
-
);
|
|
4785
|
-
}
|
|
4786
|
-
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"));
|
|
4787
|
-
console.log();
|
|
4788
|
-
}
|
|
4789
|
-
var spawnAgentTool = {
|
|
4790
|
-
definition: {
|
|
4791
|
-
name: "spawn_agent",
|
|
4792
|
-
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.",
|
|
4793
|
-
parameters: {
|
|
4794
|
-
task: {
|
|
4795
|
-
type: "string",
|
|
4796
|
-
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.",
|
|
4797
|
-
required: true
|
|
4798
|
-
},
|
|
4799
|
-
max_rounds: {
|
|
4800
|
-
type: "number",
|
|
4801
|
-
description: "Max tool-call rounds for the sub-agent (1-15, default 10). Use fewer for simple tasks, more for complex ones.",
|
|
4802
|
-
required: false
|
|
4803
|
-
}
|
|
4804
|
-
},
|
|
4805
|
-
dangerous: false
|
|
4806
|
-
},
|
|
4807
|
-
async execute(args) {
|
|
4808
|
-
const task = String(args["task"] ?? "").trim();
|
|
4809
|
-
if (!task) throw new Error("task parameter is required");
|
|
4810
|
-
const rawMaxRounds = Number(args["max_rounds"] ?? SUBAGENT_DEFAULT_MAX_ROUNDS);
|
|
4811
|
-
const maxRounds = Math.min(
|
|
4812
|
-
Math.max(Math.round(rawMaxRounds), 1),
|
|
4813
|
-
SUBAGENT_MAX_ROUNDS_LIMIT
|
|
4814
|
-
);
|
|
4815
|
-
const ctx = spawnAgentContext;
|
|
4816
|
-
if (!ctx.provider) {
|
|
4817
|
-
throw new Error("spawn_agent: provider not initialized (context not injected)");
|
|
4818
|
-
}
|
|
4819
|
-
const subRegistry = new ToolRegistry();
|
|
4820
|
-
for (const tool of subRegistry.listAll()) {
|
|
4821
|
-
if (!SUBAGENT_ALLOWED_TOOLS.has(tool.definition.name)) {
|
|
4822
|
-
subRegistry.unregister(tool.definition.name);
|
|
4823
|
-
}
|
|
4824
|
-
}
|
|
4825
|
-
const subExecutor = new SubAgentExecutor(subRegistry);
|
|
4826
|
-
const subMessages = [
|
|
4827
|
-
{ role: "user", content: task, timestamp: /* @__PURE__ */ new Date() }
|
|
4828
|
-
];
|
|
4829
|
-
const subSystemPrompt = buildSubAgentSystemPrompt(task, ctx.systemPrompt);
|
|
4830
|
-
const toolDefs = subRegistry.getDefinitions();
|
|
4831
|
-
const extraMessages = [];
|
|
4832
|
-
const totalUsage = { inputTokens: 0, outputTokens: 0 };
|
|
4833
|
-
let finalContent = "";
|
|
4834
|
-
printSubAgentHeader(task, maxRounds);
|
|
4835
|
-
try {
|
|
4836
|
-
for (let round = 0; round < maxRounds; round++) {
|
|
4837
|
-
subExecutor.setRoundInfo(round + 1, maxRounds);
|
|
4838
|
-
const result = await ctx.provider.chatWithTools(
|
|
4839
|
-
{
|
|
4840
|
-
messages: subMessages,
|
|
4841
|
-
model: ctx.model,
|
|
4842
|
-
systemPrompt: subSystemPrompt,
|
|
4843
|
-
stream: false,
|
|
4844
|
-
temperature: ctx.modelParams.temperature,
|
|
4845
|
-
maxTokens: ctx.modelParams.maxTokens,
|
|
4846
|
-
timeout: ctx.modelParams.timeout,
|
|
4847
|
-
thinking: ctx.modelParams.thinking,
|
|
4848
|
-
...extraMessages.length > 0 ? { _extraMessages: extraMessages } : {}
|
|
4849
|
-
},
|
|
4850
|
-
toolDefs
|
|
4851
|
-
);
|
|
4852
|
-
if (result.usage) {
|
|
4853
|
-
totalUsage.inputTokens += result.usage.inputTokens;
|
|
4854
|
-
totalUsage.outputTokens += result.usage.outputTokens;
|
|
4855
|
-
}
|
|
4856
|
-
if ("content" in result) {
|
|
4857
|
-
finalContent = result.content;
|
|
4858
|
-
break;
|
|
4859
|
-
}
|
|
4860
|
-
if (ctx.configManager) {
|
|
4861
|
-
googleSearchContext.configManager = ctx.configManager;
|
|
4862
|
-
}
|
|
4863
|
-
const toolResults = await subExecutor.executeAll(result.toolCalls);
|
|
4864
|
-
const reasoningContent = "reasoningContent" in result ? result.reasoningContent : void 0;
|
|
4865
|
-
const newMsgs = ctx.provider.buildToolResultMessages(
|
|
4866
|
-
result.toolCalls,
|
|
4867
|
-
toolResults,
|
|
4868
|
-
reasoningContent
|
|
4869
|
-
);
|
|
4870
|
-
extraMessages.push(...newMsgs);
|
|
4871
|
-
}
|
|
4872
|
-
if (!finalContent) {
|
|
4873
|
-
finalContent = `(Sub-agent reached maximum rounds (${maxRounds}) without producing a final response)`;
|
|
4874
|
-
}
|
|
4875
|
-
} catch (err) {
|
|
4876
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
4877
|
-
finalContent = `(Sub-agent error: ${errMsg})`;
|
|
4878
|
-
process.stderr.write(
|
|
4879
|
-
`
|
|
4880
|
-
[spawn_agent] Error in sub-agent loop: ${errMsg}
|
|
4881
|
-
`
|
|
4882
|
-
);
|
|
4883
|
-
}
|
|
4884
|
-
printSubAgentFooter(totalUsage);
|
|
4885
|
-
const lines = [
|
|
4886
|
-
"## Sub-Agent Result",
|
|
4887
|
-
"",
|
|
4888
|
-
finalContent,
|
|
4889
|
-
"",
|
|
4890
|
-
"---",
|
|
4891
|
-
`Token usage: ${totalUsage.inputTokens} input, ${totalUsage.outputTokens} output`
|
|
4892
|
-
];
|
|
4893
|
-
return lines.join("\n");
|
|
4894
|
-
}
|
|
4895
|
-
};
|
|
4896
|
-
|
|
4897
|
-
// src/tools/registry.ts
|
|
4898
|
-
import { pathToFileURL } from "url";
|
|
4899
|
-
import { existsSync as existsSync12, mkdirSync as mkdirSync6, readdirSync as readdirSync7 } from "fs";
|
|
4900
|
-
import { join as join8 } from "path";
|
|
4901
|
-
var ToolRegistry = class {
|
|
4902
|
-
tools = /* @__PURE__ */ new Map();
|
|
4903
|
-
pluginToolNames = /* @__PURE__ */ new Set();
|
|
4904
|
-
mcpToolNames = /* @__PURE__ */ new Set();
|
|
4905
|
-
constructor() {
|
|
4906
|
-
this.register(bashTool);
|
|
4907
|
-
this.register(readFileTool);
|
|
4908
|
-
this.register(writeFileTool);
|
|
4909
|
-
this.register(editFileTool);
|
|
4910
|
-
this.register(listDirTool);
|
|
4911
|
-
this.register(grepFilesTool);
|
|
4912
|
-
this.register(globFilesTool);
|
|
4913
|
-
this.register(runInteractiveTool);
|
|
4914
|
-
this.register(webFetchTool);
|
|
4915
|
-
this.register(saveLastResponseTool);
|
|
4916
|
-
this.register(saveMemoryTool);
|
|
4917
|
-
this.register(askUserTool);
|
|
4918
|
-
this.register(writeTodosTool);
|
|
4919
|
-
this.register(googleSearchTool);
|
|
4920
|
-
this.register(spawnAgentTool);
|
|
4921
|
-
this.register(runTestsTool);
|
|
4922
|
-
}
|
|
4923
|
-
register(tool) {
|
|
4924
|
-
this.tools.set(tool.definition.name, tool);
|
|
4925
|
-
}
|
|
4926
|
-
get(name) {
|
|
4927
|
-
return this.tools.get(name);
|
|
4928
|
-
}
|
|
4929
|
-
/** 注销指定工具(子代理创建过滤后的工具集时使用) */
|
|
4930
|
-
unregister(name) {
|
|
4931
|
-
return this.tools.delete(name);
|
|
4932
|
-
}
|
|
4933
|
-
/** 返回所有工具的 schema,用于发送给 AI */
|
|
4934
|
-
getDefinitions() {
|
|
4935
|
-
return [...this.tools.values()].map((t) => t.definition);
|
|
4936
|
-
}
|
|
4937
|
-
listAll() {
|
|
4938
|
-
return [...this.tools.values()];
|
|
4939
|
-
}
|
|
4940
|
-
/** Returns only tools loaded from plugins (not built-in). */
|
|
4941
|
-
listPluginTools() {
|
|
4942
|
-
return [...this.tools.values()].filter((t) => this.pluginToolNames.has(t.definition.name));
|
|
4943
|
-
}
|
|
4944
|
-
/** 注册一个 MCP 工具(名称以 mcp__ 开头) */
|
|
4945
|
-
registerMcpTool(tool) {
|
|
4946
|
-
this.tools.set(tool.definition.name, tool);
|
|
4947
|
-
this.mcpToolNames.add(tool.definition.name);
|
|
4948
|
-
}
|
|
4949
|
-
/** 返回所有 MCP 工具 */
|
|
4950
|
-
listMcpTools() {
|
|
4951
|
-
return [...this.tools.values()].filter((t) => this.mcpToolNames.has(t.definition.name));
|
|
4952
|
-
}
|
|
4953
|
-
/** 清除所有已注册的 MCP 工具(重连时先清除再重新注册) */
|
|
4954
|
-
unregisterMcpTools() {
|
|
4955
|
-
for (const name of this.mcpToolNames) {
|
|
4956
|
-
this.tools.delete(name);
|
|
4957
|
-
}
|
|
4958
|
-
this.mcpToolNames.clear();
|
|
4959
|
-
}
|
|
4960
|
-
/**
|
|
4961
|
-
* Dynamically loads .js plugin files from pluginsDir.
|
|
4962
|
-
*
|
|
4963
|
-
* Security notes:
|
|
4964
|
-
* - Only loads when allowPlugins=true (must be explicitly enabled in config).
|
|
4965
|
-
* - Plugins run with FULL Node.js privileges in the main process.
|
|
4966
|
-
* - Prints a prominent warning listing every file before loading.
|
|
4967
|
-
* - Built-in tool names cannot be overridden by plugins.
|
|
4968
|
-
*
|
|
4969
|
-
* Creates the dir if missing. Skips invalid plugins with a warning.
|
|
4970
|
-
* Returns the number of successfully loaded plugins.
|
|
4971
|
-
*/
|
|
4972
|
-
async loadPlugins(pluginsDir, allowPlugins = false) {
|
|
4973
|
-
if (!existsSync12(pluginsDir)) {
|
|
4974
|
-
try {
|
|
4975
|
-
mkdirSync6(pluginsDir, { recursive: true });
|
|
4976
|
-
} catch {
|
|
4977
|
-
}
|
|
4978
|
-
return 0;
|
|
4979
|
-
}
|
|
4980
|
-
let files;
|
|
4981
|
-
try {
|
|
4982
|
-
files = readdirSync7(pluginsDir).filter((f) => f.endsWith(".js"));
|
|
4983
|
-
} catch {
|
|
4984
|
-
return 0;
|
|
4985
|
-
}
|
|
4986
|
-
if (files.length === 0) return 0;
|
|
4987
|
-
if (!allowPlugins) {
|
|
4988
|
-
process.stderr.write(
|
|
4989
|
-
`[plugins] Found ${files.length} plugin(s) in ${pluginsDir} but loading is disabled.
|
|
4990
|
-
To enable, set "allowPlugins": true in ~/.aicli/config.json (or via /config).
|
|
4991
|
-
\u26A0 Plugins run with full system privileges. Only enable for trusted sources.
|
|
4992
|
-
`
|
|
4993
|
-
);
|
|
4994
|
-
return 0;
|
|
4995
|
-
}
|
|
4996
|
-
process.stderr.write(
|
|
4997
|
-
`
|
|
4998
|
-
[plugins] \u26A0 Loading ${files.length} plugin(s) with FULL system privileges:
|
|
4999
|
-
` + files.map((f) => ` + ${join8(pluginsDir, f)}`).join("\n") + "\n\n"
|
|
5000
|
-
);
|
|
5001
|
-
let loaded = 0;
|
|
5002
|
-
for (const file of files) {
|
|
5003
|
-
try {
|
|
5004
|
-
const fileUrl = pathToFileURL(join8(pluginsDir, file)).href;
|
|
5005
|
-
const mod = await import(fileUrl);
|
|
5006
|
-
const tool = mod.tool ?? mod.default?.tool ?? mod.default;
|
|
5007
|
-
if (!tool || typeof tool.execute !== "function" || !tool.definition?.name) {
|
|
5008
|
-
process.stderr.write(`[plugins] Skipping ${file}: missing or invalid 'tool' export
|
|
5009
|
-
`);
|
|
5010
|
-
continue;
|
|
5011
|
-
}
|
|
5012
|
-
if (this.tools.has(tool.definition.name) && !this.pluginToolNames.has(tool.definition.name)) {
|
|
5013
|
-
process.stderr.write(`[plugins] Skipping ${file}: name '${tool.definition.name}' conflicts with built-in tool
|
|
5014
|
-
`);
|
|
5015
|
-
continue;
|
|
5016
|
-
}
|
|
5017
|
-
this.register(tool);
|
|
5018
|
-
this.pluginToolNames.add(tool.definition.name);
|
|
5019
|
-
loaded++;
|
|
5020
|
-
process.stderr.write(`[plugins] Loaded: ${tool.definition.name} (${file})
|
|
5021
|
-
`);
|
|
5022
|
-
} catch (err) {
|
|
5023
|
-
process.stderr.write(`[plugins] Failed to load ${file}: ${err.message}
|
|
5024
|
-
`);
|
|
5025
|
-
}
|
|
5026
|
-
}
|
|
5027
|
-
return loaded;
|
|
5028
|
-
}
|
|
5029
|
-
};
|
|
5030
|
-
|
|
5031
|
-
// src/mcp/client.ts
|
|
5032
|
-
import { spawn as spawn2 } from "child_process";
|
|
5033
|
-
var McpClient = class {
|
|
5034
|
-
serverId;
|
|
5035
|
-
config;
|
|
5036
|
-
process = null;
|
|
5037
|
-
nextId = 1;
|
|
5038
|
-
connected = false;
|
|
5039
|
-
serverInfo = null;
|
|
5040
|
-
/** stderr 收集(最多保留最后 2KB,用于错误报告) */
|
|
5041
|
-
stderrBuffer = "";
|
|
5042
|
-
/** 缓存已发现的工具列表 */
|
|
5043
|
-
cachedTools = [];
|
|
5044
|
-
/** 错误信息(连接失败时设置) */
|
|
5045
|
-
errorMessage = null;
|
|
5046
|
-
// ── JSON-RPC 请求/响应匹配 ──────────────────────────────────────
|
|
5047
|
-
pendingRequests = /* @__PURE__ */ new Map();
|
|
5048
|
-
/** stdout 残余缓冲区(处理不完整的 JSON 行) */
|
|
5049
|
-
stdoutBuffer = "";
|
|
5050
|
-
constructor(serverId, config) {
|
|
5051
|
-
this.serverId = serverId;
|
|
5052
|
-
this.config = config;
|
|
5053
|
-
}
|
|
5054
|
-
get isConnected() {
|
|
5055
|
-
return this.connected;
|
|
5056
|
-
}
|
|
5057
|
-
get serverName() {
|
|
5058
|
-
return this.serverInfo?.name ?? this.serverId;
|
|
5059
|
-
}
|
|
5060
|
-
get tools() {
|
|
5061
|
-
return this.cachedTools;
|
|
5062
|
-
}
|
|
5063
|
-
// ══════════════════════════════════════════════════════════════════
|
|
5064
|
-
// 连接与初始化
|
|
5065
|
-
// ══════════════════════════════════════════════════════════════════
|
|
5066
|
-
async connect() {
|
|
5067
|
-
const timeout = this.config.timeout ?? MCP_CONNECT_TIMEOUT;
|
|
5068
|
-
try {
|
|
5069
|
-
this.process = spawn2(this.config.command, this.config.args ?? [], {
|
|
5070
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
5071
|
-
env: { ...process.env, ...this.config.env },
|
|
5072
|
-
// Windows 上 npx 等是 .cmd 脚本,需要 shell 模式
|
|
5073
|
-
shell: process.platform === "win32",
|
|
5074
|
-
// 不让子进程阻止父进程退出
|
|
5075
|
-
detached: false
|
|
5076
|
-
});
|
|
5077
|
-
this.process.on("error", (err) => {
|
|
5078
|
-
this.errorMessage = err.message;
|
|
5079
|
-
this.connected = false;
|
|
5080
|
-
this.rejectAllPending(new Error(`MCP server [${this.serverId}] process error: ${err.message}`));
|
|
5081
|
-
});
|
|
5082
|
-
this.process.on("exit", (code, signal) => {
|
|
5083
|
-
this.connected = false;
|
|
5084
|
-
const reason = signal ? `signal ${signal}` : `code ${code}`;
|
|
5085
|
-
this.rejectAllPending(new Error(`MCP server [${this.serverId}] exited: ${reason}`));
|
|
5086
|
-
});
|
|
5087
|
-
this.process.stdout.setEncoding("utf-8");
|
|
5088
|
-
this.process.stdout.on("data", (chunk) => this.handleStdoutData(chunk));
|
|
5089
|
-
this.process.stderr.setEncoding("utf-8");
|
|
5090
|
-
this.process.stderr.on("data", (chunk) => {
|
|
5091
|
-
this.stderrBuffer += chunk;
|
|
5092
|
-
if (this.stderrBuffer.length > 2048) {
|
|
5093
|
-
this.stderrBuffer = this.stderrBuffer.slice(-2048);
|
|
5094
|
-
}
|
|
5095
|
-
});
|
|
5096
|
-
const initResult = await this.withTimeout(
|
|
5097
|
-
this.sendRequest("initialize", {
|
|
5098
|
-
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
5099
|
-
capabilities: {},
|
|
5100
|
-
clientInfo: { name: APP_NAME, version: VERSION }
|
|
5101
|
-
}),
|
|
5102
|
-
timeout,
|
|
5103
|
-
"initialize handshake"
|
|
5104
|
-
);
|
|
5105
|
-
this.serverInfo = initResult.serverInfo;
|
|
5106
|
-
this.sendNotification("notifications/initialized");
|
|
5107
|
-
this.connected = true;
|
|
5108
|
-
await this.refreshTools();
|
|
5109
|
-
} catch (err) {
|
|
5110
|
-
this.errorMessage = err instanceof Error ? err.message : String(err);
|
|
5111
|
-
this.connected = false;
|
|
5112
|
-
this.killProcess();
|
|
5113
|
-
throw err;
|
|
5114
|
-
}
|
|
5115
|
-
}
|
|
5116
|
-
// ══════════════════════════════════════════════════════════════════
|
|
5117
|
-
// 工具操作
|
|
5118
|
-
// ══════════════════════════════════════════════════════════════════
|
|
5119
|
-
/** 刷新工具列表(tools/list) */
|
|
5120
|
-
async refreshTools() {
|
|
5121
|
-
this.ensureConnected();
|
|
5122
|
-
const result = await this.withTimeout(
|
|
5123
|
-
this.sendRequest("tools/list", {}),
|
|
5124
|
-
MCP_CALL_TIMEOUT,
|
|
5125
|
-
"tools/list"
|
|
5126
|
-
);
|
|
5127
|
-
this.cachedTools = result.tools ?? [];
|
|
5128
|
-
return this.cachedTools;
|
|
5129
|
-
}
|
|
5130
|
-
/** 调用工具(tools/call) */
|
|
5131
|
-
async callTool(name, args) {
|
|
5132
|
-
this.ensureConnected();
|
|
5133
|
-
return this.withTimeout(
|
|
5134
|
-
this.sendRequest("tools/call", { name, arguments: args }),
|
|
5135
|
-
MCP_CALL_TIMEOUT,
|
|
5136
|
-
`tools/call(${name})`
|
|
5137
|
-
);
|
|
5138
|
-
}
|
|
5139
|
-
// ══════════════════════════════════════════════════════════════════
|
|
5140
|
-
// 关闭连接
|
|
5141
|
-
// ══════════════════════════════════════════════════════════════════
|
|
5142
|
-
async close() {
|
|
5143
|
-
this.connected = false;
|
|
5144
|
-
this.rejectAllPending(new Error("Client closing"));
|
|
5145
|
-
this.killProcess();
|
|
5146
|
-
}
|
|
5147
|
-
/**
|
|
5148
|
-
* 断线重连:清理旧状态后重新执行完整的 connect() 流程。
|
|
5149
|
-
* 用于 MCP 服务器子进程意外退出后的自动或手动恢复。
|
|
5150
|
-
*/
|
|
5151
|
-
async reconnect() {
|
|
5152
|
-
this.connected = false;
|
|
5153
|
-
this.rejectAllPending(new Error("Reconnecting"));
|
|
5154
|
-
this.killProcess();
|
|
5155
|
-
this.errorMessage = null;
|
|
5156
|
-
this.stderrBuffer = "";
|
|
5157
|
-
this.stdoutBuffer = "";
|
|
5158
|
-
this.cachedTools = [];
|
|
5159
|
-
await this.connect();
|
|
5160
|
-
}
|
|
5161
|
-
// ══════════════════════════════════════════════════════════════════
|
|
5162
|
-
// 内部方法:JSON-RPC 通信
|
|
5163
|
-
// ══════════════════════════════════════════════════════════════════
|
|
5164
|
-
sendRequest(method, params) {
|
|
5165
|
-
return new Promise((resolve4, reject) => {
|
|
5166
|
-
if (!this.process?.stdin?.writable) {
|
|
5167
|
-
return reject(new Error(`MCP server [${this.serverId}] stdin not writable`));
|
|
5168
|
-
}
|
|
5169
|
-
const id = this.nextId++;
|
|
5170
|
-
const request = {
|
|
5171
|
-
jsonrpc: "2.0",
|
|
5172
|
-
id,
|
|
5173
|
-
method,
|
|
5174
|
-
...params !== void 0 ? { params } : {}
|
|
5175
|
-
};
|
|
5176
|
-
let timer;
|
|
5177
|
-
const cleanup = () => {
|
|
5178
|
-
this.pendingRequests.delete(id);
|
|
5179
|
-
clearTimeout(timer);
|
|
5180
|
-
};
|
|
5181
|
-
timer = setTimeout(() => {
|
|
5182
|
-
cleanup();
|
|
5183
|
-
reject(new Error(`MCP request [${method}] timed out (internal)`));
|
|
5184
|
-
}, MCP_CALL_TIMEOUT * 2);
|
|
5185
|
-
this.pendingRequests.set(id, {
|
|
5186
|
-
resolve: (result) => {
|
|
5187
|
-
cleanup();
|
|
5188
|
-
resolve4(result);
|
|
5189
|
-
},
|
|
5190
|
-
reject: (error) => {
|
|
5191
|
-
cleanup();
|
|
5192
|
-
reject(error);
|
|
5193
|
-
},
|
|
5194
|
-
timer
|
|
5195
|
-
});
|
|
5196
|
-
const json = JSON.stringify(request) + "\n";
|
|
5197
|
-
this.process.stdin.write(json, (err) => {
|
|
5198
|
-
if (err) {
|
|
5199
|
-
cleanup();
|
|
5200
|
-
reject(new Error(`MCP write error: ${err.message}`));
|
|
5201
|
-
}
|
|
5202
|
-
});
|
|
5203
|
-
});
|
|
2697
|
+
});
|
|
2698
|
+
});
|
|
5204
2699
|
}
|
|
5205
2700
|
sendNotification(method, params) {
|
|
5206
2701
|
if (!this.process?.stdin?.writable) return;
|
|
@@ -5256,13 +2751,13 @@ var McpClient = class {
|
|
|
5256
2751
|
}
|
|
5257
2752
|
/** Promise 超时包装 */
|
|
5258
2753
|
withTimeout(promise, ms, label) {
|
|
5259
|
-
return new Promise((
|
|
2754
|
+
return new Promise((resolve, reject) => {
|
|
5260
2755
|
const timer = setTimeout(() => {
|
|
5261
2756
|
reject(new Error(`MCP [${this.serverId}] ${label} timed out after ${ms}ms`));
|
|
5262
2757
|
}, ms);
|
|
5263
2758
|
promise.then((val) => {
|
|
5264
2759
|
clearTimeout(timer);
|
|
5265
|
-
|
|
2760
|
+
resolve(val);
|
|
5266
2761
|
}).catch((err) => {
|
|
5267
2762
|
clearTimeout(timer);
|
|
5268
2763
|
reject(err);
|
|
@@ -5526,12 +3021,12 @@ var McpManager = class {
|
|
|
5526
3021
|
};
|
|
5527
3022
|
|
|
5528
3023
|
// src/skills/manager.ts
|
|
5529
|
-
import { existsSync as
|
|
5530
|
-
import { join as
|
|
3024
|
+
import { existsSync as existsSync4, readdirSync as readdirSync2, mkdirSync as mkdirSync3, statSync } from "fs";
|
|
3025
|
+
import { join as join4 } from "path";
|
|
5531
3026
|
|
|
5532
3027
|
// src/skills/types.ts
|
|
5533
|
-
import { readFileSync as
|
|
5534
|
-
import { basename
|
|
3028
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
3029
|
+
import { basename } from "path";
|
|
5535
3030
|
function parseSimpleYaml(yaml) {
|
|
5536
3031
|
const result = {};
|
|
5537
3032
|
for (const line of yaml.split("\n")) {
|
|
@@ -5552,7 +3047,7 @@ function parseYamlArray(value) {
|
|
|
5552
3047
|
function parseSkillFile(filePath) {
|
|
5553
3048
|
let raw;
|
|
5554
3049
|
try {
|
|
5555
|
-
raw =
|
|
3050
|
+
raw = readFileSync3(filePath, "utf-8");
|
|
5556
3051
|
} catch {
|
|
5557
3052
|
return null;
|
|
5558
3053
|
}
|
|
@@ -5560,7 +3055,7 @@ function parseSkillFile(filePath) {
|
|
|
5560
3055
|
if (!frontmatterMatch) {
|
|
5561
3056
|
return {
|
|
5562
3057
|
meta: {
|
|
5563
|
-
name:
|
|
3058
|
+
name: basename(filePath, ".md"),
|
|
5564
3059
|
description: ""
|
|
5565
3060
|
},
|
|
5566
3061
|
content: raw.trim(),
|
|
@@ -5571,7 +3066,7 @@ function parseSkillFile(filePath) {
|
|
|
5571
3066
|
const parsed = parseSimpleYaml(yaml);
|
|
5572
3067
|
return {
|
|
5573
3068
|
meta: {
|
|
5574
|
-
name: parsed["name"] ??
|
|
3069
|
+
name: parsed["name"] ?? basename(filePath, ".md"),
|
|
5575
3070
|
description: parsed["description"] ?? "",
|
|
5576
3071
|
tools: parsed["tools"] ? parseYamlArray(parsed["tools"]) : void 0
|
|
5577
3072
|
},
|
|
@@ -5592,29 +3087,29 @@ var SkillManager = class {
|
|
|
5592
3087
|
/** 发现并加载 skillsDir 下所有 .md 文件,返回加载数量 */
|
|
5593
3088
|
loadSkills() {
|
|
5594
3089
|
this.skills.clear();
|
|
5595
|
-
if (!
|
|
3090
|
+
if (!existsSync4(this.skillsDir)) {
|
|
5596
3091
|
try {
|
|
5597
|
-
|
|
3092
|
+
mkdirSync3(this.skillsDir, { recursive: true });
|
|
5598
3093
|
} catch {
|
|
5599
3094
|
}
|
|
5600
3095
|
return 0;
|
|
5601
3096
|
}
|
|
5602
3097
|
let entries;
|
|
5603
3098
|
try {
|
|
5604
|
-
entries =
|
|
3099
|
+
entries = readdirSync2(this.skillsDir);
|
|
5605
3100
|
} catch {
|
|
5606
3101
|
return 0;
|
|
5607
3102
|
}
|
|
5608
3103
|
for (const entry of entries) {
|
|
5609
3104
|
let filePath;
|
|
5610
|
-
const fullPath =
|
|
3105
|
+
const fullPath = join4(this.skillsDir, entry);
|
|
5611
3106
|
if (entry.endsWith(".md")) {
|
|
5612
3107
|
filePath = fullPath;
|
|
5613
3108
|
} else {
|
|
5614
3109
|
try {
|
|
5615
|
-
if (
|
|
5616
|
-
const skillMd =
|
|
5617
|
-
if (
|
|
3110
|
+
if (statSync(fullPath).isDirectory()) {
|
|
3111
|
+
const skillMd = join4(fullPath, "SKILL.md");
|
|
3112
|
+
if (existsSync4(skillMd)) {
|
|
5618
3113
|
filePath = skillMd;
|
|
5619
3114
|
} else {
|
|
5620
3115
|
continue;
|
|
@@ -5688,7 +3183,7 @@ async function setupProxy(configProxy) {
|
|
|
5688
3183
|
}
|
|
5689
3184
|
|
|
5690
3185
|
// src/tools/diff-utils.ts
|
|
5691
|
-
import
|
|
3186
|
+
import chalk from "chalk";
|
|
5692
3187
|
function renderDiff(oldText, newText, opts = {}) {
|
|
5693
3188
|
const contextLines = opts.contextLines ?? 3;
|
|
5694
3189
|
const maxLines = opts.maxLines ?? 120;
|
|
@@ -5697,21 +3192,21 @@ function renderDiff(oldText, newText, opts = {}) {
|
|
|
5697
3192
|
const newLines = newText.split("\n");
|
|
5698
3193
|
const hunks = computeHunks(oldLines, newLines, contextLines);
|
|
5699
3194
|
if (hunks.length === 0) {
|
|
5700
|
-
return
|
|
3195
|
+
return chalk.dim(" (no changes)");
|
|
5701
3196
|
}
|
|
5702
3197
|
const output = [];
|
|
5703
3198
|
if (filePath) {
|
|
5704
|
-
output.push(
|
|
5705
|
-
output.push(
|
|
3199
|
+
output.push(chalk.bold.white(`--- ${filePath} (before)`));
|
|
3200
|
+
output.push(chalk.bold.white(`+++ ${filePath} (after)`));
|
|
5706
3201
|
}
|
|
5707
3202
|
let totalDisplayed = 0;
|
|
5708
3203
|
for (const hunk of hunks) {
|
|
5709
3204
|
if (totalDisplayed >= maxLines) {
|
|
5710
|
-
output.push(
|
|
3205
|
+
output.push(chalk.dim(` ... (diff truncated, too many changes)`));
|
|
5711
3206
|
break;
|
|
5712
3207
|
}
|
|
5713
3208
|
output.push(
|
|
5714
|
-
|
|
3209
|
+
chalk.cyan(
|
|
5715
3210
|
`@@ -${hunk.oldStart + 1},${hunk.oldCount} +${hunk.newStart + 1},${hunk.newCount} @@`
|
|
5716
3211
|
)
|
|
5717
3212
|
);
|
|
@@ -5719,11 +3214,11 @@ function renderDiff(oldText, newText, opts = {}) {
|
|
|
5719
3214
|
if (totalDisplayed >= maxLines) break;
|
|
5720
3215
|
totalDisplayed++;
|
|
5721
3216
|
if (line.type === "context") {
|
|
5722
|
-
output.push(
|
|
3217
|
+
output.push(chalk.dim(` ${line.text}`));
|
|
5723
3218
|
} else if (line.type === "remove") {
|
|
5724
|
-
output.push(
|
|
3219
|
+
output.push(chalk.red(`- ${line.text}`));
|
|
5725
3220
|
} else {
|
|
5726
|
-
output.push(
|
|
3221
|
+
output.push(chalk.green(`+ ${line.text}`));
|
|
5727
3222
|
}
|
|
5728
3223
|
}
|
|
5729
3224
|
}
|
|
@@ -5841,9 +3336,9 @@ function simpleDiff(oldLines, newLines) {
|
|
|
5841
3336
|
}
|
|
5842
3337
|
|
|
5843
3338
|
// src/repl/dev-state.ts
|
|
5844
|
-
import { existsSync as
|
|
5845
|
-
import { join as
|
|
5846
|
-
import { homedir as
|
|
3339
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync3, unlinkSync as unlinkSync2, mkdirSync as mkdirSync4 } from "fs";
|
|
3340
|
+
import { join as join5 } from "path";
|
|
3341
|
+
import { homedir as homedir2 } from "os";
|
|
5847
3342
|
var DEV_STATE_MAX_CHARS = 6e3;
|
|
5848
3343
|
var SNAPSHOT_PROMPT = `You are about to be replaced by a different AI model. Please generate a structured development state snapshot so the next model can continue seamlessly.
|
|
5849
3344
|
|
|
@@ -5896,12 +3391,12 @@ function sessionHasMeaningfulContent(messages) {
|
|
|
5896
3391
|
return hasUser && hasAssistant;
|
|
5897
3392
|
}
|
|
5898
3393
|
function getDevStatePath() {
|
|
5899
|
-
return
|
|
3394
|
+
return join5(homedir2(), CONFIG_DIR_NAME, DEV_STATE_FILE_NAME);
|
|
5900
3395
|
}
|
|
5901
3396
|
function saveDevState(content) {
|
|
5902
|
-
const configDir =
|
|
5903
|
-
if (!
|
|
5904
|
-
|
|
3397
|
+
const configDir = join5(homedir2(), CONFIG_DIR_NAME);
|
|
3398
|
+
if (!existsSync5(configDir)) {
|
|
3399
|
+
mkdirSync4(configDir, { recursive: true });
|
|
5905
3400
|
}
|
|
5906
3401
|
let trimmed = content.trim();
|
|
5907
3402
|
if (trimmed.length > DEV_STATE_MAX_CHARS) {
|
|
@@ -5912,26 +3407,26 @@ function saveDevState(content) {
|
|
|
5912
3407
|
}
|
|
5913
3408
|
trimmed += "\n\n[...truncated]";
|
|
5914
3409
|
}
|
|
5915
|
-
|
|
3410
|
+
writeFileSync3(getDevStatePath(), trimmed, "utf-8");
|
|
5916
3411
|
}
|
|
5917
3412
|
function loadDevState() {
|
|
5918
3413
|
const path = getDevStatePath();
|
|
5919
|
-
if (!
|
|
5920
|
-
const content =
|
|
3414
|
+
if (!existsSync5(path)) return null;
|
|
3415
|
+
const content = readFileSync4(path, "utf-8").trim();
|
|
5921
3416
|
return content || null;
|
|
5922
3417
|
}
|
|
5923
3418
|
function clearDevState() {
|
|
5924
3419
|
const path = getDevStatePath();
|
|
5925
|
-
if (
|
|
3420
|
+
if (existsSync5(path)) {
|
|
5926
3421
|
try {
|
|
5927
|
-
|
|
3422
|
+
unlinkSync2(path);
|
|
5928
3423
|
} catch {
|
|
5929
3424
|
}
|
|
5930
3425
|
}
|
|
5931
3426
|
}
|
|
5932
3427
|
|
|
5933
3428
|
// src/tools/hooks.ts
|
|
5934
|
-
import { execSync as
|
|
3429
|
+
import { execSync as execSync2 } from "child_process";
|
|
5935
3430
|
function shellEscape(value) {
|
|
5936
3431
|
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
5937
3432
|
}
|
|
@@ -5943,7 +3438,7 @@ function runHook(template, vars) {
|
|
|
5943
3438
|
cmd = cmd.replace(/\{args\}/g, shellEscape(vars.args ?? ""));
|
|
5944
3439
|
cmd = cmd.replace(/\{status\}/g, shellEscape(vars.status ?? ""));
|
|
5945
3440
|
try {
|
|
5946
|
-
|
|
3441
|
+
execSync2(cmd, {
|
|
5947
3442
|
timeout: 5e3,
|
|
5948
3443
|
stdio: ["pipe", "pipe", "pipe"],
|
|
5949
3444
|
encoding: "utf-8"
|
|
@@ -5972,8 +3467,6 @@ function checkPermission(toolName, args, dangerLevel, rules, defaultAction = "co
|
|
|
5972
3467
|
|
|
5973
3468
|
export {
|
|
5974
3469
|
ConfigManager,
|
|
5975
|
-
isFileWriteTool,
|
|
5976
|
-
getDangerLevel,
|
|
5977
3470
|
detectsHallucinatedFileOp,
|
|
5978
3471
|
hadPreviousWriteToolCalls,
|
|
5979
3472
|
TOOL_CALL_REMINDER,
|
|
@@ -5981,21 +3474,10 @@ export {
|
|
|
5981
3474
|
ProviderRegistry,
|
|
5982
3475
|
getContentText,
|
|
5983
3476
|
SessionManager,
|
|
5984
|
-
initTheme,
|
|
5985
|
-
theme,
|
|
5986
3477
|
getGitRoot,
|
|
5987
3478
|
getGitContext,
|
|
5988
3479
|
formatGitContextForPrompt,
|
|
5989
|
-
undoStack,
|
|
5990
3480
|
renderDiff,
|
|
5991
|
-
lastResponseStore,
|
|
5992
|
-
askUserContext,
|
|
5993
|
-
googleSearchContext,
|
|
5994
|
-
setContextWindow,
|
|
5995
|
-
getActiveMaxChars,
|
|
5996
|
-
truncateOutput,
|
|
5997
|
-
spawnAgentContext,
|
|
5998
|
-
ToolRegistry,
|
|
5999
3481
|
runHook,
|
|
6000
3482
|
checkPermission,
|
|
6001
3483
|
parseSimpleYaml,
|