thincoder 0.7.8 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +32 -13
  2. package/bin/thincoder.js +4 -0
  3. package/bin/thincoder.mjs +27 -346
  4. package/package.json +2 -2
  5. package/src/agent/dispatch.mjs +98 -0
  6. package/src/agent/helpers.mjs +185 -0
  7. package/src/agent/setup.mjs +117 -0
  8. package/src/agent-tools/goal.mjs +71 -0
  9. package/src/agent-tools/plan.mjs +31 -0
  10. package/src/agent-tools/recent-changes.mjs +23 -0
  11. package/src/agent-tools/skill.mjs +46 -0
  12. package/src/agent-tools/subagent.mjs +113 -0
  13. package/src/agent-tools/task.mjs +67 -0
  14. package/src/agent-tools/verify.mjs +198 -0
  15. package/src/agent-tools.mjs +12 -0
  16. package/src/agent.mjs +90 -1040
  17. package/src/cli/distill-command.mjs +85 -0
  18. package/src/cli/make-agent.mjs +85 -0
  19. package/src/cli/memory-command.mjs +63 -0
  20. package/src/cli/permission.mjs +41 -0
  21. package/src/cli/setup-wizard.mjs +70 -0
  22. package/src/config.mjs +5 -8
  23. package/src/context.mjs +10 -13
  24. package/src/distill.mjs +4 -3
  25. package/src/embedding.mjs +4 -2
  26. package/src/{checkpoint.mjs → git/checkpoint.mjs} +1 -1
  27. package/src/mcp/helpers.mjs +37 -0
  28. package/src/mcp/transport-http.mjs +176 -0
  29. package/src/mcp/transport-stdio.mjs +84 -0
  30. package/src/mcp/transport-ws.mjs +87 -0
  31. package/src/mcp.mjs +4 -428
  32. package/src/memory/code-index.mjs +211 -0
  33. package/src/memory/code-sync.mjs +306 -0
  34. package/src/memory/core.mjs +277 -0
  35. package/src/memory/docs.mjs +262 -0
  36. package/src/memory/schema.mjs +426 -0
  37. package/src/memory.mjs +12 -1403
  38. package/src/provider/core.mjs +239 -0
  39. package/src/provider/index.mjs +6 -0
  40. package/src/provider/rate.mjs +104 -0
  41. package/src/session.mjs +18 -5
  42. package/src/tools/bash.mjs +144 -0
  43. package/src/tools/file.mjs +205 -0
  44. package/src/tools/git.mjs +166 -0
  45. package/src/tools/glob.mjs +51 -0
  46. package/src/tools/grep.mjs +100 -0
  47. package/src/tools/index.mjs +22 -0
  48. package/src/tools/ls.mjs +36 -0
  49. package/src/tools/patch.mjs +226 -0
  50. package/src/tools/repomap-parse.mjs +168 -0
  51. package/src/tools/shared.mjs +257 -0
  52. package/src/tools/system.mjs +336 -0
  53. package/src/tools/web.mjs +121 -0
  54. package/src/tools.mjs +2 -1194
  55. package/src/tui/agent-turn.mjs +254 -0
  56. package/src/tui/ansi.mjs +32 -0
  57. package/src/tui/clipboard.mjs +48 -0
  58. package/src/tui/cmd-auto.mjs +21 -0
  59. package/src/tui/cmd-clear.mjs +26 -0
  60. package/src/tui/cmd-config.mjs +72 -0
  61. package/src/tui/cmd-exit.mjs +5 -0
  62. package/src/tui/cmd-extract.mjs +5 -0
  63. package/src/tui/cmd-goal.mjs +47 -0
  64. package/src/tui/cmd-help.mjs +25 -0
  65. package/src/tui/cmd-init.mjs +91 -0
  66. package/src/tui/cmd-mcp.mjs +146 -0
  67. package/src/tui/cmd-model.mjs +7 -0
  68. package/src/tui/cmd-new.mjs +18 -0
  69. package/src/tui/cmd-plan.mjs +21 -0
  70. package/src/tui/cmd-reindex.mjs +44 -0
  71. package/src/tui/cmd-restore.mjs +39 -0
  72. package/src/tui/cmd-session.mjs +42 -0
  73. package/src/tui/cmd-skills.mjs +17 -0
  74. package/src/tui/cmd-think.mjs +56 -0
  75. package/src/tui/config-helpers.mjs +34 -0
  76. package/src/tui/distill-cmd.mjs +45 -0
  77. package/src/tui/index.mjs +330 -0
  78. package/src/tui/interaction.mjs +79 -0
  79. package/src/tui/key-handler.mjs +267 -0
  80. package/src/tui/layout.mjs +115 -0
  81. package/src/tui/pickers.mjs +279 -0
  82. package/src/tui/render-frame.mjs +304 -0
  83. package/src/tui/render.mjs +205 -0
  84. package/src/tui/slash-commands.mjs +138 -0
  85. package/src/tui/startup.mjs +113 -0
  86. package/src/tui/wizard.mjs +168 -0
  87. package/src/tui-render.mjs +4 -0
  88. package/src/tui.mjs +3 -2566
  89. package/src/provider.mjs +0 -383
  90. /package/src/{gitmem.mjs → git/gitmem.mjs} +0 -0
  91. /package/src/{coder-overlay.md → prompts/coder.md} +0 -0
  92. /package/src/{discipline-rules.md → prompts/discipline.md} +0 -0
  93. /package/src/{explore-overlay.md → prompts/explore.md} +0 -0
  94. /package/src/{main-overlay.md → prompts/main.md} +0 -0
  95. /package/src/{plan-overlay.md → prompts/plan.md} +0 -0
  96. /package/src/{SYSTEM_PROMPT.md → prompts/system.md} +0 -0
  97. /package/src/{repomap.mjs → tools/repomap.mjs} +0 -0
package/src/tools.mjs CHANGED
@@ -1,1194 +1,2 @@
1
- /**
2
- * tools.mjs — 内置工具集
3
- * read / write / edit / insert_after / apply_patch / bash / glob / grep / websearch / ls / fetch / delete / git_diff / git_status / git_log / question / checkpoint,零依赖实现。
4
- * 工具描述从 src/tools/*.md 加载(方便人读和修改)。
5
- * readonly 标记供 agent 调度:只读工具可并行,有副作用工具串行。
6
- */
7
-
8
- import { spawn, execFileSync } from "node:child_process"
9
- import { mkdir, readFile, readdir, stat, writeFile, unlink } from "node:fs/promises"
10
- import { readFileSync, existsSync, realpathSync } from "node:fs"
11
- import { dirname, join, resolve, relative, isAbsolute, sep } from "node:path"
12
- import { fileURLToPath } from "node:url"
13
-
14
- const __dirname = dirname(fileURLToPath(import.meta.url))
15
- const DESC = (name) => readFileSync(join(__dirname, "tools", `${name}.md`), "utf8")
16
-
17
- const MAX_READ_LINES = 2000
18
- // 输出上限只是内存安全阀:超过 16k 的输出由 agent 层 offload 整体落盘(全量保留、预览+路径回喂),
19
- // 这里截断必须远高于落盘阈值,否则被截掉的内容在落盘前就永远丢了
20
- const MAX_OUTPUT_CHARS = 200_000
21
- const BASH_TIMEOUT_MS = 120_000
22
- const MAX_RESPONSE_BODY_BYTES = 5_000_000
23
- const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
24
-
25
- /** 每个工具:{ name, description, parameters, readonly, execute(args, ctx) }(定义见文件末尾导出) */
26
-
27
- /** 转成 OpenAI tools 参数格式 */
28
- export function toOpenAISchema(tool) {
29
- return {
30
- type: "function",
31
- function: {
32
- name: tool.name,
33
- description: tool.description,
34
- parameters: tool.parameters,
35
- },
36
- }
37
- }
38
-
39
- /** 剥离 ANSI 转义序列(vim/less/颜色码会冲花 TUI 渲染),并把 \r 进度条改写转成换行 */
40
- function sanitizeOutput(s) {
41
- return s
42
- .replace(/\x1b\[[0-9;?]*[\x40-\x7E]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g, "")
43
- .replace(/\r\n/g, "\n")
44
- .replace(/\r/g, "\n")
45
- }
46
-
47
- function truncate(text, max = MAX_OUTPUT_CHARS) {
48
- if (text.length <= max) return text
49
- return text.slice(0, max) + `\n[... truncated: ${text.length - max} chars omitted — redirect to a file if you need the full output]`
50
- }
51
-
52
- /** 限量读取响应体:超 limit 字节即取消流,防超大页面把整个 body 缓冲进内存 */
53
- async function readBodyText(response, limit = MAX_RESPONSE_BODY_BYTES) {
54
- if (!response.body) return ""
55
- const reader = response.body.getReader()
56
- const chunks = []
57
- let total = 0
58
- try {
59
- for (;;) {
60
- const { done, value } = await reader.read()
61
- if (done) break
62
- if (value) {
63
- chunks.push(value)
64
- total += value.length
65
- }
66
- if (total >= limit) {
67
- await reader.cancel()
68
- break
69
- }
70
- }
71
- } finally {
72
- reader.releaseLock()
73
- }
74
- return new TextDecoder("utf-8").decode(Buffer.concat(chunks))
75
- }
76
-
77
- /** 创建独立的流解码器(编码嗅探:ASCII → UTF-8 → GBK 回退) */
78
- function makeDecoder() {
79
- let decoder = null
80
- let pending = Buffer.alloc(0)
81
- return (d, flush = false) => {
82
- pending = Buffer.concat([pending, d])
83
- if (!decoder) {
84
- const hasHighByte = pending.some((b) => b >= 0x80)
85
- if (!hasHighByte) {
86
- const s = pending.toString("ascii")
87
- pending = Buffer.alloc(0)
88
- return s
89
- }
90
- for (let trim = 0; trim <= 3 && !decoder; trim++) {
91
- try {
92
- new TextDecoder("utf-8", { fatal: true }).decode(pending.subarray(0, pending.length - trim))
93
- decoder = new TextDecoder("utf-8")
94
- } catch { /* 继续尝试 */ }
95
- }
96
- if (!decoder) decoder = new TextDecoder("gbk")
97
- }
98
- const s = decoder.decode(pending, { stream: !flush })
99
- pending = Buffer.alloc(0)
100
- return s
101
- }
102
- }
103
-
104
- /** 对单个文件取 git diff,失败静默返回空 */
105
- function gitDiffOne(cwd, abs) {
106
- try {
107
- const diff = execFileSync("git", ["--no-pager", "diff", "--no-color", "--", abs], {
108
- cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1024 * 1024,
109
- }).trim()
110
- if (!diff) return ""
111
- // 截断超长 diff(超大文件改动 diff 可能几十 KB),只保留前 200 行
112
- const lines = diff.split("\n")
113
- if (lines.length <= 200) return diff
114
- return lines.slice(0, 200).join("\n") + `\n... (${lines.length - 200} more diff lines)`
115
- } catch {
116
- return ""
117
- }
118
- }
119
-
120
- /** 文件变更后自动语法检查:仅对 .mjs/.js 文件,不抛错,结果追加到工具返回值 */
121
- function autoSyntaxCheck(abs) {
122
- if (!/\.(m?js)$/i.test(abs)) return ""
123
- try {
124
- execFileSync("node", ["--check", abs], { stdio: ["ignore", "pipe", "pipe"], timeout: 10000 })
125
- return "\nSyntax: OK"
126
- } catch (e) {
127
- const err = (e.stderr || e.stdout || e.message || "").toString().split("\n").slice(0, 3).join("\n")
128
- return `\nSyntax: FAILED — ${err}`
129
- }
130
- }
131
-
132
- /** 目标可能不存在(write 新文件),逐级向上找真实存在的祖先做 realpath */
133
- function realpathNearest(abs) {
134
- let cur = abs
135
- const tail = []
136
- while (!existsSync(cur)) {
137
- const parent = dirname(cur)
138
- if (parent === cur) return abs // 到根了,原样返回(不会发生)
139
- tail.unshift(cur.slice(parent.length + 1))
140
- cur = parent
141
- }
142
- try {
143
- const real = realpathSync(cur)
144
- return tail.length ? join(real, ...tail) : real
145
- } catch {
146
- return abs
147
- }
148
- }
149
-
150
- // cwd 的 realpath 缓存:进程内 cwd 不变,只需解析一次
151
- const realCwdCache = new Map()
152
- function realCwd(cwd) {
153
- if (!realCwdCache.has(cwd)) realCwdCache.set(cwd, realpathNearest(resolve(cwd)))
154
- return realCwdCache.get(cwd)
155
- }
156
-
157
- function assertInside(cwd, resolved, p) {
158
- const rel = relative(cwd, resolved)
159
- // 跨盘符时 relative 返回绝对路径(Windows);startsWith("..") 会误伤 cwd 内的 "..foo",故精确判断
160
- if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
161
- throw new Error(`Access denied outside working directory: ${p}`)
162
- }
163
- }
164
-
165
- function resolveInCwd(ctx, p) {
166
- const cwd = realCwd(ctx.cwd)
167
- const resolved = resolve(cwd, p)
168
- assertInside(cwd, resolved, p)
169
- // 防 symlink 逃逸:cwd 内若存在指向外部的符号链接,realpath 后会落到 cwd 外
170
- const real = realpathNearest(resolved)
171
- assertInside(cwd, real, p)
172
- return resolved
173
- }
174
-
175
- /**
176
- * 破坏性预检用的粗切分:&& || ; | 换行 命令替换 子 shell 都视作命令边界。
177
- * 宁多切不少切——防 "cd x && git checkout ."、"echo $(git checkout .)" 这类写法绕过行首锚定
178
- */
179
- function shellSegments(command) {
180
- return command.split(/&&|\|\||[;|\n]|\$\(|`|[(]/)
181
- }
182
-
183
- /**
184
- * 单个命令段是否会销毁未提交改动:
185
- * checkout -- / checkout . / reset --hard / clean -f* / restore(动工作区的)
186
- * checkout <branch>、restore --staged、clean -n(dry-run)不算
187
- */
188
- function isDestructiveGitSegment(seg) {
189
- if (!/^\s*git\s/.test(seg)) return false
190
- if (/\scheckout\s+(?:--|\.(?:\s|$))/.test(seg)) return true
191
- if (/\sreset\s+--hard\b/.test(seg)) return true
192
- if (/\sclean\s+-\S*f/.test(seg)) return true
193
- if (/\srestore\s/.test(seg) && (/--worktree/.test(seg) || !/--staged/.test(seg))) return true
194
- return false
195
- }
196
-
197
- /** cwd 是否在 git 仓库内(预检用,失败静默视为不在) */
198
- function insideGitRepo(cwd) {
199
- try {
200
- execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
201
- cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
202
- })
203
- return true
204
- } catch {
205
- return false
206
- }
207
- }
208
-
209
- // ---------------------------------------------------------------- read
210
-
211
- const readTool = {
212
- name: "read",
213
- description: DESC("read"),
214
- parameters: {
215
- type: "object",
216
- properties: {
217
- path: { type: "string", description: "File path (relative to cwd or absolute)" },
218
- offset: { type: "number", description: "1-based line number to start from" },
219
- limit: { type: "number", description: `Max lines to return (default ${MAX_READ_LINES})` },
220
- },
221
- required: ["path"],
222
- },
223
- readonly: true,
224
- async execute(args, ctx) {
225
- const abs = resolveInCwd(ctx, args.path)
226
- // 注意:整文件一次性读入内存,大文件会被完整缓冲(offset/limit 只影响返回切片)
227
- const content = await readFile(abs, "utf8")
228
- const lines = content.split("\n")
229
- const offset = Math.max(1, args.offset ?? 1)
230
- const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
231
- const slice = lines.slice(offset - 1, offset - 1 + limit)
232
- const numbered = slice.map((l, i) => `${offset + i}\t${l}`).join("\n")
233
- const suffix = offset - 1 + limit < lines.length ? `\n... (${lines.length} lines total, use offset to continue)` : ""
234
- return truncate(numbered + suffix)
235
- },
236
- }
237
-
238
- // ---------------------------------------------------------------- read_image
239
-
240
- const IMAGE_EXTENSIONS = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", bmp: "image/bmp", svg: "image/svg+xml" }
241
-
242
- const readImageTool = {
243
- name: "read_image",
244
- description: DESC("read_image"),
245
- parameters: {
246
- type: "object",
247
- properties: {
248
- path: { type: "string", description: "Path to image file (relative to cwd or absolute). Supports png, jpg, gif, webp, bmp, svg." },
249
- },
250
- required: ["path"],
251
- },
252
- readonly: true,
253
- /** 返回 JSON:{ text, images },供 agent 层转为多模态 user 消息 */
254
- async execute(args, ctx) {
255
- const abs = resolveInCwd(ctx, args.path)
256
- const ext = abs.slice(abs.lastIndexOf(".") + 1).toLowerCase()
257
- const mime = IMAGE_EXTENSIONS[ext]
258
- if (!mime) throw new Error(`Unsupported image format: .${ext}. Supported: ${Object.keys(IMAGE_EXTENSIONS).join(", ")}`)
259
- const buf = await readFile(abs) // raw buffer, no encoding
260
- const b64 = buf.toString("base64")
261
- // 图片太大(>20MB base64)拒绝,避免撑爆上下文
262
- if (b64.length > 20_000_000) throw new Error(`Image too large: ${(b64.length / 1_000_000).toFixed(1)}MB base64 (max 20MB)`)
263
- const bytes = buf.length
264
- const result = JSON.stringify({
265
- text: `[read_image: ${args.path} (${mime}, ${bytes} bytes)]`,
266
- images: [{ type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } }],
267
- })
268
- // 粘贴产生的临时文件用完即删,不留垃圾
269
- const basename = abs.includes("/") ? abs.slice(abs.lastIndexOf("/") + 1) : abs.slice(abs.lastIndexOf("\\") + 1)
270
- if (basename.startsWith(".thincoder-paste-")) {
271
- try { await unlink(abs) } catch { /* 删不掉就算了 */ }
272
- }
273
- return result
274
- },
275
- }
276
-
277
- // ---------------------------------------------------------------- write
278
-
279
- const writeTool = {
280
- name: "write",
281
- description: DESC("write"),
282
- parameters: {
283
- type: "object",
284
- properties: {
285
- path: { type: "string", description: "File path (relative to cwd or absolute)" },
286
- content: { type: "string", description: "Full content to write" },
287
- },
288
- required: ["path", "content"],
289
- },
290
- readonly: false,
291
- async execute(args, ctx) {
292
- const abs = resolveInCwd(ctx, args.path)
293
- await mkdir(dirname(abs), { recursive: true })
294
- const st = await stat(abs).catch(() => null)
295
- if (st?.isDirectory()) throw new Error(`Path is a directory: ${abs}`)
296
- await writeFile(abs, args.content, "utf8")
297
- const diff = gitDiffOne(ctx.cwd, abs)
298
- return `Wrote ${args.content.length} chars to ${abs}${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
299
- },
300
- }
301
-
302
- // ---------------------------------------------------------------- edit
303
-
304
- const editTool = {
305
- name: "edit",
306
- description: DESC("edit"),
307
- parameters: {
308
- type: "object",
309
- properties: {
310
- path: { type: "string", description: "File path" },
311
- old_string: { type: "string", description: "Exact text to replace" },
312
- new_string: { type: "string", description: "Replacement text" },
313
- replace_all: { type: "boolean", description: "Replace all occurrences (default false)" },
314
- },
315
- required: ["path", "old_string", "new_string"],
316
- },
317
- readonly: false,
318
- async execute(args, ctx) {
319
- const abs = resolveInCwd(ctx, args.path)
320
- if (!args.old_string) {
321
- throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
322
- }
323
- const content = await readFile(abs, "utf8")
324
- const occurrences = content.split(args.old_string).length - 1
325
- if (occurrences === 0) {
326
- // 给出线索帮模型定位:首行预览 + 常见原因
327
- const preview = args.old_string.slice(0, 100).split("\n")[0]
328
- throw new Error(
329
- `old_string not found in ${abs}\n` +
330
- ` searched: "${preview}${args.old_string.length > 100 ? "…" : ""}"\n` +
331
- ` hints: whitespace mismatch? file already changed? try reading the file first`
332
- )
333
- }
334
- if (occurrences > 1 && !args.replace_all) {
335
- throw new Error(`old_string matches ${occurrences} times in ${abs}; provide more context or set replace_all`)
336
- }
337
- const updated = args.replace_all
338
- ? content.split(args.old_string).join(args.new_string)
339
- // 函数式替换:避免 new_string 里的 $ 替换模式(匹配串/前后文引用)被展开
340
- : content.replace(args.old_string, () => args.new_string)
341
- await writeFile(abs, updated, "utf8")
342
- const diff = gitDiffOne(ctx.cwd, abs)
343
- return `Edited ${abs}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
344
- },
345
- }
346
-
347
- // ---------------------------------------------------------------- insert_after
348
-
349
- const insertAfterTool = {
350
- name: "insert_after",
351
- description: DESC("insert_after"),
352
- parameters: {
353
- type: "object",
354
- properties: {
355
- path: { type: "string", description: "File path" },
356
- after_line: { type: "number", description: "Line number to insert after (1-based). Takes priority over after_regex." },
357
- after_regex: { type: "string", description: "JavaScript regex to find the line to insert after (must match exactly one line)" },
358
- content: { type: "string", description: "Text to insert (with leading newline if you need a blank line)" },
359
- },
360
- required: ["path", "content"],
361
- },
362
- readonly: false,
363
- async execute(args, ctx) {
364
- const abs = resolveInCwd(ctx, args.path)
365
- const text = await readFile(abs, "utf8")
366
- const lines = text.split("\n")
367
-
368
- let targetLine
369
- if (args.after_line != null) {
370
- targetLine = args.after_line
371
- if (!Number.isInteger(targetLine)) {
372
- throw new Error(`after_line must be an integer, got: ${args.after_line}`)
373
- }
374
- if (targetLine < 1 || targetLine > lines.length) {
375
- throw new Error(`after_line ${targetLine} out of range (file has ${lines.length} lines)`)
376
- }
377
- } else if (args.after_regex) {
378
- const regex = new RegExp(args.after_regex)
379
- const matches = []
380
- for (let i = 0; i < lines.length; i++) {
381
- if (regex.test(lines[i])) matches.push(i + 1)
382
- }
383
- if (matches.length === 0) throw new Error(`after_regex /${args.after_regex}/ matched no lines in ${abs}`)
384
- if (matches.length > 1) throw new Error(`after_regex /${args.after_regex}/ matched ${matches.length} lines (${matches.slice(0, 5).join(", ")}${matches.length > 5 ? "…" : ""}); use a more specific pattern or after_line instead`)
385
- targetLine = matches[0]
386
- } else {
387
- throw new Error("Either after_line or after_regex is required")
388
- }
389
-
390
- lines.splice(targetLine, 0, args.content)
391
- const updated = lines.join("\n")
392
- await writeFile(abs, updated, "utf8")
393
- const diff = gitDiffOne(ctx.cwd, abs)
394
- return `Inserted after line ${targetLine} in ${abs}${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
395
- },
396
- }
397
-
398
- // ---------------------------------------------------------------- apply_patch
399
-
400
- /**
401
- * 解析统一 diff(unified diff):返回 [{ path, isNew, hunks: [{ ops: [{type:" "|"-"|"+", text}] }] }]
402
- * 按 @@ 头的行数计数消费 hunk 行——LLM 常把上下文空行剥成纯空行,靠计数而不是行首字符判断 hunk 边界
403
- */
404
- function parsePatch(patch) {
405
- // 补丁文本常来自 CRLF 终端/模型输出,行尾 \r 会混进 hunk 内容导致上下文对不上,统一剥掉
406
- const lines = patch.replace(/\r(?=\n|$)/g, "").split("\n")
407
- const files = []
408
- let cur = null
409
- let i = 0
410
- const stripPrefix = (p) => p.replace(/^[ab]\//, "")
411
- while (i < lines.length) {
412
- const line = lines[i]
413
- if (line.startsWith("--- ")) {
414
- const oldPath = line.slice(4).trim()
415
- const plus = lines[i + 1]
416
- if (!plus?.startsWith("+++ ")) throw new Error(`Malformed patch: expected "+++" line after "${line}"`)
417
- const newPath = plus.slice(4).trim()
418
- if (newPath === "/dev/null") throw new Error("Deleting files via patch is not supported — use the delete tool")
419
- cur = { path: stripPrefix(newPath), isNew: oldPath === "/dev/null", hunks: [] }
420
- files.push(cur)
421
- i += 2
422
- continue
423
- }
424
- if (line.startsWith("@@")) {
425
- if (!cur) throw new Error("Malformed patch: hunk header before any file header")
426
- const m = line.match(/^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/)
427
- if (!m) throw new Error(`Malformed patch: bad hunk header "${line}" (need @@ -old,count +new,count @@)`)
428
- let oldNeed = m[1] == null ? 1 : Number(m[1])
429
- let newNeed = m[2] == null ? 1 : Number(m[2])
430
- const hunk = { ops: [] }
431
- i++
432
- while (oldNeed > 0 || newNeed > 0) {
433
- if (i >= lines.length) throw new Error("Malformed patch: hunk truncated (line counts in @@ header not satisfied)")
434
- const hl = lines[i]
435
- if (hl.startsWith("\\")) { i++; continue } // ""
436
- const tag = hl === "" ? " " : hl[0] // 纯空行按上下文行宽容处理
437
- const text = hl === "" ? "" : hl.slice(1)
438
- if (tag === " ") { hunk.ops.push({ type: " ", text }); oldNeed--; newNeed-- }
439
- else if (tag === "-") { hunk.ops.push({ type: "-", text }); oldNeed-- }
440
- else if (tag === "+") { hunk.ops.push({ type: "+", text }); newNeed-- }
441
- else throw new Error(`Malformed patch: unexpected line "${hl.slice(0, 60)}" inside hunk`)
442
- i++
443
- }
444
- cur.hunks.push(hunk)
445
- continue
446
- }
447
- i++ // diff --git / index / 空行等元信息跳过
448
- }
449
- if (files.length === 0) throw new Error("No file changes found in patch (need --- / +++ headers)")
450
- return files
451
- }
452
-
453
- /** 在内存行数组上按序应用 hunks;任何一步失败抛错(调用方保证不落盘)。比较时忽略行尾 \r,上下文行保留原始字节 */
454
- function applyHunks(fileLines, hunks, eol, path) {
455
- const cr = eol === "\r\n" ? "\r" : ""
456
- for (let h = 0; h < hunks.length; h++) {
457
- const oldSeq = hunks[h].ops.filter((o) => o.type !== "+").map((o) => o.text)
458
- if (oldSeq.length === 0) throw new Error(`Hunk ${h + 1} in ${path} has no context/removed lines to locate`)
459
- const matches = []
460
- for (let pos = 0; pos + oldSeq.length <= fileLines.length; pos++) {
461
- let ok = true
462
- for (let j = 0; j < oldSeq.length; j++) {
463
- if (fileLines[pos + j].replace(/\r$/, "") !== oldSeq[j]) { ok = false; break }
464
- }
465
- if (ok) matches.push(pos)
466
- }
467
- if (matches.length === 0) {
468
- const preview = oldSeq.slice(0, 3).join(" ⏎ ")
469
- throw new Error(`Hunk ${h + 1} in ${path} does not apply — context not found: "${preview}${oldSeq.length > 3 ? "…" : ""}". Read the file first and regenerate the patch from actual content.`)
470
- }
471
- if (matches.length > 1) throw new Error(`Hunk ${h + 1} in ${path} matches ${matches.length} locations — add more context lines to make it unique`)
472
- const pos = matches[0]
473
- const out = []
474
- let src = pos
475
- for (const op of hunks[h].ops) {
476
- if (op.type === " ") out.push(fileLines[src++]) // 上下文保留原始行(行尾/空白原样)
477
- else if (op.type === "-") src++
478
- else out.push(op.text + cr)
479
- }
480
- fileLines.splice(pos, oldSeq.length, ...out)
481
- }
482
- }
483
-
484
- const applyPatchTool = {
485
- name: "apply_patch",
486
- description: DESC("apply_patch"),
487
- parameters: {
488
- type: "object",
489
- properties: {
490
- patch: { type: "string", description: "Unified diff. May span multiple files; --- / +++ headers per file, @@ -old,count +new,count @@ hunks. Use --- /dev/null to create a file." },
491
- },
492
- required: ["patch"],
493
- },
494
- readonly: false,
495
- /** 供 agent 层追踪触碰文件(多路径,替代单 path 参数) */
496
- touchedPaths(args) {
497
- try { return parsePatch(args.patch ?? "").map((f) => f.path) } catch { return [] }
498
- },
499
- async execute(args, ctx) {
500
- const files = parsePatch(args.patch ?? "")
501
- // 先全部读入内存试算:任何一个 hunk 不上就整体抛错,不写半个补丁(原子性)
502
- const planned = []
503
- for (const f of files) {
504
- const abs = resolveInCwd(ctx, f.path)
505
- if (f.isNew) {
506
- if (existsSync(abs)) throw new Error(`Cannot create ${f.path}: file already exists`)
507
- const content = f.hunks.flatMap((h) => h.ops.filter((o) => o.type === "+").map((o) => o.text)).join("\n") + "\n"
508
- planned.push({ abs, path: f.path, content, isNew: true })
509
- } else {
510
- const original = await readFile(abs, "utf8").catch(() => { throw new Error(`File not found: ${f.path}`) })
511
- const eol = original.includes("\r\n") ? "\r\n" : "\n"
512
- const lines = original.split("\n")
513
- applyHunks(lines, f.hunks, eol, f.path)
514
- planned.push({ abs, path: f.path, content: lines.join("\n"), isNew: false })
515
- }
516
- }
517
- for (const p of planned) {
518
- await mkdir(dirname(p.abs), { recursive: true })
519
- await writeFile(p.abs, p.content, "utf8")
520
- }
521
- const summary = planned.map((p) => ` ${p.isNew ? "created " : "modified"} ${p.path}`).join("\n")
522
- const syntaxResults = planned.map((p) => {
523
- const r = autoSyntaxCheck(p.abs)
524
- return r ? `${p.path}:${r.replace("Syntax: ", "")}` : ""
525
- }).filter(Boolean).join("\n")
526
- return `Applied patch to ${planned.length} file(s):\n${summary}${syntaxResults ? "\n\nSyntax checks:\n" + syntaxResults : ""}`
527
- },
528
- }
529
-
530
- // ---------------------------------------------------------------- syntax_check
531
-
532
- const syntaxCheckTool = {
533
- name: "syntax_check",
534
- description: DESC("syntax_check"),
535
- parameters: {
536
- type: "object",
537
- properties: {
538
- path: { type: "string", description: "File path (.js/.mjs/.cjs only)" },
539
- },
540
- required: ["path"],
541
- },
542
- readonly: true,
543
- execute(args, ctx) {
544
- const abs = resolveInCwd(ctx, args.path)
545
- if (!/\.(?:[mc]?js)$/.test(abs)) {
546
- return `syntax_check only supports .js/.mjs/.cjs files; ${abs} skipped.`
547
- }
548
- try {
549
- execFileSync(process.execPath, ["--check", abs], {
550
- cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
551
- })
552
- return `Syntax OK: ${abs}`
553
- } catch (e) {
554
- // node --check 把错误写到 stderr
555
- const msg = (e.stderr || e.stdout || e.message || "").trim()
556
- return `Syntax error in ${abs}:\n${msg || "(unknown)"}`
557
- }
558
- },
559
- }
560
-
561
- // ---------------------------------------------------------------- bash
562
-
563
- const bashTool = {
564
- name: "bash",
565
- description: DESC("bash"),
566
- parameters: {
567
- type: "object",
568
- properties: {
569
- command: { type: "string", description: "Shell command to execute" },
570
- timeout: { type: "number", description: `Timeout in ms (default ${BASH_TIMEOUT_MS})` },
571
- },
572
- required: ["command"],
573
- },
574
- readonly: false,
575
- async execute(args, ctx) {
576
- // 安全预检:销毁性 git 操作先检查未提交改动,有则拒绝——防一键清掉几小时工作
577
- if (shellSegments(args.command).some(isDestructiveGitSegment)) {
578
- if (!insideGitRepo(ctx.cwd)) {
579
- throw new Error(`Refusing destructive git command: not a git repository: ${ctx.cwd}`)
580
- }
581
- const status = execFileSync("git", ["status", "--porcelain"], {
582
- cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
583
- }).trim()
584
- if (status) {
585
- throw new Error(
586
- `Refusing destructive git command: uncommitted changes exist. Commit or stash first.\n` +
587
- `(If uncommitted work was already lost, the checkpoint tool can restore the auto-snapshot: action=list, then action=rewind.)\n\n${status}`
588
- )
589
- }
590
- }
591
-
592
- return new Promise((resolve) => {
593
- // detached: 让子进程成为进程组组长,超时/中断时才能整树杀掉(POSIX 用负 pid 组杀,
594
- // Windows 用 taskkill /T)——只 kill 壳进程会把孙进程(如 npm test)留在后台继续跑
595
- const killTree = () => {
596
- if (process.platform === "win32") {
597
- try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
598
- } else {
599
- try { process.kill(-child.pid, "SIGKILL") } catch {}
600
- try { child.kill("SIGKILL") } catch {} // 组杀失败时兜底杀本体
601
- }
602
- }
603
- // Windows 中文系统默认代码页是 GBK (CP936),cmd.exe 重定向写文件时用 ANSI 代码页,
604
- // chcp 65001 也改不了重定向的编码。bash 工具写含 CJK 的文件会产生 GBK——
605
- // 提示词层已禁止用 bash 写文件(用 write/edit 工具替代),这里设 PYTHONIOENCODING
606
- // 覆盖 Python 脚本的 stdout 编码(Python 是唯一可能正确响应环境变量的子进程)
607
- const winCmd = process.platform === "win32"
608
- const child = spawn(args.command, {
609
- cwd: ctx.cwd,
610
- shell: true,
611
- windowsHide: true,
612
- detached: process.platform !== "win32",
613
- stdio: ["ignore", "pipe", "pipe"],
614
- env: {
615
- ...process.env,
616
- GIT_EDITOR: "true",
617
- EDITOR: "true",
618
- VISUAL: "true",
619
- GIT_PAGER: "cat",
620
- PAGER: "cat",
621
- TERM: "dumb",
622
- ...(winCmd ? { PYTHONIOENCODING: "utf-8" } : {}),
623
- },
624
- })
625
- // stdout / stderr 各自独立解码(同进程通常同编码,但分开收集更干净,
626
- // 且允许模型按 stderr 快速定位错误)
627
- const outDecoder = makeDecoder()
628
- const errDecoder = makeDecoder()
629
- let outBuf = ""
630
- let errBuf = ""
631
- let truncatedNote = ""
632
-
633
- const onStdout = (d) => {
634
- const s = sanitizeOutput(outDecoder(d))
635
- if (s) {
636
- ctx.onOutput?.(s)
637
- if (outBuf.length < 2_000_000) outBuf += s
638
- else if (!truncatedNote) truncatedNote = "\n[... output exceeded 2MB, remainder discarded]"
639
- }
640
- }
641
- const onStderr = (d) => {
642
- const s = sanitizeOutput(errDecoder(d)) // 始终解码,防 pending 无限累积
643
- if (errBuf.length < 2_000_000) errBuf += s
644
- }
645
-
646
- child.stdout.on("data", onStdout)
647
- child.stderr.on("data", onStderr)
648
-
649
- const timer = setTimeout(killTree, args.timeout ?? BASH_TIMEOUT_MS)
650
- if (ctx.signal) {
651
- ctx.signal.addEventListener("abort", killTree, { once: true })
652
- }
653
- child.on("error", (error) => {
654
- clearTimeout(timer)
655
- resolve(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
656
- })
657
- child.on("close", (code, signal) => {
658
- clearTimeout(timer)
659
- // 冲刷解码器尾部
660
- outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
661
- errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
662
- const status = signal
663
- ? `killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"}`
664
- : `exit code ${code}`
665
- const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
666
- if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
667
- parts.push(`(${status})`)
668
- resolve(truncate(parts.join("\n\n") + truncatedNote))
669
- })
670
- })
671
- },
672
- }
673
-
674
- // ---------------------------------------------------------------- glob
675
-
676
- const globTool = {
677
- name: "glob",
678
- description: DESC("glob"),
679
- parameters: {
680
- type: "object",
681
- properties: {
682
- pattern: { type: "string", description: "Glob pattern" },
683
- path: { type: "string", description: "Directory to search in (default cwd)" },
684
- },
685
- required: ["pattern"],
686
- },
687
- readonly: true,
688
- async execute(args, ctx) {
689
- const base = resolveInCwd(ctx, args.path ?? ".")
690
- const regex = globToRegex(args.pattern)
691
- const results = []
692
- for await (const relPath of walkFiles(base)) {
693
- if (regex.test(relPath)) {
694
- results.push(relPath)
695
- if (results.length >= 1000) break
696
- }
697
- }
698
- if (results.length === 0) return "(no matches)"
699
- return truncate(results.sort().join("\n"))
700
- },
701
- }
702
-
703
- /** 递归遍历文件,产出相对路径(跳过 IGNORED_DIRS) */
704
- async function* walkFiles(dir, rel = "") {
705
- let entries
706
- try {
707
- entries = await readdir(dir, { withFileTypes: true })
708
- } catch {
709
- return
710
- }
711
- for (const e of entries) {
712
- if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
713
- const relPath = rel ? `${rel}/${e.name}` : e.name
714
- if (e.isDirectory()) {
715
- yield* walkFiles(join(dir, e.name), relPath)
716
- } else {
717
- yield relPath
718
- }
719
- }
720
- }
721
-
722
- /** glob 转正则:**\/ 匹配零或多级目录,** 跨目录,* 段内,? 段内单字符 */
723
- function globToRegex(pattern) {
724
- const DS = "\u0001" // **/ 的占位符(零或多级目录)
725
- const DP = "\u0002" // ** 的占位符
726
- const escaped = pattern
727
- .replace(/[.+^${}()|\\]/g, "\\$&") // [ ] 不转义,保留为 glob 字符组语法
728
- .replace(/\*\*\//g, DS)
729
- .replace(/\*\*/g, DP)
730
- .replace(/\*/g, "[^/]*")
731
- .replace(/\?/g, "[^/]")
732
- .replaceAll(DS, "(?:.*/)?")
733
- .replaceAll(DP, ".*")
734
- return new RegExp("^" + escaped + "$")
735
- }
736
-
737
- // ---------------------------------------------------------------- grep
738
-
739
- const grepTool = {
740
- name: "grep",
741
- description: DESC("grep"),
742
- parameters: {
743
- type: "object",
744
- properties: {
745
- pattern: { type: "string", description: "Regular expression" },
746
- path: { type: "string", description: "Directory or file to search (default cwd)" },
747
- glob: { type: "string", description: "Only search files matching this glob (e.g. '*.mjs')" },
748
- before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
749
- after: { type: "integer", description: "Lines of context to show after each match (grep -A). Default 0" },
750
- },
751
- required: ["pattern"],
752
- },
753
- readonly: true,
754
- async execute(args, ctx) {
755
- const base = resolveInCwd(ctx, args.path ?? ".")
756
- const regex = new RegExp(args.pattern)
757
- const fileFilter = args.glob ? globToRegex(args.glob) : null
758
- const before = Math.max(0, Math.floor(args.before ?? 0))
759
- const after = Math.max(0, Math.floor(args.after ?? 0))
760
- const wantCtx = before > 0 || after > 0
761
- const hits = [] // { file, line(1-based), text }
762
- const fileLines = new Map() // file -> string[](仅 wantCtx 时缓存)
763
-
764
- async function search(file) {
765
- let content
766
- try {
767
- content = await readFile(file, "utf8")
768
- } catch {
769
- return // 不可读文件跳过;二进制会被按 utf8 读入并照常搜索(可能产生乱码匹配)
770
- }
771
- const lines = content.split("\n")
772
- if (wantCtx) fileLines.set(file, lines)
773
- for (let i = 0; i < lines.length; i++) {
774
- if (regex.test(lines[i])) {
775
- hits.push({ file, line: i + 1, text: lines[i] })
776
- if (hits.length >= 200) return
777
- }
778
- }
779
- }
780
-
781
- async function walk(target) {
782
- if (hits.length >= 200) return
783
- const s = await stat(target)
784
- if (!s.isDirectory()) {
785
- if (!fileFilter || fileFilter.test(target.split(/[\\/]/).pop())) await search(target)
786
- return
787
- }
788
- let entries
789
- try {
790
- entries = await readdir(target, { withFileTypes: true })
791
- } catch {
792
- return
793
- }
794
- for (const e of entries) {
795
- if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
796
- await walk(join(target, e.name))
797
- }
798
- }
799
-
800
- await walk(base)
801
- if (hits.length === 0) return "(no matches)"
802
-
803
- // 无上下文:保持原 path:line: content 格式
804
- if (!wantCtx) {
805
- return truncate(hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n"))
806
- }
807
-
808
- // 带上下文:匹配行用 ':',上下文行用 '-'(同 ripgrep);同文件相邻区间去重合并
809
- const fileMatched = new Map() // file -> Set<line>
810
- for (const h of hits) {
811
- if (!fileMatched.has(h.file)) fileMatched.set(h.file, new Set())
812
- fileMatched.get(h.file).add(h.line)
813
- }
814
- const out = []
815
- for (const [file, matchedLines] of fileMatched) {
816
- const lines = fileLines.get(file) ?? []
817
- const lineSet = new Set()
818
- for (const ml of matchedLines) {
819
- for (let l = Math.max(1, ml - before); l <= Math.min(lines.length, ml + after); l++) lineSet.add(l)
820
- }
821
- for (const l of [...lineSet].sort((a, b) => a - b)) {
822
- const sep = matchedLines.has(l) ? ":" : "-"
823
- out.push(`${file}${sep}${l}${sep} ${lines[l - 1]}`)
824
- }
825
- }
826
- return truncate(out.join("\n"))
827
- },
828
- }
829
-
830
- // ---------------------------------------------------------------- websearch
831
-
832
- const websearchTool = {
833
- name: "websearch",
834
- description: DESC("websearch"),
835
- parameters: {
836
- type: "object",
837
- properties: {
838
- query: { type: "string", description: "Search query" },
839
- limit: { type: "number", description: "Max results (default 8)" },
840
- },
841
- required: ["query"],
842
- },
843
- readonly: true,
844
- async execute(args, ctx) {
845
- const limit = args.limit ?? 8
846
- const url = `https://www.bing.com/search?q=${encodeURIComponent(args.query)}`
847
- let html
848
- try {
849
- const response = await fetch(url, {
850
- headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" },
851
- signal: ctx?.signal
852
- ? AbortSignal.any([ctx.signal, AbortSignal.timeout(15_000)])
853
- : AbortSignal.timeout(15_000),
854
- })
855
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
856
- html = await readBodyText(response)
857
- } catch (error) {
858
- throw new Error(`websearch request failed: ${error.cause?.code ?? error.message}`)
859
- }
860
-
861
- // 结果块 <li class="b_algo">:<h2><a href>标题</a></h2> + <p>摘要</p>
862
- const blocks = html.split('<li class="b_algo"').slice(1)
863
- const results = []
864
- for (const block of blocks) {
865
- const link = block.match(/<h2[^>]*><a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/)
866
- if (!link) continue
867
- const snippet = block.match(/<p[^>]*>([\s\S]*?)<\/p>/)
868
- results.push({
869
- href: link[1],
870
- title: stripTags(link[2]),
871
- snippet: snippet ? stripTags(snippet[1]) : "",
872
- })
873
- if (results.length >= limit) break
874
- }
875
- if (results.length === 0) return "(no results)"
876
- return truncate(
877
- results.map((r, i) => `${i + 1}. ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"),
878
- )
879
- },
880
- }
881
-
882
- function stripTags(html) {
883
- return html
884
- .replace(/<[^>]+>/g, "")
885
- .replace(/&#0*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
886
- .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
887
- .replace(/&ensp;/g, " ")
888
- .replace(/&lt;/g, "<")
889
- .replace(/&gt;/g, ">")
890
- .replace(/&quot;/g, '"')
891
- .replace(/&amp;/g, "&") // &amp; 必须最后解码,否则 &amp;lt; 会被二次解码成 <
892
- .replace(/\s+/g, " ")
893
- .trim()
894
- }
895
-
896
- // ---------------------------------------------------------------- ls
897
-
898
- const lsTool = {
899
- name: "ls",
900
- description: DESC("ls"),
901
- parameters: {
902
- type: "object",
903
- properties: {
904
- path: { type: "string", description: "Directory path (default cwd)" },
905
- },
906
- },
907
- readonly: true,
908
- async execute(args, ctx) {
909
- const abs = resolveInCwd(ctx, args.path ?? ".")
910
- const entries = await readdir(abs, { withFileTypes: true })
911
- const rows = await Promise.all(
912
- entries.slice(0, 500).map(async (e) => {
913
- const s = await stat(join(abs, e.name)).catch(() => null)
914
- const isDir = e.isDirectory()
915
- return {
916
- dir: isDir,
917
- name: e.name + (isDir ? "/" : ""),
918
- size: s?.size ?? 0,
919
- mtime: s ? s.mtime.toISOString().slice(0, 16).replace("T", " ") : "?",
920
- }
921
- }),
922
- )
923
- rows.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1))
924
- if (rows.length === 0) return "(empty directory)"
925
- const out = rows.map((r) => `${r.dir ? "d" : "-"} ${r.name.padEnd(40)} ${String(r.size).padStart(10)} ${r.mtime}`)
926
- return truncate(out.join("\n"))
927
- },
928
- }
929
-
930
- // ---------------------------------------------------------------- fetch
931
-
932
- const fetchTool = {
933
- name: "fetch",
934
- description: DESC("fetch"),
935
- parameters: {
936
- type: "object",
937
- properties: {
938
- url: { type: "string", description: "http/https URL" },
939
- },
940
- required: ["url"],
941
- },
942
- readonly: true,
943
- async execute(args, ctx) {
944
- if (!/^https?:\/\//.test(args.url)) throw new Error("url must start with http:// or https://")
945
- let response
946
- try {
947
- response = await fetch(args.url, {
948
- headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" },
949
- redirect: "follow",
950
- signal: ctx?.signal
951
- ? AbortSignal.any([ctx.signal, AbortSignal.timeout(20_000)])
952
- : AbortSignal.timeout(20_000),
953
- })
954
- } catch (error) {
955
- throw new Error(`fetch failed: ${error.cause?.code ?? error.message}`)
956
- }
957
- if (!response.ok) throw new Error(`fetch failed: HTTP ${response.status}`)
958
-
959
- const contentType = response.headers.get("content-type") ?? ""
960
- const body = await readBodyText(response)
961
- if (!contentType.includes("text/html")) return truncate(body)
962
- return truncate(htmlToText(body))
963
- },
964
- }
965
-
966
- /** HTML → 粗文本:去脚本样式、块级标签换行、剥标签、解码实体、压缩空行 */
967
- function htmlToText(html) {
968
- return html
969
- .replace(/<script[\s\S]*?<\/script>/gi, "")
970
- .replace(/<style[\s\S]*?<\/style>/gi, "")
971
- .replace(/<noscript[\s\S]*?<\/noscript>/gi, "")
972
- .replace(/<\/(p|div|li|ul|ol|h[1-6]|tr|table|section|article|header|footer|blockquote|pre)>/gi, "\n")
973
- .replace(/<br\s*\/?>/gi, "\n")
974
- .replace(/<li[^>]*>/gi, "- ")
975
- .replace(/<[^>]+>/g, "")
976
- .replace(/&#0*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
977
- .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
978
- .replace(/&nbsp;|&ensp;/g, " ")
979
- .replace(/&lt;/g, "<")
980
- .replace(/&gt;/g, ">")
981
- .replace(/&quot;/g, '"')
982
- .replace(/&amp;/g, "&") // &amp; 必须最后解码,否则 &amp;lt; 会被二次解码成 <
983
- .replace(/[ \t]+/g, " ")
984
- .replace(/\n\s*\n\s*\n+/g, "\n\n")
985
- .trim()
986
- }
987
-
988
- export const builtinTools = [readTool, writeTool, editTool, insertAfterTool, applyPatchTool, syntaxCheckTool, readImageTool, bashTool, globTool, grepTool, websearchTool, lsTool, fetchTool]
989
-
990
- // ---------------------------------------------------------------- delete
991
-
992
- const deleteTool = {
993
- name: "delete",
994
- description: DESC("delete"),
995
- parameters: {
996
- type: "object",
997
- properties: {
998
- path: { type: "string", description: "File path (relative to cwd or absolute)" },
999
- force: { type: "boolean", description: "Allow deleting git-tracked files (default false)" },
1000
- },
1001
- required: ["path"],
1002
- },
1003
- readonly: false,
1004
- async execute(args, ctx) {
1005
- const abs = resolveInCwd(ctx, args.path)
1006
- if (!existsSync(abs)) throw new Error(`File not found: ${abs}`)
1007
- const s = await stat(abs)
1008
- if (s.isDirectory()) throw new Error(`"${args.path}" is a directory — use bash to remove directories`)
1009
- // git 跟踪文件拒绝直接删除(安全网);未跟踪的放行
1010
- // 用解析后的相对路径(统一正斜杠),防反斜杠/非常规路径绕过 ls-files 匹配
1011
- const rel = relative(ctx.cwd, abs).replace(/\\/g, "/")
1012
- let tracked = false
1013
- try {
1014
- execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: ctx.cwd, stdio: "ignore" })
1015
- tracked = true
1016
- } catch {
1017
- // 未跟踪 / 非 git 仓库
1018
- }
1019
- if (tracked && !args.force) throw new Error(`"${args.path}" is git-tracked. Set force=true to delete anyway.`)
1020
- await unlink(abs)
1021
- return `Deleted ${abs}`
1022
- },
1023
- }
1024
-
1025
- // ---------------------------------------------------------------- git_diff
1026
-
1027
- const gitDiffTool = {
1028
- name: "git_diff",
1029
- description: DESC("git_diff"),
1030
- parameters: {
1031
- type: "object",
1032
- properties: {
1033
- staged: { type: "boolean", description: "Show staged changes (default false)" },
1034
- path: { type: "string", description: "File or directory to diff (default all)" },
1035
- ref: { type: "string", description: "Compare against this ref (default HEAD)" },
1036
- },
1037
- },
1038
- readonly: true,
1039
- execute(args, ctx) {
1040
- const ref = args.ref ?? "HEAD"
1041
- // ref 由模型提供且位于 "--" 之前:校验字符集,防 "--output=..." 之类被 git 当成选项
1042
- if (!/^[A-Za-z0-9._\/~^][A-Za-z0-9._\/~^-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
1043
- const flags = args.staged ? ["--staged"] : []
1044
- const paths = args.path ? [args.path] : []
1045
- const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
1046
- return truncate(out || "(no changes)")
1047
- },
1048
- }
1049
-
1050
- // ---------------------------------------------------------------- git_status
1051
-
1052
- const gitStatusTool = {
1053
- name: "git_status",
1054
- description: DESC("git_status"),
1055
- parameters: {
1056
- type: "object",
1057
- properties: {},
1058
- },
1059
- readonly: true,
1060
- execute(_args, ctx) {
1061
- const porcelain = runGit(ctx.cwd, ["status", "--porcelain"])
1062
- if (!porcelain) return "(clean — no changes)"
1063
-
1064
- const staged = []
1065
- const unstaged = []
1066
- const untracked = []
1067
- const conflicts = []
1068
- for (const line of porcelain.split("\n")) {
1069
- if (!line) continue
1070
- // porcelain: XY path — 2 状态字符 + 空格 + 文件路径(部分环境只 1 空格)
1071
- // 去掉可能的 CR(execFileSync 在某些 Windows git 下会残留 \r 在行末但不在换行符中)
1072
- const clean = line.replace(/\r/g, "")
1073
- // 尝试匹配 "XY path" 或 "XY path"(可变间距)
1074
- const m = clean.match(/^(..?)\s+(.+)$/)
1075
- if (!m) continue
1076
- const [, status, rawFile] = m
1077
- // 重命名条目 porcelain 输出为 "R old -> new",拆开明确展示而非当成一个字面文件名
1078
- const file = status.includes("R") && rawFile.includes(" -> ") ? rawFile.replace(" -> ", " → ") : rawFile
1079
- const idx = status[0] ?? " "
1080
- const wt = status[1] ?? " "
1081
- if (idx === "U" || wt === "U" || (idx === "A" && wt === "A")) {
1082
- conflicts.push(file)
1083
- } else if (idx === "?" && wt === "?") {
1084
- untracked.push(file)
1085
- } else {
1086
- if (idx !== " " && idx !== "?") staged.push(idx + " " + file)
1087
- if (wt !== " " && wt !== "?") unstaged.push(wt + " " + file)
1088
- }
1089
- }
1090
- const parts = []
1091
- if (staged.length) parts.push("Staged (" + staged.length + "):\n" + staged.join("\n"))
1092
- if (unstaged.length) parts.push("Unstaged (" + unstaged.length + "):\n" + unstaged.join("\n"))
1093
- if (untracked.length) parts.push("Untracked (" + untracked.length + "):\n" + untracked.join("\n"))
1094
- if (conflicts.length) parts.push("Conflicts (" + conflicts.length + "):\n" + conflicts.join("\n"))
1095
- return truncate(parts.join("\n\n"))
1096
- },
1097
- }
1098
-
1099
- // ---------------------------------------------------------------- git_log
1100
-
1101
- const gitLogTool = {
1102
- name: "git_log",
1103
- description: DESC("git_log"),
1104
- parameters: {
1105
- type: "object",
1106
- properties: {
1107
- count: { type: "number", description: "Number of commits (default 10)" },
1108
- path: { type: "string", description: "File or directory (default all)" },
1109
- oneline: { type: "boolean", description: "One-line-per-commit format (default false)" },
1110
- },
1111
- },
1112
- readonly: true,
1113
- execute(args, ctx) {
1114
- const n = args.count ?? 10
1115
- const isOneline = args.oneline
1116
- const cmdArgs = isOneline
1117
- ? ["log", "-" + n, "--oneline"]
1118
- : ["log", "-" + n, "--format=%h %ad %an %s", "--date=short"]
1119
- if (args.path) cmdArgs.push("--", args.path)
1120
- const out = runGit(ctx.cwd, cmdArgs)
1121
- return truncate(out || "(no commits)")
1122
- },
1123
- }
1124
-
1125
- // ---------------------------------------------------------------- question
1126
-
1127
- const questionTool = {
1128
- name: "question",
1129
- description: DESC("question"),
1130
- parameters: {
1131
- type: "object",
1132
- properties: {
1133
- question: { type: "string", description: "The question to ask the user" },
1134
- options: {
1135
- type: "array",
1136
- items: { type: "string" },
1137
- description: "Single-choice options for the user to pick from (optional)",
1138
- },
1139
- },
1140
- required: ["question"],
1141
- },
1142
- readonly: true,
1143
- async execute(args, ctx) {
1144
- if (!ctx.onQuestion) throw new Error("question tool not supported in this context (no UI to ask)")
1145
- return ctx.onQuestion(args.question, args.options ?? [])
1146
- },
1147
- }
1148
-
1149
- /** 执行 git 命令;非 git 仓库 / git 不可用时返回空字符串 */
1150
- function runGit(cwd, cmdArgs) {
1151
- try {
1152
- return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().replace(/\r/g, "")
1153
- } catch {
1154
- return ""
1155
- }
1156
- }
1157
-
1158
- // ---------------------------------------------------------------- checkpoint
1159
-
1160
- const checkpointTool = {
1161
- name: "checkpoint",
1162
- description: DESC("checkpoint"),
1163
- parameters: {
1164
- type: "object",
1165
- properties: {
1166
- action: { type: "string", enum: ["list", "create", "rewind"], description: "list snapshots / create one now / restore a snapshot by id" },
1167
- id: { type: "string", description: "Snapshot id (required for rewind)" },
1168
- },
1169
- required: ["action"],
1170
- },
1171
- readonly: false,
1172
- async execute(args, ctx) {
1173
- const { createCheckpoint, listCheckpoints, rewind, isGitRepo } = await import("./checkpoint.mjs")
1174
- if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
1175
- if (args.action === "create") {
1176
- const cp = await createCheckpoint(ctx.cwd)
1177
- return `Checkpoint ${cp.id} created (${cp.files} file(s) captured)`
1178
- }
1179
- if (args.action === "rewind") {
1180
- if (!args.id) throw new Error("id is required for rewind — use action=list to see snapshot ids")
1181
- const s = await rewind(ctx.cwd, args.id)
1182
- return `Rewound to checkpoint ${args.id}: patch ${s.patchApplied ? "applied" : "(empty)"}, ${s.restored} untracked file(s) restored, ${s.deleted} file(s) deleted.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
1183
- }
1184
- if (args.action === "list") {
1185
- const cps = await listCheckpoints(ctx.cwd)
1186
- if (cps.length === 0) return "(no checkpoints yet — one is auto-created before each user task)"
1187
- return cps.map((c) => `${c.id} ${new Date(c.time).toISOString()} ${c.untracked} untracked file(s)`).join("\n")
1188
- }
1189
- throw new Error(`Unknown action: ${args.action}`)
1190
- },
1191
- }
1192
-
1193
- export { deleteTool, gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool }
1194
- builtinTools.push(deleteTool, gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool)
1
+ // Stub — see src/tools/index.mjs for actual implementation
2
+ export * from "./tools/index.mjs"