thincoder 0.12.53 → 0.12.58
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/CHANGELOG.md +74 -0
- package/bin/thincoder.mjs +17 -3
- package/package.json +3 -7
- package/src/acp/bridge.mjs +1 -1
- package/src/acp.mjs +60 -18
- package/src/advisor/messages.mjs +4 -2
- package/src/advisor/run.mjs +2 -2
- package/src/agent/dispatch.mjs +66 -26
- package/src/agent/helpers.mjs +13 -2
- package/src/agent/setup.mjs +16 -3
- package/src/agent/spawn-child.mjs +3 -1
- package/src/agent-tools/advisor.mjs +19 -9
- package/src/agent-tools/eng.mjs +2 -0
- package/src/agent-tools/subagent-check.mjs +107 -0
- package/src/agent-tools/subagent.mjs +205 -42
- package/src/agent.mjs +68 -3
- package/src/cli/make-agent.mjs +25 -0
- package/src/cli/memory-command.mjs +28 -7
- package/src/config.mjs +120 -8
- package/src/context.mjs +28 -7
- package/src/escape.mjs +110 -22
- package/src/git/checkpoint.mjs +32 -6
- package/src/mcp/transport-http.mjs +13 -1
- package/src/mcp.mjs +52 -7
- package/src/memory/core.mjs +78 -10
- package/src/memory/docs.mjs +33 -7
- package/src/memory.mjs +1 -1
- package/src/model-specs.mjs +23 -0
- package/src/prompts/discipline.md +17 -3
- package/src/prompts/engineering.md +62 -5
- package/src/prompts/main.md +1 -0
- package/src/prompts/system.md +2 -1
- package/src/provider/anthropic.mjs +7 -5
- package/src/provider/core.mjs +90 -26
- package/src/provider/google.mjs +57 -24
- package/src/provider/normalize.mjs +1 -1
- package/src/provider/rate.mjs +0 -2
- package/src/provider/responses.mjs +8 -13
- package/src/provider/sse.mjs +20 -0
- package/src/session-migrate.mjs +6 -0
- package/src/session-slots.mjs +361 -0
- package/src/session.mjs +282 -306
- package/src/tools/apply_patch.md +2 -0
- package/src/tools/bash.md +2 -2
- package/src/tools/edit-batch.mjs +104 -0
- package/src/tools/edit.md +3 -0
- package/src/tools/execute.md +4 -4
- package/src/tools/execute.mjs +14 -22
- package/src/tools/file.mjs +17 -55
- package/src/tools/file_ops.md +1 -1
- package/src/tools/git-checkpoint.mjs +143 -0
- package/src/tools/git-ext.mjs +173 -0
- package/src/tools/git.md +21 -8
- package/src/tools/git.mjs +55 -177
- package/src/tools/lint.md +1 -1
- package/src/tools/linter.mjs +9 -37
- package/src/tools/patch.mjs +1 -1
- package/src/tools/shared.mjs +7 -20
- package/src/tui/agent-turn.mjs +3 -3
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-eng.mjs +1 -0
- package/src/tui/cmd-mcp-form.mjs +197 -0
- package/src/tui/cmd-mcp.mjs +255 -114
- package/src/tui/cmd-new.mjs +6 -6
- package/src/tui/cmd-restore.mjs +27 -6
- package/src/tui/cmd-session.mjs +17 -4
- package/src/tui/index.mjs +28 -27
- package/src/tui/interaction.mjs +28 -1
- package/src/tui/key-handler.mjs +14 -2
- package/src/tui/layout.mjs +81 -25
- package/src/tui/mouse.mjs +41 -2
- package/src/tui/pickers.mjs +62 -4
- package/src/tui/render-conversation.mjs +36 -93
- package/src/tui/render-frame.mjs +40 -16
- package/src/tui/render-loop.mjs +1 -1
- package/src/tui/render.mjs +4 -4
- package/src/tui/startup.mjs +4 -2
- package/src/tui/subagent-blocks.mjs +119 -4
- package/src/tui/subagent-panel.mjs +81 -0
- package/src/tui/tool-events.mjs +61 -16
- package/src/tui/tui-lifecycle.mjs +45 -0
package/src/tools/apply_patch.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
Apply a unified diff to one or more files, atomically: if any hunk fails to apply, nothing is written.
|
|
2
2
|
|
|
3
|
+
**Use it for whole-file and multi-file changes:** creating MULTIPLE new files at once (`--- /dev/null` header per file), whole-file replacement, and cross-file refactors — one unified-diff call covers the whole change. A batched call is one permission ask and one turn instead of N separate calls.
|
|
4
|
+
|
|
3
5
|
Parameters:
|
|
4
6
|
- patch (required): Unified diff text. One `--- a/path` / `+++ b/path` header pair per file, then `@@ -old,count +new,count @@` hunks. Use `--- /dev/null` to create a new file.
|
|
5
7
|
|
package/src/tools/bash.md
CHANGED
|
@@ -33,5 +33,5 @@ Notes:
|
|
|
33
33
|
- Never use bash to read, copy, or transmit secret files (.env, keys, tokens)
|
|
34
34
|
- Do NOT run destructive commands (rm -rf, force-push, drop table) without explicit user confirmation
|
|
35
35
|
- After commands that change files (git checkout, npm install, etc.), repo_outline and code_search may be stale — re-run them to get current results.
|
|
36
|
-
- Prefer read/glob/grep/ls for file operations inside the project — bash
|
|
37
|
-
- NEVER use bash to write or modify files (echo/sed/printf > file, cat << EOF, etc.). Use write/edit/insert_after/apply_patch instead — they handle encoding, escaping, and
|
|
36
|
+
- Prefer read/glob/grep/ls for file operations inside the project — file tools and bash reach the same paths (no directory restriction).
|
|
37
|
+
- NEVER use bash to write or modify files (echo/sed/printf > file, cat << EOF, etc.). Use write/edit/insert_after/apply_patch instead — they handle encoding, escaping, and EOL conventions correctly.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edit-batch.mjs — edit 工具的数组形态(edits: [...]):一次多文件原子替换。
|
|
3
|
+
* (2026-08-31 工具顺手度 §9 ②;2026-09-01 缺陷修复"同文件多条串行累积"。)
|
|
4
|
+
* 从 file.mjs 拆出(500 行硬限,先例 git-ext.mjs)——纯移动,零行为变化。
|
|
5
|
+
*
|
|
6
|
+
* 语义:同一 path 的多条编辑按序**串行累积应用**——第 n 条基于前 n-1 条已应用后的
|
|
7
|
+
* 累积内容做匹配与替换;跨 path 条目互不影响(并行原子语义);任一条失败 →
|
|
8
|
+
* 全不写(原子性保留)。
|
|
9
|
+
*/
|
|
10
|
+
import { readFile, writeFile } from "node:fs/promises"
|
|
11
|
+
import { resolveInCwd, normalizeEOL, joinWithEol, gitDiffOne, autoSyntaxCheck } from "./shared.mjs"
|
|
12
|
+
// file.mjs ↔ edit-batch.mjs 循环引用:两侧导入的都是函数声明(提升初始化),
|
|
13
|
+
// 仅在调用期使用——ESM 循环下安全(无模块求值期取值)。
|
|
14
|
+
import { recordWrite, appendWriteContext } from "./file.mjs"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Apply the `edits` array form: multi-file atomic replacement. Throws on any
|
|
18
|
+
* failure (atomic — nothing written). Returns the per-entry result text (joined).
|
|
19
|
+
*/
|
|
20
|
+
export async function applyEditBatch(args, ctx) {
|
|
21
|
+
if (!Array.isArray(args.edits) || args.edits.length === 0) {
|
|
22
|
+
throw new Error("edits must be a non-empty array of {path, old_string, new_string}")
|
|
23
|
+
}
|
|
24
|
+
if (args.path || args.old_string !== undefined || args.new_string !== undefined) {
|
|
25
|
+
throw new Error("edits array is mutually exclusive with path/old_string/new_string")
|
|
26
|
+
}
|
|
27
|
+
// 原子:先全量检查(所有文件的替换都可执行)——任一失败全不写。
|
|
28
|
+
// 2026-09-01 缺陷修复(TOOLS.md §9 ②"同文件多条规则"):同一 path 的多条编辑
|
|
29
|
+
// 按序**串行累积应用**——第 n 条基于前 n-1 条已应用后的累积内容做匹配与替换
|
|
30
|
+
// (原实现每条都基于盘上原始内容计算、写盘循环后置,同文件后者覆盖前者 →
|
|
31
|
+
// 除最后一条外全部静默丢失);跨 path 条目互不影响(并行原子语义不变)。
|
|
32
|
+
const groups = new Map() // abs → 每文件一条流水线
|
|
33
|
+
for (const e of args.edits) {
|
|
34
|
+
if (!e.path) throw new Error("each edit must have a path")
|
|
35
|
+
if (!e.old_string) throw new Error(`edit for ${e.path}: old_string must not be empty`)
|
|
36
|
+
if (typeof e.new_string !== "string") {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`edit for ${e.path}: new_string must be a string` +
|
|
39
|
+
`${e.new_string === undefined ? " (missing)" : ` (got ${typeof e.new_string})`}`,
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
const abs = resolveInCwd(ctx, e.path)
|
|
43
|
+
let g = groups.get(abs)
|
|
44
|
+
if (!g) {
|
|
45
|
+
const raw = await readFile(abs, "utf8")
|
|
46
|
+
g = { abs, path: e.path, raw, content: normalizeEOL(raw), edits: [] }
|
|
47
|
+
groups.set(abs, g)
|
|
48
|
+
}
|
|
49
|
+
g.edits.push(e)
|
|
50
|
+
}
|
|
51
|
+
const prepared = [] // 顺序 = args.edits 顺序(回显按条);recordWrite 每组一条合并快照
|
|
52
|
+
for (const g of groups.values()) {
|
|
53
|
+
g.netShift = 0 // 组内行数差累积(合并快照的 shift = 全组净漂移)
|
|
54
|
+
for (const e of g.edits) {
|
|
55
|
+
const content = g.content
|
|
56
|
+
const occurrences = content.split(e.old_string).length - 1
|
|
57
|
+
if (occurrences === 0) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`edit aborted (atomic — no files written): old_string not found in ${g.path}\n` +
|
|
60
|
+
` searched: "${e.old_string.slice(0, 100).split("\n")[0]}${e.old_string.length > 100 ? "…" : ""}"`
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
if (occurrences > 1 && !e.replace_all) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`edit aborted (atomic — no files written): old_string matches ${occurrences} times in ${g.path}; ` +
|
|
66
|
+
`provide more context or set replace_all`
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
const matchIdx = content.indexOf(e.old_string)
|
|
70
|
+
const editStartLine = matchIdx >= 0 ? content.slice(0, matchIdx).split("\n").length : 1
|
|
71
|
+
const lineShift = e.new_string.split("\n").length - e.old_string.split("\n").length
|
|
72
|
+
const updated = e.replace_all
|
|
73
|
+
? content.split(e.old_string).join(e.new_string)
|
|
74
|
+
: content.replace(e.old_string, () => e.new_string)
|
|
75
|
+
prepared.push({
|
|
76
|
+
g,
|
|
77
|
+
editStartLine, // 基于累积内容计算——已天然计入前面条目的行偏移,不再累加
|
|
78
|
+
lineShift,
|
|
79
|
+
occurrences: e.replace_all ? occurrences : 1,
|
|
80
|
+
})
|
|
81
|
+
g.content = updated // 串行累积:下一条基于本条应用后的内容
|
|
82
|
+
g.netShift += lineShift
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// 全部检查通过——每文件一次写盘(同文件多条:写入串行累积后的最终内容);
|
|
86
|
+
// recordWrite 每组一条合并快照:startLine = 组内**所有**编辑受影响行的最小值
|
|
87
|
+
// (#2,2026-09-01 交付评审尾巴——原实现取首条 = 调用序第一条,逆序条目时
|
|
88
|
+
// 护栏下界过高、受影响区内的 insert_after 被放行),shift = 全组行数差累积
|
|
89
|
+
// ——受影响区护栏覆盖组内所有编辑
|
|
90
|
+
for (const g of groups.values()) {
|
|
91
|
+
const startLine = Math.min(...prepared.filter((p) => p.g === g).map((p) => p.editStartLine))
|
|
92
|
+
await writeFile(g.abs, joinWithEol(normalizeEOL(g.content).split("\n"), g.raw), "utf8")
|
|
93
|
+
recordWrite(g.abs, { type: "edit", startLine, shift: g.netShift })
|
|
94
|
+
}
|
|
95
|
+
const results = []
|
|
96
|
+
for (const p of prepared) {
|
|
97
|
+
// #4(2026-09-01 交付评审尾巴):与单文件路径对齐——每条结果附 git diff +
|
|
98
|
+
// autoSyntaxCheck(同文件多条会重复 diff/检查,换取格式一致、实现零分支)
|
|
99
|
+
const diff = gitDiffOne(ctx.cwd, p.g.abs)
|
|
100
|
+
const base = `Edited ${p.g.path}: replaced ${p.occurrences} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(p.g.abs)}`
|
|
101
|
+
results.push(await appendWriteContext(p.g.abs, p.editStartLine, base))
|
|
102
|
+
}
|
|
103
|
+
return results.join("\n")
|
|
104
|
+
}
|
package/src/tools/edit.md
CHANGED
|
@@ -8,11 +8,14 @@ Edit a file by exact string replacement. old_string must match exactly once unle
|
|
|
8
8
|
- Rewrite an entire file → `write`
|
|
9
9
|
- Rename a symbol project-wide → `lsp` or `grep` first to map every caller
|
|
10
10
|
|
|
11
|
+
**Batch multiple edits into ONE call via the `edits` array** (preferred over N single edit calls): multiple changes to the SAME file go into one `edits` array (entries are applied serially, each based on the previous one's result); independent changes across MULTIPLE files also go into the same `edits` array — one call, atomic (any failure writes nothing). A batched call is one permission ask, one undo unit, and one turn instead of N.
|
|
12
|
+
|
|
11
13
|
Parameters:
|
|
12
14
|
- path (required): File path
|
|
13
15
|
- old_string (required): Exact text to find and replace
|
|
14
16
|
- new_string (required): Replacement text
|
|
15
17
|
- replace_all: Replace all occurrences instead of just one (default false)
|
|
18
|
+
- edits: Array of {path, old_string, new_string, replace_all?} entries — batch form; mutually exclusive with path/old_string/new_string
|
|
16
19
|
|
|
17
20
|
Notes:
|
|
18
21
|
- Prefer this over write for targeted edits — it's safer and keeps changes targeted
|
package/src/tools/execute.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Execute JavaScript — either inline `code` or a
|
|
1
|
+
Execute JavaScript — either inline `code` or a `scriptFile`. Runs in a real `node` process with top-level `await` and dynamic `import()`. Use inline `code` to compose multiple operations into one call — read, write, glob, grep, log, import, or require() — without shelling out to `bash node -e`.
|
|
2
2
|
|
|
3
3
|
**Route to execute instead of bash:**
|
|
4
4
|
- `node -e "…"` → execute (inline code; top-level await + import() + console all work)
|
|
@@ -7,15 +7,15 @@ Execute JavaScript — either inline `code` or a workspace `scriptFile`. Runs in
|
|
|
7
7
|
|
|
8
8
|
Parameters:
|
|
9
9
|
- code: JavaScript to run inline. Top-level `await` and `import('./x.mjs')` are supported. Globals: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args) — plus native require/process/console/fetch/import. Use this OR scriptFile.
|
|
10
|
-
- scriptFile: run a
|
|
10
|
+
- scriptFile: run a .mjs/.js file with node (self-contained — no prelude; the file imports what it needs). Path relative to workdir — no directory restriction. Use this OR code.
|
|
11
11
|
- nodeArgs: (scriptFile) extra node flags before the script, e.g. ["--test"], ["--check"]. Eval-like flags (--eval/--input-type/--inspect) are rejected.
|
|
12
|
-
- workdir: run in this directory (relative to cwd
|
|
12
|
+
- workdir: run in this directory (relative to cwd — no directory restriction; default cwd)
|
|
13
13
|
- filter: optional — only return output lines matching this regex (case-insensitive)
|
|
14
14
|
- timeoutMs: Timeout in milliseconds (default 30000, max 600000 — covers slow `node --test` suites and long package scripts)
|
|
15
15
|
|
|
16
16
|
Notes:
|
|
17
17
|
- `console.log(...)` and `log(...)` both print to the result; objects are JSON-stringified by `log`.
|
|
18
|
-
-
|
|
18
|
+
- The prelude's readFile/writeFile/glob/grep helpers resolve paths against the working directory (helper-only guard — `require`/`process`/`import()` are full Node, same boundary as bash).
|
|
19
19
|
- A non-zero exit / thrown exception returns the stderr (error + stack) as the result.
|
|
20
20
|
- Output capped at ~50KB; use `writeFile` to a file if you need more.
|
|
21
21
|
- Use `write`/`edit`/`apply_patch` for source edits. Still use `bash` for package-manager/CLI subprocesses (`npm test`/`npm publish`/`vsce`), servers, and interactive/TTY programs — execute covers in-process JS and `node <script>`/`node --test`/`node --check`, not arbitrary CLI or long-running programs.
|
package/src/tools/execute.mjs
CHANGED
|
@@ -11,19 +11,20 @@
|
|
|
11
11
|
* process is killed like bash).
|
|
12
12
|
*
|
|
13
13
|
* The child `import()`-s exec-prelude.mjs first for readFile/writeFile/glob/grep/
|
|
14
|
-
* log/require (paths
|
|
15
|
-
* process/import() is available —
|
|
14
|
+
* log/require (helper paths resolve against the working directory — orthopedic
|
|
15
|
+
* guard, not a sandbox). Full Node via require()/process/import() is available —
|
|
16
|
+
* same boundary as bash, no fake sandbox.
|
|
16
17
|
*
|
|
17
18
|
* Parameters:
|
|
18
19
|
* code — JS to run inline (top-level await and import() supported). Use this OR scriptFile.
|
|
19
|
-
* scriptFile — run a
|
|
20
|
+
* scriptFile — run a .mjs/.js file with node (self-contained, no prelude). Use this OR code.
|
|
20
21
|
* nodeArgs — (scriptFile) extra node flags before the script (e.g. --test, --check); eval-like flags rejected
|
|
21
|
-
* workdir — run in this sub-directory (
|
|
22
|
+
* workdir — run in this sub-directory (no directory restriction)
|
|
22
23
|
* filter — return only output lines matching this regex (case-insensitive)
|
|
23
24
|
* timeoutMs — timeout (default 30s, max 60s)
|
|
24
25
|
*/
|
|
25
26
|
import { spawn } from "node:child_process"
|
|
26
|
-
import { dirname, resolve
|
|
27
|
+
import { dirname, resolve } from "node:path"
|
|
27
28
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
28
29
|
import { DESC } from "./shared.mjs"
|
|
29
30
|
|
|
@@ -34,20 +35,12 @@ const DEFAULT_TIMEOUT = 30_000
|
|
|
34
35
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
35
36
|
const PRELUDE_URL = pathToFileURL(resolve(__dirname, "exec-prelude.mjs")).href
|
|
36
37
|
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
const rel = relative(root, abs)
|
|
41
|
-
if (isAbsolute(rel)) return false
|
|
42
|
-
return rel !== ".." && !rel.startsWith(".." + sep)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Resolve workdir relative to cwd, asserting it stays within the workspace. */
|
|
38
|
+
/** Resolve workdir relative to cwd — no boundary assertion
|
|
39
|
+
* (§10.1 2026-09-02: workspace confinement removed; the child node process is
|
|
40
|
+
* not directory-limited — same boundary as bash). */
|
|
46
41
|
function resolveBaseDir(cwd, workdir) {
|
|
47
42
|
if (!workdir || typeof workdir !== "string") return cwd
|
|
48
|
-
|
|
49
|
-
if (!isInside(cwd, abs)) throw new Error(`workdir escapes the workspace: ${workdir}`)
|
|
50
|
-
return abs
|
|
43
|
+
return resolve(cwd, workdir)
|
|
51
44
|
}
|
|
52
45
|
|
|
53
46
|
/** Keep only output lines matching a regex (execute filter, case-insensitive). */
|
|
@@ -143,7 +136,7 @@ export const executeTool = {
|
|
|
143
136
|
},
|
|
144
137
|
scriptFile: {
|
|
145
138
|
type: "string",
|
|
146
|
-
description: "Run a
|
|
139
|
+
description: "Run a .mjs/.js file with node (self-contained, no prelude). Path relative to workdir — no directory restriction. Use this OR code. For `node <script>` / `node --test <file>` / `node --check <file>`.",
|
|
147
140
|
},
|
|
148
141
|
nodeArgs: {
|
|
149
142
|
type: "array",
|
|
@@ -152,7 +145,7 @@ export const executeTool = {
|
|
|
152
145
|
},
|
|
153
146
|
workdir: {
|
|
154
147
|
type: "string",
|
|
155
|
-
description: "Run in this directory (relative to cwd
|
|
148
|
+
description: "Run in this directory (relative to cwd — no directory restriction; default cwd)",
|
|
156
149
|
},
|
|
157
150
|
filter: {
|
|
158
151
|
type: "string",
|
|
@@ -180,10 +173,9 @@ export const executeTool = {
|
|
|
180
173
|
let childArgs
|
|
181
174
|
if (args.scriptFile) {
|
|
182
175
|
if (args.code?.trim()) return "Error: pass code OR scriptFile, not both"
|
|
183
|
-
// scriptFile mode: run a
|
|
184
|
-
// no prelude (a real node process imports what it needs).
|
|
176
|
+
// scriptFile mode: run a .mjs/.js file with node [nodeArgs...]. Self-contained —
|
|
177
|
+
// no prelude (a real node process imports what it needs). No directory restriction.
|
|
185
178
|
const scriptAbs = resolve(baseDir, args.scriptFile)
|
|
186
|
-
if (!isInside(ctx.cwd, scriptAbs)) return `Error: scriptFile escapes the workspace: ${args.scriptFile}`
|
|
187
179
|
let nodeArgs
|
|
188
180
|
try { nodeArgs = validateNodeArgs(args.nodeArgs) }
|
|
189
181
|
catch (e) { return `Error: ${e.message}` }
|
package/src/tools/file.mjs
CHANGED
|
@@ -13,13 +13,10 @@ import {
|
|
|
13
13
|
findCandidates,
|
|
14
14
|
FFFD_WARNING,
|
|
15
15
|
} from "./shared.mjs";
|
|
16
|
+
import { applyEditBatch } from "./edit-batch.mjs";
|
|
16
17
|
import { specForModel } from "../config.mjs";
|
|
17
18
|
import { createHash } from "node:crypto";
|
|
18
|
-
import { mkdir } from "node:fs/promises";
|
|
19
|
-
import { readFile } from "node:fs/promises";
|
|
20
|
-
import { stat } from "node:fs/promises";
|
|
21
|
-
import { writeFile } from "node:fs/promises";
|
|
22
|
-
import { unlink } from "node:fs/promises";
|
|
19
|
+
import { mkdir, readFile, stat, writeFile, unlink } from "node:fs/promises";
|
|
23
20
|
import { join, relative, dirname } from "node:path";
|
|
24
21
|
|
|
25
22
|
const MAX_FILE_READ_BYTES = 10_000_000
|
|
@@ -54,8 +51,9 @@ export function clearLastWrite(abs) { lastWrites.delete(abs) }
|
|
|
54
51
|
* 模型拿到的不只是"inserted at L395",而是"L395 这行是什么内容"——下次再操作时
|
|
55
52
|
* 能自检"我的行号 vs 实际内容"是否匹配,匹配不上 = 行号漂了,先 read——
|
|
56
53
|
* 死循环就断了(根因:模型对行号锚点的"新鲜度"没有感知——数字本身不携带语义)。
|
|
57
|
-
* write 全文重写跳过(无行号锚点——模型刚写的知道内容)。
|
|
58
|
-
|
|
54
|
+
* write 全文重写跳过(无行号锚点——模型刚写的知道内容)。
|
|
55
|
+
* edit-batch.mjs(数组形态)复用——导出仅供内部模块,非公共 API。 */
|
|
56
|
+
export async function appendWriteContext(abs, writeLine, baseResult) {
|
|
59
57
|
const content = normalizeEOL(await readFile(abs, "utf8"))
|
|
60
58
|
const lines = content.split("\n")
|
|
61
59
|
const start = Math.max(1, writeLine - 3)
|
|
@@ -77,7 +75,7 @@ export const readTool = {
|
|
|
77
75
|
path: { type: "string", description: "File path (relative to cwd or absolute)" },
|
|
78
76
|
offset: { type: "number", description: "1-based line number to start from" },
|
|
79
77
|
limit: { type: "number", description: `Max lines to return (default ${MAX_READ_LINES})` },
|
|
80
|
-
allowExternal: { type: "boolean", description: "
|
|
78
|
+
allowExternal: { type: "boolean", description: "No-op retained for API compatibility — path resolution no longer asserts a working-directory boundary (all paths resolve relative to cwd)." },
|
|
81
79
|
hashes: { type: "boolean", description: "Include SHA256 line hashes for hash-based editing (default false). Use when you plan to edit the file with hashline_edit." },
|
|
82
80
|
},
|
|
83
81
|
required: ["path"],
|
|
@@ -222,7 +220,7 @@ export const editTool = {
|
|
|
222
220
|
replace_all: { type: "boolean", description: "Replace all occurrences (default false)" },
|
|
223
221
|
edits: {
|
|
224
222
|
type: "array",
|
|
225
|
-
description: "
|
|
223
|
+
description: "Batch form — multiple edits in ONE call, atomic (any failure writes nothing; same-file entries apply serially, each based on the previous result). Use it for multiple changes to the same file AND for independent changes across multiple files — prefer one batched call over N single edits. Mutually exclusive with path/old_string/new_string.",
|
|
226
224
|
items: {
|
|
227
225
|
type: "object",
|
|
228
226
|
properties: {
|
|
@@ -244,58 +242,22 @@ export const editTool = {
|
|
|
244
242
|
},
|
|
245
243
|
async execute(args, ctx) {
|
|
246
244
|
// 2026-08-31 工具顺手度(用户批准):数组形态——一次多文件原子替换
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
throw new Error("edits must be a non-empty array of {path, old_string, new_string}")
|
|
250
|
-
}
|
|
251
|
-
if (args.path || args.old_string !== undefined || args.new_string !== undefined) {
|
|
252
|
-
throw new Error("edits array is mutually exclusive with path/old_string/new_string")
|
|
253
|
-
}
|
|
254
|
-
// 原子:先全量 read+match 检查(所有文件都能替换)——任一失败全不写
|
|
255
|
-
const prepared = []
|
|
256
|
-
for (const e of args.edits) {
|
|
257
|
-
if (!e.path) throw new Error("each edit must have a path")
|
|
258
|
-
if (!e.old_string) throw new Error(`edit for ${e.path}: old_string must not be empty`)
|
|
259
|
-
const abs = resolveInCwd(ctx, e.path)
|
|
260
|
-
const raw = await readFile(abs, "utf8")
|
|
261
|
-
const content = normalizeEOL(raw)
|
|
262
|
-
const occurrences = content.split(e.old_string).length - 1
|
|
263
|
-
if (occurrences === 0) {
|
|
264
|
-
throw new Error(
|
|
265
|
-
`edit aborted (atomic — no files written): old_string not found in ${e.path}\n` +
|
|
266
|
-
` searched: "${e.old_string.slice(0, 100).split("\n")[0]}${e.old_string.length > 100 ? "…" : ""}"`
|
|
267
|
-
)
|
|
268
|
-
}
|
|
269
|
-
if (occurrences > 1 && !e.replace_all) {
|
|
270
|
-
throw new Error(
|
|
271
|
-
`edit aborted (atomic — no files written): old_string matches ${occurrences} times in ${e.path}; ` +
|
|
272
|
-
`provide more context or set replace_all`
|
|
273
|
-
)
|
|
274
|
-
}
|
|
275
|
-
const updated = e.replace_all
|
|
276
|
-
? content.split(e.old_string).join(e.new_string)
|
|
277
|
-
: content.replace(e.old_string, () => e.new_string)
|
|
278
|
-
const matchIdx = content.indexOf(e.old_string)
|
|
279
|
-
const editStartLine = matchIdx >= 0 ? content.slice(0, matchIdx).split("\n").length : 1
|
|
280
|
-
const lineShift = e.new_string.split("\n").length - e.old_string.split("\n").length
|
|
281
|
-
prepared.push({ abs, path: e.path, raw, updated, editStartLine, lineShift, occurrences: e.replace_all ? occurrences : 1 })
|
|
282
|
-
}
|
|
283
|
-
// 全部检查通过——逐个写
|
|
284
|
-
const results = []
|
|
285
|
-
for (const p of prepared) {
|
|
286
|
-
await writeFile(p.abs, joinWithEol(normalizeEOL(p.updated).split("\n"), p.raw), "utf8")
|
|
287
|
-
recordWrite(p.abs, { type: "edit", startLine: p.editStartLine, shift: p.lineShift })
|
|
288
|
-
const withCtx = await appendWriteContext(p.abs, p.editStartLine, `Edited ${p.path}: replaced ${p.occurrences} occurrence(s)`)
|
|
289
|
-
results.push(withCtx)
|
|
290
|
-
}
|
|
291
|
-
return results.join("\n")
|
|
292
|
-
}
|
|
245
|
+
// (应用逻辑在 edit-batch.mjs——2026-09-01 拆出,500 行硬限,先例 git-ext.mjs)
|
|
246
|
+
if (args.edits) return applyEditBatch(args, ctx)
|
|
293
247
|
|
|
294
248
|
// 单文件(现状路径)
|
|
295
249
|
const abs = resolveInCwd(ctx, args.path)
|
|
296
250
|
if (!args.old_string) {
|
|
297
251
|
throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
|
|
298
252
|
}
|
|
253
|
+
// #5(2026-09-01 交付评审尾巴):new_string 非字符串(含 undefined)在写盘前拒绝——
|
|
254
|
+
// 原缺陷:replace 回调返回 undefined 被字符串化成 "undefined" 写入盘,随后
|
|
255
|
+
// args.new_string.split 才 TypeError——文件已损坏 + 错误信息不知所云。
|
|
256
|
+
if (typeof args.new_string !== "string") {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`new_string must be a string${args.new_string === undefined ? " (missing)" : ` (got ${typeof args.new_string})`} — nothing written`,
|
|
259
|
+
)
|
|
260
|
+
}
|
|
299
261
|
const raw = await readFile(abs, "utf8")
|
|
300
262
|
const content = normalizeEOL(raw)
|
|
301
263
|
const occurrences = content.split(args.old_string).length - 1
|
package/src/tools/file_ops.md
CHANGED
|
@@ -11,6 +11,6 @@ Parameters:
|
|
|
11
11
|
- dest (required): destination path
|
|
12
12
|
|
|
13
13
|
Notes:
|
|
14
|
-
- Paths
|
|
14
|
+
- Paths resolve relative to cwd — no directory restriction (same boundary as bash; the approval gate is the guard).
|
|
15
15
|
- `dest` is overwritten if it already exists. `copy` is recursive for directories.
|
|
16
16
|
- To create a directory, use `write` (creates parent dirs) or `bash mkdir`.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-checkpoint.mjs — git 工具 checkpoint action 子系统(CHECKPOINT.md F2/F6)。
|
|
3
|
+
* git.mjs 的 checkpoint case 委托到这里:list/create/rewind/cat/versions + F6 懒清理 +
|
|
4
|
+
* F2/D7 提示行 + 文件树格式化。CLI 与 VS Code 两端同构(镜像,修改须两端同批)。
|
|
5
|
+
*/
|
|
6
|
+
import { runGit } from "./shared.mjs"
|
|
7
|
+
import { escapeXml } from "../agent/helpers.mjs"
|
|
8
|
+
import {
|
|
9
|
+
createCheckpoint,
|
|
10
|
+
listCheckpoints,
|
|
11
|
+
rewind,
|
|
12
|
+
listFileVersions,
|
|
13
|
+
catFile,
|
|
14
|
+
isGitRepo,
|
|
15
|
+
deleteCheckpointsForCwd,
|
|
16
|
+
} from "../git/checkpoint.mjs"
|
|
17
|
+
|
|
18
|
+
/** F6 lazy fallback (CHECKPOINT.md D3): an EXTERNAL git commit (via bash / IDE — not the git
|
|
19
|
+
* tool) leaves this cwd's checkpoints as pre-commit state. Compare HEAD commit time
|
|
20
|
+
* (epoch SECONDS from %ct) against the newest snapshot's meta.time (ms): `%ct × 1000` aligns
|
|
21
|
+
* both to ms. HEAD newer → every snapshot predates the commit → clear all. All-or-nothing:
|
|
22
|
+
* any snapshot NEWER than HEAD (e.g. a manual create after the external commit) skips the
|
|
23
|
+
* whole clear. Best-effort — never blocks the checkpoint op. */
|
|
24
|
+
export async function lazyClearIfCommitted(cwd) {
|
|
25
|
+
try {
|
|
26
|
+
const cps = await listCheckpoints(cwd)
|
|
27
|
+
if (cps.length === 0) return
|
|
28
|
+
const headSec = runGit(cwd, ["log", "-1", "--format=%ct"])
|
|
29
|
+
const headMs = Number.parseInt(headSec, 10) * 1000
|
|
30
|
+
if (!Number.isFinite(headMs) || headMs <= 0) return
|
|
31
|
+
const newest = cps[0] // listCheckpoints returns newest → oldest
|
|
32
|
+
if (headMs > newest.time) await deleteCheckpointsForCwd(cwd)
|
|
33
|
+
} catch {
|
|
34
|
+
// best-effort (NF7 philosophy) — a lazy-clear failure must not break list/create
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** checkpoint case 主入口(git 工具 checkpoint action 的全部子动作)。 */
|
|
39
|
+
export async function executeCheckpointAction(args, ctx) {
|
|
40
|
+
const { checkpointAction: sub, checkpointId: id, path } = args
|
|
41
|
+
if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
|
|
42
|
+
|
|
43
|
+
if (!sub) return "checkpoint: missing checkpointAction — use: list | create | rewind | cat | versions"
|
|
44
|
+
|
|
45
|
+
// F6 lazy fallback (list/create entry): an EXTERNAL git commit (HEAD time newer
|
|
46
|
+
// than the newest snapshot) means every snapshot predates a safety baseline —
|
|
47
|
+
// clear them. All-or-nothing: if any snapshot is newer than HEAD, skip entirely.
|
|
48
|
+
if (sub === "list" || sub === "create") await lazyClearIfCommitted(ctx.cwd)
|
|
49
|
+
|
|
50
|
+
if (sub === "create") {
|
|
51
|
+
const cp = await createCheckpoint(ctx.cwd)
|
|
52
|
+
return `Checkpoint ${cp.id} created (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked)`
|
|
53
|
+
}
|
|
54
|
+
if (sub === "versions") {
|
|
55
|
+
if (!path) throw new Error("path is required for versions — the file whose history you want")
|
|
56
|
+
const versions = await listFileVersions(ctx.cwd, path)
|
|
57
|
+
if (versions.length === 0) return `No snapshot copies of "${path}" found (it was never part of an auto/protection snapshot).`
|
|
58
|
+
return (
|
|
59
|
+
`Historical versions of "${path}" (${versions.length}, newest first):\n` +
|
|
60
|
+
versions.map((v) =>
|
|
61
|
+
` ${v.snapshotId} ${new Date(v.time).toISOString()} ${v.size}B sha:${v.sha} (${v.source})` +
|
|
62
|
+
(v.sha === versions[versions.indexOf(v) - 1]?.sha ? " ← same content as previous" : "")
|
|
63
|
+
).join("\n") +
|
|
64
|
+
`\nRestore a version: checkpointAction=rewind checkpointId=<snapshotId> path="${path}"`
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
if (sub === "rewind") {
|
|
68
|
+
if (!id) throw new Error("checkpointId is required for rewind — use checkpointAction=list to see snapshot ids")
|
|
69
|
+
if (!path) throw new Error("path is required for rewind — full restore is disabled (as dangerous as `git checkout -- .`). Restore files individually. Use checkpointAction=versions path=<file> to list a file's historical versions.")
|
|
70
|
+
const s = await rewind(ctx.cwd, id, { path })
|
|
71
|
+
return `Restored "${path}" (${s.type}) from checkpoint ${id}.\n(The pre-restore state was snapshotted first — you can restore again to go back.)`
|
|
72
|
+
}
|
|
73
|
+
if (sub === "cat") {
|
|
74
|
+
if (!id) throw new Error("checkpointId is required for cat — use checkpointAction=list to see snapshot ids")
|
|
75
|
+
if (!path) throw new Error("path is required for cat — specify which file to read")
|
|
76
|
+
return await catFile(ctx.cwd, id, path)
|
|
77
|
+
}
|
|
78
|
+
if (sub === "list") {
|
|
79
|
+
const cps = await listCheckpoints(ctx.cwd)
|
|
80
|
+
if (cps.length === 0) return "(no checkpoints yet)"
|
|
81
|
+
|
|
82
|
+
// Specific id: show the file tree within that snapshot
|
|
83
|
+
if (id) {
|
|
84
|
+
const cp = cps.find((c) => c.id === id)
|
|
85
|
+
if (!cp) throw new Error(`checkpoint ${id} not found`)
|
|
86
|
+
return formatFileTree(cp)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Overview: list of all snapshots (file names are XML-escaped: they are
|
|
90
|
+
// untrusted input that flows back into the model's context). F2/D7: fixed
|
|
91
|
+
// hint line at the tail — the recovery entry for a snapshot after an accident.
|
|
92
|
+
return cps.map((c) => {
|
|
93
|
+
const parts = [`${c.id} ${new Date(c.time).toISOString()}`]
|
|
94
|
+
if (c.tracked.length) parts.push(`${c.tracked.length} tracked: ${c.tracked.map(escapeXml).join(", ")}`)
|
|
95
|
+
if (c.untracked.length) parts.push(`${c.untracked.length} untracked: ${c.untracked.map(escapeXml).join(", ")}`)
|
|
96
|
+
return parts.join(" ")
|
|
97
|
+
}).join("\n") + "\n(意外丢弃改动?checkpointAction=rewind 可恢复操作前状态)"
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`Unknown checkpoint action: ${sub}. Use: list | create | rewind | cat | versions`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Format a checkpoint's file list as a directory tree (directories first, indented display) */
|
|
103
|
+
function formatFileTree(cp) {
|
|
104
|
+
// File names are XML-escaped: untrusted input that flows back into the model's context
|
|
105
|
+
const all = [
|
|
106
|
+
...(cp.tracked ?? []).map((f) => ({ path: escapeXml(f), type: "" })),
|
|
107
|
+
...(cp.untracked ?? []).map((f) => ({ path: escapeXml(f), type: " (untracked)" })),
|
|
108
|
+
]
|
|
109
|
+
if (all.length === 0) return "(empty checkpoint)"
|
|
110
|
+
|
|
111
|
+
all.sort((a, b) => a.path.localeCompare(b.path))
|
|
112
|
+
|
|
113
|
+
const tree = new Map()
|
|
114
|
+
for (const { path, type } of all) {
|
|
115
|
+
const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "."
|
|
116
|
+
if (!tree.has(dir)) tree.set(dir, [])
|
|
117
|
+
tree.get(dir).push({ name: path.slice(dir === "." ? 0 : dir.length + 1), type })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const lines = []
|
|
121
|
+
const dirs = [...tree.keys()].sort()
|
|
122
|
+
for (const dir of dirs) {
|
|
123
|
+
if (dir !== "." && !lines.includes(dir + "/")) {
|
|
124
|
+
const parts = dir.split("/")
|
|
125
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
126
|
+
const prefix = parts.slice(0, i).join("/") + "/"
|
|
127
|
+
if (!lines.includes(prefix)) lines.push(prefix)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const dir of dirs) {
|
|
132
|
+
if (dir !== ".") {
|
|
133
|
+
for (const { name, type } of tree.get(dir)) {
|
|
134
|
+
lines.push(` ${dir}/${name}${type}`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const { name, type } of tree.get(".") ?? []) {
|
|
139
|
+
lines.push(name + type)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return lines.join("\n")
|
|
143
|
+
}
|