thincoder 0.7.7 → 0.8.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 +36 -13
- package/bin/thincoder.mjs +27 -346
- package/package.json +1 -1
- package/src/agent/dispatch.mjs +98 -0
- package/src/agent/helpers.mjs +185 -0
- package/src/agent/setup.mjs +117 -0
- package/src/agent-tools/goal.mjs +71 -0
- package/src/agent-tools/plan.mjs +31 -0
- package/src/agent-tools/recent-changes.mjs +23 -0
- package/src/agent-tools/skill.mjs +46 -0
- package/src/agent-tools/subagent.mjs +113 -0
- package/src/agent-tools/task.mjs +67 -0
- package/src/agent-tools/verify.mjs +198 -0
- package/src/agent-tools.mjs +12 -0
- package/src/agent.mjs +90 -1040
- package/src/cli/distill-command.mjs +85 -0
- package/src/cli/make-agent.mjs +85 -0
- package/src/cli/memory-command.mjs +63 -0
- package/src/cli/permission.mjs +41 -0
- package/src/cli/setup-wizard.mjs +70 -0
- package/src/config.mjs +5 -8
- package/src/context.mjs +10 -13
- package/src/distill.mjs +4 -3
- package/src/embedding.mjs +4 -2
- package/src/{checkpoint.mjs → git/checkpoint.mjs} +1 -1
- package/src/mcp/helpers.mjs +37 -0
- package/src/mcp/transport-http.mjs +176 -0
- package/src/mcp/transport-stdio.mjs +84 -0
- package/src/mcp/transport-ws.mjs +87 -0
- package/src/mcp.mjs +4 -428
- package/src/memory/code-index.mjs +211 -0
- package/src/memory/code-sync.mjs +306 -0
- package/src/memory/core.mjs +277 -0
- package/src/memory/docs.mjs +262 -0
- package/src/memory/schema.mjs +426 -0
- package/src/memory.mjs +12 -1403
- package/src/provider/core.mjs +239 -0
- package/src/provider/index.mjs +6 -0
- package/src/provider/rate.mjs +104 -0
- package/src/session.mjs +18 -5
- package/src/tools/bash.md +1 -0
- package/src/tools/bash.mjs +144 -0
- package/src/tools/file.mjs +205 -0
- package/src/tools/git.mjs +166 -0
- package/src/tools/glob.mjs +51 -0
- package/src/tools/grep.mjs +100 -0
- package/src/tools/index.mjs +22 -0
- package/src/tools/ls.mjs +36 -0
- package/src/tools/patch.mjs +226 -0
- package/src/tools/repomap-parse.mjs +168 -0
- package/src/tools/shared.mjs +257 -0
- package/src/tools/system.mjs +336 -0
- package/src/tools/web.mjs +121 -0
- package/src/tools.mjs +2 -1188
- package/src/tui/agent-turn.mjs +254 -0
- package/src/tui/ansi.mjs +32 -0
- package/src/tui/clipboard.mjs +48 -0
- package/src/tui/cmd-auto.mjs +21 -0
- package/src/tui/cmd-clear.mjs +26 -0
- package/src/tui/cmd-config.mjs +72 -0
- package/src/tui/cmd-exit.mjs +5 -0
- package/src/tui/cmd-extract.mjs +5 -0
- package/src/tui/cmd-goal.mjs +47 -0
- package/src/tui/cmd-help.mjs +25 -0
- package/src/tui/cmd-init.mjs +91 -0
- package/src/tui/cmd-mcp.mjs +146 -0
- package/src/tui/cmd-model.mjs +7 -0
- package/src/tui/cmd-new.mjs +18 -0
- package/src/tui/cmd-plan.mjs +21 -0
- package/src/tui/cmd-reindex.mjs +44 -0
- package/src/tui/cmd-restore.mjs +39 -0
- package/src/tui/cmd-session.mjs +42 -0
- package/src/tui/cmd-skills.mjs +17 -0
- package/src/tui/cmd-think.mjs +56 -0
- package/src/tui/config-helpers.mjs +34 -0
- package/src/tui/distill-cmd.mjs +45 -0
- package/src/tui/index.mjs +330 -0
- package/src/tui/interaction.mjs +79 -0
- package/src/tui/key-handler.mjs +267 -0
- package/src/tui/layout.mjs +115 -0
- package/src/tui/pickers.mjs +279 -0
- package/src/tui/render-frame.mjs +304 -0
- package/src/tui/render.mjs +205 -0
- package/src/tui/slash-commands.mjs +138 -0
- package/src/tui/startup.mjs +113 -0
- package/src/tui/wizard.mjs +168 -0
- package/src/tui-render.mjs +4 -0
- package/src/tui.mjs +3 -2546
- package/src/provider.mjs +0 -383
- /package/src/{gitmem.mjs → git/gitmem.mjs} +0 -0
- /package/src/{coder-overlay.md → prompts/coder.md} +0 -0
- /package/src/{discipline-rules.md → prompts/discipline.md} +0 -0
- /package/src/{explore-overlay.md → prompts/explore.md} +0 -0
- /package/src/{main-overlay.md → prompts/main.md} +0 -0
- /package/src/{plan-overlay.md → prompts/plan.md} +0 -0
- /package/src/{SYSTEM_PROMPT.md → prompts/system.md} +0 -0
- /package/src/{repomap.mjs → tools/repomap.mjs} +0 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DESC,
|
|
3
|
+
autoSyntaxCheck,
|
|
4
|
+
resolveInCwd
|
|
5
|
+
} from "./shared.mjs";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { mkdir } from "node:fs/promises";
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import { stat } from "node:fs/promises";
|
|
10
|
+
import { writeFile } from "node:fs/promises";
|
|
11
|
+
import { unlink } from "node:fs/promises";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { join, relative, dirname } from "node:path";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 解析统一 diff(unified diff):返回 [{ path, isNew, hunks: [{ ops: [{type:" "|"-"|"+", text}] }] }]
|
|
17
|
+
* 按 @@ 头的行数计数消费 hunk 行——LLM 常把上下文空行剥成纯空行,靠计数而不是行首字符判断 hunk 边界
|
|
18
|
+
*/
|
|
19
|
+
function parsePatch(patch) {
|
|
20
|
+
// 补丁文本常来自 CRLF 终端/模型输出,行尾 \r 会混进 hunk 内容导致上下文对不上,统一剥掉
|
|
21
|
+
const lines = patch.replace(/\r(?=\n|$)/g, "").split("\n")
|
|
22
|
+
const files = []
|
|
23
|
+
let cur = null
|
|
24
|
+
let i = 0
|
|
25
|
+
const stripPrefix = (p) => p.replace(/^[ab]\//, "")
|
|
26
|
+
while (i < lines.length) {
|
|
27
|
+
const line = lines[i]
|
|
28
|
+
if (line.startsWith("--- ")) {
|
|
29
|
+
const oldPath = line.slice(4).trim()
|
|
30
|
+
const plus = lines[i + 1]
|
|
31
|
+
if (!plus?.startsWith("+++ ")) throw new Error(`Malformed patch: expected "+++" line after "${line}"`)
|
|
32
|
+
const newPath = plus.slice(4).trim()
|
|
33
|
+
if (newPath === "/dev/null") throw new Error("Deleting files via patch is not supported — use the delete tool")
|
|
34
|
+
cur = { path: stripPrefix(newPath), isNew: oldPath === "/dev/null", hunks: [] }
|
|
35
|
+
files.push(cur)
|
|
36
|
+
i += 2
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (line.startsWith("@@")) {
|
|
40
|
+
if (!cur) throw new Error("Malformed patch: hunk header before any file header")
|
|
41
|
+
const m = line.match(/^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/)
|
|
42
|
+
if (!m) throw new Error(`Malformed patch: bad hunk header "${line}" (need @@ -old,count +new,count @@)`)
|
|
43
|
+
let oldNeed = m[1] == null ? 1 : Number(m[1])
|
|
44
|
+
let newNeed = m[2] == null ? 1 : Number(m[2])
|
|
45
|
+
const hunk = { ops: [] }
|
|
46
|
+
i++
|
|
47
|
+
while (oldNeed > 0 || newNeed > 0) {
|
|
48
|
+
if (i >= lines.length) throw new Error("Malformed patch: hunk truncated (line counts in @@ header not satisfied)")
|
|
49
|
+
const hl = lines[i]
|
|
50
|
+
if (hl.startsWith("\\")) { i++; continue } // ""
|
|
51
|
+
const tag = hl === "" ? " " : hl[0] // 纯空行按上下文行宽容处理
|
|
52
|
+
const text = hl === "" ? "" : hl.slice(1)
|
|
53
|
+
if (tag === " ") { hunk.ops.push({ type: " ", text }); oldNeed--; newNeed-- }
|
|
54
|
+
else if (tag === "-") { hunk.ops.push({ type: "-", text }); oldNeed-- }
|
|
55
|
+
else if (tag === "+") { hunk.ops.push({ type: "+", text }); newNeed-- }
|
|
56
|
+
else throw new Error(`Malformed patch: unexpected line "${hl.slice(0, 60)}" inside hunk`)
|
|
57
|
+
i++
|
|
58
|
+
}
|
|
59
|
+
cur.hunks.push(hunk)
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
i++ // diff --git / index / 空行等元信息跳过
|
|
63
|
+
}
|
|
64
|
+
if (files.length === 0) throw new Error("No file changes found in patch (need --- / +++ headers)")
|
|
65
|
+
return files
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 在内存行数组上按序应用 hunks;任何一步失败抛错(调用方保证不落盘)。比较时忽略行尾 \r,上下文行保留原始字节 */
|
|
69
|
+
function applyHunks(fileLines, hunks, eol, path) {
|
|
70
|
+
const cr = eol === "\r\n" ? "\r" : ""
|
|
71
|
+
for (let h = 0; h < hunks.length; h++) {
|
|
72
|
+
const oldSeq = hunks[h].ops.filter((o) => o.type !== "+").map((o) => o.text)
|
|
73
|
+
if (oldSeq.length === 0) throw new Error(`Hunk ${h + 1} in ${path} has no context/removed lines to locate`)
|
|
74
|
+
const matches = []
|
|
75
|
+
for (let pos = 0; pos + oldSeq.length <= fileLines.length; pos++) {
|
|
76
|
+
let ok = true
|
|
77
|
+
for (let j = 0; j < oldSeq.length; j++) {
|
|
78
|
+
if (fileLines[pos + j].replace(/\r$/, "") !== oldSeq[j]) { ok = false; break }
|
|
79
|
+
}
|
|
80
|
+
if (ok) matches.push(pos)
|
|
81
|
+
}
|
|
82
|
+
if (matches.length === 0) {
|
|
83
|
+
const preview = oldSeq.slice(0, 3).join(" ⏎ ")
|
|
84
|
+
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.`)
|
|
85
|
+
}
|
|
86
|
+
if (matches.length > 1) throw new Error(`Hunk ${h + 1} in ${path} matches ${matches.length} locations — add more context lines to make it unique`)
|
|
87
|
+
const pos = matches[0]
|
|
88
|
+
const out = []
|
|
89
|
+
let src = pos
|
|
90
|
+
for (const op of hunks[h].ops) {
|
|
91
|
+
if (op.type === " ") out.push(fileLines[src++]) // 上下文保留原始行(行尾/空白原样)
|
|
92
|
+
else if (op.type === "-") src++
|
|
93
|
+
else out.push(op.text + cr)
|
|
94
|
+
}
|
|
95
|
+
fileLines.splice(pos, oldSeq.length, ...out)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const applyPatchTool = {
|
|
100
|
+
name: "apply_patch",
|
|
101
|
+
description: DESC("apply_patch"),
|
|
102
|
+
parameters: {
|
|
103
|
+
type: "object",
|
|
104
|
+
properties: {
|
|
105
|
+
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." },
|
|
106
|
+
},
|
|
107
|
+
required: ["patch"],
|
|
108
|
+
},
|
|
109
|
+
readonly: false,
|
|
110
|
+
/** 供 agent 层追踪触碰文件(多路径,替代单 path 参数) */
|
|
111
|
+
touchedPaths(args) {
|
|
112
|
+
try { return parsePatch(args.patch ?? "").map((f) => f.path) } catch { return [] }
|
|
113
|
+
},
|
|
114
|
+
async execute(args, ctx) {
|
|
115
|
+
const files = parsePatch(args.patch ?? "")
|
|
116
|
+
// 先全部读入内存试算:任何一个 hunk 不上就整体抛错,不写半个补丁(原子性)
|
|
117
|
+
const planned = []
|
|
118
|
+
for (const f of files) {
|
|
119
|
+
const abs = resolveInCwd(ctx, f.path)
|
|
120
|
+
if (f.isNew) {
|
|
121
|
+
if (existsSync(abs)) throw new Error(`Cannot create ${f.path}: file already exists`)
|
|
122
|
+
const content = f.hunks.flatMap((h) => h.ops.filter((o) => o.type === "+").map((o) => o.text)).join("\n") + "\n"
|
|
123
|
+
planned.push({ abs, path: f.path, content, isNew: true })
|
|
124
|
+
} else {
|
|
125
|
+
const original = await readFile(abs, "utf8").catch(() => { throw new Error(`File not found: ${f.path}`) })
|
|
126
|
+
const eol = original.includes("\r\n") ? "\r\n" : "\n"
|
|
127
|
+
const lines = original.split("\n")
|
|
128
|
+
applyHunks(lines, f.hunks, eol, f.path)
|
|
129
|
+
planned.push({ abs, path: f.path, content: lines.join("\n"), isNew: false })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// 多文件写:先全部写 .tmp,全部成功后再 rename——任一写失败清理已写的 .tmp 不影响已落盘的文件
|
|
133
|
+
const { rename, unlink } = await import("node:fs/promises")
|
|
134
|
+
const written = []
|
|
135
|
+
try {
|
|
136
|
+
for (const p of planned) {
|
|
137
|
+
await mkdir(dirname(p.abs), { recursive: true })
|
|
138
|
+
await writeFile(p.abs + ".thincoder-tmp", p.content, "utf8")
|
|
139
|
+
written.push(p.abs)
|
|
140
|
+
}
|
|
141
|
+
for (const p of planned) {
|
|
142
|
+
await rename(p.abs + ".thincoder-tmp", p.abs)
|
|
143
|
+
}
|
|
144
|
+
} catch (renameError) {
|
|
145
|
+
// rename 阶段失败:清理残留 .tmp 文件
|
|
146
|
+
for (const abs of written) {
|
|
147
|
+
try { await unlink(abs + ".thincoder-tmp") } catch {}
|
|
148
|
+
}
|
|
149
|
+
throw renameError
|
|
150
|
+
}
|
|
151
|
+
const summary = planned.map((p) => ` ${p.isNew ? "created " : "modified"} ${p.path}`).join("\n")
|
|
152
|
+
const syntaxResults = planned.map((p) => {
|
|
153
|
+
const r = autoSyntaxCheck(p.abs)
|
|
154
|
+
return r ? `${p.path}:${r.replace("Syntax: ", "")}` : ""
|
|
155
|
+
}).filter(Boolean).join("\n")
|
|
156
|
+
return `Applied patch to ${planned.length} file(s):\n${summary}${syntaxResults ? "\n\nSyntax checks:\n" + syntaxResults : ""}`
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------- syntax_check
|
|
161
|
+
|
|
162
|
+
export const syntaxCheckTool = {
|
|
163
|
+
name: "syntax_check",
|
|
164
|
+
description: DESC("syntax_check"),
|
|
165
|
+
parameters: {
|
|
166
|
+
type: "object",
|
|
167
|
+
properties: {
|
|
168
|
+
path: { type: "string", description: "File path (.js/.mjs/.cjs only)" },
|
|
169
|
+
},
|
|
170
|
+
required: ["path"],
|
|
171
|
+
},
|
|
172
|
+
readonly: true,
|
|
173
|
+
execute(args, ctx) {
|
|
174
|
+
const abs = resolveInCwd(ctx, args.path)
|
|
175
|
+
if (!/\.(?:[mc]?js)$/.test(abs)) {
|
|
176
|
+
return `syntax_check only supports .js/.mjs/.cjs files; ${args.path} skipped.`
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
execFileSync(process.execPath, ["--check", abs], {
|
|
180
|
+
cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
|
|
181
|
+
})
|
|
182
|
+
return `Syntax OK: ${args.path}`
|
|
183
|
+
} catch (e) {
|
|
184
|
+
// node --check 把错误写到 stderr
|
|
185
|
+
const msg = (e.stderr || e.stdout || e.message || "").trim()
|
|
186
|
+
return `Syntax error in ${args.path}:\n${msg || "(unknown)"}`
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------- bash
|
|
192
|
+
|
|
193
|
+
export const deleteTool = {
|
|
194
|
+
name: "delete",
|
|
195
|
+
description: DESC("delete"),
|
|
196
|
+
parameters: {
|
|
197
|
+
type: "object",
|
|
198
|
+
properties: {
|
|
199
|
+
path: { type: "string", description: "File path (relative to cwd or absolute)" },
|
|
200
|
+
force: { type: "boolean", description: "Allow deleting git-tracked files (default false)" },
|
|
201
|
+
},
|
|
202
|
+
required: ["path"],
|
|
203
|
+
},
|
|
204
|
+
readonly: false,
|
|
205
|
+
async execute(args, ctx) {
|
|
206
|
+
const abs = resolveInCwd(ctx, args.path)
|
|
207
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${args.path}`)
|
|
208
|
+
const s = await stat(abs)
|
|
209
|
+
if (s.isDirectory()) throw new Error(`"${args.path}" is a directory — use bash to remove directories`)
|
|
210
|
+
// git 跟踪文件拒绝直接删除(安全网);未跟踪的放行
|
|
211
|
+
// 用解析后的相对路径(统一正斜杠),防反斜杠/非常规路径绕过 ls-files 匹配
|
|
212
|
+
const rel = relative(ctx.cwd, abs).replace(/\\/g, "/")
|
|
213
|
+
let tracked = false
|
|
214
|
+
try {
|
|
215
|
+
execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: ctx.cwd, stdio: "ignore" })
|
|
216
|
+
tracked = true
|
|
217
|
+
} catch {
|
|
218
|
+
// 未跟踪 / 非 git 仓库
|
|
219
|
+
}
|
|
220
|
+
if (tracked && !args.force) throw new Error(`"${args.path}" is git-tracked. Set force=true to delete anyway.`)
|
|
221
|
+
await unlink(abs)
|
|
222
|
+
return `Deleted ${args.path}`
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------- git_diff
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* repomap-parse.mjs — 仓库依赖图解析(零依赖,纯 regex)
|
|
3
|
+
* 从 code_chunks 取已知文件列表,实时解析每个文件的 import/export 关系,
|
|
4
|
+
* 构建正向依赖图 + 反向引用图。被 repomap.mjs 的 buildSummary / buildOutline 共用。
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, existsSync } from "node:fs"
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 扫描全量文件,构建正向依赖图 + 反向引用图。
|
|
11
|
+
* 返回 { deps, importers, fileCount } 供 buildOutline / buildSummary 共用。
|
|
12
|
+
*/
|
|
13
|
+
export function buildDepGraph(db, cwd) {
|
|
14
|
+
const allFiles = db.prepare(`SELECT DISTINCT path FROM code_chunks ORDER BY path`).all().map((r) => r.path)
|
|
15
|
+
if (allFiles.length === 0) return null
|
|
16
|
+
|
|
17
|
+
const deps = new Map() // path → { imports: Set, exports: Set, size: number, dir: string }
|
|
18
|
+
const importers = new Map() // importee → Set<importer>
|
|
19
|
+
|
|
20
|
+
for (const rel of allFiles) {
|
|
21
|
+
const abs = join(cwd, ...rel.split("/"))
|
|
22
|
+
if (!existsSync(abs)) continue
|
|
23
|
+
const text = readFileSync(abs, "utf8")
|
|
24
|
+
const lines = text.split("\n")
|
|
25
|
+
const ext = rel.slice(rel.lastIndexOf(".")).toLowerCase()
|
|
26
|
+
|
|
27
|
+
let imports, exports
|
|
28
|
+
if (ext === ".py") {
|
|
29
|
+
const py = parsePyOutline(lines)
|
|
30
|
+
imports = py.imports
|
|
31
|
+
exports = py.symbols
|
|
32
|
+
} else {
|
|
33
|
+
imports = parseImports(lines, ext)
|
|
34
|
+
exports = parseExports(lines, ext)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 把 import 路径解析成相对路径(处理 ./ ../)
|
|
38
|
+
const resolved = []
|
|
39
|
+
for (let imp of imports) {
|
|
40
|
+
if (imp.startsWith("./")) imp = imp.slice(2)
|
|
41
|
+
const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ""
|
|
42
|
+
const parts = imp.split("/")
|
|
43
|
+
if (parts[0] === "..") {
|
|
44
|
+
const up = dir.split("/").filter(Boolean)
|
|
45
|
+
let i = 0
|
|
46
|
+
while (parts[i] === ".." && up.length > 0) { up.pop(); i++ }
|
|
47
|
+
resolved.push([...up, ...parts.slice(i)].join("/"))
|
|
48
|
+
} else {
|
|
49
|
+
resolved.push(dir ? `${dir}/${imp}` : imp)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "."
|
|
54
|
+
deps.set(rel, { imports: new Set(resolved), exports: new Set(exports), size: Math.floor(text.length / 1024), dir })
|
|
55
|
+
|
|
56
|
+
for (const r of resolved) {
|
|
57
|
+
if (!importers.has(r)) importers.set(r, new Set())
|
|
58
|
+
importers.get(r).add(rel)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { deps, importers, fileCount: allFiles.length }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------- 内部实现
|
|
66
|
+
|
|
67
|
+
function normalizeExt(p) {
|
|
68
|
+
return p.replace(/\.(m?js|jsx|tsx?)$/i, "")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 提取 JS/TS 文件的 import 路径(去掉 .ts/.js/.mjs 后缀统一) */
|
|
72
|
+
function parseImports(lines, ext) {
|
|
73
|
+
const imports = []
|
|
74
|
+
const text = lines.join("\n")
|
|
75
|
+
// 普通 import
|
|
76
|
+
const re = /import\s+(?:{[^}]*}|\*\s+as\s+\w+|\w+\s*,?\s*(?:{[^}]*})?)\s*from\s*['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]/g
|
|
77
|
+
let m
|
|
78
|
+
while ((m = re.exec(text))) {
|
|
79
|
+
const raw = m[1] || m[2]
|
|
80
|
+
if (!raw || raw.startsWith("node:") || !raw.startsWith(".")) continue
|
|
81
|
+
imports.push(normalizeExt(raw))
|
|
82
|
+
}
|
|
83
|
+
// re-export: export { x } from './module'
|
|
84
|
+
const reExportRe = /export\s*\{[^}]*\}\s*from\s*['"]([^'"]+)['"]/g
|
|
85
|
+
while ((m = reExportRe.exec(text))) {
|
|
86
|
+
const raw = m[1]
|
|
87
|
+
if (!raw || raw.startsWith("node:") || !raw.startsWith(".")) continue
|
|
88
|
+
imports.push(normalizeExt(raw))
|
|
89
|
+
}
|
|
90
|
+
return [...new Set(imports)]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 提取 JS/TS 文件的 export 符号 */
|
|
94
|
+
function parseExports(lines, ext) {
|
|
95
|
+
const exports = []
|
|
96
|
+
const text = lines.join("\n")
|
|
97
|
+
// export function/class/const/let/var name
|
|
98
|
+
const namedRe = /export\s+(?:async\s+)?(?:function\s+(\w+)|class\s+(\w+)|(?:const|let|var)\s+(\w+))/g
|
|
99
|
+
let m
|
|
100
|
+
while ((m = namedRe.exec(text))) {
|
|
101
|
+
exports.push(m[1] || m[2] || m[3])
|
|
102
|
+
}
|
|
103
|
+
// export default function/class name / export default expression
|
|
104
|
+
const defaultRe = /export\s+default\s+(?:(?:async\s+)?(?:function\s+(\w+)|class\s+(\w+))|(\w+))/g
|
|
105
|
+
while ((m = defaultRe.exec(text))) {
|
|
106
|
+
const name = m[1] || m[2] || m[3]
|
|
107
|
+
if (name) exports.push(name)
|
|
108
|
+
else if (!exports.some((e) => e === "default")) exports.push("default")
|
|
109
|
+
}
|
|
110
|
+
// export { a, b as c } —— 优先取 as 后的导出名
|
|
111
|
+
const braceRe = /export\s*\{([^}]+)\}/g
|
|
112
|
+
while ((m = braceRe.exec(text))) {
|
|
113
|
+
for (const name of m[1].split(",")) {
|
|
114
|
+
const parts = name.trim().split(/\s+/)
|
|
115
|
+
// "a as b" → b(导出名),"a" → a
|
|
116
|
+
const exported = parts.length >= 3 ? parts[2] : parts[0]
|
|
117
|
+
if (exported) exports.push(exported)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// export const { a, b } = ...(解构导出)
|
|
121
|
+
const destructRe = /export\s+(?:const|let|var)\s*\{([^}]+)\}\s*=/g
|
|
122
|
+
while ((m = destructRe.exec(text))) {
|
|
123
|
+
for (const name of m[1].split(",")) {
|
|
124
|
+
const parts = name.trim().split(/\s*:\s*/)
|
|
125
|
+
const n = parts[0].trim()
|
|
126
|
+
if (n) exports.push(n)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return [...new Set(exports)]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 提取 Python 的 import 和顶层 def/class */
|
|
133
|
+
function parsePyOutline(lines) {
|
|
134
|
+
const imports = []
|
|
135
|
+
const symbols = []
|
|
136
|
+
for (const line of lines) {
|
|
137
|
+
const fromRe = line.match(/^from\s+(\S+)\s+import\s+(.+)/)
|
|
138
|
+
if (fromRe) {
|
|
139
|
+
const rel = pyRelPath(fromRe[1])
|
|
140
|
+
if (rel) imports.push(rel)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
const impRe = line.match(/^import\s+(.+)/)
|
|
144
|
+
if (impRe) {
|
|
145
|
+
for (const mod of impRe[1].split(",")) {
|
|
146
|
+
const rel = pyRelPath(mod.trim().split(/\s+/)[0])
|
|
147
|
+
if (rel) imports.push(rel)
|
|
148
|
+
}
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
const defRe = line.match(/^(?:async\s+)?(?:def|class)\s+(\w+)/)
|
|
152
|
+
if (defRe) symbols.push(defRe[1])
|
|
153
|
+
}
|
|
154
|
+
return { imports: [...new Set(imports)], symbols: [...new Set(symbols)] }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Python 相对导入 → 相对文件路径:
|
|
159
|
+
* 前导 n 个点表示上溯 n-1 层("."=当前包),模块点号转路径分隔符。
|
|
160
|
+
* 非相对导入(不以 . 开头)或纯包导入("from . import x")返回 null。
|
|
161
|
+
*/
|
|
162
|
+
function pyRelPath(mod) {
|
|
163
|
+
if (!mod?.startsWith(".")) return null
|
|
164
|
+
const dots = mod.match(/^\.+/)[0].length
|
|
165
|
+
const rest = mod.slice(dots).replaceAll(".", "/")
|
|
166
|
+
if (!rest) return null
|
|
167
|
+
return normalizeExt("../".repeat(dots - 1) + rest)
|
|
168
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools/shared.mjs — 共享工具函数、常量、OpenAI schema 转换
|
|
3
|
+
* 被 tools/file.mjs / system.mjs / web.mjs / git.mjs 导入
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn, execFileSync } from "node:child_process"
|
|
7
|
+
import { readFileSync, existsSync, realpathSync } from "node:fs"
|
|
8
|
+
import { dirname, join, resolve, relative, isAbsolute, sep } from "node:path"
|
|
9
|
+
import { fileURLToPath } from "node:url"
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
export const DESC = (name) => readFileSync(join(__dirname, "..", "tools", `${name}.md`), "utf8")
|
|
13
|
+
|
|
14
|
+
export const MAX_READ_LINES = 2000
|
|
15
|
+
export const MAX_OUTPUT_CHARS = 200_000
|
|
16
|
+
export const BASH_TIMEOUT_MS = 120_000
|
|
17
|
+
export const MAX_RESPONSE_BODY_BYTES = 5_000_000
|
|
18
|
+
export const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
|
|
19
|
+
|
|
20
|
+
/** 转成 OpenAI tools 参数格式 */
|
|
21
|
+
export function toOpenAISchema(tool) {
|
|
22
|
+
return {
|
|
23
|
+
type: "function",
|
|
24
|
+
function: {
|
|
25
|
+
name: tool.name,
|
|
26
|
+
description: tool.description,
|
|
27
|
+
parameters: tool.parameters,
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 剥离 ANSI 转义序列 */
|
|
33
|
+
export function sanitizeOutput(s) {
|
|
34
|
+
return s
|
|
35
|
+
.replace(/\x1b\[[0-9;?]*[\x40-\x7E]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g, "")
|
|
36
|
+
.replace(/\r\n/g, "\n")
|
|
37
|
+
.replace(/\r/g, "\n")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function truncate(text, max = MAX_OUTPUT_CHARS) {
|
|
41
|
+
if (text.length <= max) return text
|
|
42
|
+
return text.slice(0, max) + `\n[... truncated: ${text.length - max} chars omitted — redirect to a file if you need the full output]`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 限量读取响应体 */
|
|
46
|
+
export async function readBodyText(response, limit = MAX_RESPONSE_BODY_BYTES) {
|
|
47
|
+
if (!response.body) return ""
|
|
48
|
+
const reader = response.body.getReader()
|
|
49
|
+
const chunks = []
|
|
50
|
+
let total = 0
|
|
51
|
+
try {
|
|
52
|
+
for (;;) {
|
|
53
|
+
const { done, value } = await reader.read()
|
|
54
|
+
if (done) break
|
|
55
|
+
if (value) { chunks.push(value); total += value.length }
|
|
56
|
+
if (total >= limit) { await reader.cancel(); break }
|
|
57
|
+
}
|
|
58
|
+
} finally { reader.releaseLock() }
|
|
59
|
+
return new TextDecoder("utf-8").decode(Buffer.concat(chunks))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 流解码器:编码嗅探 ASCII→UTF-8→GBK */
|
|
63
|
+
export function makeDecoder() {
|
|
64
|
+
let decoder = null
|
|
65
|
+
let pending = Buffer.alloc(0)
|
|
66
|
+
return (d, flush = false) => {
|
|
67
|
+
pending = Buffer.concat([pending, d])
|
|
68
|
+
if (!decoder) {
|
|
69
|
+
const hasHighByte = pending.some((b) => b >= 0x80)
|
|
70
|
+
if (!hasHighByte) { const s = pending.toString("ascii"); pending = Buffer.alloc(0); return s }
|
|
71
|
+
for (let trim = 0; trim <= 3 && !decoder; trim++) {
|
|
72
|
+
try { new TextDecoder("utf-8", { fatal: true }).decode(pending.subarray(0, pending.length - trim)); decoder = new TextDecoder("utf-8") }
|
|
73
|
+
catch { /* continue */ }
|
|
74
|
+
}
|
|
75
|
+
if (!decoder) decoder = new TextDecoder("gbk")
|
|
76
|
+
}
|
|
77
|
+
const s = decoder.decode(pending, { stream: !flush })
|
|
78
|
+
pending = Buffer.alloc(0)
|
|
79
|
+
return s
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 单文件 git diff,失败静默返回空。大 diff 超 maxBuffer 时截断而非吞掉 */
|
|
84
|
+
export function gitDiffOne(cwd, abs) {
|
|
85
|
+
try {
|
|
86
|
+
const diff = execFileSync("git", ["--no-pager", "diff", "--no-color", "--", abs], {
|
|
87
|
+
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 10 * 1024 * 1024,
|
|
88
|
+
}).trim()
|
|
89
|
+
if (!diff) return ""
|
|
90
|
+
const lines = diff.split("\n")
|
|
91
|
+
if (lines.length <= 200) return diff
|
|
92
|
+
return lines.slice(0, 200).join("\n") + `\n... (${lines.length - 200} more diff lines)`
|
|
93
|
+
} catch (e) {
|
|
94
|
+
// maxBuffer 溢出时 e.stdout 含已收集的部分;其他错误(非 git 仓库等)返回空
|
|
95
|
+
if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && e.stdout) {
|
|
96
|
+
const lines = e.stdout.toString().split("\n")
|
|
97
|
+
return lines.slice(0, 200).join("\n") + `\n... (diff too large, showing first 200 of more lines)`
|
|
98
|
+
}
|
|
99
|
+
return ""
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 文件变更后自动语法检查 */
|
|
104
|
+
export function autoSyntaxCheck(abs) {
|
|
105
|
+
if (!/\.(m?js)$/i.test(abs)) return ""
|
|
106
|
+
try {
|
|
107
|
+
execFileSync("node", ["--check", abs], { stdio: ["ignore", "pipe", "pipe"], timeout: 10000 })
|
|
108
|
+
return "\nSyntax: OK"
|
|
109
|
+
} catch (e) {
|
|
110
|
+
const err = (e.stderr || e.stdout || e.message || "").toString().split("\n").slice(0, 3).join("\n")
|
|
111
|
+
return `\nSyntax: FAILED — ${err}`
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 逐级向上找真实路径 */
|
|
116
|
+
export function realpathNearest(abs) {
|
|
117
|
+
let cur = abs
|
|
118
|
+
const tail = []
|
|
119
|
+
while (!existsSync(cur)) {
|
|
120
|
+
const parent = dirname(cur)
|
|
121
|
+
if (parent === cur) return abs
|
|
122
|
+
tail.unshift(cur.slice(parent.length + 1))
|
|
123
|
+
cur = parent
|
|
124
|
+
}
|
|
125
|
+
try { const real = realpathSync(cur); return tail.length ? join(real, ...tail) : real }
|
|
126
|
+
catch { return abs }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const realCwdCache = new Map()
|
|
130
|
+
export function realCwd(cwd) {
|
|
131
|
+
if (!realCwdCache.has(cwd)) realCwdCache.set(cwd, realpathNearest(resolve(cwd)))
|
|
132
|
+
return realCwdCache.get(cwd)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function assertInside(cwd, resolved, p) {
|
|
136
|
+
const rel = relative(cwd, resolved)
|
|
137
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
|
|
138
|
+
throw new Error(`Access denied outside working directory: ${p}`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function resolveInCwd(ctx, p) {
|
|
143
|
+
const cwd = realCwd(ctx.cwd)
|
|
144
|
+
const resolved = resolve(cwd, p)
|
|
145
|
+
assertInside(cwd, resolved, p)
|
|
146
|
+
const real = realpathNearest(resolved)
|
|
147
|
+
assertInside(cwd, real, p)
|
|
148
|
+
return resolved
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 破坏性预检用的粗切分(也切 > >> <,使段内破坏性检测在重定向时仍生效) */
|
|
152
|
+
export function shellSegments(command) {
|
|
153
|
+
return command.split(/&&|\|\||>>|\$\(|[;|\n<>]|`|[(]/)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 检测 shell 输出/输入重定向(> >> < 后跟文件名)——引号内未排除,保守拦截 */
|
|
157
|
+
export function hasFileRedirection(command) {
|
|
158
|
+
return /(^|[\s;&|])>{1,2}\s*\S/.test(command) || /(^|[\s;&|])<\s*\S/.test(command)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 单命令段是否为破坏性非 git 命令(保守:宁可误拦) */
|
|
162
|
+
export function isDestructiveCommand(seg) {
|
|
163
|
+
const s = seg
|
|
164
|
+
// rm 同时带递归(-r/-R)与强制(-f)标志:-rf / -fr / -r -f / -Rf 等
|
|
165
|
+
if (/\brm\b/.test(s) && /\s-\S*r/i.test(s) && /\s-\S*f/i.test(s)) return true
|
|
166
|
+
if (/\brmdir\b/i.test(s)) return true
|
|
167
|
+
if (/\bdel\b/i.test(s) && /\/f\b/i.test(s)) return true
|
|
168
|
+
if (/\brd\b/i.test(s) && /\/s\b/i.test(s)) return true
|
|
169
|
+
// format 作为命令调用(排除 --format= 之类的选项误报)
|
|
170
|
+
if (/\bformat\b\s+\S/i.test(s) && !/--format\b/i.test(s)) return true
|
|
171
|
+
if (/\bshred\b/i.test(s)) return true
|
|
172
|
+
if (/\bdd\b/.test(s) && /\bof=/i.test(s)) return true
|
|
173
|
+
if (/\bDROP\s+TABLE\b/i.test(s)) return true
|
|
174
|
+
if (/\bDELETE\s+FROM\b/i.test(s)) return true
|
|
175
|
+
if (/\bTRUNCATE\b/i.test(s)) return true
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 单命令段是否销毁未提交改动 */
|
|
180
|
+
export function isDestructiveGitSegment(seg) {
|
|
181
|
+
if (!/^\s*git\s/.test(seg)) return false
|
|
182
|
+
if (/\scheckout\s+(?:--|\.(?:\s|$))/.test(seg)) return true
|
|
183
|
+
if (/\sreset\s+--hard\b/.test(seg)) return true
|
|
184
|
+
if (/\sclean\s+-\S*f/.test(seg)) return true
|
|
185
|
+
if (/\srestore\s/.test(seg) && (/--worktree/.test(seg) || !/--staged/.test(seg))) return true
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** cwd 是否在 git 仓库内 */
|
|
190
|
+
export function insideGitRepo(cwd) {
|
|
191
|
+
try {
|
|
192
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
193
|
+
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
194
|
+
})
|
|
195
|
+
return true
|
|
196
|
+
} catch { return false }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** glob 转正则 */
|
|
200
|
+
export function globToRegex(pattern) {
|
|
201
|
+
const DS = "\u0001", DP = "\u0002"
|
|
202
|
+
const escaped = pattern
|
|
203
|
+
.replace(/\*\*\//g, DS).replace(/\*\*/g, DP)
|
|
204
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
205
|
+
.replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")
|
|
206
|
+
.replace(new RegExp(DS, "g"), "(?:.+/)?")
|
|
207
|
+
.replace(new RegExp(DP, "g"), ".*")
|
|
208
|
+
return new RegExp(`^${escaped}$`)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 剥 HTML 标签 */
|
|
212
|
+
export function stripTags(html) {
|
|
213
|
+
return html
|
|
214
|
+
.replace(/<[^>]+>/g, "")
|
|
215
|
+
.replace(/�*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
216
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
217
|
+
.replace(/ | /g, " ")
|
|
218
|
+
.replace(/</g, "<")
|
|
219
|
+
.replace(/>/g, ">")
|
|
220
|
+
.replace(/"/g, '"')
|
|
221
|
+
.replace(/&/g, "&")
|
|
222
|
+
.replace(/\s+/g, " ")
|
|
223
|
+
.trim()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** HTML → 粗文本:去脚本样式、块级标签换行、剥标签、解码实体、压缩空行 */
|
|
227
|
+
export function htmlToText(html) {
|
|
228
|
+
return html
|
|
229
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
230
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
231
|
+
.replace(/<noscript[\s\S]*?<\/noscript>/gi, "")
|
|
232
|
+
.replace(/<\/(p|div|li|ul|ol|h[1-6]|tr|table|section|article|header|footer|blockquote|pre)>/gi, "\n")
|
|
233
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
234
|
+
.replace(/<li[^>]*>/gi, "- ")
|
|
235
|
+
.replace(/<[^>]+>/g, "")
|
|
236
|
+
.replace(/�*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
237
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
238
|
+
.replace(/ | /g, " ")
|
|
239
|
+
.replace(/</g, "<")
|
|
240
|
+
.replace(/>/g, ">")
|
|
241
|
+
.replace(/"/g, '"')
|
|
242
|
+
.replace(/&/g, "&") // & 必须最后解码,否则 &lt; 会被二次解码成 <
|
|
243
|
+
.replace(/[ \t]+/g, " ")
|
|
244
|
+
.replace(/\n\s*\n\s*\n+/g, "\n\n")
|
|
245
|
+
.trim()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 执行 git 命令。maxBuffer 10MB 防大 diff/log 溢出;溢出时返回截断的部分输出而非空。 */
|
|
249
|
+
export function runGit(cwd, cmdArgs) {
|
|
250
|
+
try {
|
|
251
|
+
return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).trim().replace(/\r/g, "")
|
|
252
|
+
} catch (e) {
|
|
253
|
+
// ERR_CHILD_PROCESS_STDIO_MAXBUFFER 时 e.stdout 含部分输出,截取前 200 行返回
|
|
254
|
+
if (e.stdout) return String(e.stdout).trim().replace(/\r/g, "").split("\n").slice(0, 200).join("\n")
|
|
255
|
+
return ""
|
|
256
|
+
}
|
|
257
|
+
}
|