min-agent 0.2.1 → 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/README.md +242 -31
- package/dist/agent.js +1233 -485
- package/dist/assistant-stream.js +11 -7
- package/dist/cli/commands/chat.js +10 -0
- package/dist/cli/commands/exec.js +32 -0
- package/dist/cli/commands/history.js +58 -0
- package/dist/cli/commands/index.js +224 -0
- package/dist/cli/commands/init.js +18 -0
- package/dist/cli/commands/mcp.js +173 -0
- package/dist/cli/commands/memory.js +69 -0
- package/dist/cli/commands/models.js +21 -0
- package/dist/cli/commands/permission.js +12 -0
- package/dist/cli/commands/rules.js +33 -0
- package/dist/cli/commands/sandbox.js +13 -0
- package/dist/cli/commands/serve.js +9 -0
- package/dist/cli/commands/setup.js +4 -0
- package/dist/cli/commands/shared.js +16 -0
- package/dist/cli/commands/skills.js +119 -0
- package/dist/cli/commands/update.js +7 -0
- package/dist/cli/commands/write-config.js +30 -0
- package/dist/cli/errors.js +36 -0
- package/dist/cli/exec-prompt.js +26 -0
- package/dist/cli/option-helpers.js +53 -0
- package/dist/cli/program.js +180 -0
- package/dist/cli.js +7 -632
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +35 -17
- package/dist/compaction.js +457 -169
- package/dist/config.js +298 -38
- package/dist/confirm.js +105 -9
- package/dist/context-window.js +156 -75
- package/dist/doom-loop.js +268 -26
- package/dist/fetch-timeout.js +152 -0
- package/dist/http-approvals.js +60 -0
- package/dist/http.js +119 -0
- package/dist/instructions.js +72 -33
- package/dist/logger.js +95 -0
- package/dist/markdown.js +35 -50
- package/dist/mcp.js +847 -102
- package/dist/memory.js +128 -45
- package/dist/output.js +42 -31
- package/dist/paste-handler.js +3 -3
- package/dist/permission-cli.js +43 -0
- package/dist/plugins.js +76 -11
- package/dist/pricing.js +119 -0
- package/dist/provider.js +34 -15
- package/dist/question-format.js +60 -0
- package/dist/sandbox-cli.js +82 -0
- package/dist/sandbox.js +403 -0
- package/dist/save-throttle.js +45 -0
- package/dist/serve/common.js +404 -0
- package/dist/serve/routes-chat.js +347 -0
- package/dist/serve/routes-mcp.js +212 -0
- package/dist/serve/routes-memory.js +66 -0
- package/dist/serve/routes-meta.js +205 -0
- package/dist/serve/routes-sessions.js +61 -0
- package/dist/serve/routes-skills.js +70 -0
- package/dist/serve.js +74 -635
- package/dist/sessions.js +197 -15
- package/dist/skills.js +531 -77
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +9 -2
- package/dist/token-display.js +36 -0
- package/dist/tool-display.js +178 -0
- package/dist/tool-output.js +53 -46
- package/dist/tools/apply_patch.js +265 -0
- package/dist/tools/atomic-file.js +35 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +186 -71
- package/dist/tools/code_search.js +13 -6
- package/dist/tools/edit.js +26 -9
- package/dist/tools/explore.js +144 -16
- package/dist/tools/glob.js +7 -3
- package/dist/tools/grep.js +153 -14
- package/dist/tools/index.js +9 -24
- package/dist/tools/question.js +31 -30
- package/dist/tools/read.js +77 -15
- package/dist/tools/search-searxng.js +223 -0
- package/dist/tools/search-serper.js +189 -0
- package/dist/tools/task.js +100 -33
- package/dist/tools/todo.js +178 -67
- package/dist/tools/web_fetch.js +158 -46
- package/dist/tools/web_search.js +217 -29
- package/dist/tools/write.js +34 -11
- package/dist/tui/App.js +89 -6
- package/dist/tui/ConfirmBar.js +57 -4
- package/dist/tui/InputBar.js +504 -44
- package/dist/tui/MessageList.js +674 -20
- package/dist/tui/ModelPicker.js +113 -0
- package/dist/tui/QuestionBar.js +136 -0
- package/dist/tui/SessionPicker.js +79 -0
- package/dist/tui/StatusBar.js +14 -12
- package/dist/tui/agent-runner.js +223 -0
- package/dist/tui/caret-pos.js +177 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/click-count.js +13 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +49 -0
- package/dist/tui/hydrate.js +129 -0
- package/dist/tui/index.js +189 -31
- package/dist/tui/input-history.js +125 -0
- package/dist/tui/layout.js +88 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/prompt-queue.js +24 -0
- package/dist/tui/selection.js +226 -0
- package/dist/tui/session-switch.js +28 -0
- package/dist/tui/slash-commands.js +106 -0
- package/dist/tui/slash-handler.js +545 -0
- package/dist/tui/text-width.js +113 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/token-info.js +7 -0
- package/dist/tui/tool-children.js +19 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +29 -0
- package/dist/tui-chat.js +346 -330
- package/dist/updater.js +116 -0
- package/dist/xml-search.js +194 -0
- package/docs/API.md +410 -32
- package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
- package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
- package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
- package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
- package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
- package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
- package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
- package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
- package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
- package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
- package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
- package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
- package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
- package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
- package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
- package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
- package/package.json +12 -8
- package/skills/self-config/SKILL.md +90 -0
- package/skills/self-config/reference.md +149 -0
|
@@ -0,0 +1,2105 @@
|
|
|
1
|
+
# 第二批:CLI + 工具 + HTTP API Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** 补齐 CLI 命令(history 子命令、mcp env、update、export)、工具能力(bash cwd、apply_patch、write append、web_search 参数、chat 模式工具)、HTTP API(8 个新端点/参数)。
|
|
6
|
+
|
|
7
|
+
**Architecture:** 工具增强以纯函数 + 可注入依赖为主(apply_patch 解析器纯函数、updater 版本比较纯函数);API 端点沿用 serve.ts 现有路由模式;agent.ts 的 runOnce 加可选 options 参数透传采样参数。
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, AI SDK v6, bun:test
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 文件结构
|
|
16
|
+
|
|
17
|
+
| 文件 | 职责 |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `src/tools/apply_patch.ts`(新) | unified diff 解析与应用工具 |
|
|
20
|
+
| `tests/apply-patch.test.ts`(新) | apply_patch 测试 |
|
|
21
|
+
| `src/updater.ts`(新) | 版本检查与自动升级 |
|
|
22
|
+
| `src/tools/bash.ts`(改) | BashInput.cwd + executeBash cwd |
|
|
23
|
+
| `src/tools/explore.ts`(改) | readOnlyBashTool 加 cwd |
|
|
24
|
+
| `tests/bash.test.ts`(改) | cwd 测试 |
|
|
25
|
+
| `src/tools/write.ts`(改) | append 参数 |
|
|
26
|
+
| `tests/file-tools.test.ts`(改) | append 测试 |
|
|
27
|
+
| `src/tools/web_search.ts`(改) | language/time_range |
|
|
28
|
+
| `tests/web-search.test.ts`(改) | 参数透传测试 |
|
|
29
|
+
| `src/tools/index.ts`(改) | chat 模式加 todo + apply_patch 注册 |
|
|
30
|
+
| `src/agent.ts`(改) | buildTools explore 两种模式 + runOnce options |
|
|
31
|
+
| `src/cli.ts`(改) | history 子命令、mcp --env、update、usage |
|
|
32
|
+
| `src/serve.ts`(改) | 新端点 + 采样参数 + paste code |
|
|
33
|
+
| `tests/serve.test.ts`(改) | 新端点测试 |
|
|
34
|
+
| `docs/API.md`(改) | 新端点文档 |
|
|
35
|
+
| `README.md`(改) | CLI 命令同步 |
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Task 1: bash 支持 cwd 参数
|
|
40
|
+
|
|
41
|
+
**Files:**
|
|
42
|
+
- Modify: `src/tools/bash.ts`
|
|
43
|
+
- Modify: `src/tools/explore.ts`
|
|
44
|
+
- Test: `tests/bash.test.ts`
|
|
45
|
+
|
|
46
|
+
- [ ] **Step 1: 写失败测试**
|
|
47
|
+
|
|
48
|
+
在 `tests/bash.test.ts` 末尾追加(并补 import):
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { mkdtempSync, rmSync, realpathSync } from "fs"
|
|
52
|
+
import { tmpdir } from "os"
|
|
53
|
+
import path from "path"
|
|
54
|
+
|
|
55
|
+
test("bash runs in the specified cwd", async () => {
|
|
56
|
+
const dir = realpathSync(mkdtempSync(path.join(tmpdir(), "min-agent-bash-")))
|
|
57
|
+
try {
|
|
58
|
+
const out = await executeBash("pwd", undefined, dir)
|
|
59
|
+
expect(out).toBe(dir)
|
|
60
|
+
} finally {
|
|
61
|
+
rmSync(dir, { recursive: true, force: true })
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
67
|
+
|
|
68
|
+
Run: `bun test tests/bash.test.ts`
|
|
69
|
+
Expected: 新用例 FAIL(`pwd` 输出当前工作目录而非 dir)
|
|
70
|
+
|
|
71
|
+
- [ ] **Step 3: 修改 bash.ts**
|
|
72
|
+
|
|
73
|
+
`type BashInput` 加字段,`executeBash` 加参数,spawn 用 cwd。文件顶部补 `import path from "path"`。
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
type BashInput = { command: string; timeout?: number; cwd?: string }
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
工具 schema 与描述:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
description:
|
|
83
|
+
"Run a shell command. Use this for system operations, running builds, tests, git commands, etc. The command runs in the current working directory (or `cwd` if specified). You SHOULD set a timeout based on how long you expect the command to take. If no timeout is set, the command runs until it finishes or the user manually interrupts (Ctrl+C).",
|
|
84
|
+
inputSchema: jsonSchema<BashInput>({
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
command: { type: "string", description: "The shell command to execute" },
|
|
88
|
+
timeout: { type: "number", description: "Timeout in milliseconds. Set based on expected duration (e.g. 5000 for quick commands, 60000 for builds). Omit only for commands with unpredictable duration." },
|
|
89
|
+
cwd: { type: "string", description: "Working directory for the command (relative to the current directory or absolute)" },
|
|
90
|
+
},
|
|
91
|
+
required: ["command"],
|
|
92
|
+
}),
|
|
93
|
+
execute: async ({ command, timeout, cwd }) => {
|
|
94
|
+
if (!isAutoApprove() && isDangerousCommand(command)) {
|
|
95
|
+
const approved = await confirm(`Execute dangerous command: ${command}`)
|
|
96
|
+
if (!approved) return "Command rejected by user."
|
|
97
|
+
}
|
|
98
|
+
return executeBash(command, timeout, cwd)
|
|
99
|
+
},
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`executeBash` 签名与 spawn:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
export async function executeBash(command: string, timeout?: number, cwd?: string): Promise<string> {
|
|
106
|
+
ensureSigintHandler()
|
|
107
|
+
...
|
|
108
|
+
const proc = spawn(actualCommand, [], {
|
|
109
|
+
shell: true,
|
|
110
|
+
cwd: cwd ? path.resolve(process.cwd(), cwd) : process.cwd(),
|
|
111
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
112
|
+
detached: process.platform !== "win32",
|
|
113
|
+
env: { ...process.env, ...(isWin ? { PYTHONIOENCODING: "utf-8" } : {}) },
|
|
114
|
+
})
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- [ ] **Step 4: 修改 explore.ts 的 readOnlyBashTool**
|
|
118
|
+
|
|
119
|
+
`execute` 内透传 cwd:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
inputSchema: jsonSchema<{ command: string; timeout?: number; cwd?: string }>({
|
|
123
|
+
type: "object",
|
|
124
|
+
properties: {
|
|
125
|
+
command: { type: "string", description: "The read-only shell command to execute" },
|
|
126
|
+
timeout: { type: "number", description: "Timeout in milliseconds" },
|
|
127
|
+
cwd: { type: "string", description: "Working directory (relative or absolute)" },
|
|
128
|
+
},
|
|
129
|
+
required: ["command"],
|
|
130
|
+
}),
|
|
131
|
+
execute: async ({ command, timeout, cwd }) => {
|
|
132
|
+
if (!isReadOnlyCommand(command)) {
|
|
133
|
+
return `Error: "${command.slice(0, 80)}" is not a read-only command. Allowed: ${[...READ_ONLY_BINS].join(", ")} (read-only git subcommands only).`
|
|
134
|
+
}
|
|
135
|
+
return executeBash(command, timeout, cwd)
|
|
136
|
+
},
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- [ ] **Step 5: 运行测试确认通过**
|
|
140
|
+
|
|
141
|
+
Run: `bun test tests/bash.test.ts`
|
|
142
|
+
Expected: PASS(6 个原有用例 + 1 个新用例)
|
|
143
|
+
|
|
144
|
+
- [ ] **Step 6: 提交**
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
git add src/tools/bash.ts src/tools/explore.ts tests/bash.test.ts
|
|
148
|
+
git commit -m "feat: bash 工具支持工作目录参数"
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Task 2: write 支持 append
|
|
154
|
+
|
|
155
|
+
**Files:**
|
|
156
|
+
- Modify: `src/tools/write.ts`
|
|
157
|
+
- Test: `tests/file-tools.test.ts`
|
|
158
|
+
|
|
159
|
+
- [ ] **Step 1: 写失败测试**
|
|
160
|
+
|
|
161
|
+
在 `tests/file-tools.test.ts` 末尾追加:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
test("write appends when append is set", async () => {
|
|
165
|
+
const dir = tmpDir()
|
|
166
|
+
try {
|
|
167
|
+
const file = path.join(dir, "a.txt")
|
|
168
|
+
await exec(writeTool)({ filePath: file, content: "one\n" }, {})
|
|
169
|
+
const out = await exec(writeTool)({ filePath: file, content: "two\n", append: true }, {})
|
|
170
|
+
expect(out).toContain("(appended)")
|
|
171
|
+
expect(readFileSync(file, "utf-8")).toBe("one\ntwo\n")
|
|
172
|
+
} finally {
|
|
173
|
+
rmSync(dir, { recursive: true, force: true })
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test("write append creates the file when it does not exist", async () => {
|
|
178
|
+
const dir = tmpDir()
|
|
179
|
+
try {
|
|
180
|
+
const file = path.join(dir, "new.txt")
|
|
181
|
+
const out = await exec(writeTool)({ filePath: file, content: "hello", append: true }, {})
|
|
182
|
+
expect(out).toContain("(appended)")
|
|
183
|
+
expect(readFileSync(file, "utf-8")).toBe("hello")
|
|
184
|
+
} finally {
|
|
185
|
+
rmSync(dir, { recursive: true, force: true })
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
(`readFileSync` 已在 import 中)
|
|
191
|
+
|
|
192
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
193
|
+
|
|
194
|
+
Run: `bun test tests/file-tools.test.ts`
|
|
195
|
+
Expected: 新用例 FAIL(append 未实现,输出不含 "(appended)",且内容被覆盖)
|
|
196
|
+
|
|
197
|
+
- [ ] **Step 3: 修改 write.ts**
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
type WriteInput = { filePath: string; content: string; append?: boolean }
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
描述:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
description:
|
|
207
|
+
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does (unless `append` is true). Creates parent directories as needed.",
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
schema properties 追加:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
append: { type: "boolean", description: "If true, append to the file instead of overwriting" },
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
execute:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
execute: async ({ filePath, content, append }) => {
|
|
220
|
+
if (Buffer.byteLength(content, "utf-8") > MAX_WRITE_BYTES) {
|
|
221
|
+
return `Error: content exceeds ${MAX_WRITE_BYTES} bytes. Write in smaller chunks or check the content size.`
|
|
222
|
+
}
|
|
223
|
+
const resolved = path.resolve(process.cwd(), filePath)
|
|
224
|
+
|
|
225
|
+
// Confirm overwriting existing files
|
|
226
|
+
if (!append && !isAutoApprove() && existsSync(resolved)) {
|
|
227
|
+
const approved = await confirm(`Overwrite existing file: ${filePath}`)
|
|
228
|
+
if (!approved) return "Write rejected by user."
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
await mkdir(path.dirname(resolved), { recursive: true })
|
|
233
|
+
await writeFile(resolved, content, append ? { flag: "a", encoding: "utf-8" } : "utf-8")
|
|
234
|
+
return `Written ${Buffer.byteLength(content, "utf-8")} bytes to ${filePath}${append ? " (appended)" : ""}`
|
|
235
|
+
} catch (err: any) {
|
|
236
|
+
return `Error writing file: ${err.message}`
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
- [ ] **Step 4: 运行测试确认通过**
|
|
242
|
+
|
|
243
|
+
Run: `bun test tests/file-tools.test.ts`
|
|
244
|
+
Expected: PASS(原有用例 + 2 个新用例)
|
|
245
|
+
|
|
246
|
+
- [ ] **Step 5: 提交**
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
git add src/tools/write.ts tests/file-tools.test.ts
|
|
250
|
+
git commit -m "feat: write 工具支持追加模式"
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Task 3: apply_patch 工具(unified diff 应用)
|
|
256
|
+
|
|
257
|
+
**Files:**
|
|
258
|
+
- Create: `src/tools/apply_patch.ts`
|
|
259
|
+
- Modify: `src/tools/index.ts`(注册到 chat 与 code 工具集)
|
|
260
|
+
- Test: `tests/apply-patch.test.ts`
|
|
261
|
+
|
|
262
|
+
- [ ] **Step 1: 写失败测试**
|
|
263
|
+
|
|
264
|
+
创建 `tests/apply-patch.test.ts`:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { expect, test } from "bun:test"
|
|
268
|
+
import { mkdtempSync, readFileSync, writeFileSync, rmSync, realpathSync, existsSync } from "fs"
|
|
269
|
+
import os from "os"
|
|
270
|
+
import path from "path"
|
|
271
|
+
import { setAutoApprove } from "../src/confirm.js"
|
|
272
|
+
import { parseUnifiedDiff, applyPatch } from "../src/tools/apply_patch.js"
|
|
273
|
+
|
|
274
|
+
setAutoApprove(true)
|
|
275
|
+
|
|
276
|
+
function makeTemp(): string {
|
|
277
|
+
return realpathSync(mkdtempSync(path.join(os.tmpdir(), "min-agent-patch-")))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const MODIFY_DIFF = `--- a/foo.txt
|
|
281
|
+
+++ b/foo.txt
|
|
282
|
+
@@ -1,3 +1,3 @@
|
|
283
|
+
line1
|
|
284
|
+
-line2
|
|
285
|
+
+line2-edited
|
|
286
|
+
line3
|
|
287
|
+
`
|
|
288
|
+
|
|
289
|
+
test("parseUnifiedDiff parses headers and hunks", () => {
|
|
290
|
+
const files = parseUnifiedDiff(MODIFY_DIFF)
|
|
291
|
+
expect(files.length).toBe(1)
|
|
292
|
+
expect(files[0]!.path).toBe("foo.txt")
|
|
293
|
+
expect(files[0]!.isNew).toBe(false)
|
|
294
|
+
expect(files[0]!.hunks.length).toBe(1)
|
|
295
|
+
const lines = files[0]!.hunks[0]!.lines
|
|
296
|
+
expect(lines.map((l) => l.type)).toEqual(["context", "delete", "add", "context"])
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test("applyPatch modifies an existing file", () => {
|
|
300
|
+
const dir = makeTemp()
|
|
301
|
+
try {
|
|
302
|
+
writeFileSync(path.join(dir, "foo.txt"), "line1\nline2\nline3\n")
|
|
303
|
+
const result = applyPatch(parseUnifiedDiff(MODIFY_DIFF), dir)
|
|
304
|
+
expect(result.ok).toBe(true)
|
|
305
|
+
expect(readFileSync(path.join(dir, "foo.txt"), "utf-8")).toBe("line1\nline2-edited\nline3\n")
|
|
306
|
+
} finally {
|
|
307
|
+
rmSync(dir, { recursive: true, force: true })
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test("applyPatch creates a new file from /dev/null", () => {
|
|
312
|
+
const dir = makeTemp()
|
|
313
|
+
try {
|
|
314
|
+
const diff = `--- /dev/null
|
|
315
|
+
+++ b/new.txt
|
|
316
|
+
@@ -0,0 +1,2 @@
|
|
317
|
+
+hello
|
|
318
|
+
+world
|
|
319
|
+
`
|
|
320
|
+
const result = applyPatch(parseUnifiedDiff(diff), dir)
|
|
321
|
+
expect(result.ok).toBe(true)
|
|
322
|
+
expect(readFileSync(path.join(dir, "new.txt"), "utf-8")).toBe("hello\nworld\n")
|
|
323
|
+
} finally {
|
|
324
|
+
rmSync(dir, { recursive: true, force: true })
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
test("applyPatch deletes lines", () => {
|
|
329
|
+
const dir = makeTemp()
|
|
330
|
+
try {
|
|
331
|
+
writeFileSync(path.join(dir, "foo.txt"), "keep\nremove-me\nkeep2\n")
|
|
332
|
+
const diff = `--- a/foo.txt
|
|
333
|
+
+++ b/foo.txt
|
|
334
|
+
@@ -1,3 +1,2 @@
|
|
335
|
+
keep
|
|
336
|
+
-remove-me
|
|
337
|
+
keep2
|
|
338
|
+
`
|
|
339
|
+
const result = applyPatch(parseUnifiedDiff(diff), dir)
|
|
340
|
+
expect(result.ok).toBe(true)
|
|
341
|
+
expect(readFileSync(path.join(dir, "foo.txt"), "utf-8")).toBe("keep\nkeep2\n")
|
|
342
|
+
} finally {
|
|
343
|
+
rmSync(dir, { recursive: true, force: true })
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test("applyPatch matches hunks with small offsets", () => {
|
|
348
|
+
const dir = makeTemp()
|
|
349
|
+
try {
|
|
350
|
+
writeFileSync(path.join(dir, "foo.txt"), "a\nb\nc\nline1\nline2\nline3\nd\n")
|
|
351
|
+
const result = applyPatch(parseUnifiedDiff(MODIFY_DIFF), dir)
|
|
352
|
+
expect(result.ok).toBe(true)
|
|
353
|
+
expect(readFileSync(path.join(dir, "foo.txt"), "utf-8")).toBe("a\nb\nc\nline1\nline2-edited\nline3\nd\n")
|
|
354
|
+
} finally {
|
|
355
|
+
rmSync(dir, { recursive: true, force: true })
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test("applyPatch rejects conflicts without partial writes", () => {
|
|
360
|
+
const dir = makeTemp()
|
|
361
|
+
try {
|
|
362
|
+
writeFileSync(path.join(dir, "foo.txt"), "totally\ndifferent\ncontent\n")
|
|
363
|
+
const result = applyPatch(parseUnifiedDiff(MODIFY_DIFF), dir)
|
|
364
|
+
expect(result.ok).toBe(false)
|
|
365
|
+
expect(readFileSync(path.join(dir, "foo.txt"), "utf-8")).toBe("totally\ndifferent\ncontent\n")
|
|
366
|
+
} finally {
|
|
367
|
+
rmSync(dir, { recursive: true, force: true })
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test("applyPatch handles multiple files in one diff", () => {
|
|
372
|
+
const dir = makeTemp()
|
|
373
|
+
try {
|
|
374
|
+
writeFileSync(path.join(dir, "a.txt"), "x1\nx2\n")
|
|
375
|
+
writeFileSync(path.join(dir, "b.txt"), "y1\n")
|
|
376
|
+
const diff = `--- a/a.txt
|
|
377
|
+
+++ b/a.txt
|
|
378
|
+
@@ -1,2 +1,2 @@
|
|
379
|
+
x1
|
|
380
|
+
-x2
|
|
381
|
+
+x2b
|
|
382
|
+
--- a/b.txt
|
|
383
|
+
+++ b/b.txt
|
|
384
|
+
@@ -1 +1 @@
|
|
385
|
+
-y1
|
|
386
|
+
+y1b
|
|
387
|
+
`
|
|
388
|
+
const result = applyPatch(parseUnifiedDiff(diff), dir)
|
|
389
|
+
expect(result.ok).toBe(true)
|
|
390
|
+
expect(readFileSync(path.join(dir, "a.txt"), "utf-8")).toBe("x1\nx2b\n")
|
|
391
|
+
expect(readFileSync(path.join(dir, "b.txt"), "utf-8")).toBe("y1b\n")
|
|
392
|
+
} finally {
|
|
393
|
+
rmSync(dir, { recursive: true, force: true })
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test("applyPatch handles multiple hunks in one file", () => {
|
|
398
|
+
const dir = makeTemp()
|
|
399
|
+
try {
|
|
400
|
+
writeFileSync(path.join(dir, "foo.txt"), "one\ntwo\nthree\nfour\n")
|
|
401
|
+
const diff = `--- a/foo.txt
|
|
402
|
+
+++ b/foo.txt
|
|
403
|
+
@@ -1,2 +1,2 @@
|
|
404
|
+
-one
|
|
405
|
+
+one-edited
|
|
406
|
+
two
|
|
407
|
+
@@ -3,2 +3,2 @@
|
|
408
|
+
three
|
|
409
|
+
-four
|
|
410
|
+
+four-edited
|
|
411
|
+
`
|
|
412
|
+
const result = applyPatch(parseUnifiedDiff(diff), dir)
|
|
413
|
+
expect(result.ok).toBe(true)
|
|
414
|
+
expect(readFileSync(path.join(dir, "foo.txt"), "utf-8")).toBe("one-edited\ntwo\nthree\nfour-edited\n")
|
|
415
|
+
} finally {
|
|
416
|
+
rmSync(dir, { recursive: true, force: true })
|
|
417
|
+
}
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
test("applyPatch fails when the target file does not exist", () => {
|
|
421
|
+
const dir = makeTemp()
|
|
422
|
+
try {
|
|
423
|
+
const result = applyPatch(parseUnifiedDiff(MODIFY_DIFF), dir)
|
|
424
|
+
expect(result.ok).toBe(false)
|
|
425
|
+
if (!result.ok) expect(result.error).toContain("不存在")
|
|
426
|
+
} finally {
|
|
427
|
+
rmSync(dir, { recursive: true, force: true })
|
|
428
|
+
}
|
|
429
|
+
})
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
433
|
+
|
|
434
|
+
Run: `bun test tests/apply-patch.test.ts`
|
|
435
|
+
Expected: FAIL — module not found
|
|
436
|
+
|
|
437
|
+
- [ ] **Step 3: 实现 apply_patch.ts**
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
import { tool, jsonSchema } from "ai"
|
|
441
|
+
import { readFile, writeFile } from "fs/promises"
|
|
442
|
+
import { existsSync } from "fs"
|
|
443
|
+
import path from "path"
|
|
444
|
+
import { confirm, isAutoApprove } from "../confirm.js"
|
|
445
|
+
|
|
446
|
+
export interface HunkLine {
|
|
447
|
+
type: "context" | "delete" | "add"
|
|
448
|
+
text: string
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export interface Hunk {
|
|
452
|
+
oldStart: number
|
|
453
|
+
oldCount: number
|
|
454
|
+
newStart: number
|
|
455
|
+
newCount: number
|
|
456
|
+
lines: HunkLine[]
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export interface PatchFile {
|
|
460
|
+
path: string
|
|
461
|
+
isNew: boolean
|
|
462
|
+
hunks: Hunk[]
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export type PatchResult =
|
|
466
|
+
| { ok: true; files: { path: string; isNew: boolean; del: number; add: number }[] }
|
|
467
|
+
| { ok: false; error: string }
|
|
468
|
+
|
|
469
|
+
const OFFSET_TOLERANCE = 3
|
|
470
|
+
|
|
471
|
+
export function parseUnifiedDiff(diff: string): PatchFile[] {
|
|
472
|
+
const files: PatchFile[] = []
|
|
473
|
+
let current: PatchFile | null = null
|
|
474
|
+
let currentHunk: Hunk | null = null
|
|
475
|
+
let pendingHeader: string | null = null
|
|
476
|
+
|
|
477
|
+
const lines = diff.replace(/\r\n/g, "\n").split("\n")
|
|
478
|
+
|
|
479
|
+
for (const raw of lines) {
|
|
480
|
+
if (raw.startsWith("--- ")) {
|
|
481
|
+
pendingHeader = raw.slice(4)
|
|
482
|
+
continue
|
|
483
|
+
}
|
|
484
|
+
if (pendingHeader !== null && raw.startsWith("+++ ")) {
|
|
485
|
+
const target = raw.slice(4)
|
|
486
|
+
if (current) files.push(current)
|
|
487
|
+
current = {
|
|
488
|
+
path: stripPrefix(target),
|
|
489
|
+
isNew: pendingHeader === "/dev/null",
|
|
490
|
+
hunks: [],
|
|
491
|
+
}
|
|
492
|
+
currentHunk = null
|
|
493
|
+
pendingHeader = null
|
|
494
|
+
continue
|
|
495
|
+
}
|
|
496
|
+
if (raw.startsWith("@@ ")) {
|
|
497
|
+
const m = raw.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
|
|
498
|
+
if (m && current) {
|
|
499
|
+
currentHunk = {
|
|
500
|
+
oldStart: parseInt(m[1]!, 10),
|
|
501
|
+
oldCount: m[2] ? parseInt(m[2], 10) : 1,
|
|
502
|
+
newStart: parseInt(m[3]!, 10),
|
|
503
|
+
newCount: m[4] ? parseInt(m[4], 10) : 1,
|
|
504
|
+
lines: [],
|
|
505
|
+
}
|
|
506
|
+
current.hunks.push(currentHunk)
|
|
507
|
+
}
|
|
508
|
+
continue
|
|
509
|
+
}
|
|
510
|
+
if (currentHunk && current) {
|
|
511
|
+
if (raw.startsWith("+") && !raw.startsWith("+++")) {
|
|
512
|
+
currentHunk.lines.push({ type: "add", text: raw.slice(1) })
|
|
513
|
+
} else if (raw.startsWith("-") && !raw.startsWith("---")) {
|
|
514
|
+
currentHunk.lines.push({ type: "delete", text: raw.slice(1) })
|
|
515
|
+
} else if (raw.startsWith(" ")) {
|
|
516
|
+
currentHunk.lines.push({ type: "context", text: raw.slice(1) })
|
|
517
|
+
}
|
|
518
|
+
// "\" 行忽略
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (current) files.push(current)
|
|
522
|
+
return files
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function stripPrefix(p: string): string {
|
|
526
|
+
const trimmed = p.trim()
|
|
527
|
+
if (trimmed === "/dev/null") return trimmed
|
|
528
|
+
if (trimmed.startsWith("a/")) return trimmed.slice(2)
|
|
529
|
+
if (trimmed.startsWith("b/")) return trimmed.slice(2)
|
|
530
|
+
return trimmed
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function findMatch(lines: string[], start: number, block: string[]): number {
|
|
534
|
+
if (block.length === 0) return Math.max(0, Math.min(start, lines.length))
|
|
535
|
+
for (
|
|
536
|
+
let pos = Math.max(0, start - OFFSET_TOLERANCE);
|
|
537
|
+
pos <= Math.min(lines.length - block.length, start + OFFSET_TOLERANCE);
|
|
538
|
+
pos++
|
|
539
|
+
) {
|
|
540
|
+
let ok = true
|
|
541
|
+
for (let i = 0; i < block.length; i++) {
|
|
542
|
+
if (lines[pos + i] !== block[i]) {
|
|
543
|
+
ok = false
|
|
544
|
+
break
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (ok) return pos
|
|
548
|
+
}
|
|
549
|
+
return -1
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function applyHunk(lines: string[], hunk: Hunk): { lines: string[] } | { error: string } {
|
|
553
|
+
const block = hunk.lines.filter((l) => l.type !== "add").map((l) => l.text)
|
|
554
|
+
const pos = findMatch(lines, hunk.oldStart - 1, block)
|
|
555
|
+
if (pos === -1) {
|
|
556
|
+
return { error: `第 ${hunk.oldStart} 行附近的 hunk 未找到匹配(需要匹配 ${block.length} 行)` }
|
|
557
|
+
}
|
|
558
|
+
const result = [...lines]
|
|
559
|
+
result.splice(pos, block.length)
|
|
560
|
+
const adds = hunk.lines.filter((l) => l.type === "add").map((l) => l.text)
|
|
561
|
+
result.splice(pos, 0, ...adds)
|
|
562
|
+
return { lines: result }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function applyPatch(files: PatchFile[], cwd: string = process.cwd()): PatchResult {
|
|
566
|
+
const applied: { file: PatchFile; content: string }[] = []
|
|
567
|
+
for (const file of files) {
|
|
568
|
+
const target = path.resolve(cwd, file.path)
|
|
569
|
+
if (file.isNew && !existsSync(target)) {
|
|
570
|
+
const lines: string[] = []
|
|
571
|
+
for (const hunk of file.hunks) {
|
|
572
|
+
const r = applyHunk(lines, hunk)
|
|
573
|
+
if ("error" in r) return { ok: false, error: `${file.path}: ${r.error}` }
|
|
574
|
+
lines.length = 0
|
|
575
|
+
lines.push(...r.lines)
|
|
576
|
+
}
|
|
577
|
+
applied.push({ file, content: lines.join("\n") })
|
|
578
|
+
continue
|
|
579
|
+
}
|
|
580
|
+
if (!existsSync(target)) {
|
|
581
|
+
return { ok: false, error: `${file.path}: 文件不存在` }
|
|
582
|
+
}
|
|
583
|
+
let content: string
|
|
584
|
+
try {
|
|
585
|
+
content = readFileSync2(target)
|
|
586
|
+
} catch (err: any) {
|
|
587
|
+
return { ok: false, error: `${file.path}: 读取失败 ${err.message}` }
|
|
588
|
+
}
|
|
589
|
+
const lines = content.split("\n")
|
|
590
|
+
for (const hunk of file.hunks) {
|
|
591
|
+
const r = applyHunk(lines, hunk)
|
|
592
|
+
if ("error" in r) return { ok: false, error: `${file.path}: ${r.error}` }
|
|
593
|
+
lines.length = 0
|
|
594
|
+
lines.push(...r.lines)
|
|
595
|
+
}
|
|
596
|
+
applied.push({ file, content: lines.join("\n") })
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const changed = applied.map((a) => ({
|
|
600
|
+
path: a.file.path,
|
|
601
|
+
isNew: a.file.isNew,
|
|
602
|
+
del: a.file.hunks.reduce((s, h) => s + h.lines.filter((l) => l.type === "delete").length, 0),
|
|
603
|
+
add: a.file.hunks.reduce((s, h) => s + h.lines.filter((l) => l.type === "add").length, 0),
|
|
604
|
+
}))
|
|
605
|
+
return { ok: true, files: changed }
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function readFileSync2(p: string): string {
|
|
609
|
+
return readFileSyncSync(p)
|
|
610
|
+
}
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
注意:上面 `readFileSync2`/`readFileSyncSync` 是为了避免与 fs/promises 冲突的占位——**实际实现**应为:
|
|
614
|
+
|
|
615
|
+
```ts
|
|
616
|
+
import { readFileSync } from "fs"
|
|
617
|
+
|
|
618
|
+
function readFileSync2(p: string): string {
|
|
619
|
+
return readFileSync(p, "utf-8")
|
|
620
|
+
}
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
(`applyPatch` 是同步函数,用 fs.readFileSync;async 写入由工具 execute 层处理。**最终实现请保持 applyPatch 纯同步**,读取用 `readFileSync`(fs),写入在工具 execute 里做。为清晰起见,把写盘逻辑也放进 applyPatch:文件全部应用成功后在内存计算 content,然后由 execute 写盘前 confirm。)
|
|
624
|
+
|
|
625
|
+
最终 `apply_patch.ts` 的完整结构(以此为准,替换上面占位思路):
|
|
626
|
+
|
|
627
|
+
```ts
|
|
628
|
+
import { tool, jsonSchema } from "ai"
|
|
629
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"
|
|
630
|
+
import path from "path"
|
|
631
|
+
import { confirm, isAutoApprove } from "../confirm.js"
|
|
632
|
+
|
|
633
|
+
export interface HunkLine {
|
|
634
|
+
type: "context" | "delete" | "add"
|
|
635
|
+
text: string
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export interface Hunk {
|
|
639
|
+
oldStart: number
|
|
640
|
+
oldCount: number
|
|
641
|
+
newStart: number
|
|
642
|
+
newCount: number
|
|
643
|
+
lines: HunkLine[]
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export interface PatchFile {
|
|
647
|
+
path: string
|
|
648
|
+
isNew: boolean
|
|
649
|
+
hunks: Hunk[]
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export type PatchResult =
|
|
653
|
+
| { ok: true; files: { path: string; isNew: boolean; del: number; add: number }[] }
|
|
654
|
+
| { ok: false; error: string }
|
|
655
|
+
|
|
656
|
+
const OFFSET_TOLERANCE = 3
|
|
657
|
+
|
|
658
|
+
export function parseUnifiedDiff(diff: string): PatchFile[] {
|
|
659
|
+
const files: PatchFile[] = []
|
|
660
|
+
let current: PatchFile | null = null
|
|
661
|
+
let currentHunk: Hunk | null = null
|
|
662
|
+
let pendingHeader: string | null = null
|
|
663
|
+
|
|
664
|
+
const lines = diff.replace(/\r\n/g, "\n").split("\n")
|
|
665
|
+
|
|
666
|
+
for (const raw of lines) {
|
|
667
|
+
if (raw.startsWith("--- ")) {
|
|
668
|
+
pendingHeader = raw.slice(4)
|
|
669
|
+
continue
|
|
670
|
+
}
|
|
671
|
+
if (pendingHeader !== null && raw.startsWith("+++ ")) {
|
|
672
|
+
const target = raw.slice(4)
|
|
673
|
+
if (current) files.push(current)
|
|
674
|
+
current = { path: stripPrefix(target), isNew: pendingHeader === "/dev/null", hunks: [] }
|
|
675
|
+
currentHunk = null
|
|
676
|
+
pendingHeader = null
|
|
677
|
+
continue
|
|
678
|
+
}
|
|
679
|
+
if (raw.startsWith("@@ ")) {
|
|
680
|
+
const m = raw.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
|
|
681
|
+
if (m && current) {
|
|
682
|
+
currentHunk = {
|
|
683
|
+
oldStart: parseInt(m[1]!, 10),
|
|
684
|
+
oldCount: m[2] ? parseInt(m[2], 10) : 1,
|
|
685
|
+
newStart: parseInt(m[3]!, 10),
|
|
686
|
+
newCount: m[4] ? parseInt(m[4], 10) : 1,
|
|
687
|
+
lines: [],
|
|
688
|
+
}
|
|
689
|
+
current.hunks.push(currentHunk)
|
|
690
|
+
}
|
|
691
|
+
continue
|
|
692
|
+
}
|
|
693
|
+
if (currentHunk && current) {
|
|
694
|
+
if (raw.startsWith("+") && !raw.startsWith("+++")) {
|
|
695
|
+
currentHunk.lines.push({ type: "add", text: raw.slice(1) })
|
|
696
|
+
} else if (raw.startsWith("-") && !raw.startsWith("---")) {
|
|
697
|
+
currentHunk.lines.push({ type: "delete", text: raw.slice(1) })
|
|
698
|
+
} else if (raw.startsWith(" ")) {
|
|
699
|
+
currentHunk.lines.push({ type: "context", text: raw.slice(1) })
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (current) files.push(current)
|
|
704
|
+
return files
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function stripPrefix(p: string): string {
|
|
708
|
+
const trimmed = p.trim()
|
|
709
|
+
if (trimmed === "/dev/null") return trimmed
|
|
710
|
+
if (trimmed.startsWith("a/")) return trimmed.slice(2)
|
|
711
|
+
if (trimmed.startsWith("b/")) return trimmed.slice(2)
|
|
712
|
+
return trimmed
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function findMatch(lines: string[], start: number, block: string[]): number {
|
|
716
|
+
if (block.length === 0) return Math.max(0, Math.min(start, lines.length))
|
|
717
|
+
for (
|
|
718
|
+
let pos = Math.max(0, start - OFFSET_TOLERANCE);
|
|
719
|
+
pos <= Math.min(lines.length - block.length, start + OFFSET_TOLERANCE);
|
|
720
|
+
pos++
|
|
721
|
+
) {
|
|
722
|
+
let ok = true
|
|
723
|
+
for (let i = 0; i < block.length; i++) {
|
|
724
|
+
if (lines[pos + i] !== block[i]) {
|
|
725
|
+
ok = false
|
|
726
|
+
break
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (ok) return pos
|
|
730
|
+
}
|
|
731
|
+
return -1
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function applyHunk(lines: string[], hunk: Hunk): { lines: string[] } | { error: string } {
|
|
735
|
+
const block = hunk.lines.filter((l) => l.type !== "add").map((l) => l.text)
|
|
736
|
+
const pos = findMatch(lines, hunk.oldStart - 1, block)
|
|
737
|
+
if (pos === -1) {
|
|
738
|
+
return { error: `第 ${hunk.oldStart} 行附近的 hunk 未找到匹配(需要匹配 ${block.length} 行)` }
|
|
739
|
+
}
|
|
740
|
+
const result = [...lines]
|
|
741
|
+
result.splice(pos, block.length)
|
|
742
|
+
const adds = hunk.lines.filter((l) => l.type === "add").map((l) => l.text)
|
|
743
|
+
result.splice(pos, 0, ...adds)
|
|
744
|
+
return { lines: result }
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** 在内存中应用全部文件;成功后返回各文件新内容。 */
|
|
748
|
+
export function buildPatchedFiles(
|
|
749
|
+
files: PatchFile[],
|
|
750
|
+
cwd: string = process.cwd(),
|
|
751
|
+
): { ok: true; contents: { file: PatchFile; content: string }[] } | { ok: false; error: string } {
|
|
752
|
+
const contents: { file: PatchFile; content: string }[] = []
|
|
753
|
+
for (const file of files) {
|
|
754
|
+
const target = path.resolve(cwd, file.path)
|
|
755
|
+
const exists = existsSync(target)
|
|
756
|
+
if (file.isNew && !exists) {
|
|
757
|
+
const lines: string[] = []
|
|
758
|
+
for (const hunk of file.hunks) {
|
|
759
|
+
const r = applyHunk(lines, hunk)
|
|
760
|
+
if ("error" in r) return { ok: false, error: `${file.path}: ${r.error}` }
|
|
761
|
+
lines.length = 0
|
|
762
|
+
lines.push(...r.lines)
|
|
763
|
+
}
|
|
764
|
+
contents.push({ file, content: lines.join("\n") })
|
|
765
|
+
continue
|
|
766
|
+
}
|
|
767
|
+
if (!exists) return { ok: false, error: `${file.path}: 文件不存在` }
|
|
768
|
+
let content: string
|
|
769
|
+
try {
|
|
770
|
+
content = readFileSync(target, "utf-8")
|
|
771
|
+
} catch (err: any) {
|
|
772
|
+
return { ok: false, error: `${file.path}: 读取失败 ${err.message}` }
|
|
773
|
+
}
|
|
774
|
+
const lines = content.split("\n")
|
|
775
|
+
for (const hunk of file.hunks) {
|
|
776
|
+
const r = applyHunk(lines, hunk)
|
|
777
|
+
if ("error" in r) return { ok: false, error: `${file.path}: ${r.error}` }
|
|
778
|
+
lines.length = 0
|
|
779
|
+
lines.push(...r.lines)
|
|
780
|
+
}
|
|
781
|
+
contents.push({ file, content: lines.join("\n") })
|
|
782
|
+
}
|
|
783
|
+
return { ok: true, contents }
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export const applyPatchTool = tool<{ diff: string }, string>({
|
|
787
|
+
description:
|
|
788
|
+
"Apply a unified diff to the working tree. Use this for precise multi-file edits or when you have a generated diff. " +
|
|
789
|
+
"Supports standard unified diff format (---/+++ headers, @@ hunks, context/delete/add lines). " +
|
|
790
|
+
"New files are created when the diff targets /dev/null. Conflicts are reported without partial writes.",
|
|
791
|
+
inputSchema: jsonSchema<{ diff: string }>({
|
|
792
|
+
type: "object",
|
|
793
|
+
properties: {
|
|
794
|
+
diff: { type: "string", description: "The unified diff text to apply" },
|
|
795
|
+
},
|
|
796
|
+
required: ["diff"],
|
|
797
|
+
}),
|
|
798
|
+
execute: async ({ diff }) => {
|
|
799
|
+
const parsed = parseUnifiedDiff(diff)
|
|
800
|
+
if (parsed.length === 0) return "Error: 无法解析 diff(缺少 ---/+++ 文件头)"
|
|
801
|
+
const built = buildPatchedFiles(parsed)
|
|
802
|
+
if (!built.ok) return `Error: ${built.error}`
|
|
803
|
+
const summary = built.contents
|
|
804
|
+
.map((c) => {
|
|
805
|
+
const del = c.file.hunks.reduce((s, h) => s + h.lines.filter((l) => l.type === "delete").length, 0)
|
|
806
|
+
const add = c.file.hunks.reduce((s, h) => s + h.lines.filter((l) => l.type === "add").length, 0)
|
|
807
|
+
return ` ${c.file.path} ${c.file.isNew ? "(new)" : ""} -${del}/+${add}`
|
|
808
|
+
})
|
|
809
|
+
.join("\n")
|
|
810
|
+
if (!isAutoApprove()) {
|
|
811
|
+
const approved = await confirm(`Apply patch to ${built.contents.length} file(s)?\n${summary}`)
|
|
812
|
+
if (!approved) return "Patch rejected by user."
|
|
813
|
+
}
|
|
814
|
+
for (const c of built.contents) {
|
|
815
|
+
const target = path.resolve(process.cwd(), c.file.path)
|
|
816
|
+
mkdirSync(path.dirname(target), { recursive: true })
|
|
817
|
+
writeFileSync(target, c.content, "utf-8")
|
|
818
|
+
}
|
|
819
|
+
return `Applied patch:\n${summary}`
|
|
820
|
+
},
|
|
821
|
+
})
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
注意:
|
|
825
|
+
- `buildPatchedFiles` 是纯同步函数(供测试直接调用,不写盘)
|
|
826
|
+
- `applyPatch` 测试 import 的 `parseUnifiedDiff, applyPatch` —— **测试改为 import `parseUnifiedDiff, buildPatchedFiles`**,断言改为检查 `buildPatchedFiles` 返回的 contents(写盘由工具 execute 负责,测试用 `setAutoApprove(true)` 后可走 execute 验证写盘)。测试文件中的 `applyPatch(...)` 调用全部替换为:
|
|
827
|
+
|
|
828
|
+
```ts
|
|
829
|
+
const built = buildPatchedFiles(parseUnifiedDiff(diff), dir)
|
|
830
|
+
expect(built.ok).toBe(true)
|
|
831
|
+
if (built.ok) expect(built.contents[0]!.content).toBe("line1\nline2-edited\nline3\n")
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
冲突/不存在用例断言 `built.ok === false` 与 `built.error`。**请按此调整 Step 1 的测试代码**(Step 1 中 `applyPatch` 调用点统一改 `buildPatchedFiles`,断言相应调整)。
|
|
835
|
+
|
|
836
|
+
- [ ] **Step 4: 注册到 tools/index.ts**
|
|
837
|
+
|
|
838
|
+
`createChatTools` 与 `createCodeTools` 的 tools 对象都加一行:
|
|
839
|
+
|
|
840
|
+
```ts
|
|
841
|
+
import { applyPatchTool } from "./apply_patch.js"
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
```ts
|
|
845
|
+
apply_patch: applyPatchTool,
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
- [ ] **Step 5: 运行测试确认通过**
|
|
849
|
+
|
|
850
|
+
Run: `bun test tests/apply-patch.test.ts`
|
|
851
|
+
Expected: PASS(9 个用例)
|
|
852
|
+
|
|
853
|
+
- [ ] **Step 6: 提交**
|
|
854
|
+
|
|
855
|
+
```bash
|
|
856
|
+
git add src/tools/apply_patch.ts src/tools/index.ts tests/apply-patch.test.ts
|
|
857
|
+
git commit -m "feat: apply_patch 工具支持 unified diff 应用"
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
---
|
|
861
|
+
|
|
862
|
+
## Task 4: web_search 参数扩展
|
|
863
|
+
|
|
864
|
+
**Files:**
|
|
865
|
+
- Modify: `src/tools/web_search.ts`
|
|
866
|
+
- Test: `tests/web-search.test.ts`
|
|
867
|
+
|
|
868
|
+
- [ ] **Step 1: 写失败测试**
|
|
869
|
+
|
|
870
|
+
在 `tests/web-search.test.ts` 末尾追加:
|
|
871
|
+
|
|
872
|
+
```ts
|
|
873
|
+
test("web_search forwards language and time_range params", async () => {
|
|
874
|
+
let captured = ""
|
|
875
|
+
const originalFetch = globalThis.fetch
|
|
876
|
+
globalThis.fetch = (async (url: any) => {
|
|
877
|
+
captured = String(url)
|
|
878
|
+
return new Response(JSON.stringify({ results: [] }), {
|
|
879
|
+
status: 200,
|
|
880
|
+
headers: { "Content-Type": "application/json" },
|
|
881
|
+
})
|
|
882
|
+
}) as typeof fetch
|
|
883
|
+
try {
|
|
884
|
+
await exec({ query: "q", language: "zh", time_range: "week" }, {})
|
|
885
|
+
expect(captured).toContain("language=zh")
|
|
886
|
+
expect(captured).toContain("time_range=week")
|
|
887
|
+
} finally {
|
|
888
|
+
globalThis.fetch = originalFetch
|
|
889
|
+
}
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
test("web_search ignores an invalid time_range", async () => {
|
|
893
|
+
let captured = ""
|
|
894
|
+
const originalFetch = globalThis.fetch
|
|
895
|
+
globalThis.fetch = (async (url: any) => {
|
|
896
|
+
captured = String(url)
|
|
897
|
+
return new Response(JSON.stringify({ results: [] }), {
|
|
898
|
+
status: 200,
|
|
899
|
+
headers: { "Content-Type": "application/json" },
|
|
900
|
+
})
|
|
901
|
+
}) as typeof fetch
|
|
902
|
+
try {
|
|
903
|
+
await exec({ query: "q", time_range: "decade" }, {})
|
|
904
|
+
expect(captured).not.toContain("time_range")
|
|
905
|
+
} finally {
|
|
906
|
+
globalThis.fetch = originalFetch
|
|
907
|
+
}
|
|
908
|
+
})
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
912
|
+
|
|
913
|
+
Run: `bun test tests/web-search.test.ts`
|
|
914
|
+
Expected: 新用例 FAIL(URL 不含 language/time_range)
|
|
915
|
+
|
|
916
|
+
- [ ] **Step 3: 修改 web_search.ts**
|
|
917
|
+
|
|
918
|
+
```ts
|
|
919
|
+
type WebSearchInput = { query: string; categories?: string; language?: string; time_range?: string }
|
|
920
|
+
|
|
921
|
+
const VALID_TIME_RANGES = new Set(["day", "week", "month", "year"])
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
schema properties 追加:
|
|
925
|
+
|
|
926
|
+
```ts
|
|
927
|
+
language: { type: "string", description: "Search language code (e.g. zh, en). Passed to the backend when valid" },
|
|
928
|
+
time_range: { type: "string", description: "Time range filter: day, week, month, year" },
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
execute:
|
|
932
|
+
|
|
933
|
+
```ts
|
|
934
|
+
execute: async ({ query, categories, language, time_range }) => {
|
|
935
|
+
const cat = categories && VALID_CATEGORIES.has(categories) ? categories : "general"
|
|
936
|
+
try {
|
|
937
|
+
const params = new URLSearchParams({
|
|
938
|
+
q: query,
|
|
939
|
+
format: "json",
|
|
940
|
+
categories: cat,
|
|
941
|
+
})
|
|
942
|
+
if (language && language.trim()) params.set("language", language.trim())
|
|
943
|
+
if (time_range && VALID_TIME_RANGES.has(time_range)) params.set("time_range", time_range)
|
|
944
|
+
const response = await fetch(`${searxngBase()}/search?${params}`, {
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
- [ ] **Step 4: 运行测试确认通过**
|
|
948
|
+
|
|
949
|
+
Run: `bun test tests/web-search.test.ts`
|
|
950
|
+
Expected: PASS(2 个原有用例 + 2 个新用例)
|
|
951
|
+
|
|
952
|
+
- [ ] **Step 5: 提交**
|
|
953
|
+
|
|
954
|
+
```bash
|
|
955
|
+
git add src/tools/web_search.ts tests/web-search.test.ts
|
|
956
|
+
git commit -m "feat: web_search 支持语言与时间范围参数"
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
---
|
|
960
|
+
|
|
961
|
+
## Task 5: chat 模式工具扩展
|
|
962
|
+
|
|
963
|
+
**Files:**
|
|
964
|
+
- Modify: `src/tools/index.ts`
|
|
965
|
+
- Modify: `src/agent.ts`(buildTools)
|
|
966
|
+
|
|
967
|
+
- [ ] **Step 1: 修改 tools/index.ts**
|
|
968
|
+
|
|
969
|
+
`createChatTools` 加 todo(文件顶部已 import todoTool):
|
|
970
|
+
|
|
971
|
+
```ts
|
|
972
|
+
export function createChatTools(): Record<string, Tool> {
|
|
973
|
+
const tools: Record<string, Tool> = {
|
|
974
|
+
bash: bashTool,
|
|
975
|
+
read: readTool,
|
|
976
|
+
write: writeTool,
|
|
977
|
+
edit: editTool,
|
|
978
|
+
glob: globTool,
|
|
979
|
+
grep: grepTool,
|
|
980
|
+
web_search: webSearchTool,
|
|
981
|
+
web_fetch: webFetchTool,
|
|
982
|
+
todo: todoTool,
|
|
983
|
+
question: questionTool,
|
|
984
|
+
}
|
|
985
|
+
return tools
|
|
986
|
+
}
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
- [ ] **Step 2: 修改 agent.ts buildTools**
|
|
990
|
+
|
|
991
|
+
现有(约 202-223 行):
|
|
992
|
+
|
|
993
|
+
```ts
|
|
994
|
+
async function buildTools(
|
|
995
|
+
mode: "chat" | "code",
|
|
996
|
+
modelId: string | undefined,
|
|
997
|
+
abortSignal: AbortSignal | undefined,
|
|
998
|
+
): Promise<Record<string, Tool>> {
|
|
999
|
+
const builtinTools = mode === "code" ? createCodeTools() : createChatTools()
|
|
1000
|
+
if (mode === "code") builtinTools["todo"] = createTodoTool()
|
|
1001
|
+
const mcpTools = getMcpTools()
|
|
1002
|
+
const memoryTools = getMemoryTools()
|
|
1003
|
+
const pluginTools = await loadPluginTools()
|
|
1004
|
+
const skills = getSkills()
|
|
1005
|
+
const allTools: Record<string, Tool> = { ...builtinTools, ...memoryTools, ...pluginTools }
|
|
1006
|
+
for (const [id, t] of Object.entries(mcpTools)) allTools[id] = t
|
|
1007
|
+
if (skills.length > 0) allTools["skill"] = getSkillsTool()
|
|
1008
|
+
if (mode === "code") {
|
|
1009
|
+
const { createTaskTool } = await import("./tools/task.js")
|
|
1010
|
+
const { createExploreTool } = await import("./tools/explore.js")
|
|
1011
|
+
allTools["task"] = createTaskTool(modelId, abortSignal)
|
|
1012
|
+
allTools["explore"] = createExploreTool(modelId, abortSignal)
|
|
1013
|
+
}
|
|
1014
|
+
return allTools
|
|
1015
|
+
}
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
改为:
|
|
1019
|
+
|
|
1020
|
+
```ts
|
|
1021
|
+
async function buildTools(
|
|
1022
|
+
mode: "chat" | "code",
|
|
1023
|
+
modelId: string | undefined,
|
|
1024
|
+
abortSignal: AbortSignal | undefined,
|
|
1025
|
+
): Promise<Record<string, Tool>> {
|
|
1026
|
+
const builtinTools = mode === "code" ? createCodeTools() : createChatTools()
|
|
1027
|
+
builtinTools["todo"] = createTodoTool()
|
|
1028
|
+
const mcpTools = getMcpTools()
|
|
1029
|
+
const memoryTools = getMemoryTools()
|
|
1030
|
+
const pluginTools = await loadPluginTools()
|
|
1031
|
+
const skills = getSkills()
|
|
1032
|
+
const allTools: Record<string, Tool> = { ...builtinTools, ...memoryTools, ...pluginTools }
|
|
1033
|
+
for (const [id, t] of Object.entries(mcpTools)) allTools[id] = t
|
|
1034
|
+
if (skills.length > 0) allTools["skill"] = getSkillsTool()
|
|
1035
|
+
const { createExploreTool } = await import("./tools/explore.js")
|
|
1036
|
+
allTools["explore"] = createExploreTool(modelId, abortSignal)
|
|
1037
|
+
if (mode === "code") {
|
|
1038
|
+
const { createTaskTool } = await import("./tools/task.js")
|
|
1039
|
+
allTools["task"] = createTaskTool(modelId, abortSignal)
|
|
1040
|
+
}
|
|
1041
|
+
return allTools
|
|
1042
|
+
}
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
(todo 两种模式都覆盖为 per-run 实例;explore 两种模式都有;task 仅 code)
|
|
1046
|
+
|
|
1047
|
+
- [ ] **Step 3: 验证**
|
|
1048
|
+
|
|
1049
|
+
Run: `bun run typecheck`(仓库根)和 `bun test`
|
|
1050
|
+
Expected: 无错误,全部通过
|
|
1051
|
+
|
|
1052
|
+
- [ ] **Step 4: 提交**
|
|
1053
|
+
|
|
1054
|
+
```bash
|
|
1055
|
+
git add src/tools/index.ts src/agent.ts
|
|
1056
|
+
git commit -m "feat: chat 模式提供 todo 与 explore 工具"
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
---
|
|
1060
|
+
|
|
1061
|
+
## Task 6: CLI 增强(history 子命令、mcp --env、update)
|
|
1062
|
+
|
|
1063
|
+
**Files:**
|
|
1064
|
+
- Create: `src/updater.ts`
|
|
1065
|
+
- Modify: `src/cli.ts`
|
|
1066
|
+
|
|
1067
|
+
- [ ] **Step 1: 实现 updater.ts**
|
|
1068
|
+
|
|
1069
|
+
```ts
|
|
1070
|
+
import { readFileSync } from "fs"
|
|
1071
|
+
import path from "path"
|
|
1072
|
+
import { fileURLToPath } from "url"
|
|
1073
|
+
import { execSync } from "child_process"
|
|
1074
|
+
|
|
1075
|
+
export function localVersion(): string {
|
|
1076
|
+
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../package.json")
|
|
1077
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"))
|
|
1078
|
+
return pkg.version ?? "0.0.0"
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
export function compareVersions(a: string, b: string): number {
|
|
1082
|
+
const pa = a.split(".").map(Number)
|
|
1083
|
+
const pb = b.split(".").map(Number)
|
|
1084
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
1085
|
+
const va = pa[i] ?? 0
|
|
1086
|
+
const vb = pb[i] ?? 0
|
|
1087
|
+
if (va !== vb) return va - vb
|
|
1088
|
+
}
|
|
1089
|
+
return 0
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
export async function fetchLatestVersion(): Promise<string | null> {
|
|
1093
|
+
try {
|
|
1094
|
+
const response = await fetch("https://registry.npmjs.org/min-agent/latest", {
|
|
1095
|
+
signal: AbortSignal.timeout(10000),
|
|
1096
|
+
headers: { Accept: "application/json" },
|
|
1097
|
+
})
|
|
1098
|
+
if (!response.ok) return null
|
|
1099
|
+
const data = (await response.json()) as { version?: string }
|
|
1100
|
+
return data.version ?? null
|
|
1101
|
+
} catch {
|
|
1102
|
+
return null
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
export async function runUpdate(): Promise<void> {
|
|
1107
|
+
const current = localVersion()
|
|
1108
|
+
console.log(`当前版本: ${current}`)
|
|
1109
|
+
const latest = await fetchLatestVersion()
|
|
1110
|
+
if (!latest) {
|
|
1111
|
+
console.error("无法检查新版本(网络不可用)。")
|
|
1112
|
+
return
|
|
1113
|
+
}
|
|
1114
|
+
if (compareVersions(current, latest) >= 0) {
|
|
1115
|
+
console.log(`已是最新版本 (${latest})`)
|
|
1116
|
+
return
|
|
1117
|
+
}
|
|
1118
|
+
console.log(`发现新版本: ${latest}`)
|
|
1119
|
+
console.log("正在升级 min-agent...")
|
|
1120
|
+
try {
|
|
1121
|
+
execSync("npm install -g min-agent@latest", { stdio: "inherit" })
|
|
1122
|
+
console.log(`✓ 已升级到 ${latest},请重启终端后生效`)
|
|
1123
|
+
} catch {
|
|
1124
|
+
console.error("自动升级失败。请手动执行: npm install -g min-agent@latest")
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
- [ ] **Step 2: 修改 cli.ts**
|
|
1130
|
+
|
|
1131
|
+
**a) printUsage 更新**(Commands 区):
|
|
1132
|
+
|
|
1133
|
+
```
|
|
1134
|
+
min-agent history List saved sessions
|
|
1135
|
+
min-agent history delete <id> Delete a saved session
|
|
1136
|
+
min-agent history rename <id> <t> Rename a saved session
|
|
1137
|
+
min-agent history export <id> [-o <file>] Export a session as JSON
|
|
1138
|
+
min-agent update Check for updates and upgrade
|
|
1139
|
+
```
|
|
1140
|
+
|
|
1141
|
+
并把 `min-agent mcp add <name> <cmd>` 行后补充:
|
|
1142
|
+
|
|
1143
|
+
```
|
|
1144
|
+
(--env KEY=VALUE 可重复)
|
|
1145
|
+
```
|
|
1146
|
+
|
|
1147
|
+
**b) parseMcpAddArgs 加 --env**:
|
|
1148
|
+
|
|
1149
|
+
```ts
|
|
1150
|
+
function parseMcpAddArgs(argv: string[]): {
|
|
1151
|
+
skipCheck: boolean
|
|
1152
|
+
url?: string
|
|
1153
|
+
token?: string
|
|
1154
|
+
sse: boolean
|
|
1155
|
+
environment: Record<string, string>
|
|
1156
|
+
cmd: string[]
|
|
1157
|
+
} {
|
|
1158
|
+
let skipCheck = false
|
|
1159
|
+
let url: string | undefined
|
|
1160
|
+
let token: string | undefined
|
|
1161
|
+
let sse = false
|
|
1162
|
+
const environment: Record<string, string> = {}
|
|
1163
|
+
const cmd: string[] = []
|
|
1164
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1165
|
+
const a = argv[i]
|
|
1166
|
+
if (a === "--skip-check") {
|
|
1167
|
+
skipCheck = true
|
|
1168
|
+
continue
|
|
1169
|
+
}
|
|
1170
|
+
if (a === "--sse") {
|
|
1171
|
+
sse = true
|
|
1172
|
+
continue
|
|
1173
|
+
}
|
|
1174
|
+
if (a === "--url" && argv[i + 1]) {
|
|
1175
|
+
url = argv[++i]
|
|
1176
|
+
continue
|
|
1177
|
+
}
|
|
1178
|
+
if (a === "--token" && argv[i + 1]) {
|
|
1179
|
+
token = argv[++i]
|
|
1180
|
+
continue
|
|
1181
|
+
}
|
|
1182
|
+
if (a === "--env" && argv[i + 1]) {
|
|
1183
|
+
const kv = argv[++i]
|
|
1184
|
+
const eq = kv.indexOf("=")
|
|
1185
|
+
if (eq <= 0) {
|
|
1186
|
+
console.error(`Invalid --env format: ${kv} (use KEY=VALUE)`)
|
|
1187
|
+
process.exit(1)
|
|
1188
|
+
}
|
|
1189
|
+
environment[kv.slice(0, eq)] = kv.slice(eq + 1)
|
|
1190
|
+
continue
|
|
1191
|
+
}
|
|
1192
|
+
cmd.push(a)
|
|
1193
|
+
}
|
|
1194
|
+
return { skipCheck, url, token, sse, environment, cmd }
|
|
1195
|
+
}
|
|
1196
|
+
```
|
|
1197
|
+
|
|
1198
|
+
**c) mcp add case 应用 environment**:
|
|
1199
|
+
|
|
1200
|
+
```ts
|
|
1201
|
+
const { skipCheck, url, token, sse, environment, cmd } = parseMcpAddArgs(args.slice(3))
|
|
1202
|
+
|
|
1203
|
+
let entry: McpServerConfig
|
|
1204
|
+
if (url?.trim()) {
|
|
1205
|
+
entry = {
|
|
1206
|
+
url: url.trim(),
|
|
1207
|
+
enabled: true,
|
|
1208
|
+
remoteTransport: sse ? "sse" : "auto",
|
|
1209
|
+
}
|
|
1210
|
+
if (token?.trim()) entry.token = token.trim()
|
|
1211
|
+
} else if (cmd.length > 0) {
|
|
1212
|
+
entry = { command: cmd, enabled: true }
|
|
1213
|
+
} else {
|
|
1214
|
+
console.error("Usage: min-agent mcp add <name> [--skip-check] <command...>")
|
|
1215
|
+
console.error(" or: min-agent mcp add <name> --url <https://host/mcp> [--sse] [--token <bearer>] [--skip-check]")
|
|
1216
|
+
process.exit(1)
|
|
1217
|
+
}
|
|
1218
|
+
if (Object.keys(environment).length > 0) entry.environment = environment
|
|
1219
|
+
```
|
|
1220
|
+
|
|
1221
|
+
**d) history 分支扩展**(在现有 list 逻辑前加子命令分发):
|
|
1222
|
+
|
|
1223
|
+
```ts
|
|
1224
|
+
case "history": {
|
|
1225
|
+
const subcommand = args[1]
|
|
1226
|
+
if (subcommand === "delete") {
|
|
1227
|
+
const id = args[2]
|
|
1228
|
+
if (!id) { console.error("Usage: min-agent history delete <id>"); process.exit(1) }
|
|
1229
|
+
const { deleteSession } = await import("./sessions.js")
|
|
1230
|
+
if (deleteSession(id)) {
|
|
1231
|
+
console.log(`✓ 会话 ${id} 已删除`)
|
|
1232
|
+
} else {
|
|
1233
|
+
console.error(`会话 ${id} 不存在`)
|
|
1234
|
+
process.exit(1)
|
|
1235
|
+
}
|
|
1236
|
+
break
|
|
1237
|
+
}
|
|
1238
|
+
if (subcommand === "rename") {
|
|
1239
|
+
const id = args[2]
|
|
1240
|
+
const title = args.slice(3).join(" ")
|
|
1241
|
+
if (!id || !title) { console.error("Usage: min-agent history rename <id> <title>"); process.exit(1) }
|
|
1242
|
+
const { renameSession } = await import("./sessions.js")
|
|
1243
|
+
if (renameSession(id, title)) {
|
|
1244
|
+
console.log(`✓ 会话 ${id} 已重命名为: ${title}`)
|
|
1245
|
+
} else {
|
|
1246
|
+
console.error(`会话 ${id} 不存在`)
|
|
1247
|
+
process.exit(1)
|
|
1248
|
+
}
|
|
1249
|
+
break
|
|
1250
|
+
}
|
|
1251
|
+
if (subcommand === "export") {
|
|
1252
|
+
const id = args[2]
|
|
1253
|
+
if (!id) { console.error("Usage: min-agent history export <id> [-o <file>]"); process.exit(1) }
|
|
1254
|
+
const { loadSession } = await import("./sessions.js")
|
|
1255
|
+
const session = loadSession(id)
|
|
1256
|
+
if (!session) {
|
|
1257
|
+
console.error(`会话 ${id} 不存在`)
|
|
1258
|
+
process.exit(1)
|
|
1259
|
+
}
|
|
1260
|
+
const json = JSON.stringify(session, null, 2)
|
|
1261
|
+
const outIdx = args.indexOf("-o")
|
|
1262
|
+
const outFile = outIdx !== -1 ? args[outIdx + 1] : undefined
|
|
1263
|
+
if (outFile) {
|
|
1264
|
+
writeFileSync(outFile, json, "utf-8")
|
|
1265
|
+
console.log(`✓ 会话 ${id} 已导出到 ${outFile}`)
|
|
1266
|
+
} else {
|
|
1267
|
+
console.log(json)
|
|
1268
|
+
}
|
|
1269
|
+
break
|
|
1270
|
+
}
|
|
1271
|
+
const { listSessions } = await import("./sessions.js")
|
|
1272
|
+
const sessions = listSessions()
|
|
1273
|
+
if (sessions.length === 0) {
|
|
1274
|
+
console.log("No saved sessions.")
|
|
1275
|
+
console.log("Sessions are auto-saved when you exit interactive chat.")
|
|
1276
|
+
} else {
|
|
1277
|
+
console.log(`Sessions (${sessions.length}):`)
|
|
1278
|
+
for (const s of sessions.slice(0, 20)) {
|
|
1279
|
+
const date = s.updated.split("T")[0]
|
|
1280
|
+
console.log(` ${s.id} ${date} ${s.title} (${s.messageCount} msgs)`)
|
|
1281
|
+
}
|
|
1282
|
+
console.log("\nResume with: min-agent chat --resume <id>")
|
|
1283
|
+
}
|
|
1284
|
+
break
|
|
1285
|
+
}
|
|
1286
|
+
```
|
|
1287
|
+
|
|
1288
|
+
(`writeFileSync` 已在 cli.ts import)
|
|
1289
|
+
|
|
1290
|
+
**e) update case**(在 `case "init"` 之前添加):
|
|
1291
|
+
|
|
1292
|
+
```ts
|
|
1293
|
+
case "update": {
|
|
1294
|
+
const { runUpdate } = await import("./updater.js")
|
|
1295
|
+
await runUpdate()
|
|
1296
|
+
break
|
|
1297
|
+
}
|
|
1298
|
+
```
|
|
1299
|
+
|
|
1300
|
+
- [ ] **Step 3: 验证**
|
|
1301
|
+
|
|
1302
|
+
Run: `bun run typecheck`(仓库根)和 `bun test`
|
|
1303
|
+
Expected: 无错误,全部通过
|
|
1304
|
+
|
|
1305
|
+
- [ ] **Step 4: 冒烟**
|
|
1306
|
+
|
|
1307
|
+
Run: `bun run src/cli.ts history delete`(应提示 Usage)与 `bun run src/cli.ts --help`
|
|
1308
|
+
Expected: 正常输出
|
|
1309
|
+
|
|
1310
|
+
- [ ] **Step 5: 提交**
|
|
1311
|
+
|
|
1312
|
+
```bash
|
|
1313
|
+
git add src/updater.ts src/cli.ts
|
|
1314
|
+
git commit -m "feat: CLI history 子命令、mcp --env 与 update 升级"
|
|
1315
|
+
```
|
|
1316
|
+
|
|
1317
|
+
---
|
|
1318
|
+
|
|
1319
|
+
## Task 7: agent.ts 采样参数 options
|
|
1320
|
+
|
|
1321
|
+
**Files:**
|
|
1322
|
+
- Modify: `src/agent.ts`
|
|
1323
|
+
|
|
1324
|
+
- [ ] **Step 1: 实现**
|
|
1325
|
+
|
|
1326
|
+
在 `RunOnceCallbacks` 接口之后添加:
|
|
1327
|
+
|
|
1328
|
+
```ts
|
|
1329
|
+
export interface RunOptions {
|
|
1330
|
+
temperature?: number
|
|
1331
|
+
maxTokens?: number
|
|
1332
|
+
topP?: number
|
|
1333
|
+
}
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
`runOnceCore` 签名加 `options` 参数(mode 之后):
|
|
1337
|
+
|
|
1338
|
+
```ts
|
|
1339
|
+
async function runOnceCore(
|
|
1340
|
+
messages: ModelMessage[],
|
|
1341
|
+
systemPrompt: string,
|
|
1342
|
+
modelId: string | undefined,
|
|
1343
|
+
abortSignal: AbortSignal | undefined,
|
|
1344
|
+
callbacks: RunOnceCallbacks | undefined,
|
|
1345
|
+
tracker: TokenTracker | undefined,
|
|
1346
|
+
mode: "chat" | "code",
|
|
1347
|
+
options?: RunOptions,
|
|
1348
|
+
): Promise<void> {
|
|
1349
|
+
```
|
|
1350
|
+
|
|
1351
|
+
`streamText` 调用加条件展开(`maxRetries` 之后、`abortSignal` 之后):
|
|
1352
|
+
|
|
1353
|
+
```ts
|
|
1354
|
+
const result = streamText({
|
|
1355
|
+
model,
|
|
1356
|
+
system: systemPrompt,
|
|
1357
|
+
messages,
|
|
1358
|
+
tools: allTools,
|
|
1359
|
+
stopWhen: stepCountIs(MAX_STEPS),
|
|
1360
|
+
maxRetries: 3,
|
|
1361
|
+
abortSignal,
|
|
1362
|
+
...(options?.temperature != null ? { temperature: options.temperature } : {}),
|
|
1363
|
+
...(options?.maxTokens != null ? { maxTokens: options.maxTokens } : {}),
|
|
1364
|
+
...(options?.topP != null ? { topP: options.topP } : {}),
|
|
1365
|
+
onStepFinish({ stepNumber }) {
|
|
1366
|
+
stepCount = stepNumber
|
|
1367
|
+
if (stepNumber >= MAX_STEPS) maxStepsReached = true
|
|
1368
|
+
},
|
|
1369
|
+
onError() {},
|
|
1370
|
+
})
|
|
1371
|
+
```
|
|
1372
|
+
|
|
1373
|
+
`runOnce` 与 `runOnceWithSystem` 加 options 参数并透传:
|
|
1374
|
+
|
|
1375
|
+
```ts
|
|
1376
|
+
export async function runOnce(
|
|
1377
|
+
messages: ModelMessage[],
|
|
1378
|
+
instructions: string[],
|
|
1379
|
+
modelId?: string,
|
|
1380
|
+
abortSignal?: AbortSignal,
|
|
1381
|
+
callbacks?: RunOnceCallbacks,
|
|
1382
|
+
tracker?: TokenTracker,
|
|
1383
|
+
options?: RunOptions,
|
|
1384
|
+
) {
|
|
1385
|
+
await runOnceCore(messages, buildSystemPrompt(instructions), modelId, abortSignal, callbacks, tracker, "chat", options)
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/** runOnce variant that accepts a pre-built system prompt (for code mode) */
|
|
1389
|
+
export async function runOnceWithSystem(
|
|
1390
|
+
messages: ModelMessage[],
|
|
1391
|
+
systemPrompt: string,
|
|
1392
|
+
modelId?: string,
|
|
1393
|
+
abortSignal?: AbortSignal,
|
|
1394
|
+
callbacks?: RunOnceCallbacks,
|
|
1395
|
+
tracker?: TokenTracker,
|
|
1396
|
+
options?: RunOptions,
|
|
1397
|
+
) {
|
|
1398
|
+
await runOnceCore(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, "code", options)
|
|
1399
|
+
}
|
|
1400
|
+
```
|
|
1401
|
+
|
|
1402
|
+
- [ ] **Step 2: 验证**
|
|
1403
|
+
|
|
1404
|
+
Run: `bun run typecheck`(仓库根)和 `bun test`
|
|
1405
|
+
Expected: 无错误,全部通过(现有调用点不传 options,行为不变)
|
|
1406
|
+
|
|
1407
|
+
- [ ] **Step 3: 提交**
|
|
1408
|
+
|
|
1409
|
+
```bash
|
|
1410
|
+
git add src/agent.ts
|
|
1411
|
+
git commit -m "feat: agent 支持采样参数透传"
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
---
|
|
1415
|
+
|
|
1416
|
+
## Task 8: serve 端点 — sessions/:id、rename、chat/undo
|
|
1417
|
+
|
|
1418
|
+
**Files:**
|
|
1419
|
+
- Modify: `src/serve.ts`
|
|
1420
|
+
- Test: `tests/serve.test.ts`
|
|
1421
|
+
|
|
1422
|
+
- [ ] **Step 1: 写失败测试**
|
|
1423
|
+
|
|
1424
|
+
在 `tests/serve.test.ts` 追加(import 加 `saveSession`,从 `../src/sessions.js`):
|
|
1425
|
+
|
|
1426
|
+
```ts
|
|
1427
|
+
import { saveSession } from "../src/sessions.js"
|
|
1428
|
+
|
|
1429
|
+
test("F6: GET /v1/sessions/:id 返回会话详情", async () => {
|
|
1430
|
+
const id = saveSession([{ role: "user", content: "hello" }])
|
|
1431
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/sessions/${id}`)
|
|
1432
|
+
expect(res.status).toBe(200)
|
|
1433
|
+
const body = await res.json()
|
|
1434
|
+
expect(body.session.meta.id).toBe(id)
|
|
1435
|
+
expect(body.session.messages).toHaveLength(1)
|
|
1436
|
+
})
|
|
1437
|
+
|
|
1438
|
+
test("F6: GET /v1/sessions/:id 不存在的会话返回 404", async () => {
|
|
1439
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/sessions/no-such-id`)
|
|
1440
|
+
expect(res.status).toBe(404)
|
|
1441
|
+
})
|
|
1442
|
+
|
|
1443
|
+
test("F6: POST /v1/sessions/:id/rename 重命名会话", async () => {
|
|
1444
|
+
const id = saveSession([{ role: "user", content: "hi" }])
|
|
1445
|
+
const { status, text } = await postJson(`/v1/sessions/${id}/rename`, { title: "renamed" })
|
|
1446
|
+
expect(status).toBe(200)
|
|
1447
|
+
expect(JSON.parse(text).title).toBe("renamed")
|
|
1448
|
+
const session = JSON.parse(await (await fetch(`http://127.0.0.1:${port}/v1/sessions/${id}`)).text())
|
|
1449
|
+
expect(session.session.meta.title).toBe("renamed")
|
|
1450
|
+
})
|
|
1451
|
+
|
|
1452
|
+
test("F6: POST /v1/sessions/:id/rename 缺 title 返回 400", async () => {
|
|
1453
|
+
const id = saveSession([{ role: "user", content: "hi" }])
|
|
1454
|
+
const { status } = await postJson(`/v1/sessions/${id}/rename`, {})
|
|
1455
|
+
expect(status).toBe(400)
|
|
1456
|
+
})
|
|
1457
|
+
|
|
1458
|
+
test("F6: POST /v1/chat/undo 截断到最后一条 user 消息", async () => {
|
|
1459
|
+
const id = saveSession([
|
|
1460
|
+
{ role: "user", content: "q1" },
|
|
1461
|
+
{ role: "assistant", content: "a1" },
|
|
1462
|
+
{ role: "user", content: "q2" },
|
|
1463
|
+
{ role: "assistant", content: "a2" },
|
|
1464
|
+
])
|
|
1465
|
+
const { status, text } = await postJson("/v1/chat/undo", { session_id: id })
|
|
1466
|
+
expect(status).toBe(200)
|
|
1467
|
+
const body = JSON.parse(text)
|
|
1468
|
+
expect(body.message_count).toBe(3)
|
|
1469
|
+
expect(body.messages[2]!.role).toBe("user")
|
|
1470
|
+
expect(body.messages[2]!.content).toBe("q2")
|
|
1471
|
+
})
|
|
1472
|
+
|
|
1473
|
+
test("F6: POST /v1/chat/undo 缺 session_id 返回 400", async () => {
|
|
1474
|
+
const { status } = await postJson("/v1/chat/undo", {})
|
|
1475
|
+
expect(status).toBe(400)
|
|
1476
|
+
})
|
|
1477
|
+
```
|
|
1478
|
+
|
|
1479
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
1480
|
+
|
|
1481
|
+
Run: `bun test tests/serve.test.ts`
|
|
1482
|
+
Expected: 新用例 FAIL(404 或 400)
|
|
1483
|
+
|
|
1484
|
+
- [ ] **Step 3: 实现 serve.ts**
|
|
1485
|
+
|
|
1486
|
+
**a)** import 加 `renameSession`:
|
|
1487
|
+
|
|
1488
|
+
```ts
|
|
1489
|
+
import { loadSession, saveSession, listSessions, deleteSession, renameSession } from "./sessions.js"
|
|
1490
|
+
```
|
|
1491
|
+
|
|
1492
|
+
**b)** 在 `DELETE /v1/sessions/` 路由之后添加:
|
|
1493
|
+
|
|
1494
|
+
```ts
|
|
1495
|
+
if (req.method === "GET" && pathname.startsWith("/v1/sessions/")) {
|
|
1496
|
+
const id = pathname.slice("/v1/sessions/".length)
|
|
1497
|
+
if (!id) { sendJson(res, 400, { error: "missing_session_id" }); return }
|
|
1498
|
+
const session = loadSession(id)
|
|
1499
|
+
if (!session) { sendJson(res, 404, { error: "session_not_found", session_id: id }); return }
|
|
1500
|
+
sendJson(res, 200, { session })
|
|
1501
|
+
return
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
if (req.method === "POST" && pathname.startsWith("/v1/sessions/") && pathname.endsWith("/rename")) {
|
|
1505
|
+
const id = pathname.slice("/v1/sessions/".length, -"/rename".length)
|
|
1506
|
+
const parsed = await readJsonBody<{ title?: string }>(req)
|
|
1507
|
+
if (!parsed.ok) { sendJsonError(res, parsed); return }
|
|
1508
|
+
const title = parsed.value.title?.trim()
|
|
1509
|
+
if (!title) { sendJson(res, 400, { error: "missing_title" }); return }
|
|
1510
|
+
if (!renameSession(id, title)) { sendJson(res, 404, { error: "session_not_found", session_id: id }); return }
|
|
1511
|
+
sendJson(res, 200, { ok: true, session_id: id, title })
|
|
1512
|
+
return
|
|
1513
|
+
}
|
|
1514
|
+
```
|
|
1515
|
+
|
|
1516
|
+
**c)** 在 `POST /v1/chat/compact` 路由附近添加 chat/undo:
|
|
1517
|
+
|
|
1518
|
+
```ts
|
|
1519
|
+
if (req.method === "POST" && pathname === "/v1/chat/undo") {
|
|
1520
|
+
const parsed = await readJsonBody<{ session_id?: string }>(req)
|
|
1521
|
+
if (!parsed.ok) { sendJsonError(res, parsed); return }
|
|
1522
|
+
const body = parsed.value
|
|
1523
|
+
if (!body.session_id) { sendJson(res, 400, { error: "missing_session_id" }); return }
|
|
1524
|
+
const session = loadSession(body.session_id)
|
|
1525
|
+
if (!session) { sendJson(res, 404, { error: "session_not_found" }); return }
|
|
1526
|
+
let userIdx = -1
|
|
1527
|
+
for (let i = session.messages.length - 1; i >= 0; i--) {
|
|
1528
|
+
if (session.messages[i]!.role === "user") {
|
|
1529
|
+
userIdx = i
|
|
1530
|
+
break
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
if (userIdx === -1) { sendJson(res, 400, { error: "no_user_message" }); return }
|
|
1534
|
+
session.messages = session.messages.slice(0, userIdx + 1)
|
|
1535
|
+
saveSession(session.messages, body.session_id)
|
|
1536
|
+
sendJson(res, 200, { ok: true, message_count: session.messages.length, messages: session.messages })
|
|
1537
|
+
return
|
|
1538
|
+
}
|
|
1539
|
+
```
|
|
1540
|
+
|
|
1541
|
+
注意:chat/undo 路由必须放在 `/v1/chat` 的 POST 精确匹配之前或之后均可(路径不同);放在 `/v1/chat/compact` 之后即可。
|
|
1542
|
+
|
|
1543
|
+
- [ ] **Step 4: 运行测试确认通过**
|
|
1544
|
+
|
|
1545
|
+
Run: `bun test tests/serve.test.ts`
|
|
1546
|
+
Expected: PASS(原有 + 6 个新用例)
|
|
1547
|
+
|
|
1548
|
+
- [ ] **Step 5: 提交**
|
|
1549
|
+
|
|
1550
|
+
```bash
|
|
1551
|
+
git add src/serve.ts tests/serve.test.ts
|
|
1552
|
+
git commit -m "feat: API sessions 详情/重命名与会话撤销端点"
|
|
1553
|
+
```
|
|
1554
|
+
|
|
1555
|
+
---
|
|
1556
|
+
|
|
1557
|
+
## Task 9: serve 端点 — skills/mcp toggle、paste code
|
|
1558
|
+
|
|
1559
|
+
**Files:**
|
|
1560
|
+
- Modify: `src/serve.ts`
|
|
1561
|
+
- Test: `tests/serve.test.ts`
|
|
1562
|
+
|
|
1563
|
+
- [ ] **Step 1: 写失败测试**
|
|
1564
|
+
|
|
1565
|
+
在 `tests/serve.test.ts` 末尾追加:
|
|
1566
|
+
|
|
1567
|
+
```ts
|
|
1568
|
+
test("F7: POST /v1/skills/:name/disable 与 enable", async () => {
|
|
1569
|
+
const { status } = await postJson("/v1/skills/my-skill/disable", {})
|
|
1570
|
+
expect(status).toBe(200)
|
|
1571
|
+
const config = JSON.parse(require("fs").readFileSync(path.join(configDir, "config.json"), "utf-8"))
|
|
1572
|
+
expect(config.disabledSkills).toContain("my-skill")
|
|
1573
|
+
await postJson("/v1/skills/my-skill/enable", {})
|
|
1574
|
+
const config2 = JSON.parse(require("fs").readFileSync(path.join(configDir, "config.json"), "utf-8"))
|
|
1575
|
+
expect(config2.disabledSkills ?? []).not.toContain("my-skill")
|
|
1576
|
+
})
|
|
1577
|
+
|
|
1578
|
+
test("F7: POST /v1/mcp/:name/disable 与 enable", async () => {
|
|
1579
|
+
const { loadMcpConfig, saveMcpConfig } = await import("../src/mcp.js")
|
|
1580
|
+
const cfg = loadMcpConfig()
|
|
1581
|
+
cfg.mcpServers["test-server"] = { command: ["echo", "hi"], enabled: true }
|
|
1582
|
+
saveMcpConfig(cfg)
|
|
1583
|
+
const { status } = await postJson("/v1/mcp/test-server/disable", {})
|
|
1584
|
+
expect(status).toBe(200)
|
|
1585
|
+
expect(loadMcpConfig().mcpServers["test-server"]!.enabled).toBe(false)
|
|
1586
|
+
await postJson("/v1/mcp/test-server/enable", {})
|
|
1587
|
+
expect(loadMcpConfig().mcpServers["test-server"]!.enabled).toBe(true)
|
|
1588
|
+
})
|
|
1589
|
+
|
|
1590
|
+
test("F7: POST /v1/mcp/:name/disable 不存在的服务器返回 404", async () => {
|
|
1591
|
+
const { status } = await postJson("/v1/mcp/no-such-server/disable", {})
|
|
1592
|
+
expect(status).toBe(404)
|
|
1593
|
+
})
|
|
1594
|
+
|
|
1595
|
+
test("F7: POST /v1/paste 支持 code 模式", async () => {
|
|
1596
|
+
const { status, text } = await postJson("/v1/paste", {
|
|
1597
|
+
image_base64: PNG_B64,
|
|
1598
|
+
message: "分析",
|
|
1599
|
+
code: true,
|
|
1600
|
+
stream: false,
|
|
1601
|
+
})
|
|
1602
|
+
expect(status).toBe(200)
|
|
1603
|
+
const body = JSON.parse(text)
|
|
1604
|
+
expect(body.mode).toBe("code")
|
|
1605
|
+
expect(body.project).toBeTruthy()
|
|
1606
|
+
})
|
|
1607
|
+
```
|
|
1608
|
+
|
|
1609
|
+
注意:测试中避免 `require`,改用顶部已 import 的 `readFileSync`(`tests/serve.test.ts` 顶部已 import `readFileSync` from "fs")。skills 测试改用:
|
|
1610
|
+
|
|
1611
|
+
```ts
|
|
1612
|
+
test("F7: POST /v1/skills/:name/disable 与 enable", async () => {
|
|
1613
|
+
const { status } = await postJson("/v1/skills/my-skill/disable", {})
|
|
1614
|
+
expect(status).toBe(200)
|
|
1615
|
+
const config = JSON.parse(readFileSync(path.join(configDir, "config.json"), "utf-8"))
|
|
1616
|
+
expect(config.disabledSkills).toContain("my-skill")
|
|
1617
|
+
await postJson("/v1/skills/my-skill/enable", {})
|
|
1618
|
+
const config2 = JSON.parse(readFileSync(path.join(configDir, "config.json"), "utf-8"))
|
|
1619
|
+
expect(config2.disabledSkills ?? []).not.toContain("my-skill")
|
|
1620
|
+
})
|
|
1621
|
+
```
|
|
1622
|
+
|
|
1623
|
+
(注意:config.json 的读取与 saveConfig 的缓存——serve 进程的 loadConfig 有 mtime 缓存,测试进程直接读文件断言即可)
|
|
1624
|
+
|
|
1625
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
1626
|
+
|
|
1627
|
+
Run: `bun test tests/serve.test.ts`
|
|
1628
|
+
Expected: 新用例 FAIL(404)
|
|
1629
|
+
|
|
1630
|
+
- [ ] **Step 3: 实现 serve.ts**
|
|
1631
|
+
|
|
1632
|
+
**a)** 在 skills 路由(`GET /v1/skills`)之后添加:
|
|
1633
|
+
|
|
1634
|
+
```ts
|
|
1635
|
+
const skillToggle = pathname.match(/^\/v1\/skills\/([^/]+)\/(enable|disable)$/)
|
|
1636
|
+
if (req.method === "POST" && skillToggle) {
|
|
1637
|
+
const name = decodeURIComponent(skillToggle[1]!)
|
|
1638
|
+
const enabled = skillToggle[2] === "enable"
|
|
1639
|
+
const config = loadConfig()
|
|
1640
|
+
const disabled = new Set(config.disabledSkills ?? [])
|
|
1641
|
+
if (enabled) disabled.delete(name)
|
|
1642
|
+
else disabled.add(name)
|
|
1643
|
+
config.disabledSkills = [...disabled]
|
|
1644
|
+
saveConfig(config)
|
|
1645
|
+
sendJson(res, 200, { ok: true, name, enabled })
|
|
1646
|
+
return
|
|
1647
|
+
}
|
|
1648
|
+
```
|
|
1649
|
+
|
|
1650
|
+
(需要 `saveConfig` import:`import { loadConfig, saveConfig, fetchModels, isConfigured } from "./config.js"`)
|
|
1651
|
+
|
|
1652
|
+
**b)** 在 MCP DELETE 路由之后添加:
|
|
1653
|
+
|
|
1654
|
+
```ts
|
|
1655
|
+
const mcpToggle = pathname.match(/^\/v1\/mcp\/([^/]+)\/(enable|disable)$/)
|
|
1656
|
+
if (req.method === "POST" && mcpToggle) {
|
|
1657
|
+
const name = decodeURIComponent(mcpToggle[1]!)
|
|
1658
|
+
const enabled = mcpToggle[2] === "enable"
|
|
1659
|
+
const { loadMcpConfig, saveMcpConfig } = await import("./mcp.js")
|
|
1660
|
+
const config = loadMcpConfig()
|
|
1661
|
+
if (!config.mcpServers[name]) { sendJson(res, 404, { error: "mcp_not_found", name }); return }
|
|
1662
|
+
config.mcpServers[name]!.enabled = enabled
|
|
1663
|
+
saveMcpConfig(config)
|
|
1664
|
+
sendJson(res, 200, { ok: true, name, enabled })
|
|
1665
|
+
return
|
|
1666
|
+
}
|
|
1667
|
+
```
|
|
1668
|
+
|
|
1669
|
+
**c)** /v1/paste 支持 code 模式:
|
|
1670
|
+
|
|
1671
|
+
paste 的 body 类型与提取处(约 575 行)改为:
|
|
1672
|
+
|
|
1673
|
+
```ts
|
|
1674
|
+
const parsed = await readJsonBody<{ image_base64?: string; mime_type?: string; message?: string; session_id?: string; model?: string; stream?: boolean; code?: boolean; temperature?: number; maxTokens?: number; topP?: number }>(req)
|
|
1675
|
+
```
|
|
1676
|
+
|
|
1677
|
+
提取处添加:
|
|
1678
|
+
|
|
1679
|
+
```ts
|
|
1680
|
+
const codeMode = body.code === true
|
|
1681
|
+
```
|
|
1682
|
+
|
|
1683
|
+
流式分支:
|
|
1684
|
+
|
|
1685
|
+
```ts
|
|
1686
|
+
if (stream) {
|
|
1687
|
+
res.writeHead(200, sseHeaders(c))
|
|
1688
|
+
res.flushHeaders?.()
|
|
1689
|
+
const callbacks = createSseCallbacks(res, messages, { sessionId })
|
|
1690
|
+
try {
|
|
1691
|
+
if (codeMode) {
|
|
1692
|
+
const project = scanProject()
|
|
1693
|
+
const system = buildCodeSystemPrompt(project, instructions)
|
|
1694
|
+
await runOnceWithSystem(messages, system, modelId, abort.signal, callbacks)
|
|
1695
|
+
} else {
|
|
1696
|
+
await runOnce(messages, instructions, modelId, abort.signal, callbacks)
|
|
1697
|
+
}
|
|
1698
|
+
} catch (err: any) {
|
|
1699
|
+
if (!res.writableEnded) {
|
|
1700
|
+
sseWrite(res, { type: "fatal", message: err?.message ?? String(err) })
|
|
1701
|
+
res.end()
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
} else {
|
|
1705
|
+
const { callbacks, toolCalls, toolResults, finishInfo } = createJsonCallbacks()
|
|
1706
|
+
if (codeMode) {
|
|
1707
|
+
const project = scanProject()
|
|
1708
|
+
const system = buildCodeSystemPrompt(project, instructions)
|
|
1709
|
+
await runOnceWithSystem(messages, system, modelId, abort.signal, callbacks)
|
|
1710
|
+
sendJson(res, 200, {
|
|
1711
|
+
messages,
|
|
1712
|
+
assistant: [...messages].reverse().find((m) => m.role === "assistant") ?? null,
|
|
1713
|
+
tool_calls: toolCalls,
|
|
1714
|
+
tool_results: toolResults,
|
|
1715
|
+
session_id: sessionId ? saveSessionSafe(messages, sessionId) : undefined,
|
|
1716
|
+
step_count: finishInfo()?.stepCount ?? 0,
|
|
1717
|
+
usage: finishInfo()?.usage ?? null,
|
|
1718
|
+
has_error: finishInfo()?.hasError ?? false,
|
|
1719
|
+
aborted: finishInfo()?.aborted ?? false,
|
|
1720
|
+
max_steps_reached: finishInfo()?.maxStepsReached ?? false,
|
|
1721
|
+
mode: "code",
|
|
1722
|
+
project,
|
|
1723
|
+
})
|
|
1724
|
+
} else {
|
|
1725
|
+
await runOnce(messages, instructions, modelId, abort.signal, callbacks)
|
|
1726
|
+
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")
|
|
1727
|
+
let saved: string | undefined
|
|
1728
|
+
if (sessionId) { try { saved = saveSession(messages, sessionId) } catch {} }
|
|
1729
|
+
const fi = finishInfo()
|
|
1730
|
+
sendJson(res, 200, {
|
|
1731
|
+
messages,
|
|
1732
|
+
assistant: lastAssistant ?? null,
|
|
1733
|
+
tool_calls: toolCalls,
|
|
1734
|
+
tool_results: toolResults,
|
|
1735
|
+
session_id: saved,
|
|
1736
|
+
step_count: fi?.stepCount ?? 0,
|
|
1737
|
+
usage: fi?.usage ?? null,
|
|
1738
|
+
has_error: fi?.hasError ?? false,
|
|
1739
|
+
aborted: fi?.aborted ?? false,
|
|
1740
|
+
max_steps_reached: fi?.maxStepsReached ?? false,
|
|
1741
|
+
})
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
```
|
|
1745
|
+
|
|
1746
|
+
注意:上述代码中 `saveSessionSafe` 是占位——**实际实现**保持与现有一致的保存逻辑(`if (sessionId) { try { saved = saveSession(messages, sessionId) } catch {} }`),并让 `assistant` 变量在分支前提取。为避免分支重复,重写整个 paste 非流式部分为:
|
|
1747
|
+
|
|
1748
|
+
```ts
|
|
1749
|
+
} else {
|
|
1750
|
+
const { callbacks, toolCalls, toolResults, finishInfo } = createJsonCallbacks()
|
|
1751
|
+
let project: ProjectInfo | undefined
|
|
1752
|
+
if (codeMode) {
|
|
1753
|
+
project = scanProject()
|
|
1754
|
+
const system = buildCodeSystemPrompt(project, instructions)
|
|
1755
|
+
await runOnceWithSystem(messages, system, modelId, abort.signal, callbacks)
|
|
1756
|
+
} else {
|
|
1757
|
+
await runOnce(messages, instructions, modelId, abort.signal, callbacks)
|
|
1758
|
+
}
|
|
1759
|
+
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")
|
|
1760
|
+
let saved: string | undefined
|
|
1761
|
+
if (sessionId) { try { saved = saveSession(messages, sessionId) } catch {} }
|
|
1762
|
+
const fi = finishInfo()
|
|
1763
|
+
sendJson(res, 200, {
|
|
1764
|
+
messages,
|
|
1765
|
+
assistant: lastAssistant ?? null,
|
|
1766
|
+
tool_calls: toolCalls,
|
|
1767
|
+
tool_results: toolResults,
|
|
1768
|
+
session_id: saved,
|
|
1769
|
+
step_count: fi?.stepCount ?? 0,
|
|
1770
|
+
usage: fi?.usage ?? null,
|
|
1771
|
+
has_error: fi?.hasError ?? false,
|
|
1772
|
+
aborted: fi?.aborted ?? false,
|
|
1773
|
+
max_steps_reached: fi?.maxStepsReached ?? false,
|
|
1774
|
+
...(codeMode ? { mode: "code", project } : {}),
|
|
1775
|
+
})
|
|
1776
|
+
}
|
|
1777
|
+
```
|
|
1778
|
+
|
|
1779
|
+
流式分支同样用 project 变量(code 时)并保持 createSseCallbacks 的 opts 不带 project(paste 不做 session save 变更)。**以这个最终版本为准**。
|
|
1780
|
+
|
|
1781
|
+
(`ProjectInfo` 类型已在 serve.ts import 中:`import { scanProject, buildCodeSystemPrompt, type ProjectInfo } from "./code-mode.js"`)
|
|
1782
|
+
|
|
1783
|
+
- [ ] **Step 4: 运行测试确认通过**
|
|
1784
|
+
|
|
1785
|
+
Run: `bun test tests/serve.test.ts`
|
|
1786
|
+
Expected: PASS(原有 + 4 个新用例)
|
|
1787
|
+
|
|
1788
|
+
- [ ] **Step 5: 提交**
|
|
1789
|
+
|
|
1790
|
+
```bash
|
|
1791
|
+
git add src/serve.ts tests/serve.test.ts
|
|
1792
|
+
git commit -m "feat: API skills/mcp 启停端点与 paste code 模式"
|
|
1793
|
+
```
|
|
1794
|
+
|
|
1795
|
+
---
|
|
1796
|
+
|
|
1797
|
+
## Task 10: serve 端点 — GET /v1/cost + 采样参数透传
|
|
1798
|
+
|
|
1799
|
+
**Files:**
|
|
1800
|
+
- Modify: `src/serve.ts`
|
|
1801
|
+
- Modify: `src/agent.ts`(Task 7 已完成,此任务只需 serve 透传)
|
|
1802
|
+
- Test: `tests/serve.test.ts`
|
|
1803
|
+
|
|
1804
|
+
- [ ] **Step 1: 写失败测试**
|
|
1805
|
+
|
|
1806
|
+
在 `tests/serve.test.ts` 末尾追加:
|
|
1807
|
+
|
|
1808
|
+
```ts
|
|
1809
|
+
test("F8: GET /v1/cost 返回价格与成本", async () => {
|
|
1810
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/cost?model=${BAD_MODEL}&input=1000000&output=500000`)
|
|
1811
|
+
expect(res.status).toBe(200)
|
|
1812
|
+
const body = await res.json()
|
|
1813
|
+
expect(body.model).toBe(BAD_MODEL)
|
|
1814
|
+
expect(body.cost_usd).toBe(20)
|
|
1815
|
+
})
|
|
1816
|
+
|
|
1817
|
+
test("F8: /v1/chat 接受采样参数不报错", async () => {
|
|
1818
|
+
const { status } = await postJson("/v1/chat", {
|
|
1819
|
+
message: "say hi",
|
|
1820
|
+
stream: false,
|
|
1821
|
+
temperature: 0.5,
|
|
1822
|
+
maxTokens: 100,
|
|
1823
|
+
topP: 0.9,
|
|
1824
|
+
})
|
|
1825
|
+
expect(status).toBe(200)
|
|
1826
|
+
})
|
|
1827
|
+
```
|
|
1828
|
+
|
|
1829
|
+
注意:cost 测试需要 config.json 提供 pricing 覆盖(避免网络)。在 `beforeAll` 的 config.json 写入中加入:
|
|
1830
|
+
|
|
1831
|
+
```ts
|
|
1832
|
+
writeFileSync(
|
|
1833
|
+
path.join(configDir, "config.json"),
|
|
1834
|
+
JSON.stringify({
|
|
1835
|
+
provider: { baseURL: "http://127.0.0.1:1/v1", apiKey: "test", defaultModel: BAD_MODEL },
|
|
1836
|
+
pricing: { [BAD_MODEL]: { inputPerMillion: 10, outputPerMillion: 20 } },
|
|
1837
|
+
}),
|
|
1838
|
+
)
|
|
1839
|
+
```
|
|
1840
|
+
|
|
1841
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
1842
|
+
|
|
1843
|
+
Run: `bun test tests/serve.test.ts`
|
|
1844
|
+
Expected: 新用例 FAIL(404 或 cost_usd 为 null)
|
|
1845
|
+
|
|
1846
|
+
- [ ] **Step 3: 实现 serve.ts**
|
|
1847
|
+
|
|
1848
|
+
**a)** import 加 pricing:
|
|
1849
|
+
|
|
1850
|
+
```ts
|
|
1851
|
+
import { getModelPrice, estimateCost } from "./pricing.js"
|
|
1852
|
+
```
|
|
1853
|
+
|
|
1854
|
+
**b)** 在 `/v1/context` 路由之后添加 cost:
|
|
1855
|
+
|
|
1856
|
+
```ts
|
|
1857
|
+
if (req.method === "GET" && pathname === "/v1/cost") {
|
|
1858
|
+
const config = loadConfig()
|
|
1859
|
+
const modelId = url.searchParams.get("model") ?? config.provider?.defaultModel ?? null
|
|
1860
|
+
const input = parseInt(url.searchParams.get("input") ?? "0", 10)
|
|
1861
|
+
const output = parseInt(url.searchParams.get("output") ?? "0", 10)
|
|
1862
|
+
const price = modelId ? await getModelPrice(modelId) : null
|
|
1863
|
+
const cost = price ? estimateCost({ inputTokens: input, outputTokens: output }, price) : null
|
|
1864
|
+
sendJson(res, 200, { model: modelId, price, cost_usd: cost })
|
|
1865
|
+
return
|
|
1866
|
+
}
|
|
1867
|
+
```
|
|
1868
|
+
|
|
1869
|
+
**c)** ChatBody 加采样参数(约 233 行):
|
|
1870
|
+
|
|
1871
|
+
```ts
|
|
1872
|
+
interface ChatBody {
|
|
1873
|
+
message?: string
|
|
1874
|
+
messages?: ModelMessage[]
|
|
1875
|
+
model?: string
|
|
1876
|
+
stream?: boolean
|
|
1877
|
+
session_id?: string
|
|
1878
|
+
images?: string[]
|
|
1879
|
+
temperature?: number
|
|
1880
|
+
maxTokens?: number
|
|
1881
|
+
topP?: number
|
|
1882
|
+
}
|
|
1883
|
+
```
|
|
1884
|
+
|
|
1885
|
+
**d)** handleChatRequest 中提取 runOptions(在 `abort` 定义之后):
|
|
1886
|
+
|
|
1887
|
+
```ts
|
|
1888
|
+
const runOptions: { temperature?: number; maxTokens?: number; topP?: number } = {
|
|
1889
|
+
temperature: typeof body.temperature === "number" ? body.temperature : undefined,
|
|
1890
|
+
maxTokens: typeof body.maxTokens === "number" ? body.maxTokens : undefined,
|
|
1891
|
+
topP: typeof body.topP === "number" ? body.topP : undefined,
|
|
1892
|
+
}
|
|
1893
|
+
```
|
|
1894
|
+
|
|
1895
|
+
`run` 函数改为:
|
|
1896
|
+
|
|
1897
|
+
```ts
|
|
1898
|
+
const run = (callbacks: RunOnceCallbacks | undefined) =>
|
|
1899
|
+
codeMode
|
|
1900
|
+
? runOnceWithSystem(messages, codeSystemPrompt!, modelId, abort.signal, callbacks, undefined, runOptions)
|
|
1901
|
+
: runOnce(messages, instructions, modelId, abort.signal, callbacks, undefined, runOptions)
|
|
1902
|
+
```
|
|
1903
|
+
|
|
1904
|
+
**e)** paste 也透传采样参数:paste 分支的 runOnce 调用加 `undefined, runOptions`(paste 的 body 类型已在 Task 9 扩展)。在 paste 分支提取处添加:
|
|
1905
|
+
|
|
1906
|
+
```ts
|
|
1907
|
+
const runOptions: { temperature?: number; maxTokens?: number; topP?: number } = {
|
|
1908
|
+
temperature: typeof body.temperature === "number" ? body.temperature : undefined,
|
|
1909
|
+
maxTokens: typeof body.maxTokens === "number" ? body.maxTokens : undefined,
|
|
1910
|
+
topP: typeof body.topP === "number" ? body.topP : undefined,
|
|
1911
|
+
}
|
|
1912
|
+
```
|
|
1913
|
+
|
|
1914
|
+
流式与 JSON 分支的 runOnce/runOnceWithSystem 调用加 `undefined, runOptions`。
|
|
1915
|
+
|
|
1916
|
+
- [ ] **Step 4: 运行测试确认通过**
|
|
1917
|
+
|
|
1918
|
+
Run: `bun test tests/serve.test.ts`
|
|
1919
|
+
Expected: PASS(原有 + 2 个新用例)
|
|
1920
|
+
|
|
1921
|
+
- [ ] **Step 5: 提交**
|
|
1922
|
+
|
|
1923
|
+
```bash
|
|
1924
|
+
git add src/serve.ts tests/serve.test.ts
|
|
1925
|
+
git commit -m "feat: API 成本端点与采样参数透传"
|
|
1926
|
+
```
|
|
1927
|
+
|
|
1928
|
+
---
|
|
1929
|
+
|
|
1930
|
+
## Task 11: 文档同步(API.md、README、usage)
|
|
1931
|
+
|
|
1932
|
+
**Files:**
|
|
1933
|
+
- Modify: `docs/API.md`
|
|
1934
|
+
- Modify: `README.md`
|
|
1935
|
+
|
|
1936
|
+
- [ ] **Step 1: 更新 API.md**
|
|
1937
|
+
|
|
1938
|
+
**a)** 端点一览表追加:
|
|
1939
|
+
|
|
1940
|
+
```markdown
|
|
1941
|
+
| GET | `/v1/sessions/:id` | 会话详情 |
|
|
1942
|
+
| POST | `/v1/sessions/:id/rename` | 重命名会话 |
|
|
1943
|
+
| POST | `/v1/chat/undo` | 撤销最后一轮 |
|
|
1944
|
+
| POST | `/v1/skills/:name/enable` | 启用技能 |
|
|
1945
|
+
| POST | `/v1/skills/:name/disable` | 禁用技能 |
|
|
1946
|
+
| POST | `/v1/mcp/:name/enable` | 启用 MCP 服务器 |
|
|
1947
|
+
| POST | `/v1/mcp/:name/disable` | 禁用 MCP 服务器 |
|
|
1948
|
+
| GET | `/v1/cost` | 模型价格与成本估算 |
|
|
1949
|
+
```
|
|
1950
|
+
|
|
1951
|
+
**b)** `POST /v1/chat` 请求字段表追加:
|
|
1952
|
+
|
|
1953
|
+
```markdown
|
|
1954
|
+
| `temperature` | number | 采样温度(透传模型 API) |
|
|
1955
|
+
| `maxTokens` | number | 最大输出 token 数 |
|
|
1956
|
+
| `topP` | number | 核采样参数 |
|
|
1957
|
+
```
|
|
1958
|
+
|
|
1959
|
+
**c)** `POST /v1/paste` 请求字段表追加:
|
|
1960
|
+
|
|
1961
|
+
```markdown
|
|
1962
|
+
| `code` | boolean | 使用 code 模式(项目感知) |
|
|
1963
|
+
| `temperature` | number | 采样温度 |
|
|
1964
|
+
| `maxTokens` | number | 最大输出 token 数 |
|
|
1965
|
+
| `topP` | number | 核采样参数 |
|
|
1966
|
+
```
|
|
1967
|
+
|
|
1968
|
+
code 模式的响应说明追加:"`code: true` 时响应额外包含 `mode: "code"` 与 `project` 字段。"
|
|
1969
|
+
|
|
1970
|
+
**d)** 新增端点说明章节(sessions/rename/undo/skills toggle/mcp toggle/cost),参考现有章节格式:
|
|
1971
|
+
|
|
1972
|
+
```markdown
|
|
1973
|
+
## `GET /v1/sessions/:id`
|
|
1974
|
+
|
|
1975
|
+
```json
|
|
1976
|
+
{ "session": { "meta": { "id": "abc", "title": "...", ... }, "messages": [...] } }
|
|
1977
|
+
```
|
|
1978
|
+
|
|
1979
|
+
## `POST /v1/sessions/:id/rename`
|
|
1980
|
+
|
|
1981
|
+
```json
|
|
1982
|
+
// 请求
|
|
1983
|
+
{ "title": "new title" }
|
|
1984
|
+
// 响应
|
|
1985
|
+
{ "ok": true, "session_id": "abc", "title": "new title" }
|
|
1986
|
+
```
|
|
1987
|
+
|
|
1988
|
+
## `POST /v1/chat/undo`
|
|
1989
|
+
|
|
1990
|
+
```json
|
|
1991
|
+
// 请求
|
|
1992
|
+
{ "session_id": "abc123" }
|
|
1993
|
+
// 响应
|
|
1994
|
+
{ "ok": true, "message_count": 3, "messages": [...] }
|
|
1995
|
+
```
|
|
1996
|
+
|
|
1997
|
+
截断到最后一条 user 消息(保留该消息,删除其后的 assistant/tool 消息)。
|
|
1998
|
+
|
|
1999
|
+
## `POST /v1/skills/:name/enable` / `/disable`
|
|
2000
|
+
|
|
2001
|
+
```json
|
|
2002
|
+
{ "ok": true, "name": "my-skill", "enabled": false }
|
|
2003
|
+
```
|
|
2004
|
+
|
|
2005
|
+
## `POST /v1/mcp/:name/enable` / `/disable`
|
|
2006
|
+
|
|
2007
|
+
```json
|
|
2008
|
+
{ "ok": true, "name": "fs", "enabled": true }
|
|
2009
|
+
```
|
|
2010
|
+
|
|
2011
|
+
## `GET /v1/cost`
|
|
2012
|
+
|
|
2013
|
+
| 参数 | 说明 |
|
|
2014
|
+
|------|------|
|
|
2015
|
+
| `model` | 模型 ID(默认配置默认模型) |
|
|
2016
|
+
| `input` | 输入 token 数 |
|
|
2017
|
+
| `output` | 输出 token 数 |
|
|
2018
|
+
|
|
2019
|
+
```json
|
|
2020
|
+
{ "model": "gpt-4o", "price": { "inputPerMillion": 2.5, "outputPerMillion": 10 }, "cost_usd": 7.5 }
|
|
2021
|
+
```
|
|
2022
|
+
```
|
|
2023
|
+
|
|
2024
|
+
- [ ] **Step 2: 更新 README.md**
|
|
2025
|
+
|
|
2026
|
+
Commands 区(约 27-40 行)更新:
|
|
2027
|
+
|
|
2028
|
+
```markdown
|
|
2029
|
+
min-agent history # List saved sessions
|
|
2030
|
+
min-agent history delete <id> # Delete a session
|
|
2031
|
+
min-agent history rename <id> <t> # Rename a session
|
|
2032
|
+
min-agent history export <id> [-o file] # Export a session as JSON
|
|
2033
|
+
min-agent update # Check for updates and upgrade
|
|
2034
|
+
```
|
|
2035
|
+
|
|
2036
|
+
MCP 区补充 --env 示例:
|
|
2037
|
+
|
|
2038
|
+
```markdown
|
|
2039
|
+
min-agent mcp add <name> <cmd...> --env KEY=VALUE # Local stdio with env vars
|
|
2040
|
+
```
|
|
2041
|
+
|
|
2042
|
+
- [ ] **Step 3: 提交**
|
|
2043
|
+
|
|
2044
|
+
```bash
|
|
2045
|
+
git add docs/API.md README.md
|
|
2046
|
+
git commit -m "docs: 第二批 API 与 CLI 文档同步"
|
|
2047
|
+
```
|
|
2048
|
+
|
|
2049
|
+
---
|
|
2050
|
+
|
|
2051
|
+
## Task 12: 全量验证 + 冒烟
|
|
2052
|
+
|
|
2053
|
+
- [ ] **Step 1: 类型检查**
|
|
2054
|
+
|
|
2055
|
+
Run: `bun run typecheck`(仓库根)
|
|
2056
|
+
Expected: 无错误
|
|
2057
|
+
|
|
2058
|
+
- [ ] **Step 2: 全量测试**
|
|
2059
|
+
|
|
2060
|
+
Run: `bun test`
|
|
2061
|
+
Expected: 全部通过
|
|
2062
|
+
|
|
2063
|
+
- [ ] **Step 3: 手动冒烟**
|
|
2064
|
+
|
|
2065
|
+
Run:
|
|
2066
|
+
```bash
|
|
2067
|
+
bun run src/cli.ts --help
|
|
2068
|
+
bun run src/cli.ts history export no-such-id
|
|
2069
|
+
bun run src/cli.ts history delete no-such-id
|
|
2070
|
+
bun run src/cli.ts mcp add test echo hi --env FOO=bar --skip-check
|
|
2071
|
+
bun run src/cli.ts mcp info test
|
|
2072
|
+
bun run src/cli.ts mcp remove test
|
|
2073
|
+
```
|
|
2074
|
+
|
|
2075
|
+
预期:help 含新命令;export/delete 报"会话不存在" exit 1;mcp add 显示 `(0 tools detected)` 且 info 显示 Env: FOO;remove 成功。
|
|
2076
|
+
|
|
2077
|
+
- [ ] **Step 4: 提交(如有遗留改动)**
|
|
2078
|
+
|
|
2079
|
+
```bash
|
|
2080
|
+
git add -A
|
|
2081
|
+
git commit -m "chore: 第二批验证收尾"
|
|
2082
|
+
```
|
|
2083
|
+
|
|
2084
|
+
---
|
|
2085
|
+
|
|
2086
|
+
## 自审记录
|
|
2087
|
+
|
|
2088
|
+
**Spec 覆盖:**
|
|
2089
|
+
- history delete/rename/export → Task 6
|
|
2090
|
+
- mcp add --env → Task 6
|
|
2091
|
+
- update 自动升级 → Task 6(updater.ts)
|
|
2092
|
+
- bash cwd → Task 1
|
|
2093
|
+
- apply_patch → Task 3
|
|
2094
|
+
- write append → Task 2
|
|
2095
|
+
- web_search 参数 → Task 4
|
|
2096
|
+
- chat 模式工具 → Task 5
|
|
2097
|
+
- GET /v1/sessions/:id、rename → Task 8
|
|
2098
|
+
- skills/mcp enable-disable → Task 9
|
|
2099
|
+
- paste code → Task 9
|
|
2100
|
+
- GET /v1/cost → Task 10
|
|
2101
|
+
- POST /v1/chat/undo → Task 8
|
|
2102
|
+
- 采样参数透传 → Task 7(agent)+ Task 10(serve)
|
|
2103
|
+
- 文档同步 → Task 11
|
|
2104
|
+
|
|
2105
|
+
**类型一致性:** `RunOptions` 在 Task 7 定义并被 Task 10 使用;`buildPatchedFiles`/`parseUnifiedDiff` 在 Task 3 定义并被测试使用;`PatchResult` 判别联合类型一致。
|