jinzd-ai-cli 0.1.25 → 0.1.26

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/CLAUDE.md CHANGED
@@ -69,7 +69,8 @@ src/
69
69
  ├── ask-user.ts # ask_user 工具(agentic 循环中向用户提问,等待文本回答)
70
70
  ├── write-todos.ts # write_todos 工具(任务拆解与进度跟踪,终端实时渲染)
71
71
  ├── google-search.ts # google_search 工具(Google Custom Search API 搜索网页)
72
- └── spawn-agent.ts # spawn_agent 工具(独立子代理 agentic 循环 + SubAgentExecutor)
72
+ ├── spawn-agent.ts # spawn_agent 工具(独立子代理 agentic 循环 + SubAgentExecutor)
73
+ └── run-tests.ts # run_tests 工具(自动检测项目类型、运行测试、JUnit XML 解析、结构化报告)
73
74
  ```
74
75
 
75
76
  ## 常用命令
@@ -213,6 +214,7 @@ AICLI_NO_STREAM 设为 1 禁用流式输出
213
214
  | `write_todos` | safe | AI 拆解复杂任务为子任务列表,终端实时渲染进度(pending/in_progress/completed) |
214
215
  | `google_search` | safe | Google Custom Search API 搜索网页,需配置 API Key + Search Engine ID (cx) |
215
216
  | `spawn_agent` | safe | 委派独立子代理执行特定任务(隔离对话 + agentic 循环,write 自动确认,destructive 阻止) |
217
+ | `run_tests` | safe | 运行项目测试(自动检测 Maven/Gradle/npm/pytest/cargo/go),JUnit XML 解析,结构化报告 |
216
218
  | `mcp__*` | safe | MCP 服务器暴露的动态工具(命名格式:`mcp__<serverId>__<toolName>`) |
217
219
 
218
220
  ### 危险级别与确认机制
@@ -338,9 +340,46 @@ const stdinLines = Array.isArray(rawStdin) ? rawStdin.map(String)
338
340
  - [ ] **macOS/Linux 完整测试**:跨平台逻辑已处理(`$SHELL` fallback、UTF-8 env vars),但尚未在非 Windows 环境实际运行验证。
339
341
  - [x] **Token 用量显示**:已通过 `/cost` 命令实现(v0.1.23),显示 session 累计 input/output/total tokens。
340
342
  - [ ] **GitHub 仓库迁移**:当前在 gitee,npm 包受众需要 GitHub 托管。
341
- - [ ] **web_fetch DNS 解析时 SSRF 防护**:当前只检查 URL hostname 字符串,若 hostname 是域名(非裸 IP)则未检查其解析后的 IP 是否为私有地址。需在发起请求后对实际连接 IP 做二次校验(复杂,低频风险)。
343
+ - [x] **web_fetch DNS 解析时 SSRF 防护**(v0.1.25):新增 `resolveAndCheck()` 函数,用 `dns.promises.lookup()` 预解析域名,检查结果 IP 是否为私有地址。初始 URL redirect 目标均校验。
342
344
  - [ ] **`persistentCwd` 全局状态**:bash 工具的当前工作目录是模块级全局变量,多 session 并发时可能串扰。现阶段单 session REPL 无影响,GUI 多会话扩展时需重构为 per-session 状态。
343
345
 
346
+ ## 本轮开发完成记录(2026-03-01,v0.1.25 → v0.1.26)
347
+
348
+ ### 新增功能:Context 自动管理 + 测试报告 + 脚手架
349
+
350
+ **功能 1:Context 自动管理**
351
+ - `src/core/constants.ts`:新增 `CONTEXT_PRESSURE_THRESHOLD`(0.8) / `CONTEXT_WARNING_THRESHOLD`(0.6)
352
+ - `src/config/schema.ts`:新增 `autoCompact: z.boolean().default(true)` 配置项
353
+ - `src/repl/repl.ts`:新增 `estimateTokens()`(字符估算 token)、`estimateConversationTokens()`(system prompt + messages 总估算)、`getContextWindowSize()`、`checkContextPressure()`(超 80% 自动 compact)
354
+ - 在 `handleChatSimple()` 和 `handleChatWithTools()` 末尾自动调用 `checkContextPressure()`
355
+ - `/status` 命令新增 `Context%` 行:显示估算 token / context window(绿色 <60%,黄色 60-80%,红色 >80%)
356
+
357
+ **功能 2:`run_tests` 工具 + `/test` 命令**
358
+ - 新增 `src/tools/builtin/run-tests.ts`:
359
+ - 自动检测项目类型:Maven → `mvn test`、Gradle → `gradle test`、npm → `npm test`、pytest、cargo、go test
360
+ - JUnit XML 解析:扫描 `target/surefire-reports/*.xml`,提取 testsuite 属性 + 失败用例详情
361
+ - 通用文本解析:正则匹配 Maven/Jest/pytest/cargo/go 格式的测试结果摘要
362
+ - 彩色终端报告(直接 `console.log()`,绕过 executor 截断)
363
+ - 返回 Markdown 结构化报告给 AI
364
+ - 支持 `command`(自定义命令)和 `filter`(测试名过滤)参数
365
+ - `src/tools/registry.ts`:注册 `runTestsTool`
366
+ - `src/tools/types.ts`:`getDangerLevel()` 中 `run_tests` → `safe`
367
+ - `src/core/constants.ts`:`SUBAGENT_ALLOWED_TOOLS` 新增 `run_tests`,新增 `TEST_TIMEOUT = 300_000`
368
+ - `/test` REPL 命令:快捷方式调用 `executeTests()`
369
+
370
+ **功能 3:`/scaffold` 脚手架命令**
371
+ - `src/repl/commands/index.ts`:新增 `/scaffold <description>` 命令
372
+ - 构建结构化 prompt → 通过 `ctx.sendAsChat()` 注入为用户消息 → AI 使用现有工具(bash/write_file/spawn_agent)自动创建项目骨架
373
+ - `src/repl/repl.ts`:新增 `sendAsChat(message)` 方法——添加 user message + 触发 handleChatWithTools
374
+ - CommandContext 新增 `estimateConversationTokens`、`getContextWindowSize`、`sendAsChat` 三个回调
375
+
376
+ **版本与收尾**
377
+ - `src/core/constants.ts`:VERSION `0.1.25` → `0.1.26`
378
+ - `package.json`:version 同步
379
+ - `src/repl/renderer.ts`:/about 工具计数 15→16,命令计数 26→28,新增 3 条特性(Context 管理、run_tests、/scaffold)
380
+
381
+ ---
382
+
344
383
  ## 本轮开发完成记录(2026-02-28,v0.1.24 → v0.1.25)
345
384
 
346
385
  ### 新增功能:Tab 自动补全 + 流式 Token 计数
@@ -361,6 +400,16 @@ const stdinLines = Array.isArray(rawStdin) ? rawStdin.map(String)
361
400
  - 返回值新增 `tokensShown: boolean` 标志,防止 REPL 重复调用 `renderUsage()`
362
401
  - `src/repl/repl.ts`:`handleChatSimple()` + tee 流式路径传入 `showTokens`/`sessionTotal`,条件跳过后续 `renderUsage()`
363
402
 
403
+ **Bug 修复:DeepSeek contextWindow**
404
+ - `src/providers/deepseek.ts`:`deepseek-chat`(V3)contextWindow 65536 → 131072(128K),与官方 API 文档一致。`deepseek-reasoner`(R1)保持 65536(64K)。
405
+
406
+ **安全加固:web_fetch DNS SSRF 二次校验**
407
+ - `src/tools/builtin/web-fetch.ts`:新增 `resolveAndCheck()` 函数,用 `dns.promises.lookup()` 预解析域名
408
+ - 域名解析到私有 IP(RFC1918/loopback/link-local)时阻断请求
409
+ - 初始 URL 和 redirect 目标均校验
410
+ - IP 字面量跳过 DNS 解析(已由 `isPrivateHost()` 覆盖)
411
+ - DNS 解析失败不拦截,让后续 fetch 自然报错
412
+
364
413
  **版本与收尾**
365
414
  - `src/core/constants.ts`:VERSION `0.1.24` → `0.1.25`
366
415
  - `package.json`:version 同步
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/tools/builtin/run-tests.ts
4
+ import { execSync } from "child_process";
5
+ import { existsSync, readFileSync, readdirSync } from "fs";
6
+ import { join } from "path";
7
+ import { platform } from "os";
8
+ import chalk from "chalk";
9
+
10
+ // src/core/constants.ts
11
+ var VERSION = "0.1.26";
12
+ var APP_NAME = "ai-cli";
13
+ var CONFIG_DIR_NAME = ".aicli";
14
+ var CONFIG_FILE_NAME = "config.json";
15
+ var HISTORY_DIR_NAME = "history";
16
+ var PLUGINS_DIR_NAME = "plugins";
17
+ var SKILLS_DIR_NAME = "skills";
18
+ var CUSTOM_COMMANDS_DIR_NAME = "commands";
19
+ var CONTEXT_FILE_CANDIDATES = ["AICLI.md", "CLAUDE.md"];
20
+ var MEMORY_FILE_NAME = "memory.md";
21
+ var MEMORY_MAX_CHARS = 1e4;
22
+ var DEV_STATE_FILE_NAME = "dev-state.md";
23
+ var DEFAULT_MAX_TOKENS = 8192;
24
+ var MCP_TOOL_PREFIX = "mcp__";
25
+ var MCP_CONNECT_TIMEOUT = 3e4;
26
+ var MCP_CALL_TIMEOUT = 6e4;
27
+ var MCP_PROTOCOL_VERSION = "2024-11-05";
28
+ var PLAN_MODE_READONLY_TOOLS = /* @__PURE__ */ new Set([
29
+ "read_file",
30
+ "list_dir",
31
+ "grep_files",
32
+ "glob_files",
33
+ "web_fetch",
34
+ "google_search",
35
+ "ask_user",
36
+ // 允许:可向用户澄清需求
37
+ "write_todos"
38
+ // 允许:可输出任务列表作为实施计划
39
+ ]);
40
+ var PLAN_MODE_SYSTEM_ADDON = `# \u{1F50D} Plan Mode \u2014 \u53EA\u8BFB\u89C4\u5212\u6A21\u5F0F
41
+
42
+ \u4F60\u5F53\u524D\u5904\u4E8E\u53EA\u8BFB\u89C4\u5212\uFF08Plan\uFF09\u6A21\u5F0F\u3002
43
+
44
+ **\u5141\u8BB8\u7684\u5DE5\u5177**\uFF1Aread_file \xB7 list_dir \xB7 grep_files \xB7 glob_files \xB7 web_fetch \xB7 google_search \xB7 ask_user \xB7 write_todos
45
+ **\u7981\u7528\u7684\u5DE5\u5177**\uFF1Abash \xB7 write_file \xB7 edit_file \xB7 run_interactive \xB7 save_last_response \xB7 save_memory \u53CA\u6240\u6709 MCP \u5DE5\u5177
46
+
47
+ **\u4F60\u7684\u4EFB\u52A1**\uFF1A
48
+ 1. \u4F7F\u7528\u53EA\u8BFB\u5DE5\u5177\u5168\u9762\u5206\u6790\u4EE3\u7801\u5E93\u3001\u6587\u4EF6\u7ED3\u6784\u548C\u73B0\u6709\u5B9E\u73B0
49
+ 2. \u4F7F\u7528 ask_user \u5411\u7528\u6237\u6F84\u6E05\u4E0D\u660E\u786E\u7684\u9700\u6C42
50
+ 3. \u5236\u5B9A\u8BE6\u7EC6\u7684\u5B9E\u65BD\u8BA1\u5212\uFF08\u53EF\u7528 write_todos \u5C55\u793A\u4EFB\u52A1\u5217\u8868\uFF09\uFF0C\u5305\u542B\uFF1A
51
+ - \u9700\u8981\u4FEE\u6539\u6216\u521B\u5EFA\u7684\u6587\u4EF6\u5217\u8868
52
+ - \u6BCF\u4E2A\u6587\u4EF6\u7684\u5177\u4F53\u6539\u52A8\u5185\u5BB9
53
+ - \u6267\u884C\u987A\u5E8F\u548C\u4F9D\u8D56\u5173\u7CFB
54
+ - \u6F5C\u5728\u98CE\u9669\u548C\u6CE8\u610F\u4E8B\u9879
55
+
56
+ \u5B8C\u6210\u89C4\u5212\u540E\uFF0C\u8BF7\u660E\u786E\u544A\u77E5\u7528\u6237\uFF1A\u8F93\u5165 \`/plan execute\` \u5F00\u59CB\u6267\u884C\u8BA1\u5212\uFF0C\u6216 \`/plan exit\` \u653E\u5F03\u6B64\u8BA1\u5212\u3002`;
57
+ var SUBAGENT_DEFAULT_MAX_ROUNDS = 10;
58
+ var SUBAGENT_MAX_ROUNDS_LIMIT = 15;
59
+ var SUBAGENT_ALLOWED_TOOLS = /* @__PURE__ */ new Set([
60
+ "bash",
61
+ "read_file",
62
+ "write_file",
63
+ "edit_file",
64
+ "list_dir",
65
+ "grep_files",
66
+ "glob_files",
67
+ "run_interactive",
68
+ "web_fetch",
69
+ "google_search",
70
+ "write_todos",
71
+ "run_tests"
72
+ ]);
73
+ var CONTEXT_PRESSURE_THRESHOLD = 0.8;
74
+ var TEST_TIMEOUT = 3e5;
75
+ var AUTHOR = "\u664B\u6B63\u4E1C";
76
+ var AUTHOR_EMAIL = "zhengdong.jin@gmail.com";
77
+ var DESCRIPTION = "\u8DE8\u5E73\u53F0 REPL \u98CE\u683C AI \u5BF9\u8BDD\u5DE5\u5177\uFF0C\u652F\u6301\u591A Provider \u4E0E Agentic \u5DE5\u5177\u8C03\u7528";
78
+
79
+ // src/tools/builtin/run-tests.ts
80
+ var IS_WINDOWS = platform() === "win32";
81
+ function detectProject(cwd) {
82
+ if (existsSync(join(cwd, "pom.xml"))) {
83
+ return { type: "java", framework: "Maven (JUnit)", command: IS_WINDOWS ? "mvn.cmd test" : "mvn test" };
84
+ }
85
+ if (existsSync(join(cwd, "build.gradle")) || existsSync(join(cwd, "build.gradle.kts"))) {
86
+ const wrapper = IS_WINDOWS ? "gradlew.bat" : "./gradlew";
87
+ const cmd = existsSync(join(cwd, IS_WINDOWS ? "gradlew.bat" : "gradlew")) ? `${wrapper} test` : "gradle test";
88
+ return { type: "java", framework: "Gradle (JUnit)", command: cmd };
89
+ }
90
+ if (existsSync(join(cwd, "package.json"))) {
91
+ try {
92
+ const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
93
+ const testScript = pkg?.scripts?.test;
94
+ if (testScript && testScript !== 'echo "Error: no test specified" && exit 1') {
95
+ return { type: "node", framework: "npm", command: "npm test" };
96
+ }
97
+ } catch {
98
+ }
99
+ }
100
+ if (existsSync(join(cwd, "pyproject.toml")) || existsSync(join(cwd, "setup.py")) || existsSync(join(cwd, "pytest.ini"))) {
101
+ return { type: "python", framework: "pytest", command: "pytest -v" };
102
+ }
103
+ if (existsSync(join(cwd, "Cargo.toml"))) {
104
+ return { type: "rust", framework: "cargo", command: "cargo test" };
105
+ }
106
+ if (existsSync(join(cwd, "go.mod"))) {
107
+ return { type: "go", framework: "go test", command: "go test ./..." };
108
+ }
109
+ return null;
110
+ }
111
+ function parseJUnitXml(xmlContent) {
112
+ const summary = { tests: 0, passed: 0, failures: 0, errors: 0, skipped: 0, duration: 0, failedTests: [] };
113
+ const suiteMatch = xmlContent.match(/<testsuite[^>]*\btests="(\d+)"[^>]*/);
114
+ if (suiteMatch) {
115
+ summary.tests = parseInt(suiteMatch[1], 10);
116
+ const failMatch = xmlContent.match(/<testsuite[^>]*\bfailures="(\d+)"/);
117
+ const errMatch = xmlContent.match(/<testsuite[^>]*\berrors="(\d+)"/);
118
+ const skipMatch = xmlContent.match(/<testsuite[^>]*\bskipped="(\d+)"/);
119
+ const timeMatch = xmlContent.match(/<testsuite[^>]*\btime="([^"]*)"/);
120
+ summary.failures = failMatch ? parseInt(failMatch[1], 10) : 0;
121
+ summary.errors = errMatch ? parseInt(errMatch[1], 10) : 0;
122
+ summary.skipped = skipMatch ? parseInt(skipMatch[1], 10) : 0;
123
+ summary.duration = timeMatch ? parseFloat(timeMatch[1]) : 0;
124
+ summary.passed = summary.tests - summary.failures - summary.errors - summary.skipped;
125
+ }
126
+ const tcPattern = /<testcase[^>]*\bname="([^"]*)"[^>]*classname="([^"]*)"[^>]*>[\s\S]*?<(failure|error)[^>]*>([^<]*)/g;
127
+ let m;
128
+ while ((m = tcPattern.exec(xmlContent)) !== null) {
129
+ summary.failedTests.push({
130
+ name: `${m[2]}#${m[1]}`,
131
+ message: m[4].trim().slice(0, 200)
132
+ });
133
+ }
134
+ return summary;
135
+ }
136
+ function findJUnitReports(cwd) {
137
+ const dirs = [
138
+ join(cwd, "target", "surefire-reports"),
139
+ // Maven
140
+ join(cwd, "build", "test-results", "test"),
141
+ // Gradle
142
+ join(cwd, "build", "test-results")
143
+ // Gradle (older)
144
+ ];
145
+ const xmlFiles = [];
146
+ for (const dir of dirs) {
147
+ if (!existsSync(dir)) continue;
148
+ try {
149
+ const files = readdirSync(dir);
150
+ for (const f of files) {
151
+ if (f.endsWith(".xml")) xmlFiles.push(join(dir, f));
152
+ }
153
+ } catch {
154
+ }
155
+ }
156
+ return xmlFiles;
157
+ }
158
+ function parseGenericOutput(output) {
159
+ const mvn = output.match(/Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)/);
160
+ if (mvn) {
161
+ const [, tests, failures, errors, skipped] = mvn.map(Number);
162
+ return { tests, failures, errors, skipped, passed: tests - failures - errors - skipped };
163
+ }
164
+ const jest = output.match(/Tests:\s+(?:(\d+)\s+passed,?\s*)?(?:(\d+)\s+failed,?\s*)?(?:(\d+)\s+skipped,?\s*)?(\d+)\s+total/);
165
+ if (jest) {
166
+ const passed = parseInt(jest[1] ?? "0", 10);
167
+ const failures = parseInt(jest[2] ?? "0", 10);
168
+ const skipped = parseInt(jest[3] ?? "0", 10);
169
+ const tests = parseInt(jest[4], 10);
170
+ return { tests, passed, failures, skipped, errors: 0 };
171
+ }
172
+ const pytest = output.match(/(\d+)\s+passed(?:.*?(\d+)\s+failed)?(?:.*?(\d+)\s+error)?/);
173
+ if (pytest) {
174
+ const passed = parseInt(pytest[1], 10);
175
+ const failures = parseInt(pytest[2] ?? "0", 10);
176
+ const errors = parseInt(pytest[3] ?? "0", 10);
177
+ return { tests: passed + failures + errors, passed, failures, errors, skipped: 0 };
178
+ }
179
+ const cargo = output.match(/test result:.*?(\d+)\s+passed;\s*(\d+)\s+failed;\s*(\d+)\s+ignored/);
180
+ if (cargo) {
181
+ const passed = parseInt(cargo[1], 10);
182
+ const failures = parseInt(cargo[2], 10);
183
+ const skipped = parseInt(cargo[3], 10);
184
+ return { tests: passed + failures + skipped, passed, failures, skipped, errors: 0 };
185
+ }
186
+ const goPass = (output.match(/^ok\s/gm) ?? []).length;
187
+ const goFail = (output.match(/^FAIL\s/gm) ?? []).length;
188
+ if (goPass + goFail > 0) {
189
+ return { tests: goPass + goFail, passed: goPass, failures: goFail, errors: 0, skipped: 0 };
190
+ }
191
+ return null;
192
+ }
193
+ function formatReport(summary, framework, output, exitCode) {
194
+ const status = summary.failures + summary.errors > 0 ? "FAILED \u2717" : "PASSED \u2713";
195
+ const lines = [];
196
+ lines.push(`## Test Results \u2014 ${status}`);
197
+ lines.push(`**Total: ${summary.tests} | Passed: ${summary.passed} | Failed: ${summary.failures}${summary.errors > 0 ? ` | Errors: ${summary.errors}` : ""} | Skipped: ${summary.skipped}**`);
198
+ if (summary.duration > 0) {
199
+ lines.push(`Duration: ${summary.duration.toFixed(1)}s | Framework: ${framework}`);
200
+ } else {
201
+ lines.push(`Framework: ${framework}`);
202
+ }
203
+ if (summary.failedTests.length > 0) {
204
+ lines.push("");
205
+ lines.push("### Failed Tests");
206
+ for (const ft of summary.failedTests) {
207
+ lines.push(`- ${ft.name} \u2014 ${ft.message}`);
208
+ }
209
+ }
210
+ const outputLines = output.split("\n");
211
+ const tail = outputLines.length > 150 ? outputLines.slice(-150) : outputLines;
212
+ lines.push("");
213
+ lines.push(`### Output (last ${tail.length} lines)`);
214
+ lines.push("```");
215
+ lines.push(tail.join("\n"));
216
+ lines.push("```");
217
+ return lines.join("\n");
218
+ }
219
+ function renderColorReport(summary, framework) {
220
+ const isPass = summary.failures + summary.errors === 0;
221
+ const status = isPass ? chalk.green.bold("PASSED \u2713") : chalk.red.bold("FAILED \u2717");
222
+ console.log();
223
+ console.log(` ${chalk.bold("Test Results")} \u2014 ${status}`);
224
+ console.log(
225
+ ` Total: ${chalk.bold(String(summary.tests))} | ${chalk.green(`Passed: ${summary.passed}`)} | ${chalk.red(`Failed: ${summary.failures}`)}` + (summary.errors > 0 ? ` | ${chalk.red(`Errors: ${summary.errors}`)}` : "") + ` | ${chalk.yellow(`Skipped: ${summary.skipped}`)}`
226
+ );
227
+ if (summary.duration > 0) {
228
+ console.log(` Duration: ${summary.duration.toFixed(1)}s | Framework: ${framework}`);
229
+ }
230
+ if (summary.failedTests.length > 0) {
231
+ console.log(chalk.red("\n Failed Tests:"));
232
+ for (const ft of summary.failedTests) {
233
+ console.log(chalk.red(` - ${ft.name}`));
234
+ if (ft.message) console.log(chalk.dim(` ${ft.message}`));
235
+ }
236
+ }
237
+ console.log();
238
+ }
239
+ async function executeTests(args) {
240
+ const cwd = process.cwd();
241
+ const customCmd = args["command"] ? String(args["command"]).trim() : "";
242
+ const filter = args["filter"] ? String(args["filter"]).trim() : "";
243
+ let command;
244
+ let framework;
245
+ if (customCmd) {
246
+ command = customCmd;
247
+ framework = "custom";
248
+ } else {
249
+ const detected = detectProject(cwd);
250
+ if (!detected) {
251
+ return "Error: Could not detect project type. No pom.xml, build.gradle, package.json, pyproject.toml, Cargo.toml, or go.mod found.\nPlease specify a test command using the `command` parameter.";
252
+ }
253
+ command = detected.command;
254
+ framework = detected.framework;
255
+ if (filter) {
256
+ if (detected.type === "java" && command.includes("mvn")) {
257
+ command += ` -Dtest="${filter}"`;
258
+ } else if (detected.type === "python") {
259
+ command += ` -k "${filter}"`;
260
+ } else if (detected.type === "rust") {
261
+ command += ` ${filter}`;
262
+ } else if (detected.type === "go") {
263
+ command = `go test ./... -run "${filter}"`;
264
+ } else if (detected.type === "node") {
265
+ command += ` -- --grep "${filter}"`;
266
+ }
267
+ }
268
+ }
269
+ let output;
270
+ let exitCode = 0;
271
+ try {
272
+ const buf = execSync(command, {
273
+ cwd,
274
+ timeout: TEST_TIMEOUT,
275
+ encoding: "buffer",
276
+ stdio: ["pipe", "pipe", "pipe"],
277
+ env: {
278
+ ...process.env,
279
+ ...IS_WINDOWS ? {} : { FORCE_COLOR: "0" }
280
+ }
281
+ });
282
+ output = buf.toString("utf-8");
283
+ } catch (err) {
284
+ const e = err;
285
+ exitCode = e.status ?? 1;
286
+ const stdout = e.stdout?.toString("utf-8") ?? "";
287
+ const stderr = e.stderr?.toString("utf-8") ?? "";
288
+ output = stdout + (stderr ? "\n" + stderr : "");
289
+ }
290
+ let summary = {
291
+ tests: 0,
292
+ passed: 0,
293
+ failures: 0,
294
+ errors: 0,
295
+ skipped: 0,
296
+ duration: 0,
297
+ failedTests: []
298
+ };
299
+ const xmlFiles = findJUnitReports(cwd);
300
+ if (xmlFiles.length > 0) {
301
+ for (const xmlFile of xmlFiles) {
302
+ try {
303
+ const xml = readFileSync(xmlFile, "utf-8");
304
+ const parsed = parseJUnitXml(xml);
305
+ summary.tests += parsed.tests;
306
+ summary.passed += parsed.passed;
307
+ summary.failures += parsed.failures;
308
+ summary.errors += parsed.errors;
309
+ summary.skipped += parsed.skipped;
310
+ summary.duration += parsed.duration;
311
+ summary.failedTests.push(...parsed.failedTests);
312
+ } catch {
313
+ }
314
+ }
315
+ } else {
316
+ const parsed = parseGenericOutput(output);
317
+ if (parsed) {
318
+ summary = { ...summary, ...parsed };
319
+ } else {
320
+ summary.tests = 1;
321
+ if (exitCode === 0) {
322
+ summary.passed = 1;
323
+ } else {
324
+ summary.failures = 1;
325
+ }
326
+ }
327
+ }
328
+ renderColorReport(summary, framework);
329
+ return formatReport(summary, framework, output, exitCode);
330
+ }
331
+ var runTestsTool = {
332
+ definition: {
333
+ name: "run_tests",
334
+ description: "Run project tests and return a structured report. Auto-detects project type (Maven/Gradle/npm/pytest/cargo/go). Returns test counts (passed/failed/skipped) and failed test details.",
335
+ parameters: {
336
+ command: {
337
+ type: "string",
338
+ description: "Optional: custom test command to run (overrides auto-detection)",
339
+ required: false
340
+ },
341
+ filter: {
342
+ type: "string",
343
+ description: 'Optional: test name filter/pattern (e.g., "ExamService" to run only matching tests)',
344
+ required: false
345
+ }
346
+ }
347
+ },
348
+ async execute(args) {
349
+ return executeTests(args);
350
+ }
351
+ };
352
+
353
+ export {
354
+ VERSION,
355
+ APP_NAME,
356
+ CONFIG_DIR_NAME,
357
+ CONFIG_FILE_NAME,
358
+ HISTORY_DIR_NAME,
359
+ PLUGINS_DIR_NAME,
360
+ SKILLS_DIR_NAME,
361
+ CUSTOM_COMMANDS_DIR_NAME,
362
+ CONTEXT_FILE_CANDIDATES,
363
+ MEMORY_FILE_NAME,
364
+ MEMORY_MAX_CHARS,
365
+ DEV_STATE_FILE_NAME,
366
+ DEFAULT_MAX_TOKENS,
367
+ MCP_TOOL_PREFIX,
368
+ MCP_CONNECT_TIMEOUT,
369
+ MCP_CALL_TIMEOUT,
370
+ MCP_PROTOCOL_VERSION,
371
+ PLAN_MODE_READONLY_TOOLS,
372
+ PLAN_MODE_SYSTEM_ADDON,
373
+ SUBAGENT_DEFAULT_MAX_ROUNDS,
374
+ SUBAGENT_MAX_ROUNDS_LIMIT,
375
+ SUBAGENT_ALLOWED_TOOLS,
376
+ CONTEXT_PRESSURE_THRESHOLD,
377
+ AUTHOR,
378
+ AUTHOR_EMAIL,
379
+ DESCRIPTION,
380
+ executeTests,
381
+ runTestsTool
382
+ };
package/dist/index.js CHANGED
@@ -1,4 +1,33 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ APP_NAME,
4
+ AUTHOR,
5
+ AUTHOR_EMAIL,
6
+ CONFIG_DIR_NAME,
7
+ CONFIG_FILE_NAME,
8
+ CONTEXT_FILE_CANDIDATES,
9
+ CONTEXT_PRESSURE_THRESHOLD,
10
+ CUSTOM_COMMANDS_DIR_NAME,
11
+ DEFAULT_MAX_TOKENS,
12
+ DESCRIPTION,
13
+ DEV_STATE_FILE_NAME,
14
+ HISTORY_DIR_NAME,
15
+ MCP_CALL_TIMEOUT,
16
+ MCP_CONNECT_TIMEOUT,
17
+ MCP_PROTOCOL_VERSION,
18
+ MCP_TOOL_PREFIX,
19
+ MEMORY_FILE_NAME,
20
+ MEMORY_MAX_CHARS,
21
+ PLAN_MODE_READONLY_TOOLS,
22
+ PLAN_MODE_SYSTEM_ADDON,
23
+ PLUGINS_DIR_NAME,
24
+ SKILLS_DIR_NAME,
25
+ SUBAGENT_ALLOWED_TOOLS,
26
+ SUBAGENT_DEFAULT_MAX_ROUNDS,
27
+ SUBAGENT_MAX_ROUNDS_LIMIT,
28
+ VERSION,
29
+ runTestsTool
30
+ } from "./chunk-BE5SYJ2G.js";
2
31
 
3
32
  // src/index.ts
4
33
  import { program } from "commander";
@@ -100,6 +129,10 @@ var ConfigSchema = z.object({
100
129
  })).default([]),
101
130
  // 无规则匹配时的默认权限动作
102
131
  defaultPermission: z.enum(["auto-approve", "deny", "confirm"]).default("confirm"),
132
+ // 自动上下文压缩开关
133
+ // 当对话估算 token 数超过模型 contextWindow 的 80% 时,自动触发 compact 压缩旧消息
134
+ // 默认开启。设为 false 则仅在手动 /compact 时压缩
135
+ autoCompact: z.boolean().default(true),
103
136
  // 插件加载开关(安全控制)
104
137
  // 默认 false:不自动加载 ~/.aicli/plugins/ 中的插件文件。
105
138
  // 插件以完整 Node.js 权限在主进程中执行(可读写文件、访问网络、执行命令),
@@ -144,72 +177,6 @@ var EnvLoader = class {
144
177
  }
145
178
  };
146
179
 
147
- // src/core/constants.ts
148
- var VERSION = "0.1.25";
149
- var APP_NAME = "ai-cli";
150
- var CONFIG_DIR_NAME = ".aicli";
151
- var CONFIG_FILE_NAME = "config.json";
152
- var HISTORY_DIR_NAME = "history";
153
- var PLUGINS_DIR_NAME = "plugins";
154
- var SKILLS_DIR_NAME = "skills";
155
- var CUSTOM_COMMANDS_DIR_NAME = "commands";
156
- var CONTEXT_FILE_CANDIDATES = ["AICLI.md", "CLAUDE.md"];
157
- var MEMORY_FILE_NAME = "memory.md";
158
- var MEMORY_MAX_CHARS = 1e4;
159
- var DEV_STATE_FILE_NAME = "dev-state.md";
160
- var DEFAULT_MAX_TOKENS = 8192;
161
- var MCP_TOOL_PREFIX = "mcp__";
162
- var MCP_CONNECT_TIMEOUT = 3e4;
163
- var MCP_CALL_TIMEOUT = 6e4;
164
- var MCP_PROTOCOL_VERSION = "2024-11-05";
165
- var PLAN_MODE_READONLY_TOOLS = /* @__PURE__ */ new Set([
166
- "read_file",
167
- "list_dir",
168
- "grep_files",
169
- "glob_files",
170
- "web_fetch",
171
- "google_search",
172
- "ask_user",
173
- // 允许:可向用户澄清需求
174
- "write_todos"
175
- // 允许:可输出任务列表作为实施计划
176
- ]);
177
- var PLAN_MODE_SYSTEM_ADDON = `# \u{1F50D} Plan Mode \u2014 \u53EA\u8BFB\u89C4\u5212\u6A21\u5F0F
178
-
179
- \u4F60\u5F53\u524D\u5904\u4E8E\u53EA\u8BFB\u89C4\u5212\uFF08Plan\uFF09\u6A21\u5F0F\u3002
180
-
181
- **\u5141\u8BB8\u7684\u5DE5\u5177**\uFF1Aread_file \xB7 list_dir \xB7 grep_files \xB7 glob_files \xB7 web_fetch \xB7 google_search \xB7 ask_user \xB7 write_todos
182
- **\u7981\u7528\u7684\u5DE5\u5177**\uFF1Abash \xB7 write_file \xB7 edit_file \xB7 run_interactive \xB7 save_last_response \xB7 save_memory \u53CA\u6240\u6709 MCP \u5DE5\u5177
183
-
184
- **\u4F60\u7684\u4EFB\u52A1**\uFF1A
185
- 1. \u4F7F\u7528\u53EA\u8BFB\u5DE5\u5177\u5168\u9762\u5206\u6790\u4EE3\u7801\u5E93\u3001\u6587\u4EF6\u7ED3\u6784\u548C\u73B0\u6709\u5B9E\u73B0
186
- 2. \u4F7F\u7528 ask_user \u5411\u7528\u6237\u6F84\u6E05\u4E0D\u660E\u786E\u7684\u9700\u6C42
187
- 3. \u5236\u5B9A\u8BE6\u7EC6\u7684\u5B9E\u65BD\u8BA1\u5212\uFF08\u53EF\u7528 write_todos \u5C55\u793A\u4EFB\u52A1\u5217\u8868\uFF09\uFF0C\u5305\u542B\uFF1A
188
- - \u9700\u8981\u4FEE\u6539\u6216\u521B\u5EFA\u7684\u6587\u4EF6\u5217\u8868
189
- - \u6BCF\u4E2A\u6587\u4EF6\u7684\u5177\u4F53\u6539\u52A8\u5185\u5BB9
190
- - \u6267\u884C\u987A\u5E8F\u548C\u4F9D\u8D56\u5173\u7CFB
191
- - \u6F5C\u5728\u98CE\u9669\u548C\u6CE8\u610F\u4E8B\u9879
192
-
193
- \u5B8C\u6210\u89C4\u5212\u540E\uFF0C\u8BF7\u660E\u786E\u544A\u77E5\u7528\u6237\uFF1A\u8F93\u5165 \`/plan execute\` \u5F00\u59CB\u6267\u884C\u8BA1\u5212\uFF0C\u6216 \`/plan exit\` \u653E\u5F03\u6B64\u8BA1\u5212\u3002`;
194
- var SUBAGENT_DEFAULT_MAX_ROUNDS = 10;
195
- var SUBAGENT_MAX_ROUNDS_LIMIT = 15;
196
- var SUBAGENT_ALLOWED_TOOLS = /* @__PURE__ */ new Set([
197
- "bash",
198
- "read_file",
199
- "write_file",
200
- "edit_file",
201
- "list_dir",
202
- "grep_files",
203
- "glob_files",
204
- "run_interactive",
205
- "web_fetch",
206
- "google_search",
207
- "write_todos"
208
- ]);
209
- var AUTHOR = "\u664B\u6B63\u4E1C";
210
- var AUTHOR_EMAIL = "zhengdong.jin@gmail.com";
211
- var DESCRIPTION = "\u8DE8\u5E73\u53F0 REPL \u98CE\u683C AI \u5BF9\u8BDD\u5DE5\u5177\uFF0C\u652F\u6301\u591A Provider \u4E0E Agentic \u5DE5\u5177\u8C03\u7528";
212
-
213
180
  // src/core/errors.ts
214
181
  var AiCliError = class extends Error {
215
182
  constructor(message, options) {
@@ -1703,7 +1670,7 @@ var Renderer = class {
1703
1670
  console.log(chalk.dim(" Gemini (Google) \xB7 \u667A\u8C31\u6E05\u8A00 \xB7 \u81EA\u5B9A\u4E49 OpenAI \u517C\u5BB9"));
1704
1671
  console.log(HR);
1705
1672
  const mcpToolCount = mcpInfo?.tools ?? 0;
1706
- const toolTotal = 15 + pluginCount + mcpToolCount;
1673
+ const toolTotal = 16 + pluginCount + mcpToolCount;
1707
1674
  const extras = [];
1708
1675
  if (pluginCount > 0) extras.push(`${pluginCount} \u4E2A\u63D2\u4EF6`);
1709
1676
  if (mcpToolCount > 0) extras.push(`${mcpToolCount} \u4E2A MCP`);
@@ -1724,12 +1691,13 @@ var Renderer = class {
1724
1691
  console.log(tool("ask_user", "\u5411\u7528\u6237\u63D0\u95EE\u5E76\u7B49\u5F85\u56DE\u7B54\uFF08agentic \u5FAA\u73AF\u4E2D\u8BF7\u6C42\u6F84\u6E05\uFF09"));
1725
1692
  console.log(tool("write_todos", "\u62C6\u89E3\u4EFB\u52A1\u4E3A\u5B50\u4EFB\u52A1\u5217\u8868\uFF0C\u5B9E\u65F6\u663E\u793A\u8FDB\u5EA6"));
1726
1693
  console.log(tool("spawn_agent", "\u59D4\u6D3E\u72EC\u7ACB\u5B50\u4EE3\u7406\u6267\u884C\u7279\u5B9A\u4EFB\u52A1\uFF08\u9694\u79BB\u5BF9\u8BDD + \u81EA\u52A8\u5DE5\u5177\u8C03\u7528\u5FAA\u73AF\uFF09"));
1694
+ console.log(tool("run_tests", "\u8FD0\u884C\u9879\u76EE\u6D4B\u8BD5\u5E76\u8FD4\u56DE\u7ED3\u6784\u5316\u62A5\u544A\uFF08\u81EA\u52A8\u68C0\u6D4B Maven/npm/pytest \u7B49\uFF09"));
1727
1695
  console.log(HR);
1728
- console.log(chalk.gray(" REPL \u547D\u4EE4\uFF0826\u4E2A\uFF09\uFF1A"));
1696
+ console.log(chalk.gray(" REPL \u547D\u4EE4\uFF0828\u4E2A\uFF09\uFF1A"));
1729
1697
  console.log(chalk.dim(" /help /about /provider /model /clear /compact /plan /session"));
1730
1698
  console.log(chalk.dim(" /system /context /status /search /undo /export /copy /cost"));
1731
1699
  console.log(chalk.dim(" /init /skill /tools /plugins /mcp /config /checkpoint /review"));
1732
- console.log(chalk.dim(" /commands /exit"));
1700
+ console.log(chalk.dim(" /commands /test /scaffold /exit"));
1733
1701
  console.log(HR);
1734
1702
  console.log(chalk.gray(" \u4E3B\u8981\u7279\u6027\uFF1A"));
1735
1703
  console.log(feat("Agentic \u5FAA\u73AF\uFF08\u6700\u591A 20 \u8F6E\u5DE5\u5177\u8C03\u7528\uFF0C\u6700\u7EC8\u56DE\u7B54\u6D41\u5F0F\u8F93\u51FA\uFF09"));
@@ -1757,6 +1725,9 @@ var Renderer = class {
1757
1725
  console.log(feat("Custom Commands\uFF1A~/.aicli/commands/*.md \u7528\u6237\u81EA\u5B9A\u4E49 REPL \u547D\u4EE4"));
1758
1726
  console.log(feat("Tab \u81EA\u52A8\u8865\u5168\uFF1A\u547D\u4EE4\u540D/\u5B50\u547D\u4EE4\u53C2\u6570/@\u6587\u4EF6\u8DEF\u5F84\uFF0C\u6309 Tab \u89E6\u53D1"));
1759
1727
  console.log(feat("\u6D41\u5F0F Token \u8BA1\u6570\uFF1A\u6D41\u5F0F\u8F93\u51FA\u7ED3\u675F\u540E\u7ACB\u5373\u5185\u8054\u663E\u793A\u7CBE\u786E/\u4F30\u7B97 token \u6570"));
1728
+ console.log(feat("Context \u81EA\u52A8\u7BA1\u7406\uFF1A\u4F30\u7B97 token \u5360\u7528\u7387\uFF0C\u8D85 80% \u81EA\u52A8\u538B\u7F29\uFF0C/status \u663E\u793A\u767E\u5206\u6BD4"));
1729
+ console.log(feat("run_tests \u5DE5\u5177\uFF1A\u81EA\u52A8\u68C0\u6D4B\u9879\u76EE\u7C7B\u578B\uFF0C\u8FD0\u884C\u6D4B\u8BD5\uFF0CJUnit XML \u89E3\u6790\uFF0C\u7ED3\u6784\u5316\u62A5\u544A"));
1730
+ console.log(feat("/scaffold \u811A\u624B\u67B6\uFF1A\u63CF\u8FF0\u9879\u76EE\u9700\u6C42 \u2192 AI \u4F7F\u7528\u5DE5\u5177\u81EA\u52A8\u521B\u5EFA\u5B8C\u6574\u9879\u76EE\u9AA8\u67B6"));
1760
1731
  console.log(feat("\u72EC\u7ACB\u53EF\u6267\u884C\u6587\u4EF6\u6253\u5305\uFF08~56MB\uFF0C\u65E0\u9700 Node.js \u73AF\u5883\uFF09"));
1761
1732
  console.log();
1762
1733
  }
@@ -2354,6 +2325,8 @@ function createDefaultCommands() {
2354
2325
  " /checkpoint [save|restore|delete] <name> - Session checkpoints",
2355
2326
  " /review [--staged] [--detailed] - AI code review from git diff",
2356
2327
  " /commands [reload] - List/reload custom commands (~/.aicli/commands/)",
2328
+ " /test [command|filter] - Run project tests and show structured report",
2329
+ " /scaffold <description> - Generate project scaffolding with AI",
2357
2330
  " /exit - Exit"
2358
2331
  ] : [];
2359
2332
  console.log("\nAvailable commands:");
@@ -2601,6 +2574,15 @@ function createDefaultCommands() {
2601
2574
  ` Tokens : in ${tokenUsage.inputTokens.toLocaleString()} + out ${tokenUsage.outputTokens.toLocaleString()} = ${totalTokens.toLocaleString()} (session total)`
2602
2575
  );
2603
2576
  }
2577
+ const ctxWindowSize = ctx.getContextWindowSize();
2578
+ if (ctxWindowSize > 0) {
2579
+ const estimated = ctx.estimateConversationTokens();
2580
+ const pct = Math.round(estimated / ctxWindowSize * 100);
2581
+ const estStr = fmtCtx(estimated);
2582
+ const winStr = fmtCtx(ctxWindowSize);
2583
+ const pctColor = pct >= 80 ? chalk2.red : pct >= 60 ? chalk2.yellow : chalk2.green;
2584
+ console.log(` Context% : ~${estStr} / ${winStr} tokens (${pctColor(`${pct}%`)})`);
2585
+ }
2604
2586
  console.log();
2605
2587
  }
2606
2588
  },
@@ -3195,6 +3177,58 @@ ${text}
3195
3177
  console.log();
3196
3178
  }
3197
3179
  },
3180
+ {
3181
+ name: "test",
3182
+ description: "Run project tests and show structured report",
3183
+ usage: "/test [command|filter]",
3184
+ async execute(args, _ctx) {
3185
+ const { executeTests } = await import("./run-tests-QXZ3ZQ4S.js");
3186
+ const argStr = args.join(" ").trim();
3187
+ let testArgs = {};
3188
+ if (argStr) {
3189
+ const isCommand = argStr.includes(" ") || /^(mvn|gradle|npm|pytest|cargo|go)\b/.test(argStr);
3190
+ testArgs = isCommand ? { command: argStr } : { filter: argStr };
3191
+ }
3192
+ const report = await executeTests(testArgs);
3193
+ const firstLines = report.split("\n").slice(0, 3).join("\n");
3194
+ console.log(chalk2.dim(firstLines));
3195
+ }
3196
+ },
3197
+ {
3198
+ name: "scaffold",
3199
+ description: "Generate project scaffolding with AI",
3200
+ usage: "/scaffold <description>",
3201
+ async execute(args, ctx) {
3202
+ const description = args.join(" ").trim();
3203
+ if (!description) {
3204
+ console.log(chalk2.yellow("Usage: /scaffold <project description>"));
3205
+ console.log(chalk2.dim(" Example: /scaffold Spring Boot REST API with PostgreSQL for exam system"));
3206
+ console.log(chalk2.dim(" Example: /scaffold React + TypeScript dashboard with authentication"));
3207
+ return;
3208
+ }
3209
+ const cwd = process.cwd();
3210
+ const prompt = [
3211
+ "\u8BF7\u6839\u636E\u4EE5\u4E0B\u63CF\u8FF0\u751F\u6210\u5B8C\u6574\u7684\u9879\u76EE\u9AA8\u67B6\uFF1A",
3212
+ "",
3213
+ `\u9879\u76EE\u63CF\u8FF0\uFF1A${description}`,
3214
+ `\u5DE5\u4F5C\u76EE\u5F55\uFF1A${cwd}`,
3215
+ "",
3216
+ "\u8981\u6C42\uFF1A",
3217
+ "1. \u521B\u5EFA\u5B8C\u6574\u7684\u76EE\u5F55\u7ED3\u6784\u548C\u6838\u5FC3\u6587\u4EF6",
3218
+ "2. \u5305\u542B\u4F9D\u8D56\u7BA1\u7406\u6587\u4EF6\uFF08pom.xml / package.json / requirements.txt \u7B49\uFF09",
3219
+ "3. \u5305\u542B\u57FA\u7840\u914D\u7F6E\u6587\u4EF6\uFF08\u6570\u636E\u5E93\u8FDE\u63A5\u3001\u5E94\u7528\u914D\u7F6E\u7B49\uFF09",
3220
+ "4. \u5305\u542B\u793A\u4F8B\u4EE3\u7801\uFF08\u4E3B\u5165\u53E3\u3001\u793A\u4F8B Controller/Service/Repository\uFF09",
3221
+ "5. \u5305\u542B README.md \u8BF4\u660E\u6784\u5EFA\u548C\u8FD0\u884C\u65B9\u5F0F",
3222
+ "6. \u4F7F\u7528\u884C\u4E1A\u6700\u4F73\u5B9E\u8DF5\u548C\u5408\u7406\u7684\u9879\u76EE\u7ED3\u6784",
3223
+ "",
3224
+ "\u8BF7\u4F7F\u7528\u5DE5\u5177\u9010\u6B65\u521B\u5EFA\u6587\u4EF6\u3002\u5148\u7528 list_dir \u68C0\u67E5\u76EE\u5F55\u662F\u5426\u4E3A\u7A7A\uFF0C\u7136\u540E\u521B\u5EFA\u9879\u76EE\u7ED3\u6784\u3002"
3225
+ ].join("\n");
3226
+ console.log(chalk2.cyan(`
3227
+ Scaffolding: ${description}`));
3228
+ console.log(chalk2.dim(" AI will create the project structure using available tools...\n"));
3229
+ await ctx.sendAsChat(prompt);
3230
+ }
3231
+ },
3198
3232
  {
3199
3233
  name: "exit",
3200
3234
  description: "Exit the REPL",
@@ -4310,6 +4344,7 @@ ${stderr}`);
4310
4344
  }
4311
4345
 
4312
4346
  // src/tools/builtin/web-fetch.ts
4347
+ import { promises as dnsPromises } from "dns";
4313
4348
  function htmlToText(html) {
4314
4349
  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, "");
4315
4350
  text = text.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (_m, lvl, content) => {
@@ -4365,6 +4400,18 @@ function isPrivateHost(hostname) {
4365
4400
  }
4366
4401
  return false;
4367
4402
  }
4403
+ async function resolveAndCheck(hostname) {
4404
+ const h = hostname.replace(/^\[|\]$/g, "");
4405
+ if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h) || h.includes(":")) return;
4406
+ try {
4407
+ const { address } = await dnsPromises.lookup(h);
4408
+ if (isPrivateHost(address)) {
4409
+ throw new Error(`Blocked: "${hostname}" resolves to private address ${address}. web_fetch is restricted to public URLs.`);
4410
+ }
4411
+ } catch (e) {
4412
+ if (e.message.startsWith("Blocked:")) throw e;
4413
+ }
4414
+ }
4368
4415
  var webFetchTool = {
4369
4416
  definition: {
4370
4417
  name: "web_fetch",
@@ -4393,6 +4440,7 @@ var webFetchTool = {
4393
4440
  if (isPrivateHost(parsedUrl.hostname)) {
4394
4441
  throw new Error(`Blocked: "${url}" resolves to a private/internal address. web_fetch is restricted to public URLs.`);
4395
4442
  }
4443
+ await resolveAndCheck(parsedUrl.hostname);
4396
4444
  } catch (e) {
4397
4445
  if (e.message.startsWith("Blocked:")) throw e;
4398
4446
  throw new Error(`Invalid URL: "${url}"`);
@@ -4420,6 +4468,7 @@ var webFetchTool = {
4420
4468
  if (isPrivateHost(finalParsed.hostname)) {
4421
4469
  throw new Error(`Blocked: redirect landed on private address "${finalUrl}".`);
4422
4470
  }
4471
+ await resolveAndCheck(finalParsed.hostname);
4423
4472
  } catch (e) {
4424
4473
  if (e.message.startsWith("Blocked:")) throw e;
4425
4474
  }
@@ -4861,7 +4910,7 @@ function getDangerLevel(toolName, args) {
4861
4910
  if (/\b(bash|sh|zsh|cmd|powershell|pwsh|python|node|ruby|perl)\b/i.test(exe)) return "write";
4862
4911
  return "write";
4863
4912
  }
4864
- 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") return "safe";
4913
+ 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";
4865
4914
  return "write";
4866
4915
  }
4867
4916
 
@@ -5158,6 +5207,7 @@ var ToolRegistry = class {
5158
5207
  this.register(writeTodosTool);
5159
5208
  this.register(googleSearchTool);
5160
5209
  this.register(spawnAgentTool);
5210
+ this.register(runTestsTool);
5161
5211
  }
5162
5212
  register(tool) {
5163
5213
  this.tools.set(tool.definition.name, tool);
@@ -6858,6 +6908,11 @@ ${content}
6858
6908
  return { parts, hasImage: imageParts.length > 0, refs };
6859
6909
  }
6860
6910
  var MAX_TOOL_ROUNDS = 20;
6911
+ function fmtTokens(n) {
6912
+ if (n >= 1e6) return `${Math.round(n / 1e5) / 10}M`;
6913
+ if (n >= 1e3) return `${Math.round(n / 1024)}K`;
6914
+ return `${n}`;
6915
+ }
6861
6916
  var Repl = class {
6862
6917
  constructor(providers, sessions, config, events) {
6863
6918
  this.providers = providers;
@@ -7470,6 +7525,67 @@ ${response.content.trim()}
7470
7525
  maxTokens: params.maxTokens ?? DEFAULT_MAX_TOKENS
7471
7526
  };
7472
7527
  }
7528
+ // ─── Context 自动管理 ───────────────────────────────────────────────────
7529
+ /**
7530
+ * 估算文本的 token 数。
7531
+ * 混合 CJK / ASCII 文本平均约 2.5 字符 = 1 token(与 renderer.ts 中的估算公式一致)。
7532
+ */
7533
+ estimateTokens(text) {
7534
+ return Math.ceil(text.length / 2.5);
7535
+ }
7536
+ /**
7537
+ * 估算当前对话的总 token 消耗(system prompt + 所有 session messages)。
7538
+ */
7539
+ estimateConversationTokens() {
7540
+ let total = 0;
7541
+ const sysPrompt = this.buildCurrentSystemPrompt();
7542
+ if (sysPrompt) total += this.estimateTokens(sysPrompt);
7543
+ const session = this.sessions.current;
7544
+ if (session) {
7545
+ for (const msg of session.messages) {
7546
+ if (typeof msg.content === "string") {
7547
+ total += this.estimateTokens(msg.content);
7548
+ } else if (Array.isArray(msg.content)) {
7549
+ for (const part of msg.content) {
7550
+ if (part.type === "text" && part.text) total += this.estimateTokens(part.text);
7551
+ }
7552
+ }
7553
+ }
7554
+ }
7555
+ return total;
7556
+ }
7557
+ /**
7558
+ * 获取当前模型的 context window 大小。
7559
+ */
7560
+ getContextWindowSize() {
7561
+ try {
7562
+ const provider = this.providers.get(this.currentProvider);
7563
+ const modelInfo = provider.info.models.find((m) => m.id === this.currentModel);
7564
+ return modelInfo?.contextWindow ?? 0;
7565
+ } catch {
7566
+ return 0;
7567
+ }
7568
+ }
7569
+ /**
7570
+ * 检查上下文压力,超过阈值时自动压缩。
7571
+ * 在每次 AI 回复后调用。
7572
+ */
7573
+ async checkContextPressure() {
7574
+ if (!this.config.get("autoCompact")) return;
7575
+ const contextWindow = this.getContextWindowSize();
7576
+ if (contextWindow <= 0) return;
7577
+ const estimated = this.estimateConversationTokens();
7578
+ const ratio = estimated / contextWindow;
7579
+ if (ratio >= CONTEXT_PRESSURE_THRESHOLD) {
7580
+ console.log(
7581
+ chalk10.yellow(
7582
+ `
7583
+ \u26A0 Context pressure: ~${Math.round(ratio * 100)}% of ${fmtTokens(contextWindow)} window used. Auto-compacting...`
7584
+ )
7585
+ );
7586
+ await this.compactSession("\u4FDD\u7559\u5173\u952E\u4E0A\u4E0B\u6587\uFF0C\u538B\u7F29\u65E7\u5185\u5BB9\uFF0C\u786E\u4FDD\u540E\u7EED\u5BF9\u8BDD\u53EF\u65E0\u7F1D\u7EE7\u7EED");
7587
+ }
7588
+ }
7473
7589
  // ─── Tab 自动补全 ──────────────────────────────────────────────────────
7474
7590
  /**
7475
7591
  * readline completer 回调。根据输入上下文返回候选补全列表。
@@ -7654,6 +7770,7 @@ ${response.content.trim()}
7654
7770
  spinner.stop();
7655
7771
  }
7656
7772
  }
7773
+ await this.checkContextPressure();
7657
7774
  }
7658
7775
  async handleChatWithTools(provider, messages) {
7659
7776
  const session = this.sessions.current;
@@ -7796,6 +7913,7 @@ ${response.content.trim()}
7796
7913
  );
7797
7914
  } finally {
7798
7915
  spinner.stop();
7916
+ await this.checkContextPressure();
7799
7917
  }
7800
7918
  }
7801
7919
  async handleCommand(input2) {
@@ -7929,6 +8047,20 @@ ${response.content.trim()}
7929
8047
  restoreCheckpoint: (name) => this.sessions.current?.restoreCheckpoint(name) ?? false,
7930
8048
  deleteCheckpoint: (name) => this.sessions.current?.deleteCheckpoint(name) ?? false,
7931
8049
  getCustomCommandManager: () => this.customCommandManager ?? null,
8050
+ estimateConversationTokens: () => this.estimateConversationTokens(),
8051
+ getContextWindowSize: () => this.getContextWindowSize(),
8052
+ sendAsChat: async (message) => {
8053
+ const session = this.sessions.current;
8054
+ if (!session) return;
8055
+ session.addMessage({ role: "user", content: message, timestamp: /* @__PURE__ */ new Date() });
8056
+ const provider = this.providers.get(this.currentProvider);
8057
+ const toolCapable = provider;
8058
+ if (typeof toolCapable.chatWithTools === "function") {
8059
+ await this.handleChatWithTools(toolCapable, session.messages);
8060
+ } else {
8061
+ await this.handleChatSimple(provider, session.messages);
8062
+ }
8063
+ },
7932
8064
  exit: () => this.handleExit()
7933
8065
  };
7934
8066
  await cmd.execute(args, ctx);
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ executeTests,
4
+ runTestsTool
5
+ } from "./chunk-BE5SYJ2G.js";
6
+ export {
7
+ executeTests,
8
+ runTestsTool
9
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jinzd-ai-cli",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "Cross-platform REPL-style AI CLI with multi-provider support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",