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/README.md +90 -46
- package/bin/thincoder.mjs +20 -16
- package/package.json +2 -2
- package/src/SYSTEM_PROMPT.md +7 -6
- package/src/agent.mjs +356 -59
- package/src/coder-overlay.md +2 -1
- package/src/config.mjs +54 -25
- package/src/context.mjs +128 -48
- package/src/explore-overlay.md +6 -2
- package/src/main-overlay.md +8 -0
- package/src/memory.mjs +653 -4
- package/src/plan-overlay.md +13 -0
- package/src/provider.mjs +74 -7
- package/src/repomap.mjs +204 -0
- package/src/session.mjs +142 -15
- package/src/skills.mjs +2 -1
- package/src/tools/bash.md +2 -0
- package/src/tools/glob.md +1 -1
- package/src/tools.mjs +63 -13
- package/src/tui.mjs +245 -43
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
|
-
|
|
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;?]*[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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:
|
|
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 =
|
|
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:
|
|
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}`)
|