thincoder 0.6.0 → 0.7.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.
package/src/tools.mjs CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * tools.mjs — 内置工具集
3
- * read / write / edit / bash / glob / grep / websearch / ls / fetch / delete / git_diff / git_status / git_log / question,零依赖实现。
3
+ * read / write / edit / insert_after / apply_patch / bash / glob / grep / websearch / ls / fetch / delete / git_diff / git_status / git_log / question / checkpoint,零依赖实现。
4
4
  * 工具描述从 src/tools/*.md 加载(方便人读和修改)。
5
5
  * readonly 标记供 agent 调度:只读工具可并行,有副作用工具串行。
6
6
  */
7
7
 
8
8
  import { spawn, execFileSync } from "node:child_process"
9
9
  import { mkdir, readFile, readdir, stat, writeFile, unlink } from "node:fs/promises"
10
- import { readFileSync, existsSync } from "node:fs"
11
- import { dirname, join, resolve, relative } from "node:path"
10
+ import { readFileSync, existsSync, realpathSync } from "node:fs"
11
+ import { dirname, join, resolve, relative, isAbsolute, sep } from "node:path"
12
12
  import { fileURLToPath } from "node:url"
13
13
 
14
14
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -19,6 +19,7 @@ const MAX_READ_LINES = 2000
19
19
  // 这里截断必须远高于落盘阈值,否则被截掉的内容在落盘前就永远丢了
20
20
  const MAX_OUTPUT_CHARS = 200_000
21
21
  const BASH_TIMEOUT_MS = 120_000
22
+ const MAX_RESPONSE_BODY_BYTES = 5_000_000
22
23
  const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
23
24
 
24
25
  /** 每个工具:{ name, description, parameters, readonly, execute(args, ctx) }(定义见文件末尾导出) */
@@ -45,7 +46,59 @@ function sanitizeOutput(s) {
45
46
 
46
47
  function truncate(text, max = MAX_OUTPUT_CHARS) {
47
48
  if (text.length <= max) return text
48
- return text.slice(0, max) + `\n... (truncated, ${text.length - max} chars omitted)`
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
+ }
49
102
  }
50
103
 
51
104
  /** 对单个文件取 git diff,失败静默返回空 */
@@ -64,12 +117,83 @@ function gitDiffOne(cwd, abs) {
64
117
  }
65
118
  }
66
119
 
120
+ /** 目标可能不存在(write 新文件),逐级向上找真实存在的祖先做 realpath */
121
+ function realpathNearest(abs) {
122
+ let cur = abs
123
+ const tail = []
124
+ while (!existsSync(cur)) {
125
+ const parent = dirname(cur)
126
+ if (parent === cur) return abs // 到根了,原样返回(不会发生)
127
+ tail.unshift(cur.slice(parent.length + 1))
128
+ cur = parent
129
+ }
130
+ try {
131
+ const real = realpathSync(cur)
132
+ return tail.length ? join(real, ...tail) : real
133
+ } catch {
134
+ return abs
135
+ }
136
+ }
137
+
138
+ // cwd 的 realpath 缓存:进程内 cwd 不变,只需解析一次
139
+ const realCwdCache = new Map()
140
+ function realCwd(cwd) {
141
+ if (!realCwdCache.has(cwd)) realCwdCache.set(cwd, realpathNearest(resolve(cwd)))
142
+ return realCwdCache.get(cwd)
143
+ }
144
+
145
+ function assertInside(cwd, resolved, p) {
146
+ const rel = relative(cwd, resolved)
147
+ // 跨盘符时 relative 返回绝对路径(Windows);startsWith("..") 会误伤 cwd 内的 "..foo",故精确判断
148
+ if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
149
+ throw new Error(`Access denied outside working directory: ${p}`)
150
+ }
151
+ }
152
+
67
153
  function resolveInCwd(ctx, p) {
68
- const resolved = resolve(ctx.cwd, p)
69
- if (relative(ctx.cwd, resolved).startsWith("..")) throw new Error(`Access denied outside working directory: ${p}`)
154
+ const cwd = realCwd(ctx.cwd)
155
+ const resolved = resolve(cwd, p)
156
+ assertInside(cwd, resolved, p)
157
+ // 防 symlink 逃逸:cwd 内若存在指向外部的符号链接,realpath 后会落到 cwd 外
158
+ const real = realpathNearest(resolved)
159
+ assertInside(cwd, real, p)
70
160
  return resolved
71
161
  }
72
162
 
163
+ /**
164
+ * 破坏性预检用的粗切分:&& || ; | 换行 命令替换 子 shell 都视作命令边界。
165
+ * 宁多切不少切——防 "cd x && git checkout ."、"echo $(git checkout .)" 这类写法绕过行首锚定
166
+ */
167
+ function shellSegments(command) {
168
+ return command.split(/&&|\|\||[;|\n]|\$\(|`|[(]/)
169
+ }
170
+
171
+ /**
172
+ * 单个命令段是否会销毁未提交改动:
173
+ * checkout -- / checkout . / reset --hard / clean -f* / restore(动工作区的)
174
+ * checkout <branch>、restore --staged、clean -n(dry-run)不算
175
+ */
176
+ function isDestructiveGitSegment(seg) {
177
+ if (!/^\s*git\s/.test(seg)) return false
178
+ if (/\scheckout\s+(?:--|\.(?:\s|$))/.test(seg)) return true
179
+ if (/\sreset\s+--hard\b/.test(seg)) return true
180
+ if (/\sclean\s+-\S*f/.test(seg)) return true
181
+ if (/\srestore\s/.test(seg) && (/--worktree/.test(seg) || !/--staged/.test(seg))) return true
182
+ return false
183
+ }
184
+
185
+ /** cwd 是否在 git 仓库内(预检用,失败静默视为不在) */
186
+ function insideGitRepo(cwd) {
187
+ try {
188
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
189
+ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
190
+ })
191
+ return true
192
+ } catch {
193
+ return false
194
+ }
195
+ }
196
+
73
197
  // ---------------------------------------------------------------- read
74
198
 
75
199
  const readTool = {
@@ -87,6 +211,7 @@ const readTool = {
87
211
  readonly: true,
88
212
  async execute(args, ctx) {
89
213
  const abs = resolveInCwd(ctx, args.path)
214
+ // 注意:整文件一次性读入内存,大文件会被完整缓冲(offset/limit 只影响返回切片)
90
215
  const content = await readFile(abs, "utf8")
91
216
  const lines = content.split("\n")
92
217
  const offset = Math.max(1, args.offset ?? 1)
@@ -141,6 +266,9 @@ const editTool = {
141
266
  readonly: false,
142
267
  async execute(args, ctx) {
143
268
  const abs = resolveInCwd(ctx, args.path)
269
+ if (!args.old_string) {
270
+ throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
271
+ }
144
272
  const content = await readFile(abs, "utf8")
145
273
  const occurrences = content.split(args.old_string).length - 1
146
274
  if (occurrences === 0) {
@@ -157,13 +285,224 @@ const editTool = {
157
285
  }
158
286
  const updated = args.replace_all
159
287
  ? content.split(args.old_string).join(args.new_string)
160
- : content.replace(args.old_string, args.new_string)
288
+ // 函数式替换:避免 new_string 里的 $ 替换模式(匹配串/前后文引用)被展开
289
+ : content.replace(args.old_string, () => args.new_string)
161
290
  await writeFile(abs, updated, "utf8")
162
291
  const diff = gitDiffOne(ctx.cwd, abs)
163
292
  return `Edited ${abs}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}`
164
293
  },
165
294
  }
166
295
 
296
+ // ---------------------------------------------------------------- insert_after
297
+
298
+ const insertAfterTool = {
299
+ name: "insert_after",
300
+ description: DESC("insert_after"),
301
+ parameters: {
302
+ type: "object",
303
+ properties: {
304
+ path: { type: "string", description: "File path" },
305
+ after_line: { type: "number", description: "Line number to insert after (1-based). Takes priority over after_regex." },
306
+ after_regex: { type: "string", description: "JavaScript regex to find the line to insert after (must match exactly one line)" },
307
+ content: { type: "string", description: "Text to insert (with leading newline if you need a blank line)" },
308
+ },
309
+ required: ["path", "content"],
310
+ },
311
+ readonly: false,
312
+ async execute(args, ctx) {
313
+ const abs = resolveInCwd(ctx, args.path)
314
+ const text = await readFile(abs, "utf8")
315
+ const lines = text.split("\n")
316
+
317
+ let targetLine
318
+ if (args.after_line != null) {
319
+ targetLine = args.after_line
320
+ if (!Number.isInteger(targetLine)) {
321
+ throw new Error(`after_line must be an integer, got: ${args.after_line}`)
322
+ }
323
+ if (targetLine < 1 || targetLine > lines.length) {
324
+ throw new Error(`after_line ${targetLine} out of range (file has ${lines.length} lines)`)
325
+ }
326
+ } else if (args.after_regex) {
327
+ const regex = new RegExp(args.after_regex)
328
+ const matches = []
329
+ for (let i = 0; i < lines.length; i++) {
330
+ if (regex.test(lines[i])) matches.push(i + 1)
331
+ }
332
+ if (matches.length === 0) throw new Error(`after_regex /${args.after_regex}/ matched no lines in ${abs}`)
333
+ 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`)
334
+ targetLine = matches[0]
335
+ } else {
336
+ throw new Error("Either after_line or after_regex is required")
337
+ }
338
+
339
+ lines.splice(targetLine, 0, args.content)
340
+ const updated = lines.join("\n")
341
+ await writeFile(abs, updated, "utf8")
342
+ const diff = gitDiffOne(ctx.cwd, abs)
343
+ return `Inserted after line ${targetLine} in ${abs}${diff ? "\n" + diff : ""}`
344
+ },
345
+ }
346
+
347
+ // ---------------------------------------------------------------- apply_patch
348
+
349
+ /**
350
+ * 解析统一 diff(unified diff):返回 [{ path, isNew, hunks: [{ ops: [{type:" "|"-"|"+", text}] }] }]
351
+ * 按 @@ 头的行数计数消费 hunk 行——LLM 常把上下文空行剥成纯空行,靠计数而不是行首字符判断 hunk 边界
352
+ */
353
+ function parsePatch(patch) {
354
+ // 补丁文本常来自 CRLF 终端/模型输出,行尾 \r 会混进 hunk 内容导致上下文对不上,统一剥掉
355
+ const lines = patch.replace(/\r(?=\n|$)/g, "").split("\n")
356
+ const files = []
357
+ let cur = null
358
+ let i = 0
359
+ const stripPrefix = (p) => p.replace(/^[ab]\//, "")
360
+ while (i < lines.length) {
361
+ const line = lines[i]
362
+ if (line.startsWith("--- ")) {
363
+ const oldPath = line.slice(4).trim()
364
+ const plus = lines[i + 1]
365
+ if (!plus?.startsWith("+++ ")) throw new Error(`Malformed patch: expected "+++" line after "${line}"`)
366
+ const newPath = plus.slice(4).trim()
367
+ if (newPath === "/dev/null") throw new Error("Deleting files via patch is not supported — use the delete tool")
368
+ cur = { path: stripPrefix(newPath), isNew: oldPath === "/dev/null", hunks: [] }
369
+ files.push(cur)
370
+ i += 2
371
+ continue
372
+ }
373
+ if (line.startsWith("@@")) {
374
+ if (!cur) throw new Error("Malformed patch: hunk header before any file header")
375
+ const m = line.match(/^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/)
376
+ if (!m) throw new Error(`Malformed patch: bad hunk header "${line}" (need @@ -old,count +new,count @@)`)
377
+ let oldNeed = m[1] == null ? 1 : Number(m[1])
378
+ let newNeed = m[2] == null ? 1 : Number(m[2])
379
+ const hunk = { ops: [] }
380
+ i++
381
+ while (oldNeed > 0 || newNeed > 0) {
382
+ if (i >= lines.length) throw new Error("Malformed patch: hunk truncated (line counts in @@ header not satisfied)")
383
+ const hl = lines[i]
384
+ if (hl.startsWith("\\")) { i++; continue } // ""
385
+ const tag = hl === "" ? " " : hl[0] // 纯空行按上下文行宽容处理
386
+ const text = hl === "" ? "" : hl.slice(1)
387
+ if (tag === " ") { hunk.ops.push({ type: " ", text }); oldNeed--; newNeed-- }
388
+ else if (tag === "-") { hunk.ops.push({ type: "-", text }); oldNeed-- }
389
+ else if (tag === "+") { hunk.ops.push({ type: "+", text }); newNeed-- }
390
+ else throw new Error(`Malformed patch: unexpected line "${hl.slice(0, 60)}" inside hunk`)
391
+ i++
392
+ }
393
+ cur.hunks.push(hunk)
394
+ continue
395
+ }
396
+ i++ // diff --git / index / 空行等元信息跳过
397
+ }
398
+ if (files.length === 0) throw new Error("No file changes found in patch (need --- / +++ headers)")
399
+ return files
400
+ }
401
+
402
+ /** 在内存行数组上按序应用 hunks;任何一步失败抛错(调用方保证不落盘)。比较时忽略行尾 \r,上下文行保留原始字节 */
403
+ function applyHunks(fileLines, hunks, eol, path) {
404
+ const cr = eol === "\r\n" ? "\r" : ""
405
+ for (let h = 0; h < hunks.length; h++) {
406
+ const oldSeq = hunks[h].ops.filter((o) => o.type !== "+").map((o) => o.text)
407
+ if (oldSeq.length === 0) throw new Error(`Hunk ${h + 1} in ${path} has no context/removed lines to locate`)
408
+ const matches = []
409
+ for (let pos = 0; pos + oldSeq.length <= fileLines.length; pos++) {
410
+ let ok = true
411
+ for (let j = 0; j < oldSeq.length; j++) {
412
+ if (fileLines[pos + j].replace(/\r$/, "") !== oldSeq[j]) { ok = false; break }
413
+ }
414
+ if (ok) matches.push(pos)
415
+ }
416
+ if (matches.length === 0) {
417
+ const preview = oldSeq.slice(0, 3).join(" ⏎ ")
418
+ 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.`)
419
+ }
420
+ if (matches.length > 1) throw new Error(`Hunk ${h + 1} in ${path} matches ${matches.length} locations — add more context lines to make it unique`)
421
+ const pos = matches[0]
422
+ const out = []
423
+ let src = pos
424
+ for (const op of hunks[h].ops) {
425
+ if (op.type === " ") out.push(fileLines[src++]) // 上下文保留原始行(行尾/空白原样)
426
+ else if (op.type === "-") src++
427
+ else out.push(op.text + cr)
428
+ }
429
+ fileLines.splice(pos, oldSeq.length, ...out)
430
+ }
431
+ }
432
+
433
+ const applyPatchTool = {
434
+ name: "apply_patch",
435
+ description: DESC("apply_patch"),
436
+ parameters: {
437
+ type: "object",
438
+ properties: {
439
+ 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." },
440
+ },
441
+ required: ["patch"],
442
+ },
443
+ readonly: false,
444
+ /** 供 agent 层追踪触碰文件(多路径,替代单 path 参数) */
445
+ touchedPaths(args) {
446
+ try { return parsePatch(args.patch ?? "").map((f) => f.path) } catch { return [] }
447
+ },
448
+ async execute(args, ctx) {
449
+ const files = parsePatch(args.patch ?? "")
450
+ // 先全部读入内存试算:任何一个 hunk 不上就整体抛错,不写半个补丁(原子性)
451
+ const planned = []
452
+ for (const f of files) {
453
+ const abs = resolveInCwd(ctx, f.path)
454
+ if (f.isNew) {
455
+ if (existsSync(abs)) throw new Error(`Cannot create ${f.path}: file already exists`)
456
+ const content = f.hunks.flatMap((h) => h.ops.filter((o) => o.type === "+").map((o) => o.text)).join("\n") + "\n"
457
+ planned.push({ abs, path: f.path, content, isNew: true })
458
+ } else {
459
+ const original = await readFile(abs, "utf8").catch(() => { throw new Error(`File not found: ${f.path}`) })
460
+ const eol = original.includes("\r\n") ? "\r\n" : "\n"
461
+ const lines = original.split("\n")
462
+ applyHunks(lines, f.hunks, eol, f.path)
463
+ planned.push({ abs, path: f.path, content: lines.join("\n"), isNew: false })
464
+ }
465
+ }
466
+ for (const p of planned) {
467
+ await mkdir(dirname(p.abs), { recursive: true })
468
+ await writeFile(p.abs, p.content, "utf8")
469
+ }
470
+ const summary = planned.map((p) => ` ${p.isNew ? "created " : "modified"} ${p.path}`).join("\n")
471
+ return `Applied patch to ${planned.length} file(s):\n${summary}`
472
+ },
473
+ }
474
+
475
+ // ---------------------------------------------------------------- syntax_check
476
+
477
+ const syntaxCheckTool = {
478
+ name: "syntax_check",
479
+ description: DESC("syntax_check"),
480
+ parameters: {
481
+ type: "object",
482
+ properties: {
483
+ path: { type: "string", description: "File path (.js/.mjs/.cjs only)" },
484
+ },
485
+ required: ["path"],
486
+ },
487
+ readonly: true,
488
+ execute(args, ctx) {
489
+ const abs = resolveInCwd(ctx, args.path)
490
+ if (!/\.(?:[mc]?js)$/.test(abs)) {
491
+ return `syntax_check only supports .js/.mjs/.cjs files; ${abs} skipped.`
492
+ }
493
+ try {
494
+ execFileSync(process.execPath, ["--check", abs], {
495
+ cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
496
+ })
497
+ return `Syntax OK: ${abs}`
498
+ } catch (e) {
499
+ // node --check 把错误写到 stderr
500
+ const msg = (e.stderr || e.stdout || e.message || "").trim()
501
+ return `Syntax error in ${abs}:\n${msg || "(unknown)"}`
502
+ }
503
+ },
504
+ }
505
+
167
506
  // ---------------------------------------------------------------- bash
168
507
 
169
508
  const bashTool = {
@@ -179,27 +518,38 @@ const bashTool = {
179
518
  },
180
519
  readonly: false,
181
520
  async execute(args, ctx) {
182
- // 安全预检:销毁性 git 操作(checkout -- / reset --hard)先检查未提交改动,
183
- // 有则拒绝——防一键清掉几小时工作(像今天 git checkout -- 六个文件那次)
184
- const DESTRUCTIVE_GIT = /^git\s+(?:checkout\s+--?\s+|reset\s+--hard\b)/
185
- if (DESTRUCTIVE_GIT.test(args.command)) {
521
+ // 安全预检:销毁性 git 操作先检查未提交改动,有则拒绝——防一键清掉几小时工作
522
+ if (shellSegments(args.command).some(isDestructiveGitSegment)) {
523
+ if (!insideGitRepo(ctx.cwd)) {
524
+ throw new Error(`Refusing destructive git command: not a git repository: ${ctx.cwd}`)
525
+ }
186
526
  const status = execFileSync("git", ["status", "--porcelain"], {
187
527
  cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
188
528
  }).trim()
189
529
  if (status) {
190
530
  throw new Error(
191
- `Refusing destructive git command: uncommitted changes exist. Commit or stash first.\n\n${status}`
531
+ `Refusing destructive git command: uncommitted changes exist. Commit or stash first.\n` +
532
+ `(If uncommitted work was already lost, the checkpoint tool can restore the auto-snapshot: action=list, then action=rewind.)\n\n${status}`
192
533
  )
193
534
  }
194
535
  }
195
536
 
196
537
  return new Promise((resolve) => {
538
+ // detached: 让子进程成为进程组组长,超时/中断时才能整树杀掉(POSIX 用负 pid 组杀,
539
+ // Windows 用 taskkill /T)——只 kill 壳进程会把孙进程(如 npm test)留在后台继续跑
540
+ const killTree = () => {
541
+ if (process.platform === "win32") {
542
+ try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
543
+ } else {
544
+ try { process.kill(-child.pid, "SIGKILL") } catch {}
545
+ try { child.kill("SIGKILL") } catch {} // 组杀失败时兜底杀本体
546
+ }
547
+ }
197
548
  const child = spawn(args.command, {
198
549
  cwd: ctx.cwd,
199
550
  shell: true,
200
551
  windowsHide: true,
201
- // TTY 环境:stdin 置空(vim/less 这类交互程序立刻吃到 EOF 退出,而不是干等),
202
- // 并通过环境变量缴械编辑器/分页器/花哨输出
552
+ detached: process.platform !== "win32",
203
553
  stdio: ["ignore", "pipe", "pipe"],
204
554
  env: {
205
555
  ...process.env,
@@ -211,71 +561,50 @@ const bashTool = {
211
561
  TERM: "dumb",
212
562
  },
213
563
  })
214
- // 编码嗅探:cmd 自带消息是 GBK,git/node 等程序是 UTF-8,平台判断不了。
215
- // 策略:纯 ASCII 段两种编码一致,直接透传不判定;遇到高位字节才用
216
- // fatal UTF-8 试解(容忍尾部 1~3 字节截断),失败则判 GBK;一经判定不再变更。
217
- // 已知边界:GBK 字节流极低概率恰好构成合法 UTF-8 序列,会误判为 UTF-8 产生乱码。
218
- // 更严谨的做法是 chcp 探测控制台代码页,但当前策略覆盖 99.9% 场景,不值得那份复杂度。
219
- let decoder = null
220
- let pending = Buffer.alloc(0)
221
- const feed = (d, flush = false) => {
222
- pending = Buffer.concat([pending, d])
223
- if (!decoder) {
224
- const hasHighByte = pending.some((b) => b >= 0x80)
225
- if (!hasHighByte) {
226
- // 纯 ASCII:UTF-8/GBK 完全一致,透传即可(无需判定)
227
- const s = pending.toString("ascii")
228
- pending = Buffer.alloc(0)
229
- return s
230
- }
231
- for (let trim = 0; trim <= 3 && !decoder; trim++) {
232
- try {
233
- new TextDecoder("utf-8", { fatal: true }).decode(pending.subarray(0, pending.length - trim))
234
- decoder = new TextDecoder("utf-8")
235
- } catch {
236
- // 继续尝试
237
- }
238
- }
239
- if (!decoder) decoder = new TextDecoder("gbk")
240
- }
241
- const s = decoder.decode(pending, { stream: !flush })
242
- pending = Buffer.alloc(0)
243
- return s
244
- }
245
-
246
- let out = ""
564
+ // stdout / stderr 各自独立解码(同进程通常同编码,但分开收集更干净,
565
+ // 且允许模型按 stderr 快速定位错误)
566
+ const outDecoder = makeDecoder()
567
+ const errDecoder = makeDecoder()
568
+ let outBuf = ""
569
+ let errBuf = ""
247
570
  let truncatedNote = ""
248
- const onData = (d) => {
249
- const s = sanitizeOutput(feed(d))
250
- // 输出实时透传给 UI;本地缓冲超 2MB 后停止累积(防内存爆炸)
251
- if (s) ctx.onOutput?.(s)
252
- if (out.length < 2_000_000) {
253
- out += s
254
- } else if (!truncatedNote) {
255
- truncatedNote = "\n... (output exceeded 2MB, remainder discarded)"
571
+
572
+ const onStdout = (d) => {
573
+ const s = sanitizeOutput(outDecoder(d))
574
+ if (s) {
575
+ ctx.onOutput?.(s)
576
+ if (outBuf.length < 2_000_000) outBuf += s
577
+ else if (!truncatedNote) truncatedNote = "\n[... output exceeded 2MB, remainder discarded]"
256
578
  }
257
579
  }
258
- child.stdout.on("data", onData)
259
- child.stderr.on("data", onData)
580
+ const onStderr = (d) => {
581
+ const s = sanitizeOutput(errDecoder(d)) // 始终解码,防 pending 无限累积
582
+ if (errBuf.length < 2_000_000) errBuf += s
583
+ }
260
584
 
261
- const timer = setTimeout(() => child.kill(), args.timeout ?? BASH_TIMEOUT_MS)
262
- // 用户中止:杀进程
585
+ child.stdout.on("data", onStdout)
586
+ child.stderr.on("data", onStderr)
587
+
588
+ const timer = setTimeout(killTree, args.timeout ?? BASH_TIMEOUT_MS)
263
589
  if (ctx.signal) {
264
- ctx.signal.addEventListener("abort", () => child.kill(), { once: true })
590
+ ctx.signal.addEventListener("abort", killTree, { once: true })
265
591
  }
266
592
  child.on("error", (error) => {
267
593
  clearTimeout(timer)
268
- resolve(truncate(`Command failed: ${error.message}\n${out}`))
594
+ resolve(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
269
595
  })
270
596
  child.on("close", (code, signal) => {
271
597
  clearTimeout(timer)
272
- out += sanitizeOutput(feed(Buffer.alloc(0), true)) // 最终判定 + 冲刷解码器尾部
273
- const suffix = signal
274
- ? `\n(killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"})`
275
- : code !== 0
276
- ? `\n(exit code ${code})`
277
- : ""
278
- resolve(truncate((out.trim() || "(no output)") + suffix + truncatedNote))
598
+ // 冲刷解码器尾部
599
+ outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
600
+ errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
601
+ const status = signal
602
+ ? `killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"}`
603
+ : `exit code ${code}`
604
+ const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
605
+ if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
606
+ parts.push(`(${status})`)
607
+ resolve(truncate(parts.join("\n\n") + truncatedNote))
279
608
  })
280
609
  })
281
610
  },
@@ -334,7 +663,7 @@ function globToRegex(pattern) {
334
663
  const DS = "\u0001" // **/ 的占位符(零或多级目录)
335
664
  const DP = "\u0002" // ** 的占位符
336
665
  const escaped = pattern
337
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
666
+ .replace(/[.+^${}()|\\]/g, "\\$&") // [ ] 不转义,保留为 glob 字符组语法
338
667
  .replace(/\*\*\//g, DS)
339
668
  .replace(/\*\*/g, DP)
340
669
  .replace(/\*/g, "[^/]*")
@@ -355,6 +684,8 @@ const grepTool = {
355
684
  pattern: { type: "string", description: "Regular expression" },
356
685
  path: { type: "string", description: "Directory or file to search (default cwd)" },
357
686
  glob: { type: "string", description: "Only search files matching this glob (e.g. '*.mjs')" },
687
+ before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
688
+ after: { type: "integer", description: "Lines of context to show after each match (grep -A). Default 0" },
358
689
  },
359
690
  required: ["pattern"],
360
691
  },
@@ -363,26 +694,31 @@ const grepTool = {
363
694
  const base = resolveInCwd(ctx, args.path ?? ".")
364
695
  const regex = new RegExp(args.pattern)
365
696
  const fileFilter = args.glob ? globToRegex(args.glob) : null
366
- const matches = []
697
+ const before = Math.max(0, Math.floor(args.before ?? 0))
698
+ const after = Math.max(0, Math.floor(args.after ?? 0))
699
+ const wantCtx = before > 0 || after > 0
700
+ const hits = [] // { file, line(1-based), text }
701
+ const fileLines = new Map() // file -> string[](仅 wantCtx 时缓存)
367
702
 
368
703
  async function search(file) {
369
704
  let content
370
705
  try {
371
706
  content = await readFile(file, "utf8")
372
707
  } catch {
373
- return // 二进制/不可读文件跳过
708
+ return // 不可读文件跳过;二进制会被按 utf8 读入并照常搜索(可能产生乱码匹配)
374
709
  }
375
710
  const lines = content.split("\n")
711
+ if (wantCtx) fileLines.set(file, lines)
376
712
  for (let i = 0; i < lines.length; i++) {
377
713
  if (regex.test(lines[i])) {
378
- matches.push(`${file}:${i + 1}: ${lines[i]}`)
379
- if (matches.length >= 200) return
714
+ hits.push({ file, line: i + 1, text: lines[i] })
715
+ if (hits.length >= 200) return
380
716
  }
381
717
  }
382
718
  }
383
719
 
384
720
  async function walk(target) {
385
- if (matches.length >= 200) return
721
+ if (hits.length >= 200) return
386
722
  const s = await stat(target)
387
723
  if (!s.isDirectory()) {
388
724
  if (!fileFilter || fileFilter.test(target.split(/[\\/]/).pop())) await search(target)
@@ -401,8 +737,32 @@ const grepTool = {
401
737
  }
402
738
 
403
739
  await walk(base)
404
- if (matches.length === 0) return "(no matches)"
405
- return truncate(matches.join("\n"))
740
+ if (hits.length === 0) return "(no matches)"
741
+
742
+ // 无上下文:保持原 path:line: content 格式
743
+ if (!wantCtx) {
744
+ return truncate(hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n"))
745
+ }
746
+
747
+ // 带上下文:匹配行用 ':',上下文行用 '-'(同 ripgrep);同文件相邻区间去重合并
748
+ const fileMatched = new Map() // file -> Set<line>
749
+ for (const h of hits) {
750
+ if (!fileMatched.has(h.file)) fileMatched.set(h.file, new Set())
751
+ fileMatched.get(h.file).add(h.line)
752
+ }
753
+ const out = []
754
+ for (const [file, matchedLines] of fileMatched) {
755
+ const lines = fileLines.get(file) ?? []
756
+ const lineSet = new Set()
757
+ for (const ml of matchedLines) {
758
+ for (let l = Math.max(1, ml - before); l <= Math.min(lines.length, ml + after); l++) lineSet.add(l)
759
+ }
760
+ for (const l of [...lineSet].sort((a, b) => a - b)) {
761
+ const sep = matchedLines.has(l) ? ":" : "-"
762
+ out.push(`${file}${sep}${l}${sep} ${lines[l - 1]}`)
763
+ }
764
+ }
765
+ return truncate(out.join("\n"))
406
766
  },
407
767
  }
408
768
 
@@ -432,7 +792,7 @@ const websearchTool = {
432
792
  : AbortSignal.timeout(15_000),
433
793
  })
434
794
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
435
- html = await response.text()
795
+ html = await readBodyText(response)
436
796
  } catch (error) {
437
797
  throw new Error(`websearch request failed: ${error.cause?.code ?? error.message}`)
438
798
  }
@@ -464,10 +824,10 @@ function stripTags(html) {
464
824
  .replace(/&#0*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
465
825
  .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
466
826
  .replace(/&ensp;/g, " ")
467
- .replace(/&amp;/g, "&")
468
827
  .replace(/&lt;/g, "<")
469
828
  .replace(/&gt;/g, ">")
470
829
  .replace(/&quot;/g, '"')
830
+ .replace(/&amp;/g, "&") // &amp; 必须最后解码,否则 &amp;lt; 会被二次解码成 <
471
831
  .replace(/\s+/g, " ")
472
832
  .trim()
473
833
  }
@@ -536,7 +896,7 @@ const fetchTool = {
536
896
  if (!response.ok) throw new Error(`fetch failed: HTTP ${response.status}`)
537
897
 
538
898
  const contentType = response.headers.get("content-type") ?? ""
539
- const body = await response.text()
899
+ const body = await readBodyText(response)
540
900
  if (!contentType.includes("text/html")) return truncate(body)
541
901
  return truncate(htmlToText(body))
542
902
  },
@@ -555,16 +915,16 @@ function htmlToText(html) {
555
915
  .replace(/&#0*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
556
916
  .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
557
917
  .replace(/&nbsp;|&ensp;/g, " ")
558
- .replace(/&amp;/g, "&")
559
918
  .replace(/&lt;/g, "<")
560
919
  .replace(/&gt;/g, ">")
561
920
  .replace(/&quot;/g, '"')
921
+ .replace(/&amp;/g, "&") // &amp; 必须最后解码,否则 &amp;lt; 会被二次解码成 <
562
922
  .replace(/[ \t]+/g, " ")
563
923
  .replace(/\n\s*\n\s*\n+/g, "\n\n")
564
924
  .trim()
565
925
  }
566
926
 
567
- export const builtinTools = [readTool, writeTool, editTool, bashTool, globTool, grepTool, websearchTool, lsTool, fetchTool]
927
+ export const builtinTools = [readTool, writeTool, editTool, insertAfterTool, applyPatchTool, syntaxCheckTool, bashTool, globTool, grepTool, websearchTool, lsTool, fetchTool]
568
928
 
569
929
  // ---------------------------------------------------------------- delete
570
930
 
@@ -617,6 +977,8 @@ const gitDiffTool = {
617
977
  readonly: true,
618
978
  execute(args, ctx) {
619
979
  const ref = args.ref ?? "HEAD"
980
+ // ref 由模型提供且位于 "--" 之前:校验字符集,防 "--output=..." 之类被 git 当成选项
981
+ if (!/^[A-Za-z0-9._\/~^][A-Za-z0-9._\/~^-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
620
982
  const flags = args.staged ? ["--staged"] : []
621
983
  const paths = args.path ? [args.path] : []
622
984
  const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
@@ -650,7 +1012,9 @@ const gitStatusTool = {
650
1012
  // 尝试匹配 "XY path" 或 "XY path"(可变间距)
651
1013
  const m = clean.match(/^(..?)\s+(.+)$/)
652
1014
  if (!m) continue
653
- const [, status, file] = m
1015
+ const [, status, rawFile] = m
1016
+ // 重命名条目 porcelain 输出为 "R old -> new",拆开明确展示而非当成一个字面文件名
1017
+ const file = status.includes("R") && rawFile.includes(" -> ") ? rawFile.replace(" -> ", " → ") : rawFile
654
1018
  const idx = status[0] ?? " "
655
1019
  const wt = status[1] ?? " "
656
1020
  if (idx === "U" || wt === "U" || (idx === "A" && wt === "A")) {
@@ -730,5 +1094,40 @@ function runGit(cwd, cmdArgs) {
730
1094
  }
731
1095
  }
732
1096
 
733
- export { deleteTool, gitDiffTool, gitStatusTool, gitLogTool, questionTool }
734
- builtinTools.push(deleteTool, gitDiffTool, gitStatusTool, gitLogTool, questionTool)
1097
+ // ---------------------------------------------------------------- checkpoint
1098
+
1099
+ const checkpointTool = {
1100
+ name: "checkpoint",
1101
+ description: DESC("checkpoint"),
1102
+ parameters: {
1103
+ type: "object",
1104
+ properties: {
1105
+ action: { type: "string", enum: ["list", "create", "rewind"], description: "list snapshots / create one now / restore a snapshot by id" },
1106
+ id: { type: "string", description: "Snapshot id (required for rewind)" },
1107
+ },
1108
+ required: ["action"],
1109
+ },
1110
+ readonly: false,
1111
+ async execute(args, ctx) {
1112
+ const { createCheckpoint, listCheckpoints, rewind, isGitRepo } = await import("./checkpoint.mjs")
1113
+ if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
1114
+ if (args.action === "create") {
1115
+ const cp = await createCheckpoint(ctx.cwd)
1116
+ return `Checkpoint ${cp.id} created (${cp.files} file(s) captured)`
1117
+ }
1118
+ if (args.action === "rewind") {
1119
+ if (!args.id) throw new Error("id is required for rewind — use action=list to see snapshot ids")
1120
+ const s = await rewind(ctx.cwd, args.id)
1121
+ 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.)`
1122
+ }
1123
+ if (args.action === "list") {
1124
+ const cps = await listCheckpoints(ctx.cwd)
1125
+ if (cps.length === 0) return "(no checkpoints yet — one is auto-created before each user task)"
1126
+ return cps.map((c) => `${c.id} ${new Date(c.time).toISOString()} ${c.untracked} untracked file(s)`).join("\n")
1127
+ }
1128
+ throw new Error(`Unknown action: ${args.action}`)
1129
+ },
1130
+ }
1131
+
1132
+ export { deleteTool, gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool }
1133
+ builtinTools.push(deleteTool, gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool)