thincoder 0.5.0 → 0.7.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
@@ -45,7 +45,50 @@ function sanitizeOutput(s) {
45
45
 
46
46
  function truncate(text, max = MAX_OUTPUT_CHARS) {
47
47
  if (text.length <= max) return text
48
- return text.slice(0, max) + `\n... (truncated, ${text.length - max} chars omitted)`
48
+ return text.slice(0, max) + `\n[... truncated: ${text.length - max} chars omitted — redirect to a file if you need the full output]`
49
+ }
50
+
51
+ /** 创建独立的流解码器(编码嗅探:ASCII → UTF-8 → GBK 回退) */
52
+ function makeDecoder() {
53
+ let decoder = null
54
+ let pending = Buffer.alloc(0)
55
+ return (d, flush = false) => {
56
+ pending = Buffer.concat([pending, d])
57
+ if (!decoder) {
58
+ const hasHighByte = pending.some((b) => b >= 0x80)
59
+ if (!hasHighByte) {
60
+ const s = pending.toString("ascii")
61
+ pending = Buffer.alloc(0)
62
+ return s
63
+ }
64
+ for (let trim = 0; trim <= 3 && !decoder; trim++) {
65
+ try {
66
+ new TextDecoder("utf-8", { fatal: true }).decode(pending.subarray(0, pending.length - trim))
67
+ decoder = new TextDecoder("utf-8")
68
+ } catch { /* 继续尝试 */ }
69
+ }
70
+ if (!decoder) decoder = new TextDecoder("gbk")
71
+ }
72
+ const s = decoder.decode(pending, { stream: !flush })
73
+ pending = Buffer.alloc(0)
74
+ return s
75
+ }
76
+ }
77
+
78
+ /** 对单个文件取 git diff,失败静默返回空 */
79
+ function gitDiffOne(cwd, abs) {
80
+ try {
81
+ const diff = execFileSync("git", ["--no-pager", "diff", "--no-color", "--", abs], {
82
+ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1024 * 1024,
83
+ }).trim()
84
+ if (!diff) return ""
85
+ // 截断超长 diff(超大文件改动 diff 可能几十 KB),只保留前 200 行
86
+ const lines = diff.split("\n")
87
+ if (lines.length <= 200) return diff
88
+ return lines.slice(0, 200).join("\n") + `\n... (${lines.length - 200} more diff lines)`
89
+ } catch {
90
+ return ""
91
+ }
49
92
  }
50
93
 
51
94
  function resolveInCwd(ctx, p) {
@@ -102,7 +145,8 @@ const writeTool = {
102
145
  const st = await stat(abs).catch(() => null)
103
146
  if (st?.isDirectory()) throw new Error(`Path is a directory: ${abs}`)
104
147
  await writeFile(abs, args.content, "utf8")
105
- return `Wrote ${args.content.length} chars to ${abs}`
148
+ const diff = gitDiffOne(ctx.cwd, abs)
149
+ return `Wrote ${args.content.length} chars to ${abs}${diff ? "\n" + diff : ""}`
106
150
  },
107
151
  }
108
152
 
@@ -127,7 +171,13 @@ const editTool = {
127
171
  const content = await readFile(abs, "utf8")
128
172
  const occurrences = content.split(args.old_string).length - 1
129
173
  if (occurrences === 0) {
130
- throw new Error(`old_string not found in ${abs}`)
174
+ // 给出线索帮模型定位:首行预览 + 常见原因
175
+ const preview = args.old_string.slice(0, 100).split("\n")[0]
176
+ throw new Error(
177
+ `old_string not found in ${abs}\n` +
178
+ ` searched: "${preview}${args.old_string.length > 100 ? "…" : ""}"\n` +
179
+ ` hints: whitespace mismatch? file already changed? try reading the file first`
180
+ )
131
181
  }
132
182
  if (occurrences > 1 && !args.replace_all) {
133
183
  throw new Error(`old_string matches ${occurrences} times in ${abs}; provide more context or set replace_all`)
@@ -136,7 +186,87 @@ const editTool = {
136
186
  ? content.split(args.old_string).join(args.new_string)
137
187
  : content.replace(args.old_string, args.new_string)
138
188
  await writeFile(abs, updated, "utf8")
139
- return `Edited ${abs}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)`
189
+ const diff = gitDiffOne(ctx.cwd, abs)
190
+ return `Edited ${abs}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}`
191
+ },
192
+ }
193
+
194
+ // ---------------------------------------------------------------- insert_after
195
+
196
+ const insertAfterTool = {
197
+ name: "insert_after",
198
+ description: DESC("insert_after"),
199
+ parameters: {
200
+ type: "object",
201
+ properties: {
202
+ path: { type: "string", description: "File path" },
203
+ after_line: { type: "number", description: "Line number to insert after (1-based). Takes priority over after_regex." },
204
+ after_regex: { type: "string", description: "JavaScript regex to find the line to insert after (must match exactly one line)" },
205
+ content: { type: "string", description: "Text to insert (with leading newline if you need a blank line)" },
206
+ },
207
+ required: ["path", "content"],
208
+ },
209
+ readonly: false,
210
+ async execute(args, ctx) {
211
+ const abs = resolveInCwd(ctx, args.path)
212
+ const text = await readFile(abs, "utf8")
213
+ const lines = text.split("\n")
214
+
215
+ let targetLine
216
+ if (args.after_line != null) {
217
+ targetLine = args.after_line
218
+ if (targetLine < 0 || targetLine > lines.length) {
219
+ throw new Error(`after_line ${targetLine} out of range (file has ${lines.length} lines)`)
220
+ }
221
+ } else if (args.after_regex) {
222
+ const regex = new RegExp(args.after_regex)
223
+ const matches = []
224
+ for (let i = 0; i < lines.length; i++) {
225
+ if (regex.test(lines[i])) matches.push(i + 1)
226
+ }
227
+ if (matches.length === 0) throw new Error(`after_regex /${args.after_regex}/ matched no lines in ${abs}`)
228
+ 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`)
229
+ targetLine = matches[0]
230
+ } else {
231
+ throw new Error("Either after_line or after_regex is required")
232
+ }
233
+
234
+ lines.splice(targetLine, 0, args.content)
235
+ const updated = lines.join("\n")
236
+ await writeFile(abs, updated, "utf8")
237
+ const diff = gitDiffOne(ctx.cwd, abs)
238
+ return `Inserted after line ${targetLine} in ${abs}${diff ? "\n" + diff : ""}`
239
+ },
240
+ }
241
+
242
+ // ---------------------------------------------------------------- syntax_check
243
+
244
+ const syntaxCheckTool = {
245
+ name: "syntax_check",
246
+ description: DESC("syntax_check"),
247
+ parameters: {
248
+ type: "object",
249
+ properties: {
250
+ path: { type: "string", description: "File path (.js/.mjs/.cjs only)" },
251
+ },
252
+ required: ["path"],
253
+ },
254
+ readonly: true,
255
+ execute(args, ctx) {
256
+ const abs = resolveInCwd(ctx, args.path)
257
+ if (!/\.(?:[mc]?js)$/.test(abs)) {
258
+ return `syntax_check only supports .js/.mjs/.cjs files; ${abs} skipped.`
259
+ }
260
+ try {
261
+ execFileSync(process.execPath, ["--check", abs], {
262
+ cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
263
+ })
264
+ return `Syntax OK: ${abs}`
265
+ } catch (e) {
266
+ // node --check 把错误写到 stderr
267
+ const msg = (e.stderr || e.stdout || e.message || "").trim()
268
+ return `Syntax error in ${abs}:\n${msg || "(unknown)"}`
269
+ }
140
270
  },
141
271
  }
142
272
 
@@ -174,8 +304,6 @@ const bashTool = {
174
304
  cwd: ctx.cwd,
175
305
  shell: true,
176
306
  windowsHide: true,
177
- // 无 TTY 环境:stdin 置空(vim/less 这类交互程序立刻吃到 EOF 退出,而不是干等),
178
- // 并通过环境变量缴械编辑器/分页器/花哨输出
179
307
  stdio: ["ignore", "pipe", "pipe"],
180
308
  env: {
181
309
  ...process.env,
@@ -187,71 +315,49 @@ const bashTool = {
187
315
  TERM: "dumb",
188
316
  },
189
317
  })
190
- // 编码嗅探:cmd 自带消息是 GBK,git/node 等程序是 UTF-8,平台判断不了。
191
- // 策略:纯 ASCII 段两种编码一致,直接透传不判定;遇到高位字节才用
192
- // fatal UTF-8 试解(容忍尾部 1~3 字节截断),失败则判 GBK;一经判定不再变更。
193
- // 已知边界:GBK 字节流极低概率恰好构成合法 UTF-8 序列,会误判为 UTF-8 产生乱码。
194
- // 更严谨的做法是 chcp 探测控制台代码页,但当前策略覆盖 99.9% 场景,不值得那份复杂度。
195
- let decoder = null
196
- let pending = Buffer.alloc(0)
197
- const feed = (d, flush = false) => {
198
- pending = Buffer.concat([pending, d])
199
- if (!decoder) {
200
- const hasHighByte = pending.some((b) => b >= 0x80)
201
- if (!hasHighByte) {
202
- // 纯 ASCII:UTF-8/GBK 完全一致,透传即可(无需判定)
203
- const s = pending.toString("ascii")
204
- pending = Buffer.alloc(0)
205
- return s
206
- }
207
- for (let trim = 0; trim <= 3 && !decoder; trim++) {
208
- try {
209
- new TextDecoder("utf-8", { fatal: true }).decode(pending.subarray(0, pending.length - trim))
210
- decoder = new TextDecoder("utf-8")
211
- } catch {
212
- // 继续尝试
213
- }
214
- }
215
- if (!decoder) decoder = new TextDecoder("gbk")
216
- }
217
- const s = decoder.decode(pending, { stream: !flush })
218
- pending = Buffer.alloc(0)
219
- return s
220
- }
221
-
222
- let out = ""
318
+ // stdout / stderr 各自独立解码(同进程通常同编码,但分开收集更干净,
319
+ // 且允许模型按 stderr 快速定位错误)
320
+ const outDecoder = makeDecoder()
321
+ const errDecoder = makeDecoder()
322
+ let outBuf = ""
323
+ let errBuf = ""
223
324
  let truncatedNote = ""
224
- const onData = (d) => {
225
- const s = sanitizeOutput(feed(d))
226
- // 输出实时透传给 UI;本地缓冲超 2MB 后停止累积(防内存爆炸)
227
- if (s) ctx.onOutput?.(s)
228
- if (out.length < 2_000_000) {
229
- out += s
230
- } else if (!truncatedNote) {
231
- truncatedNote = "\n... (output exceeded 2MB, remainder discarded)"
325
+
326
+ const onStdout = (d) => {
327
+ const s = sanitizeOutput(outDecoder(d))
328
+ if (s) {
329
+ ctx.onOutput?.(s)
330
+ if (outBuf.length < 2_000_000) outBuf += s
331
+ else if (!truncatedNote) truncatedNote = "\n[... output exceeded 2MB, remainder discarded]"
232
332
  }
233
333
  }
234
- child.stdout.on("data", onData)
235
- child.stderr.on("data", onData)
334
+ const onStderr = (d) => {
335
+ errBuf += sanitizeOutput(errDecoder(d))
336
+ }
337
+
338
+ child.stdout.on("data", onStdout)
339
+ child.stderr.on("data", onStderr)
236
340
 
237
341
  const timer = setTimeout(() => child.kill(), args.timeout ?? BASH_TIMEOUT_MS)
238
- // 用户中止:杀进程
239
342
  if (ctx.signal) {
240
343
  ctx.signal.addEventListener("abort", () => child.kill(), { once: true })
241
344
  }
242
345
  child.on("error", (error) => {
243
346
  clearTimeout(timer)
244
- resolve(truncate(`Command failed: ${error.message}\n${out}`))
347
+ resolve(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
245
348
  })
246
349
  child.on("close", (code, signal) => {
247
350
  clearTimeout(timer)
248
- out += sanitizeOutput(feed(Buffer.alloc(0), true)) // 最终判定 + 冲刷解码器尾部
249
- const suffix = signal
250
- ? `\n(killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"})`
251
- : code !== 0
252
- ? `\n(exit code ${code})`
253
- : ""
254
- resolve(truncate((out.trim() || "(no output)") + suffix + truncatedNote))
351
+ // 冲刷解码器尾部
352
+ outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
353
+ errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
354
+ const status = signal
355
+ ? `killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"}`
356
+ : `exit code ${code}`
357
+ const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
358
+ if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
359
+ parts.push(`(${status})`)
360
+ resolve(truncate(parts.join("\n\n") + truncatedNote))
255
361
  })
256
362
  })
257
363
  },
@@ -272,7 +378,7 @@ const globTool = {
272
378
  },
273
379
  readonly: true,
274
380
  async execute(args, ctx) {
275
- const base = resolve(ctx.cwd, args.path ?? ".")
381
+ const base = resolveInCwd(ctx, args.path ?? ".")
276
382
  const regex = globToRegex(args.pattern)
277
383
  const results = []
278
384
  for await (const relPath of walkFiles(base)) {
@@ -331,15 +437,21 @@ const grepTool = {
331
437
  pattern: { type: "string", description: "Regular expression" },
332
438
  path: { type: "string", description: "Directory or file to search (default cwd)" },
333
439
  glob: { type: "string", description: "Only search files matching this glob (e.g. '*.mjs')" },
440
+ before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
441
+ after: { type: "integer", description: "Lines of context to show after each match (grep -A). Default 0" },
334
442
  },
335
443
  required: ["pattern"],
336
444
  },
337
445
  readonly: true,
338
446
  async execute(args, ctx) {
339
- const base = resolve(ctx.cwd, args.path ?? ".")
447
+ const base = resolveInCwd(ctx, args.path ?? ".")
340
448
  const regex = new RegExp(args.pattern)
341
449
  const fileFilter = args.glob ? globToRegex(args.glob) : null
342
- const matches = []
450
+ const before = Math.max(0, Math.floor(args.before ?? 0))
451
+ const after = Math.max(0, Math.floor(args.after ?? 0))
452
+ const wantCtx = before > 0 || after > 0
453
+ const hits = [] // { file, line(1-based), text }
454
+ const fileLines = new Map() // file -> string[](仅 wantCtx 时缓存)
343
455
 
344
456
  async function search(file) {
345
457
  let content
@@ -349,16 +461,17 @@ const grepTool = {
349
461
  return // 二进制/不可读文件跳过
350
462
  }
351
463
  const lines = content.split("\n")
464
+ if (wantCtx) fileLines.set(file, lines)
352
465
  for (let i = 0; i < lines.length; i++) {
353
466
  if (regex.test(lines[i])) {
354
- matches.push(`${file}:${i + 1}: ${lines[i]}`)
355
- if (matches.length >= 200) return
467
+ hits.push({ file, line: i + 1, text: lines[i] })
468
+ if (hits.length >= 200) return
356
469
  }
357
470
  }
358
471
  }
359
472
 
360
473
  async function walk(target) {
361
- if (matches.length >= 200) return
474
+ if (hits.length >= 200) return
362
475
  const s = await stat(target)
363
476
  if (!s.isDirectory()) {
364
477
  if (!fileFilter || fileFilter.test(target.split(/[\\/]/).pop())) await search(target)
@@ -377,8 +490,32 @@ const grepTool = {
377
490
  }
378
491
 
379
492
  await walk(base)
380
- if (matches.length === 0) return "(no matches)"
381
- return truncate(matches.join("\n"))
493
+ if (hits.length === 0) return "(no matches)"
494
+
495
+ // 无上下文:保持原 path:line: content 格式
496
+ if (!wantCtx) {
497
+ return truncate(hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n"))
498
+ }
499
+
500
+ // 带上下文:匹配行用 ':',上下文行用 '-'(同 ripgrep);同文件相邻区间去重合并
501
+ const fileMatched = new Map() // file -> Set<line>
502
+ for (const h of hits) {
503
+ if (!fileMatched.has(h.file)) fileMatched.set(h.file, new Set())
504
+ fileMatched.get(h.file).add(h.line)
505
+ }
506
+ const out = []
507
+ for (const [file, matchedLines] of fileMatched) {
508
+ const lines = fileLines.get(file) ?? []
509
+ const lineSet = new Set()
510
+ for (const ml of matchedLines) {
511
+ for (let l = Math.max(1, ml - before); l <= Math.min(lines.length, ml + after); l++) lineSet.add(l)
512
+ }
513
+ for (const l of [...lineSet].sort((a, b) => a - b)) {
514
+ const sep = matchedLines.has(l) ? ":" : "-"
515
+ out.push(`${file}${sep}${l}${sep} ${lines[l - 1]}`)
516
+ }
517
+ }
518
+ return truncate(out.join("\n"))
382
519
  },
383
520
  }
384
521
 
@@ -461,7 +598,7 @@ const lsTool = {
461
598
  },
462
599
  readonly: true,
463
600
  async execute(args, ctx) {
464
- const abs = resolve(ctx.cwd, args.path ?? ".")
601
+ const abs = resolveInCwd(ctx, args.path ?? ".")
465
602
  const entries = await readdir(abs, { withFileTypes: true })
466
603
  const rows = await Promise.all(
467
604
  entries.slice(0, 500).map(async (e) => {
@@ -540,7 +677,7 @@ function htmlToText(html) {
540
677
  .trim()
541
678
  }
542
679
 
543
- export const builtinTools = [readTool, writeTool, editTool, bashTool, globTool, grepTool, websearchTool, lsTool, fetchTool]
680
+ export const builtinTools = [readTool, writeTool, editTool, insertAfterTool, syntaxCheckTool, bashTool, globTool, grepTool, websearchTool, lsTool, fetchTool]
544
681
 
545
682
  // ---------------------------------------------------------------- delete
546
683