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/README.md +90 -45
- package/package.json +2 -2
- package/src/SYSTEM_PROMPT.md +6 -3
- package/src/agent.mjs +87 -13
- package/src/coder-overlay.md +1 -1
- package/src/config.mjs +47 -23
- package/src/mcp.mjs +94 -3
- package/src/memory.mjs +64 -68
- package/src/provider.mjs +21 -4
- package/src/tools/bash.md +14 -1
- package/src/tools/glob.md +1 -1
- package/src/tools/grep.md +3 -0
- package/src/tools/insert_after.md +13 -0
- package/src/tools/question.md +1 -0
- package/src/tools/syntax_check.md +10 -0
- package/src/tools/websearch.md +1 -0
- package/src/tools.mjs +205 -68
- package/src/tui.mjs +204 -59
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...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
let
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (s)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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${
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
355
|
-
if (
|
|
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 (
|
|
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 (
|
|
381
|
-
|
|
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 =
|
|
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
|
|