thincoder 0.4.0 → 0.6.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/src/tools.mjs CHANGED
@@ -15,7 +15,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
15
15
  const DESC = (name) => readFileSync(join(__dirname, "tools", `${name}.md`), "utf8")
16
16
 
17
17
  const MAX_READ_LINES = 2000
18
- const MAX_OUTPUT_CHARS = 50_000
18
+ // 输出上限只是内存安全阀:超过 16k 的输出由 agent 层 offload 整体落盘(全量保留、预览+路径回喂),
19
+ // 这里截断必须远高于落盘阈值,否则被截掉的内容在落盘前就永远丢了
20
+ const MAX_OUTPUT_CHARS = 200_000
19
21
  const BASH_TIMEOUT_MS = 120_000
20
22
  const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
21
23
 
@@ -36,7 +38,7 @@ export function toOpenAISchema(tool) {
36
38
  /** 剥离 ANSI 转义序列(vim/less/颜色码会冲花 TUI 渲染),并把 \r 进度条改写转成换行 */
37
39
  function sanitizeOutput(s) {
38
40
  return s
39
- .replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g, "")
41
+ .replace(/\x1b\[[0-9;?]*[\x40-\x7E]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g, "")
40
42
  .replace(/\r\n/g, "\n")
41
43
  .replace(/\r/g, "\n")
42
44
  }
@@ -46,8 +48,26 @@ function truncate(text, max = MAX_OUTPUT_CHARS) {
46
48
  return text.slice(0, max) + `\n... (truncated, ${text.length - max} chars omitted)`
47
49
  }
48
50
 
51
+ /** 对单个文件取 git diff,失败静默返回空 */
52
+ function gitDiffOne(cwd, abs) {
53
+ try {
54
+ const diff = execFileSync("git", ["--no-pager", "diff", "--no-color", "--", abs], {
55
+ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1024 * 1024,
56
+ }).trim()
57
+ if (!diff) return ""
58
+ // 截断超长 diff(超大文件改动 diff 可能几十 KB),只保留前 200 行
59
+ const lines = diff.split("\n")
60
+ if (lines.length <= 200) return diff
61
+ return lines.slice(0, 200).join("\n") + `\n... (${lines.length - 200} more diff lines)`
62
+ } catch {
63
+ return ""
64
+ }
65
+ }
66
+
49
67
  function resolveInCwd(ctx, p) {
50
- return resolve(ctx.cwd, p)
68
+ const resolved = resolve(ctx.cwd, p)
69
+ if (relative(ctx.cwd, resolved).startsWith("..")) throw new Error(`Access denied outside working directory: ${p}`)
70
+ return resolved
51
71
  }
52
72
 
53
73
  // ---------------------------------------------------------------- read
@@ -95,8 +115,11 @@ const writeTool = {
95
115
  async execute(args, ctx) {
96
116
  const abs = resolveInCwd(ctx, args.path)
97
117
  await mkdir(dirname(abs), { recursive: true })
118
+ const st = await stat(abs).catch(() => null)
119
+ if (st?.isDirectory()) throw new Error(`Path is a directory: ${abs}`)
98
120
  await writeFile(abs, args.content, "utf8")
99
- return `Wrote ${args.content.length} chars to ${abs}`
121
+ const diff = gitDiffOne(ctx.cwd, abs)
122
+ return `Wrote ${args.content.length} chars to ${abs}${diff ? "\n" + diff : ""}`
100
123
  },
101
124
  }
102
125
 
@@ -121,7 +144,13 @@ const editTool = {
121
144
  const content = await readFile(abs, "utf8")
122
145
  const occurrences = content.split(args.old_string).length - 1
123
146
  if (occurrences === 0) {
124
- throw new Error(`old_string not found in ${abs}`)
147
+ // 给出线索帮模型定位:首行预览 + 常见原因
148
+ const preview = args.old_string.slice(0, 100).split("\n")[0]
149
+ throw new Error(
150
+ `old_string not found in ${abs}\n` +
151
+ ` searched: "${preview}${args.old_string.length > 100 ? "…" : ""}"\n` +
152
+ ` hints: whitespace mismatch? file already changed? try reading the file first`
153
+ )
125
154
  }
126
155
  if (occurrences > 1 && !args.replace_all) {
127
156
  throw new Error(`old_string matches ${occurrences} times in ${abs}; provide more context or set replace_all`)
@@ -130,7 +159,8 @@ const editTool = {
130
159
  ? content.split(args.old_string).join(args.new_string)
131
160
  : content.replace(args.old_string, args.new_string)
132
161
  await writeFile(abs, updated, "utf8")
133
- return `Edited ${abs}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)`
162
+ const diff = gitDiffOne(ctx.cwd, abs)
163
+ return `Edited ${abs}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}`
134
164
  },
135
165
  }
136
166
 
@@ -149,6 +179,20 @@ const bashTool = {
149
179
  },
150
180
  readonly: false,
151
181
  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)) {
186
+ const status = execFileSync("git", ["status", "--porcelain"], {
187
+ cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
188
+ }).trim()
189
+ if (status) {
190
+ throw new Error(
191
+ `Refusing destructive git command: uncommitted changes exist. Commit or stash first.\n\n${status}`
192
+ )
193
+ }
194
+ }
195
+
152
196
  return new Promise((resolve) => {
153
197
  const child = spawn(args.command, {
154
198
  cwd: ctx.cwd,
@@ -170,6 +214,8 @@ const bashTool = {
170
214
  // 编码嗅探:cmd 自带消息是 GBK,git/node 等程序是 UTF-8,平台判断不了。
171
215
  // 策略:纯 ASCII 段两种编码一致,直接透传不判定;遇到高位字节才用
172
216
  // fatal UTF-8 试解(容忍尾部 1~3 字节截断),失败则判 GBK;一经判定不再变更。
217
+ // 已知边界:GBK 字节流极低概率恰好构成合法 UTF-8 序列,会误判为 UTF-8 产生乱码。
218
+ // 更严谨的做法是 chcp 探测控制台代码页,但当前策略覆盖 99.9% 场景,不值得那份复杂度。
173
219
  let decoder = null
174
220
  let pending = Buffer.alloc(0)
175
221
  const feed = (d, flush = false) => {
@@ -250,7 +296,7 @@ const globTool = {
250
296
  },
251
297
  readonly: true,
252
298
  async execute(args, ctx) {
253
- const base = resolve(ctx.cwd, args.path ?? ".")
299
+ const base = resolveInCwd(ctx, args.path ?? ".")
254
300
  const regex = globToRegex(args.pattern)
255
301
  const results = []
256
302
  for await (const relPath of walkFiles(base)) {
@@ -314,7 +360,7 @@ const grepTool = {
314
360
  },
315
361
  readonly: true,
316
362
  async execute(args, ctx) {
317
- const base = resolve(ctx.cwd, args.path ?? ".")
363
+ const base = resolveInCwd(ctx, args.path ?? ".")
318
364
  const regex = new RegExp(args.pattern)
319
365
  const fileFilter = args.glob ? globToRegex(args.glob) : null
320
366
  const matches = []
@@ -374,14 +420,16 @@ const websearchTool = {
374
420
  required: ["query"],
375
421
  },
376
422
  readonly: true,
377
- async execute(args) {
423
+ async execute(args, ctx) {
378
424
  const limit = args.limit ?? 8
379
425
  const url = `https://www.bing.com/search?q=${encodeURIComponent(args.query)}`
380
426
  let html
381
427
  try {
382
428
  const response = await fetch(url, {
383
429
  headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" },
384
- signal: AbortSignal.timeout(15_000),
430
+ signal: ctx?.signal
431
+ ? AbortSignal.any([ctx.signal, AbortSignal.timeout(15_000)])
432
+ : AbortSignal.timeout(15_000),
385
433
  })
386
434
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
387
435
  html = await response.text()
@@ -437,7 +485,7 @@ const lsTool = {
437
485
  },
438
486
  readonly: true,
439
487
  async execute(args, ctx) {
440
- const abs = resolve(ctx.cwd, args.path ?? ".")
488
+ const abs = resolveInCwd(ctx, args.path ?? ".")
441
489
  const entries = await readdir(abs, { withFileTypes: true })
442
490
  const rows = await Promise.all(
443
491
  entries.slice(0, 500).map(async (e) => {
@@ -471,14 +519,16 @@ const fetchTool = {
471
519
  required: ["url"],
472
520
  },
473
521
  readonly: true,
474
- async execute(args) {
522
+ async execute(args, ctx) {
475
523
  if (!/^https?:\/\//.test(args.url)) throw new Error("url must start with http:// or https://")
476
524
  let response
477
525
  try {
478
526
  response = await fetch(args.url, {
479
527
  headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" },
480
528
  redirect: "follow",
481
- signal: AbortSignal.timeout(20_000),
529
+ signal: ctx?.signal
530
+ ? AbortSignal.any([ctx.signal, AbortSignal.timeout(20_000)])
531
+ : AbortSignal.timeout(20_000),
482
532
  })
483
533
  } catch (error) {
484
534
  throw new Error(`fetch failed: ${error.cause?.code ?? error.message}`)