thincoder 0.1.0 → 0.3.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 +59 -35
- package/bin/thincoder.mjs +194 -23
- package/package.json +1 -1
- package/src/SYSTEM_PROMPT.md +30 -0
- package/src/agent.mjs +471 -61
- package/src/coder-overlay.md +14 -0
- package/src/config.mjs +91 -20
- package/src/context.mjs +41 -6
- package/src/explore-overlay.md +10 -0
- package/src/mcp.mjs +359 -0
- package/src/provider.mjs +7 -1
- package/src/session.mjs +7 -4
- package/src/skills.mjs +75 -0
- package/src/tools/bash.md +13 -0
- package/src/tools/delete.md +9 -0
- package/src/tools/edit.md +12 -0
- package/src/tools/fetch.md +10 -0
- package/src/tools/git_diff.md +11 -0
- package/src/tools/git_log.md +10 -0
- package/src/tools/git_status.md +8 -0
- package/src/tools/glob.md +11 -0
- package/src/tools/grep.md +12 -0
- package/src/tools/ls.md +9 -0
- package/src/tools/question.md +10 -0
- package/src/tools/read.md +10 -0
- package/src/tools/websearch.md +10 -0
- package/src/tools/write.md +9 -0
- package/src/tools.mjs +191 -20
- package/src/tui.mjs +1181 -122
package/src/session.mjs
CHANGED
|
@@ -17,12 +17,14 @@ export function sessionPath(cwd) {
|
|
|
17
17
|
/** 保存会话(同步:退出清理路径也能用) */
|
|
18
18
|
export function saveSession(agent) {
|
|
19
19
|
const data = {
|
|
20
|
-
version:
|
|
20
|
+
version: 2,
|
|
21
21
|
cwd: agent.cwd,
|
|
22
|
-
|
|
22
|
+
activeProvider: agent.provider?.name,
|
|
23
23
|
updatedAt: Date.now(),
|
|
24
24
|
history: agent.history,
|
|
25
25
|
tasks: agent.tasks ?? [],
|
|
26
|
+
planMode: agent.planMode ?? false,
|
|
27
|
+
goal: agent.goal ?? null,
|
|
26
28
|
}
|
|
27
29
|
const p = sessionPath(agent.cwd)
|
|
28
30
|
mkdirSync(dirname(p), { recursive: true })
|
|
@@ -35,7 +37,8 @@ export function loadSession(cwd) {
|
|
|
35
37
|
const p = sessionPath(cwd)
|
|
36
38
|
if (!existsSync(p)) return null
|
|
37
39
|
const data = JSON.parse(readFileSync(p, "utf8"))
|
|
38
|
-
if (data?.version !== 1
|
|
40
|
+
if (data?.version !== 1 && data?.version !== 2) return null
|
|
41
|
+
if (!Array.isArray(data.history)) return null
|
|
39
42
|
return data
|
|
40
43
|
} catch {
|
|
41
44
|
return null
|
|
@@ -46,7 +49,7 @@ export function loadSession(cwd) {
|
|
|
46
49
|
export function clearSession(cwd) {
|
|
47
50
|
try {
|
|
48
51
|
const p = sessionPath(cwd)
|
|
49
|
-
if (existsSync(p)) writeFileSync(p, JSON.stringify({ version:
|
|
52
|
+
if (existsSync(p)) writeFileSync(p, JSON.stringify({ version: 2, cwd, history: [], tasks: [] }), "utf8")
|
|
50
53
|
} catch {
|
|
51
54
|
// 清不掉就算了,下次保存会覆盖
|
|
52
55
|
}
|
package/src/skills.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skills.mjs — 技能系统
|
|
3
|
+
* 从 .thincoder/skills/ 目录发现 .md 技能文件,
|
|
4
|
+
* 注入到 system prompt 供 agent 按需加载。
|
|
5
|
+
* 用 skill 工具激活指定技能,内容以 <skill-loaded> 包裹写入对话历史。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFile, readdir, stat } from "node:fs/promises"
|
|
9
|
+
import { join } from "node:path"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 扫描 .thincoder/skills/ 目录,返回技能列表。
|
|
13
|
+
* 每个技能:{ name, path, description } — name 取文件名(去扩展名)。
|
|
14
|
+
* 目录不存在或无文件返回空数组。
|
|
15
|
+
*/
|
|
16
|
+
export async function loadSkills(cwd) {
|
|
17
|
+
const dir = join(cwd, ".thincoder", "skills")
|
|
18
|
+
let entries
|
|
19
|
+
try {
|
|
20
|
+
entries = await readdir(dir)
|
|
21
|
+
} catch {
|
|
22
|
+
return []
|
|
23
|
+
}
|
|
24
|
+
const skills = []
|
|
25
|
+
for (const name of entries) {
|
|
26
|
+
if (!/^[a-zA-Z0-9_-]+\.md$/.test(name)) continue // 与 readSkill 的名字校验一致,防"列得出、读不了"
|
|
27
|
+
const p = join(dir, name)
|
|
28
|
+
try {
|
|
29
|
+
const s = await stat(p)
|
|
30
|
+
if (!s.isFile()) continue
|
|
31
|
+
// 提取描述(前 400 字符里第一段非空、非标题行)
|
|
32
|
+
const head = await readFile(p, "utf8")
|
|
33
|
+
const body = head.slice(0, 400).split("\n")
|
|
34
|
+
let desc = ""
|
|
35
|
+
for (const line of body) {
|
|
36
|
+
const t = line.trim()
|
|
37
|
+
if (t && !t.startsWith("#") && !t.startsWith("---")) {
|
|
38
|
+
desc = t.slice(0, 120)
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
skills.push({ name: name.replace(/\.md$/, ""), path: p, description: desc || "(no description)" })
|
|
43
|
+
} catch {
|
|
44
|
+
// 读失败跳过
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return skills
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 生成技能列表文本,注入 system prompt。
|
|
52
|
+
* 最多 3 个(占位少),超过则标 "... and N more"。
|
|
53
|
+
*/
|
|
54
|
+
export function formatSkillListing(skills) {
|
|
55
|
+
if (skills.length === 0) return ""
|
|
56
|
+
const listed = skills.slice(0, 3)
|
|
57
|
+
const lines = listed.map((s) => `- **${s.name}**: ${s.description}`)
|
|
58
|
+
if (skills.length > 3) lines.push(` ... and ${skills.length - 3} more`)
|
|
59
|
+
return "Available skills (use the skill tool to load one):\n" + lines.join("\n")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 读取指定技能文件的完整内容。
|
|
64
|
+
* 返回文本,找不到返回 null。
|
|
65
|
+
*/
|
|
66
|
+
export async function readSkill(cwd, name) {
|
|
67
|
+
// 安全检查:技能名只能是字母数字 + 连字符/下划线
|
|
68
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) return null
|
|
69
|
+
const p = join(cwd, ".thincoder", "skills", `${name}.md`)
|
|
70
|
+
try {
|
|
71
|
+
return await readFile(p, "utf8")
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Execute a shell command and return stdout+stderr. Use for running commands, builds, tests.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- command (required): Shell command to execute
|
|
5
|
+
- timeout: Timeout in milliseconds (default 120000, max ~300000)
|
|
6
|
+
|
|
7
|
+
Notes:
|
|
8
|
+
- There is NO TTY — editors, pagers (vim, less), and interactive prompts WILL hang. Always pass non-interactive flags: `git commit -m`, `git --no-pager`, `-y`/`--yes` where applicable
|
|
9
|
+
- The environment sets GIT_PAGER=cat, PAGER=cat, EDITOR=true, TERM=dumb — but still always use non-interactive flags
|
|
10
|
+
- Output is capped at ~50000 chars; if you need more, redirect to a file and read it
|
|
11
|
+
- On Windows, use Unix shell syntax inside bash commands (Git Bash): forward slashes, `/dev/null` not `NUL`
|
|
12
|
+
- Never use bash to read, copy, or transmit secret files (.env, keys, tokens)
|
|
13
|
+
- Do NOT run destructive commands (rm -rf, force-push, drop table) without explicit user confirmation
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Delete a file. Use when the agent created a temporary or junk file that should be cleaned up, or when the user explicitly asks to delete something. Refuses to delete git-tracked files as a safety measure — tracked files should be edited or removed via bash with explicit user confirmation.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- path (required): File path, relative to cwd or absolute
|
|
5
|
+
|
|
6
|
+
Notes:
|
|
7
|
+
- Untracked or non-git files are deleted immediately
|
|
8
|
+
- Tracked files require force=true (user must confirm separately)
|
|
9
|
+
- Directories must be removed with bash (rm -rf)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Edit a file by exact string replacement. old_string must match exactly once unless replace_all is set.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- path (required): File path
|
|
5
|
+
- old_string (required): Exact text to find and replace
|
|
6
|
+
- new_string (required): Replacement text
|
|
7
|
+
- replace_all: Replace all occurrences instead of just one (default false)
|
|
8
|
+
|
|
9
|
+
Notes:
|
|
10
|
+
- Prefer this over write for targeted edits — it's safer and keeps diffs small
|
|
11
|
+
- If old_string matches zero times: error. If it matches multiple times without replace_all: error — add more surrounding context to make it unique
|
|
12
|
+
- Never fabricate the old_string — copy it verbatim from the actual file using read first
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Fetch a URL and return its content as text. HTML pages are stripped to readable text. Use after websearch to read full documents.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- url (required): http/https URL
|
|
5
|
+
|
|
6
|
+
Notes:
|
|
7
|
+
- Follows redirects automatically
|
|
8
|
+
- Timeout: 20 seconds
|
|
9
|
+
- HTML pages are converted to plain text (scripts, styles, navigation stripped)
|
|
10
|
+
- Non-HTML responses are returned as-is (truncated at ~50000 chars)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Show git diff (unified format). Use to see uncommitted changes, staged changes, or diff against a specific ref.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- staged: Show staged changes (default false, shows working tree diff)
|
|
5
|
+
- path: File or directory to diff (default all)
|
|
6
|
+
- ref: Compare against a ref (default HEAD)
|
|
7
|
+
|
|
8
|
+
Notes:
|
|
9
|
+
- Only works inside a git repository
|
|
10
|
+
- Output is standard unified diff — LLMs understand this natively
|
|
11
|
+
- If no changes, returns "(no changes)"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Show recent git commit history. Use to understand the project's recent changes, conventions, and pace.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- count: Number of commits to show (default 10)
|
|
5
|
+
- path: File or directory to show history for (default all)
|
|
6
|
+
- oneline: Compact one-line-per-commit format (default false)
|
|
7
|
+
|
|
8
|
+
Notes:
|
|
9
|
+
- Only works inside a git repository
|
|
10
|
+
- Output includes hash, author, date, and message
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Show structured git status. Use to understand the current working tree state: what files are staged, modified, untracked, or conflicting.
|
|
2
|
+
|
|
3
|
+
Returns categorized lists: staged, unstaged, untracked, conflicts.
|
|
4
|
+
|
|
5
|
+
Notes:
|
|
6
|
+
- Only works inside a git repository
|
|
7
|
+
- Output is categorized for easy parsing by the agent
|
|
8
|
+
- No side effects — read-only
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Find files by glob pattern (e.g. 'src/**/*.mjs'). Returns matching paths.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- pattern (required): Glob pattern — supports **, *, ?, and character classes
|
|
5
|
+
- path: Directory to search in (default cwd)
|
|
6
|
+
|
|
7
|
+
Notes:
|
|
8
|
+
- Skips node_modules, .git, dist, build, .turbo, coverage
|
|
9
|
+
- Results capped at 1000 matches
|
|
10
|
+
- Use this to discover file structure; use grep to search file contents
|
|
11
|
+
- Prefer patterns with a literal anchor (extension or subdirectory) over bare wildcards
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Search file contents with a regex. Returns matching lines as path:line: content.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- pattern (required): JavaScript regular expression
|
|
5
|
+
- path: Directory or file to search (default cwd)
|
|
6
|
+
- glob: Only search files matching this glob (e.g. '*.mjs')
|
|
7
|
+
|
|
8
|
+
Notes:
|
|
9
|
+
- Skips node_modules, .git, dist, build, .turbo, coverage
|
|
10
|
+
- Results capped at 200 matches
|
|
11
|
+
- Binary/unreadable files are silently skipped
|
|
12
|
+
- Use this to find usages, definitions, patterns; use glob to find files by name
|
package/src/tools/ls.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
List directory contents with type, size, and modification time. Directories listed first. Use to see what a directory contains (glob only matches files).
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- path: Directory path (default cwd)
|
|
5
|
+
|
|
6
|
+
Notes:
|
|
7
|
+
- Shows first 500 entries
|
|
8
|
+
- Directories are prefixed with `/` and listed before files
|
|
9
|
+
- Use this for a quick overview; use glob when you have a specific file pattern in mind
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Ask the user a question and wait for their response. Use when the task is ambiguous, you need a design decision, or you're stuck and need human judgment.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- question (required): The question to ask the user
|
|
5
|
+
- options: Array of single-choice options for the user to pick from (optional)
|
|
6
|
+
|
|
7
|
+
Notes:
|
|
8
|
+
- The agent loop pauses until the user answers
|
|
9
|
+
- The answer is injected as the next user message
|
|
10
|
+
- Use sparingly — prefer making reasonable decisions when possible
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Read a text file. Returns numbered lines. Use offset/limit to page large files.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- path (required): File path, relative to cwd or absolute
|
|
5
|
+
- offset: 1-based line number to start reading from
|
|
6
|
+
- limit: Max lines to return (default 2000)
|
|
7
|
+
|
|
8
|
+
Notes:
|
|
9
|
+
- Always prefer this over `cat` or shell-based reading — it caps output and avoids large dumps
|
|
10
|
+
- Use offset for pagination when the file is large
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Search the web (Bing). Returns result titles, URLs, and snippets. Use for looking up current information, docs, error messages.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- query (required): Search query
|
|
5
|
+
- limit: Max results (default 8)
|
|
6
|
+
|
|
7
|
+
Notes:
|
|
8
|
+
- Use this for information that is NOT in the local codebase — current docs, error messages, API references
|
|
9
|
+
- Follow up with `fetch` to read full pages from the results
|
|
10
|
+
- Results are scraped from Bing HTML — some formatting may be imperfect
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Write content to a file. Creates parent directories; overwrites existing file.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- path (required): File path, relative to cwd or absolute
|
|
5
|
+
- content (required): Full content to write
|
|
6
|
+
|
|
7
|
+
Notes:
|
|
8
|
+
- This overwrites the entire file — use `edit` for targeted changes
|
|
9
|
+
- The file is atomic: it either writes completely or fails
|
package/src/tools.mjs
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* tools.mjs — 内置工具集
|
|
3
|
-
* read / write / edit / bash / glob / grep,零依赖实现。
|
|
3
|
+
* read / write / edit / bash / glob / grep / websearch / ls / fetch / delete / git_diff / git_status / git_log / question,零依赖实现。
|
|
4
|
+
* 工具描述从 src/tools/*.md 加载(方便人读和修改)。
|
|
4
5
|
* readonly 标记供 agent 调度:只读工具可并行,有副作用工具串行。
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import { spawn } from "node:child_process"
|
|
8
|
-
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"
|
|
9
|
-
import {
|
|
8
|
+
import { spawn, execFileSync } from "node:child_process"
|
|
9
|
+
import { mkdir, readFile, readdir, stat, writeFile, unlink } from "node:fs/promises"
|
|
10
|
+
import { readFileSync, existsSync } from "node:fs"
|
|
11
|
+
import { dirname, join, resolve, relative } from "node:path"
|
|
12
|
+
import { fileURLToPath } from "node:url"
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const DESC = (name) => readFileSync(join(__dirname, "tools", `${name}.md`), "utf8")
|
|
10
16
|
|
|
11
17
|
const MAX_READ_LINES = 2000
|
|
12
18
|
const MAX_OUTPUT_CHARS = 50_000
|
|
@@ -48,8 +54,7 @@ function resolveInCwd(ctx, p) {
|
|
|
48
54
|
|
|
49
55
|
const readTool = {
|
|
50
56
|
name: "read",
|
|
51
|
-
description:
|
|
52
|
-
"Read a text file. Returns numbered lines. Use offset/limit to page large files.",
|
|
57
|
+
description: DESC("read"),
|
|
53
58
|
parameters: {
|
|
54
59
|
type: "object",
|
|
55
60
|
properties: {
|
|
@@ -77,7 +82,7 @@ const readTool = {
|
|
|
77
82
|
|
|
78
83
|
const writeTool = {
|
|
79
84
|
name: "write",
|
|
80
|
-
description: "
|
|
85
|
+
description: DESC("write"),
|
|
81
86
|
parameters: {
|
|
82
87
|
type: "object",
|
|
83
88
|
properties: {
|
|
@@ -99,8 +104,7 @@ const writeTool = {
|
|
|
99
104
|
|
|
100
105
|
const editTool = {
|
|
101
106
|
name: "edit",
|
|
102
|
-
description:
|
|
103
|
-
"Edit a file by exact string replacement. old_string must match exactly once unless replace_all is set.",
|
|
107
|
+
description: DESC("edit"),
|
|
104
108
|
parameters: {
|
|
105
109
|
type: "object",
|
|
106
110
|
properties: {
|
|
@@ -134,8 +138,7 @@ const editTool = {
|
|
|
134
138
|
|
|
135
139
|
const bashTool = {
|
|
136
140
|
name: "bash",
|
|
137
|
-
description:
|
|
138
|
-
"Execute a shell command and return stdout+stderr. Use for running commands, builds, tests.",
|
|
141
|
+
description: DESC("bash"),
|
|
139
142
|
parameters: {
|
|
140
143
|
type: "object",
|
|
141
144
|
properties: {
|
|
@@ -210,6 +213,10 @@ const bashTool = {
|
|
|
210
213
|
child.stderr.on("data", onData)
|
|
211
214
|
|
|
212
215
|
const timer = setTimeout(() => child.kill(), args.timeout ?? BASH_TIMEOUT_MS)
|
|
216
|
+
// 用户中止:杀进程
|
|
217
|
+
if (ctx.signal) {
|
|
218
|
+
ctx.signal.addEventListener("abort", () => child.kill(), { once: true })
|
|
219
|
+
}
|
|
213
220
|
child.on("error", (error) => {
|
|
214
221
|
clearTimeout(timer)
|
|
215
222
|
resolve(truncate(`Command failed: ${error.message}\n${out}`))
|
|
@@ -218,7 +225,7 @@ const bashTool = {
|
|
|
218
225
|
clearTimeout(timer)
|
|
219
226
|
out += sanitizeOutput(feed(Buffer.alloc(0), true)) // 最终判定 + 冲刷解码器尾部
|
|
220
227
|
const suffix = signal
|
|
221
|
-
?
|
|
228
|
+
? `\n(killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"})`
|
|
222
229
|
: code !== 0
|
|
223
230
|
? `\n(exit code ${code})`
|
|
224
231
|
: ""
|
|
@@ -232,7 +239,7 @@ const bashTool = {
|
|
|
232
239
|
|
|
233
240
|
const globTool = {
|
|
234
241
|
name: "glob",
|
|
235
|
-
description: "
|
|
242
|
+
description: DESC("glob"),
|
|
236
243
|
parameters: {
|
|
237
244
|
type: "object",
|
|
238
245
|
properties: {
|
|
@@ -295,7 +302,7 @@ function globToRegex(pattern) {
|
|
|
295
302
|
|
|
296
303
|
const grepTool = {
|
|
297
304
|
name: "grep",
|
|
298
|
-
description: "
|
|
305
|
+
description: DESC("grep"),
|
|
299
306
|
parameters: {
|
|
300
307
|
type: "object",
|
|
301
308
|
properties: {
|
|
@@ -357,8 +364,7 @@ const grepTool = {
|
|
|
357
364
|
|
|
358
365
|
const websearchTool = {
|
|
359
366
|
name: "websearch",
|
|
360
|
-
description:
|
|
361
|
-
"Search the web (Bing). Returns result titles, URLs, and snippets. Use for looking up current information, docs, error messages.",
|
|
367
|
+
description: DESC("websearch"),
|
|
362
368
|
parameters: {
|
|
363
369
|
type: "object",
|
|
364
370
|
properties: {
|
|
@@ -422,8 +428,7 @@ function stripTags(html) {
|
|
|
422
428
|
|
|
423
429
|
const lsTool = {
|
|
424
430
|
name: "ls",
|
|
425
|
-
description:
|
|
426
|
-
"List directory contents with type, size, and modification time. Directories listed first. Use to see what a directory contains (glob only matches files).",
|
|
431
|
+
description: DESC("ls"),
|
|
427
432
|
parameters: {
|
|
428
433
|
type: "object",
|
|
429
434
|
properties: {
|
|
@@ -457,8 +462,7 @@ const lsTool = {
|
|
|
457
462
|
|
|
458
463
|
const fetchTool = {
|
|
459
464
|
name: "fetch",
|
|
460
|
-
description:
|
|
461
|
-
"Fetch a URL and return its content as text (HTML pages are stripped to readable text). Use after websearch to read full documents.",
|
|
465
|
+
description: DESC("fetch"),
|
|
462
466
|
parameters: {
|
|
463
467
|
type: "object",
|
|
464
468
|
properties: {
|
|
@@ -511,3 +515,170 @@ function htmlToText(html) {
|
|
|
511
515
|
}
|
|
512
516
|
|
|
513
517
|
export const builtinTools = [readTool, writeTool, editTool, bashTool, globTool, grepTool, websearchTool, lsTool, fetchTool]
|
|
518
|
+
|
|
519
|
+
// ---------------------------------------------------------------- delete
|
|
520
|
+
|
|
521
|
+
const deleteTool = {
|
|
522
|
+
name: "delete",
|
|
523
|
+
description: DESC("delete"),
|
|
524
|
+
parameters: {
|
|
525
|
+
type: "object",
|
|
526
|
+
properties: {
|
|
527
|
+
path: { type: "string", description: "File path (relative to cwd or absolute)" },
|
|
528
|
+
force: { type: "boolean", description: "Allow deleting git-tracked files (default false)" },
|
|
529
|
+
},
|
|
530
|
+
required: ["path"],
|
|
531
|
+
},
|
|
532
|
+
readonly: false,
|
|
533
|
+
async execute(args, ctx) {
|
|
534
|
+
const abs = resolveInCwd(ctx, args.path)
|
|
535
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`)
|
|
536
|
+
const s = await stat(abs)
|
|
537
|
+
if (s.isDirectory()) throw new Error(`"${args.path}" is a directory — use bash to remove directories`)
|
|
538
|
+
// git 跟踪文件拒绝直接删除(安全网);未跟踪的放行
|
|
539
|
+
// 用解析后的相对路径(统一正斜杠),防反斜杠/非常规路径绕过 ls-files 匹配
|
|
540
|
+
const rel = relative(ctx.cwd, abs).replace(/\\/g, "/")
|
|
541
|
+
let tracked = false
|
|
542
|
+
try {
|
|
543
|
+
execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: ctx.cwd, stdio: "ignore" })
|
|
544
|
+
tracked = true
|
|
545
|
+
} catch {
|
|
546
|
+
// 未跟踪 / 非 git 仓库
|
|
547
|
+
}
|
|
548
|
+
if (tracked && !args.force) throw new Error(`"${args.path}" is git-tracked. Set force=true to delete anyway.`)
|
|
549
|
+
await unlink(abs)
|
|
550
|
+
return `Deleted ${abs}`
|
|
551
|
+
},
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ---------------------------------------------------------------- git_diff
|
|
555
|
+
|
|
556
|
+
const gitDiffTool = {
|
|
557
|
+
name: "git_diff",
|
|
558
|
+
description: DESC("git_diff"),
|
|
559
|
+
parameters: {
|
|
560
|
+
type: "object",
|
|
561
|
+
properties: {
|
|
562
|
+
staged: { type: "boolean", description: "Show staged changes (default false)" },
|
|
563
|
+
path: { type: "string", description: "File or directory to diff (default all)" },
|
|
564
|
+
ref: { type: "string", description: "Compare against this ref (default HEAD)" },
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
readonly: true,
|
|
568
|
+
execute(args, ctx) {
|
|
569
|
+
const ref = args.ref ?? "HEAD"
|
|
570
|
+
const flags = args.staged ? ["--staged"] : []
|
|
571
|
+
const paths = args.path ? [args.path] : []
|
|
572
|
+
const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
|
|
573
|
+
return truncate(out || "(no changes)")
|
|
574
|
+
},
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ---------------------------------------------------------------- git_status
|
|
578
|
+
|
|
579
|
+
const gitStatusTool = {
|
|
580
|
+
name: "git_status",
|
|
581
|
+
description: DESC("git_status"),
|
|
582
|
+
parameters: {
|
|
583
|
+
type: "object",
|
|
584
|
+
properties: {},
|
|
585
|
+
},
|
|
586
|
+
readonly: true,
|
|
587
|
+
execute(_args, ctx) {
|
|
588
|
+
const porcelain = runGit(ctx.cwd, ["status", "--porcelain"])
|
|
589
|
+
if (!porcelain) return "(clean — no changes)"
|
|
590
|
+
|
|
591
|
+
const staged = []
|
|
592
|
+
const unstaged = []
|
|
593
|
+
const untracked = []
|
|
594
|
+
const conflicts = []
|
|
595
|
+
for (const line of porcelain.split("\n")) {
|
|
596
|
+
if (!line) continue
|
|
597
|
+
// porcelain: XY path — 2 状态字符 + 空格 + 文件路径(部分环境只 1 空格)
|
|
598
|
+
// 去掉可能的 CR(execFileSync 在某些 Windows git 下会残留 \r 在行末但不在换行符中)
|
|
599
|
+
const clean = line.replace(/\r/g, "")
|
|
600
|
+
// 尝试匹配 "XY path" 或 "XY path"(可变间距)
|
|
601
|
+
const m = clean.match(/^(..?)\s+(.+)$/)
|
|
602
|
+
if (!m) continue
|
|
603
|
+
const [, status, file] = m
|
|
604
|
+
const idx = status[0] ?? " "
|
|
605
|
+
const wt = status[1] ?? " "
|
|
606
|
+
if (idx === "U" || wt === "U" || (idx === "A" && wt === "A")) {
|
|
607
|
+
conflicts.push(file)
|
|
608
|
+
} else if (idx === "?" && wt === "?") {
|
|
609
|
+
untracked.push(file)
|
|
610
|
+
} else {
|
|
611
|
+
if (idx !== " " && idx !== "?") staged.push(idx + " " + file)
|
|
612
|
+
if (wt !== " " && wt !== "?") unstaged.push(wt + " " + file)
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
const parts = []
|
|
616
|
+
if (staged.length) parts.push("Staged (" + staged.length + "):\n" + staged.join("\n"))
|
|
617
|
+
if (unstaged.length) parts.push("Unstaged (" + unstaged.length + "):\n" + unstaged.join("\n"))
|
|
618
|
+
if (untracked.length) parts.push("Untracked (" + untracked.length + "):\n" + untracked.join("\n"))
|
|
619
|
+
if (conflicts.length) parts.push("Conflicts (" + conflicts.length + "):\n" + conflicts.join("\n"))
|
|
620
|
+
return truncate(parts.join("\n\n"))
|
|
621
|
+
},
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ---------------------------------------------------------------- git_log
|
|
625
|
+
|
|
626
|
+
const gitLogTool = {
|
|
627
|
+
name: "git_log",
|
|
628
|
+
description: DESC("git_log"),
|
|
629
|
+
parameters: {
|
|
630
|
+
type: "object",
|
|
631
|
+
properties: {
|
|
632
|
+
count: { type: "number", description: "Number of commits (default 10)" },
|
|
633
|
+
path: { type: "string", description: "File or directory (default all)" },
|
|
634
|
+
oneline: { type: "boolean", description: "One-line-per-commit format (default false)" },
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
readonly: true,
|
|
638
|
+
execute(args, ctx) {
|
|
639
|
+
const n = args.count ?? 10
|
|
640
|
+
const isOneline = args.oneline
|
|
641
|
+
const cmdArgs = isOneline
|
|
642
|
+
? ["log", "-" + n, "--oneline"]
|
|
643
|
+
: ["log", "-" + n, "--format=%h %ad %an %s", "--date=short"]
|
|
644
|
+
if (args.path) cmdArgs.push("--", args.path)
|
|
645
|
+
const out = runGit(ctx.cwd, cmdArgs)
|
|
646
|
+
return truncate(out || "(no commits)")
|
|
647
|
+
},
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ---------------------------------------------------------------- question
|
|
651
|
+
|
|
652
|
+
const questionTool = {
|
|
653
|
+
name: "question",
|
|
654
|
+
description: DESC("question"),
|
|
655
|
+
parameters: {
|
|
656
|
+
type: "object",
|
|
657
|
+
properties: {
|
|
658
|
+
question: { type: "string", description: "The question to ask the user" },
|
|
659
|
+
options: {
|
|
660
|
+
type: "array",
|
|
661
|
+
items: { type: "string" },
|
|
662
|
+
description: "Single-choice options for the user to pick from (optional)",
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
required: ["question"],
|
|
666
|
+
},
|
|
667
|
+
readonly: true,
|
|
668
|
+
async execute(args, ctx) {
|
|
669
|
+
if (!ctx.onQuestion) throw new Error("question tool not supported in this context (no UI to ask)")
|
|
670
|
+
return ctx.onQuestion(args.question, args.options ?? [])
|
|
671
|
+
},
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** 执行 git 命令;非 git 仓库 / git 不可用时返回空字符串 */
|
|
675
|
+
function runGit(cwd, cmdArgs) {
|
|
676
|
+
try {
|
|
677
|
+
return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().replace(/\r/g, "")
|
|
678
|
+
} catch {
|
|
679
|
+
return ""
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export { deleteTool, gitDiffTool, gitStatusTool, gitLogTool, questionTool }
|
|
684
|
+
builtinTools.push(deleteTool, gitDiffTool, gitStatusTool, gitLogTool, questionTool)
|