thincoder 0.12.54 → 0.12.59
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 +98 -0
- package/README.md +1 -1
- package/bin/thincoder.mjs +25 -3
- package/package.json +3 -7
- package/src/acp/bridge.mjs +132 -26
- package/src/advisor/messages.mjs +38 -3
- package/src/advisor/run.mjs +91 -53
- package/src/advisor.mjs +15 -7
- package/src/agent/dispatch.mjs +156 -39
- package/src/agent/helpers.mjs +46 -4
- package/src/agent/setup.mjs +102 -19
- package/src/agent/spawn-child.mjs +28 -1
- package/src/agent-tools/advisor.mjs +43 -11
- package/src/agent-tools/consult.mjs +37 -6
- package/src/agent-tools/eng.mjs +4 -1
- package/src/agent-tools/goal.mjs +11 -1
- package/src/agent-tools/read-history.mjs +160 -0
- package/src/agent-tools/settings.mjs +162 -0
- package/src/agent-tools/skill.mjs +2 -1
- package/src/agent-tools/subagent-actions.mjs +432 -0
- package/src/agent-tools/subagent-async.mjs +427 -0
- package/src/agent-tools/subagent-scheduler.mjs +319 -0
- package/src/agent-tools/subagent.mjs +565 -128
- package/src/agent-tools/task.mjs +4 -3
- package/src/agent-tools/timer.mjs +9 -4
- package/src/agent-tools/verify.mjs +161 -49
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +182 -81
- package/src/auto-think.mjs +14 -0
- package/src/cli/make-agent.mjs +27 -1
- package/src/cli/memory-command.mjs +28 -7
- package/src/cli/permission.mjs +8 -1
- package/src/config.mjs +125 -8
- package/src/context.mjs +115 -34
- package/src/distill.mjs +19 -1
- package/src/escape.mjs +82 -27
- package/src/log.mjs +195 -0
- package/src/mcp/transport-http.mjs +13 -1
- package/src/mcp.mjs +52 -7
- package/src/memory/code-sync.mjs +1 -1
- package/src/memory/core.mjs +204 -10
- package/src/memory/docs.mjs +197 -62
- package/src/memory.mjs +1 -1
- package/src/model-specs.mjs +38 -1
- package/src/prompts/advisor-design.md +46 -0
- package/src/prompts/advisor-round1.md +49 -2
- package/src/prompts/advisor-round2.md +47 -0
- package/src/prompts/advisor-round3.md +47 -0
- package/src/prompts/coder.md +22 -0
- package/src/prompts/consult-base.md +13 -0
- package/src/prompts/discipline.md +25 -6
- package/src/prompts/eng-coder.md +2 -2
- package/src/prompts/engineering-sub.md +23 -1
- package/src/prompts/engineering.md +157 -50
- package/src/prompts/explore.md +1 -2
- package/src/prompts/main.md +11 -5
- package/src/prompts/methodology-template.md +14 -0
- package/src/prompts/system.md +5 -2
- package/src/provider/anthropic.mjs +7 -5
- package/src/provider/core.mjs +104 -28
- 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.mjs +15 -0
- package/src/tools/apply_patch.md +5 -1
- package/src/tools/bash.md +3 -3
- package/src/tools/delete.md +1 -0
- package/src/tools/edit-batch.mjs +92 -0
- package/src/tools/edit-diff.mjs +265 -0
- package/src/tools/edit.md +11 -6
- package/src/tools/execute.md +8 -8
- package/src/tools/execute.mjs +31 -35
- package/src/tools/file.mjs +26 -114
- package/src/tools/file_ops.md +3 -2
- package/src/tools/get_current_time.md +3 -1
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +8 -16
- package/src/tools/hashline_edit.md +2 -0
- package/src/tools/index.mjs +3 -2
- package/src/tools/insert_after.md +2 -1
- package/src/tools/lint.md +3 -1
- package/src/tools/linter.mjs +9 -37
- package/src/tools/lsp.md +4 -1
- package/src/tools/patch.mjs +84 -13
- package/src/tools/pdf-parse-text.mjs +497 -0
- package/src/tools/pdf-parse-xref.mjs +499 -0
- package/src/tools/pdf.mjs +155 -0
- package/src/tools/question.md +2 -1
- package/src/tools/read.md +1 -0
- package/src/tools/read_pdf.md +21 -0
- package/src/tools/repomap.mjs +1 -1
- package/src/tools/shared.mjs +11 -32
- package/src/tools/system.mjs +6 -21
- package/src/tools/tree.md +2 -1
- package/src/tools/web.mjs +5 -3
- package/src/tools/websearch.md +2 -1
- package/src/tools/write.md +2 -0
- package/src/traces/trace-store.mjs +224 -0
- package/src/tui/agent-turn.mjs +387 -24
- package/src/tui/clipboard.mjs +17 -6
- package/src/tui/cmd-config.mjs +29 -9
- package/src/tui/cmd-eng.mjs +1 -0
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-mcp-form.mjs +197 -0
- package/src/tui/cmd-mcp.mjs +264 -114
- package/src/tui/cmd-think.mjs +1 -1
- package/src/tui/index.mjs +49 -95
- package/src/tui/interaction.mjs +41 -3
- package/src/tui/key-handler.mjs +105 -143
- package/src/tui/key-modes.mjs +215 -0
- package/src/tui/layout.mjs +22 -1
- package/src/tui/mouse.mjs +41 -1
- package/src/tui/pickers.mjs +73 -7
- package/src/tui/render-conversation.mjs +13 -161
- package/src/tui/render-frame.mjs +45 -20
- package/src/tui/render-loop.mjs +4 -1
- package/src/tui/render-segments.mjs +165 -0
- package/src/tui/render.mjs +4 -4
- package/src/tui/startup.mjs +40 -2
- package/src/tui/subagent-blocks.mjs +404 -111
- package/src/tui/subagent-panel.mjs +88 -13
- package/src/tui/tool-args.mjs +10 -2
- package/src/tui/tool-events.mjs +172 -95
- package/src/tui/update-notice.mjs +72 -0
- package/src/tui/wizard.mjs +36 -6
- package/src/agent-tools/escalate.mjs +0 -179
- package/src/tools/exec-prelude.mjs +0 -84
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edit-diff.mjs — edit 工具的行级 diff 内核(TOOLS.md §15 D15.1,2026-09-04)。
|
|
3
|
+
*
|
|
4
|
+
* edit 新语义(用户裁定——breaking——不承诺旧行为兼容):old_string = 变化区**当前内容**
|
|
5
|
+
* (必须精确存在、单次匹配——不变);new_string = 该区的**期望结果**。判定序:
|
|
6
|
+
* 0. 分支 0(TOOLS.md §15.2——单行精确替换):old 单行 && new 单行 && old 全文唯一 &&
|
|
7
|
+
* new 非空 → **就地替换该行**(行数不变——EOL 由调用方 joinWithEol 恢复);
|
|
8
|
+
* 1. 零重叠(old 每一行都不出现在 new 行集中)→ 按**插入**——new 整体插入在 old
|
|
9
|
+
* 最后一行之后(旧内容保留——数据零丢失);
|
|
10
|
+
* 2. 有公共行(≥1)→ 行级 LCS——公共行保留、差异行增删;
|
|
11
|
+
* 3. new 与 old 行级完全一致 → 原样替换(no-op 语义——仍报成功)。
|
|
12
|
+
* 空 new_string(纯删除意图)→ 显式报错(不静默——先于分支 0——单行替换永不成删除);
|
|
13
|
+
* 分支 0 只在 computeEditEntry 条目判定层(壳/桥/批量自动继承)——applyPatchLines
|
|
14
|
+
* (纯 diff 层)语义不动。old/new 行数各上限 1000(超限报错)。
|
|
15
|
+
*
|
|
16
|
+
* 行尾权威 = EDIT-TOOL-EOL-DESIGN.md:判定/应用在 normalizeEOL 后的 LF 域计算,
|
|
17
|
+
* 写回由调用方 joinWithEol(原文) 恢复原行尾。
|
|
18
|
+
* 模块拆分(file.mjs ≤500 硬限):file.mjs 只留工具壳与转发——单形态执行体与前置校验
|
|
19
|
+
* 分支整段迁出至本模块(模块拆分写优先纪律:先迁后删、逻辑体不变、wiring 导入)。
|
|
20
|
+
*/
|
|
21
|
+
import { readFile, writeFile } from "node:fs/promises"
|
|
22
|
+
import {
|
|
23
|
+
resolveInCwd, normalizeEOL, joinWithEol, gitDiffOne, autoSyntaxCheck, findCandidates,
|
|
24
|
+
} from "./shared.mjs"
|
|
25
|
+
// file.mjs ↔ edit-diff.mjs 循环引用:两侧导入的都是函数声明(提升初始化),
|
|
26
|
+
// 仅在调用期使用——ESM 循环下安全(无模块求值期取值)。
|
|
27
|
+
import { recordWrite, appendWriteContext, lastWriteOf, isDirty } from "./file.mjs"
|
|
28
|
+
|
|
29
|
+
export const MAX_DIFF_LINES = 1000
|
|
30
|
+
export const REGION_TOO_LARGE = "edit region too large — narrow the change"
|
|
31
|
+
export const EMPTY_NEW_STRING = "empty new_string — for deletion, keep the context lines you want to preserve in both old_string and new_string"
|
|
32
|
+
export const EDIT_ARGS_MUTEX = "edits array is mutually exclusive with top-level old_string/new_string — a top-level path is allowed (default for entries without their own path); provide each change's old_string/new_string inside its edits entry"
|
|
33
|
+
|
|
34
|
+
/** 行切分(尾随换行终止最后一行——非额外空行):"a\nb\n" → ["a","b"]。 */
|
|
35
|
+
function splitLines(text) {
|
|
36
|
+
const lines = text.split("\n")
|
|
37
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
|
|
38
|
+
return lines
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 行级 LCS(整行相等判定)合并:公共行保留(LCS 序)、old 独有行删除、new 独有行按其在
|
|
43
|
+
* new 中相对公共行的位置插入。结果在 LF 域;区域尾随换行随 oldText(区域边界保持)。
|
|
44
|
+
* 返回 { ok: true, resultText } 或 { ok: false, reason }。
|
|
45
|
+
*/
|
|
46
|
+
export function applyPatchLines(oldText, newText) {
|
|
47
|
+
if (newText === "") return { ok: false, reason: EMPTY_NEW_STRING }
|
|
48
|
+
const oldLines = splitLines(oldText)
|
|
49
|
+
const newLines = splitLines(newText)
|
|
50
|
+
if (oldLines.length > MAX_DIFF_LINES || newLines.length > MAX_DIFF_LINES) {
|
|
51
|
+
return { ok: false, reason: REGION_TOO_LARGE }
|
|
52
|
+
}
|
|
53
|
+
// 判定 1(F15.2):零重叠 → 插入——new 整体插在 old 最后一行之后(旧内容保留)
|
|
54
|
+
const newSet = new Set(newLines)
|
|
55
|
+
const merged = oldLines.some((l) => newSet.has(l))
|
|
56
|
+
? lcsMerge(oldLines, newLines) // 判定 2/3——公共行保留、差异行增删
|
|
57
|
+
: [...oldLines, ...newLines]
|
|
58
|
+
return { ok: true, resultText: merged.join("\n") + (oldText.endsWith("\n") ? "\n" : "") }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** LCS 推导(DP + 回溯)——整行相等判定;结果 = 公共行 + 差异行增删的合并序列。 */
|
|
62
|
+
function lcsMerge(a, b) {
|
|
63
|
+
const n = a.length
|
|
64
|
+
const m = b.length
|
|
65
|
+
const width = m + 1
|
|
66
|
+
const dp = new Uint16Array((n + 1) * width)
|
|
67
|
+
for (let i = 1; i <= n; i++) {
|
|
68
|
+
for (let j = 1; j <= m; j++) {
|
|
69
|
+
dp[i * width + j] = a[i - 1] === b[j - 1]
|
|
70
|
+
? dp[(i - 1) * width + (j - 1)] + 1
|
|
71
|
+
: Math.max(dp[(i - 1) * width + j], dp[i * width + (j - 1)])
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const merged = []
|
|
75
|
+
let i = n
|
|
76
|
+
let j = m
|
|
77
|
+
while (i > 0 && j > 0) {
|
|
78
|
+
if (a[i - 1] === b[j - 1]) {
|
|
79
|
+
merged.push(a[i - 1])
|
|
80
|
+
i--
|
|
81
|
+
j--
|
|
82
|
+
} else if (dp[(i - 1) * width + j] >= dp[i * width + (j - 1)]) {
|
|
83
|
+
i-- // old 独有行——删除
|
|
84
|
+
} else {
|
|
85
|
+
merged.push(b[j - 1]) // new 独有行——按 LCS 相对位置插入
|
|
86
|
+
j--
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
while (j > 0) { merged.push(b[j - 1]); j-- }
|
|
90
|
+
return merged.reverse()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* D15.3#9 修订(2026-09-05 用户裁定——顶层 path + edits 并存合法化:顶层 path = 无自带
|
|
95
|
+
* path 条目的默认——模型直觉形态「顶层 path + 数组条目」不再拒绝;条目自带 path 优先)。
|
|
96
|
+
* 互斥收窄为只对顶层 old_string/new_string——edits 下它们无批语义可解释——顶层 path 不再触发。
|
|
97
|
+
*/
|
|
98
|
+
export function assertEditArgsExclusive(args) {
|
|
99
|
+
if (args.old_string !== undefined || args.new_string !== undefined) {
|
|
100
|
+
throw new Error(EDIT_ARGS_MUTEX)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 前置校验(空 old / 非字符串 new)——error 文本按调用形态(单形态 rich / 批量 label)。
|
|
106
|
+
* opts: { label = ""(批量前缀 "edit for <path>: "), rich = true(单形态完整文本) }
|
|
107
|
+
*/
|
|
108
|
+
export function validateEditEntry(entry, opts = {}) {
|
|
109
|
+
const label = opts.label ?? ""
|
|
110
|
+
if (!entry.old_string) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
label + "old_string must not be empty" + (opts.rich === false ? "" : " (empty string matches everywhere and would corrupt the file)")
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
if (typeof entry.new_string !== "string") {
|
|
116
|
+
throw new Error(
|
|
117
|
+
label + `new_string must be a string${entry.new_string === undefined ? " (missing)" : ` (got ${typeof entry.new_string})`}` +
|
|
118
|
+
(opts.rich === false ? "" : " — nothing written")
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 分支 0 形态判定(TOOLS.md §15.2 D15.9.1):old/new 各为单行——无内部换行(splitLines
|
|
125
|
+
* 语义:尾随单个换行符是行终止而非新行——old/new 行内容均不含 \n)——且 new 非空
|
|
126
|
+
* ("" 经 splitLines 切分为 []——由下方 !== "" 守卫先拦截——空 new 不落本分支——落
|
|
127
|
+
* applyPatchLines 的空 new 显式错误——单行替换永不成删除)。多匹配与 replace_all 不落
|
|
128
|
+
* 本分支(occurrences 错误 / 字面全部先行)。
|
|
129
|
+
*/
|
|
130
|
+
function isSingleLineReplace(entry) {
|
|
131
|
+
return (
|
|
132
|
+
entry.new_string !== "" &&
|
|
133
|
+
splitLines(entry.old_string).length === 1 &&
|
|
134
|
+
splitLines(entry.new_string).length === 1
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 条目级判定+应用(单形态与批量共用——D15.1"批量条目判定+应用调用 edit-diff"):
|
|
140
|
+
* 匹配校验(精确存在——非 replace_all 单次)→ 按判定序应用(分支 0 单行替换 / 零重叠插入 /
|
|
141
|
+
* LCS 替换 / replace_all 字面)→
|
|
142
|
+
* 元数据(受影响区首行/行数差/次数——recordWrite 与结果回显用)。
|
|
143
|
+
* 返回 { updated, editStartLine, lineShift, occurrences };失败抛错(含路径/引导)。
|
|
144
|
+
* opts: { path, abortPrefix(批量 "edit aborted (atomic — no files written): ") }
|
|
145
|
+
*/
|
|
146
|
+
/**
|
|
147
|
+
* P15.11(2026-09-05 用户裁定——edit 连续失败分析):old_string 逐字 not-found 时——
|
|
148
|
+
* 若文件中存在**唯一**窗口:行数与 old 相同、逐行 trim() 相等(内容零差异——差异仅前导/
|
|
149
|
+
* 尾随空白——EOL 已在 normalize 域消除)→ 返回 { actual }(actual = 文件窗口原文);
|
|
150
|
+
* 多窗口(两处 trim 同内容不同空白)→ null(歧义不猜——报错引导);old 含尾换行 → null
|
|
151
|
+
* (终止符语义边界——不做窗口猜测)。模型从 read 记忆拷贝 old_string 时丢/加前导空格是
|
|
152
|
+
* 纯机械损耗(本轮 12 次 edit 失败中 8 次空白差异)——内容零差异时自动落点 + 结果明示。
|
|
153
|
+
*/
|
|
154
|
+
function findWhitespaceVariant(content, old) {
|
|
155
|
+
if (old.endsWith("\n")) return null
|
|
156
|
+
const oldLines = old.split("\n")
|
|
157
|
+
const fileLines = content.split("\n")
|
|
158
|
+
const m = oldLines.length
|
|
159
|
+
if (m === 0 || fileLines.length < m) return null
|
|
160
|
+
const trimmed = oldLines.map((l) => l.trim())
|
|
161
|
+
let hit = null
|
|
162
|
+
for (let i = 0; i + m <= fileLines.length; i++) {
|
|
163
|
+
let same = true
|
|
164
|
+
for (let j = 0; j < m; j++) {
|
|
165
|
+
if (fileLines[i + j].trim() !== trimmed[j]) { same = false; break }
|
|
166
|
+
}
|
|
167
|
+
if (!same) continue
|
|
168
|
+
const actual = fileLines.slice(i, i + m).join("\n")
|
|
169
|
+
if (actual === old) continue // 逐字已匹配——occurrences=0 前提下不会发生
|
|
170
|
+
if (hit) return null // 多窗口空白差异 → 歧义 → 不猜
|
|
171
|
+
hit = { actual }
|
|
172
|
+
}
|
|
173
|
+
return hit
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* P15.11 note 文案(各端成功消息追加——双端同句)
|
|
178
|
+
*/
|
|
179
|
+
export const WHITESPACE_VARIANT_NOTE = "applied to the unique whitespace-only match (content identical, leading/trailing whitespace differs from your old_string)"
|
|
180
|
+
|
|
181
|
+
export function computeEditEntry(content, entry, opts = {}) {
|
|
182
|
+
validateEditEntry(entry, { ...opts, rich: !opts.abortPrefix })
|
|
183
|
+
// P15.11:not-found 时先查唯一空白差异窗口——命中则以其原文为实际 old 继续(内容零差异
|
|
184
|
+
// ——自动落点 + note 明示);实质差异/歧义仍走下方 not-found 报错(不猜内容)。
|
|
185
|
+
let old = entry.old_string
|
|
186
|
+
let occurrences = content.split(old).length - 1
|
|
187
|
+
let note = null
|
|
188
|
+
if (occurrences === 0) {
|
|
189
|
+
const variant = findWhitespaceVariant(content, old)
|
|
190
|
+
if (variant) { old = variant.actual; occurrences = 1; note = WHITESPACE_VARIANT_NOTE }
|
|
191
|
+
}
|
|
192
|
+
if (occurrences === 0) {
|
|
193
|
+
const preview = entry.old_string.slice(0, 100).split("\n")[0]
|
|
194
|
+
const cands = findCandidates(content.split("\n"), entry.old_string)
|
|
195
|
+
let candText = ""
|
|
196
|
+
if (cands.length > 0) {
|
|
197
|
+
const header = entry.old_string.includes("\n")
|
|
198
|
+
? ` similar lines (old_string line 1: "${entry.old_string.split("\n")[0].slice(0, 80)}"):`
|
|
199
|
+
: " similar lines:"
|
|
200
|
+
candText = "\n" + header + "\n" + cands.map((c) => ` L${c.line}: ${c.preview} (${Math.round(c.score * 100)}%)`).join("\n")
|
|
201
|
+
}
|
|
202
|
+
throw new Error(
|
|
203
|
+
`${opts.abortPrefix ?? ""}old_string not found in ${opts.path ?? "file"}\n` +
|
|
204
|
+
` searched: "${preview}${entry.old_string.length > 100 ? "…" : ""}" — use grep to locate the actual content\n` +
|
|
205
|
+
(opts.absPath && lastWriteOf(opts.absPath)?.type === "write"
|
|
206
|
+
? ` hints: this file was modified since your last read (write 全文重写后内容全变) — re-read it to refresh your copy of the content, then retry\n`
|
|
207
|
+
: opts.absPath && isDirty(opts.absPath)
|
|
208
|
+
? ` hints: this file was modified since your last read (a prior write marked it dirty) — re-read it to refresh your copy of the content, then retry\n`
|
|
209
|
+
: ` hints: whitespace mismatch? file already changed? try reading the file first\n`) +
|
|
210
|
+
candText
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
if (occurrences > 1 && !entry.replace_all) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`${opts.abortPrefix ?? ""}old_string matches ${occurrences} times in ${opts.path ?? "file"}; provide more context or set replace_all`
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
const matchIdx = content.indexOf(old)
|
|
219
|
+
const editStartLine = matchIdx >= 0 ? content.slice(0, matchIdx).split("\n").length : 1
|
|
220
|
+
let updated
|
|
221
|
+
let resultForShift
|
|
222
|
+
if (entry.replace_all) {
|
|
223
|
+
// 字面替换——不做插入规则(old 多处时"插到哪处"无定义)——不落分支 0(§15.2 D15.9.2)
|
|
224
|
+
updated = content.split(old).join(entry.new_string)
|
|
225
|
+
resultForShift = entry.new_string
|
|
226
|
+
} else if (isSingleLineReplace(entry)) {
|
|
227
|
+
// 分支 0(§15.2 D15.9.1——单行精确替换):old 单行 && new 单行 && old 唯一匹配
|
|
228
|
+
// (occ==1 既有校验保证)&& new 非空(空 new 显式错误先于本分支——防删除)→ **就地
|
|
229
|
+
// 替换该行**——不再零重叠插入(P15.8——行尾段编辑反复踩的插入坑)。只替换行内容段:
|
|
230
|
+
// old/new 的尾随换行符属文件结构而非行内容——留在原位——行数不变——EOL 由调用方
|
|
231
|
+
// joinWithEol 恢复(normalizeEOL 后本域为 LF)。边界声明(评审 2026-09-04):new 为
|
|
232
|
+
// 行内容空串(如 new="\n"——置空行意图)时,常规带终止换行的行变空行(行数不变);
|
|
233
|
+
// 唯一例外是**无终止换行的末行**被置空——该行随文件结束自然消失(行数 −1——文件格式
|
|
234
|
+
// 固有边界——空行无法表达)——不落 EMPTY_NEW_STRING 错误路径(new 本身非空串)。
|
|
235
|
+
const oldLine = splitLines(old)[0]
|
|
236
|
+
const newLine = splitLines(entry.new_string)[0]
|
|
237
|
+
updated = content.slice(0, matchIdx) + newLine + content.slice(matchIdx + oldLine.length)
|
|
238
|
+
resultForShift = newLine
|
|
239
|
+
} else {
|
|
240
|
+
const r = applyPatchLines(old, entry.new_string)
|
|
241
|
+
if (!r.ok) throw new Error(r.reason)
|
|
242
|
+
updated = content.slice(0, matchIdx) + r.resultText + content.slice(matchIdx + old.length)
|
|
243
|
+
resultForShift = r.resultText
|
|
244
|
+
}
|
|
245
|
+
// 行数差 = 应用后区域行数 − 旧区域行数(分支 0 单行替换:0;插入:new 行数;LCS:new−old)
|
|
246
|
+
const lineShift = splitLines(resultForShift).length - splitLines(old).length
|
|
247
|
+
return { updated, editStartLine, lineShift, occurrences: entry.replace_all ? occurrences : 1, note }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 单形态 edit 执行体(自 file.mjs 整段迁出——模块拆分写优先纪律:逻辑体不变)——
|
|
252
|
+
* file.mjs 只留工具壳与转发。
|
|
253
|
+
*/
|
|
254
|
+
export async function runSingleEdit(args, ctx) {
|
|
255
|
+
const abs = resolveInCwd(ctx, args.path)
|
|
256
|
+
const raw = await readFile(abs, "utf8")
|
|
257
|
+
const content = normalizeEOL(raw)
|
|
258
|
+
const out = computeEditEntry(content, args, { path: args.path, absPath: abs })
|
|
259
|
+
// 写回按原文行尾(joinWithEol)——normalizeEOL 先行避免 \r\n 污染 split/join
|
|
260
|
+
await writeFile(abs, joinWithEol(normalizeEOL(out.updated).split("\n"), raw), "utf8")
|
|
261
|
+
recordWrite(abs, { type: "edit", startLine: out.editStartLine, shift: out.lineShift })
|
|
262
|
+
const diff = gitDiffOne(ctx.cwd, abs)
|
|
263
|
+
const baseResult = `Edited ${args.path}: replaced ${out.occurrences} occurrence(s)${out.note ? ` — ${out.note}` : ""}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
264
|
+
return await appendWriteContext(abs, out.editStartLine, baseResult)
|
|
265
|
+
}
|
package/src/tools/edit.md
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
|
-
Edit a file
|
|
1
|
+
Edit a file as a patch. old_string is the current content of the region to change (must match exactly once); new_string is the desired result of that region. Lines shared by both are kept; lines only in new_string take their position relative to the shared lines (LCS order) — when no line overlaps, new_string is inserted after old_string (old content stays) — except a unique single-line old_string paired with a single-line new_string: that exact line is replaced in place (line count unchanged); for a multi-line replacement, include a shared context line — for adding a new line use insert_after: a unique single-line old/new pair replaces the line in place; multi-line zero-overlap pairs still insert per the diff rules above. replace_all keeps literal replacement of every occurrence — the insert rule does not apply.
|
|
2
2
|
|
|
3
3
|
**Routing — pick the right edit tool:**
|
|
4
4
|
- Precise line-targeted change → `hashline_edit` (hash-based, immune to whitespace/encoding drift — preferred)
|
|
5
|
-
-
|
|
6
|
-
- Add a function/block after a known line → `insert_after`
|
|
5
|
+
- Add a line/entry after a known line → insert_after — includes checklist items and doc lines.
|
|
7
6
|
- Same change across multiple files or many spots → `apply_patch`
|
|
8
7
|
- Rewrite an entire file → `write`
|
|
9
8
|
- Rename a symbol project-wide → `lsp` or `grep` first to map every caller
|
|
10
9
|
|
|
10
|
+
**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. A top-level path may accompany the array — entries without their own path inherit it (entry paths override).
|
|
11
|
+
|
|
11
12
|
Parameters:
|
|
12
|
-
- path (required
|
|
13
|
-
- old_string (required):
|
|
14
|
-
- new_string (required):
|
|
13
|
+
- path: File path (single form: required; with the edits array: optional — the default for entries without their own path)
|
|
14
|
+
- old_string (required): Current content of the region to change (must match exactly once)
|
|
15
|
+
- new_string (required): Desired result of the region — diffed against old_string (shared lines kept; zero overlap → new_string inserted after old_string — a unique single-line old/new pair replaces the line in place)
|
|
15
16
|
- replace_all: Replace all occurrences instead of just one (default false)
|
|
17
|
+
- edits: Array of {path?, old_string, new_string, replace_all?} entries — batch form; mutually exclusive with top-level old_string/new_string — a top-level path is allowed and applies to entries without their own path
|
|
16
18
|
|
|
17
19
|
Notes:
|
|
18
20
|
- Prefer this over write for targeted edits — it's safer and keeps changes targeted
|
|
19
21
|
- If old_string matches zero times: error. If it matches multiple times without replace_all: error — add more surrounding context to make it unique
|
|
22
|
+
- new_string empty (deletion) is an explicit error — keep the context lines you want to preserve in both old_string and new_string
|
|
23
|
+
- Returns `Edited <path>: replaced N occurrence(s)` + git diff + syntax-check note + context block (L..-L..).
|
|
20
24
|
- Never fabricate the old_string — copy it verbatim from the actual file using read first
|
|
25
|
+
- use the most recent read of the file as the source of old_string / line numbers / hashes — re-read after the file changed
|
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 child `node` process — a pure node ESM environment: top-level `await` and dynamic `import()` are available, no globals are injected. File reads/writes/searches belong to the dedicated read/ls/glob/grep/write/edit tools — not to execute. If a script genuinely needs fs/path, `import` the `node:` module inside the code (one explicit import line).
|
|
2
2
|
|
|
3
3
|
**Route to execute instead of bash:**
|
|
4
4
|
- `node -e "…"` → execute (inline code; top-level await + import() + console all work)
|
|
@@ -6,16 +6,16 @@ Execute JavaScript — either inline `code` or a workspace `scriptFile`. Runs in
|
|
|
6
6
|
- `node --test <file>` / `node --check <file>` → execute with scriptFile + nodeArgs
|
|
7
7
|
|
|
8
8
|
Parameters:
|
|
9
|
-
- code: JavaScript to run inline. Top-level `await` and `import('./x.mjs')` are supported.
|
|
10
|
-
- scriptFile: run a
|
|
9
|
+
- code: JavaScript to run inline. Top-level `await` and `import('./x.mjs')` are supported. Pure node ESM — no preloaded helpers; import `node:fs`/`node:path` etc. yourself when needed. File reads/writes go through the dedicated read/ls/glob/grep/write/edit tools. Use this OR scriptFile.
|
|
10
|
+
- scriptFile: run a .mjs/.js file with node (self-contained — 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
|
-
- timeoutMs: Timeout in milliseconds (default 30000, max 600000 — covers
|
|
14
|
+
- timeoutMs: Timeout in milliseconds (default 30000, max 600000 — covers `node --test` suites and package scripts)
|
|
15
15
|
|
|
16
16
|
Notes:
|
|
17
|
-
- `console.log(...)`
|
|
18
|
-
- File paths are confined to the workspace root (`..` traversal is denied) — but `require`/`process`/`import()` are full Node, same boundary as bash.
|
|
17
|
+
- `console.log(...)` prints to the result; objects are JSON-stringified where needed.
|
|
19
18
|
- A non-zero exit / thrown exception returns the stderr (error + stack) as the result.
|
|
20
|
-
- Output capped at ~50KB;
|
|
19
|
+
- Output is capped at ~50KB; when a script overruns it, an explicit `[output truncated]` marker is appended — print large results in chunks, or have the script write them to a file (node:fs) and read that file back with the `read` tool.
|
|
21
20
|
- 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.
|
|
21
|
+
- Irreversibility: it runs with full filesystem access and no automatic undo — script side effects are permanent; checkpoint (git) before risky bulk operations.
|
package/src/tools/execute.mjs
CHANGED
|
@@ -10,44 +10,41 @@
|
|
|
10
10
|
* killable timeout (an in-process infinite loop would freeze the CLI; a child
|
|
11
11
|
* process is killed like bash).
|
|
12
12
|
*
|
|
13
|
-
* The child
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* The child runs PURE node ESM — no helpers are injected (exec-prelude.mjs
|
|
14
|
+
* retired 2026-09-03, TOOLS.md §12: preloaded readFile/writeFile/glob/grep/log
|
|
15
|
+
* helpers made execute look like a file tool, bypassing the dedicated
|
|
16
|
+
* read/ls/glob/grep/write/edit tools). Scripts that need fs/path import the
|
|
17
|
+
* node: modules themselves — same boundary as bash, no fake sandbox.
|
|
16
18
|
*
|
|
17
19
|
* Parameters:
|
|
18
20
|
* code — JS to run inline (top-level await and import() supported). Use this OR scriptFile.
|
|
19
|
-
* scriptFile — run a
|
|
21
|
+
* scriptFile — run a .mjs/.js file with node (self-contained, imports what it needs). Use this OR code.
|
|
20
22
|
* nodeArgs — (scriptFile) extra node flags before the script (e.g. --test, --check); eval-like flags rejected
|
|
21
|
-
* workdir — run in this sub-directory (
|
|
23
|
+
* workdir — run in this sub-directory (no directory restriction)
|
|
22
24
|
* filter — return only output lines matching this regex (case-insensitive)
|
|
23
|
-
* timeoutMs — timeout (default 30s, max
|
|
25
|
+
* timeoutMs — timeout (default 30s, max 600000ms)
|
|
24
26
|
*/
|
|
25
27
|
import { spawn } from "node:child_process"
|
|
26
|
-
import {
|
|
27
|
-
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
28
|
+
import { resolve } from "node:path"
|
|
28
29
|
import { DESC } from "./shared.mjs"
|
|
29
30
|
|
|
30
31
|
const MAX_SCRIPT = 50_000
|
|
31
32
|
const MAX_OUTPUT = 50_000
|
|
32
33
|
const DEFAULT_TIMEOUT = 30_000
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const rel = relative(root, abs)
|
|
41
|
-
if (isAbsolute(rel)) return false
|
|
42
|
-
return rel !== ".." && !rel.startsWith(".." + sep)
|
|
43
|
-
}
|
|
35
|
+
/**
|
|
36
|
+
* 超时错误文本——带重试引导(TOOLS.md §14.1 D14.1.2——"下一跳"):数字 = 实际生效的
|
|
37
|
+
* timeoutMs(Math.min(t, 600_000) 或默认 30s)——上限 600000 与 schema/头注/execute.md 一致。
|
|
38
|
+
*/
|
|
39
|
+
const timeoutErrorText = (timeoutMs) =>
|
|
40
|
+
`Error: script timed out after ${timeoutMs}ms — retry with a larger timeoutMs (up to 600000) for long scripts, or use bash (default 120s) for shell commands`
|
|
44
41
|
|
|
45
|
-
/** Resolve workdir relative to cwd
|
|
42
|
+
/** Resolve workdir relative to cwd — no boundary assertion
|
|
43
|
+
* (§10.1 2026-09-02: workspace confinement removed; the child node process is
|
|
44
|
+
* not directory-limited — same boundary as bash). */
|
|
46
45
|
function resolveBaseDir(cwd, workdir) {
|
|
47
46
|
if (!workdir || typeof workdir !== "string") return cwd
|
|
48
|
-
|
|
49
|
-
if (!isInside(cwd, abs)) throw new Error(`workdir escapes the workspace: ${workdir}`)
|
|
50
|
-
return abs
|
|
47
|
+
return resolve(cwd, workdir)
|
|
51
48
|
}
|
|
52
49
|
|
|
53
50
|
/** Keep only output lines matching a regex (execute filter, case-insensitive). */
|
|
@@ -63,11 +60,10 @@ function applyFilter(output, filter) {
|
|
|
63
60
|
|
|
64
61
|
/** Spawn node with the given args, capture stdout/stderr, enforce timeout/abort.
|
|
65
62
|
* Resolves { text, ok } — ok=false on non-zero exit / timeout / abort. */
|
|
66
|
-
function runNode(childArgs, baseDir,
|
|
63
|
+
function runNode(childArgs, baseDir, timeoutMs, signal) {
|
|
67
64
|
return new Promise((resolvePromise) => {
|
|
68
65
|
const child = spawn(process.execPath, childArgs, {
|
|
69
66
|
cwd: baseDir,
|
|
70
|
-
env: { ...process.env, THINCODER_EXEC_ROOT: root },
|
|
71
67
|
stdio: ["ignore", "pipe", "pipe"],
|
|
72
68
|
windowsHide: true,
|
|
73
69
|
})
|
|
@@ -87,7 +83,7 @@ function runNode(childArgs, baseDir, root, timeoutMs, signal) {
|
|
|
87
83
|
const kill = () => { try { child.kill("SIGKILL") } catch { /* already gone */ } }
|
|
88
84
|
// After kill, wait for "close" (child fully reaped) before settling — settling
|
|
89
85
|
// early races the caller deleting the cwd dir while the child still holds it.
|
|
90
|
-
const armKick = () => { kickTimer = setTimeout(() => settle(mode === "abort" ? "(stopped)" :
|
|
86
|
+
const armKick = () => { kickTimer = setTimeout(() => settle(mode === "abort" ? "(stopped)" : timeoutErrorText(timeoutMs), false), 3000) }
|
|
91
87
|
const onAbort = () => { if (mode) return; mode = "abort"; kill(); armKick() }
|
|
92
88
|
|
|
93
89
|
timer = setTimeout(() => { if (!mode) { mode = "timeout"; kill(); armKick() } }, timeoutMs)
|
|
@@ -107,7 +103,7 @@ function runNode(childArgs, baseDir, root, timeoutMs, signal) {
|
|
|
107
103
|
child.on("error", (e) => settle(`Error: failed to start node: ${e.message}`, false))
|
|
108
104
|
child.on("close", (code) => {
|
|
109
105
|
if (mode === "abort") return settle("(stopped)", false)
|
|
110
|
-
if (mode === "timeout") return settle(
|
|
106
|
+
if (mode === "timeout") return settle(timeoutErrorText(timeoutMs), false)
|
|
111
107
|
const out = outBuf.trimEnd()
|
|
112
108
|
const err = errBuf.trim()
|
|
113
109
|
if (code === 0) {
|
|
@@ -139,11 +135,11 @@ export const executeTool = {
|
|
|
139
135
|
properties: {
|
|
140
136
|
code: {
|
|
141
137
|
type: "string",
|
|
142
|
-
description: "JavaScript code to execute (top-level await and dynamic import() supported).
|
|
138
|
+
description: "JavaScript code to execute (top-level await and dynamic import() supported). Pure node ESM — no preloaded globals; import node: modules (fs/path) yourself when needed. File reads/writes go through the dedicated read/ls/glob/grep/write/edit tools. Use this OR scriptFile.",
|
|
143
139
|
},
|
|
144
140
|
scriptFile: {
|
|
145
141
|
type: "string",
|
|
146
|
-
description: "Run a
|
|
142
|
+
description: "Run a .mjs/.js file with node (self-contained — the file imports what it needs). Path relative to workdir — no directory restriction. Use this OR code. For `node <script>` / `node --test <file>` / `node --check <file>`.",
|
|
147
143
|
},
|
|
148
144
|
nodeArgs: {
|
|
149
145
|
type: "array",
|
|
@@ -152,7 +148,7 @@ export const executeTool = {
|
|
|
152
148
|
},
|
|
153
149
|
workdir: {
|
|
154
150
|
type: "string",
|
|
155
|
-
description: "Run in this directory (relative to cwd
|
|
151
|
+
description: "Run in this directory (relative to cwd — no directory restriction; default cwd)",
|
|
156
152
|
},
|
|
157
153
|
filter: {
|
|
158
154
|
type: "string",
|
|
@@ -180,10 +176,9 @@ export const executeTool = {
|
|
|
180
176
|
let childArgs
|
|
181
177
|
if (args.scriptFile) {
|
|
182
178
|
if (args.code?.trim()) return "Error: pass code OR scriptFile, not both"
|
|
183
|
-
// scriptFile mode: run a
|
|
184
|
-
//
|
|
179
|
+
// scriptFile mode: run a .mjs/.js file with node [nodeArgs...]. Self-contained —
|
|
180
|
+
// a real node process imports what it needs. No directory restriction.
|
|
185
181
|
const scriptAbs = resolve(baseDir, args.scriptFile)
|
|
186
|
-
if (!isInside(ctx.cwd, scriptAbs)) return `Error: scriptFile escapes the workspace: ${args.scriptFile}`
|
|
187
182
|
let nodeArgs
|
|
188
183
|
try { nodeArgs = validateNodeArgs(args.nodeArgs) }
|
|
189
184
|
catch (e) { return `Error: ${e.message}` }
|
|
@@ -194,12 +189,13 @@ export const executeTool = {
|
|
|
194
189
|
if (code.length > MAX_SCRIPT) {
|
|
195
190
|
return `Error: script too large (${code.length} > ${MAX_SCRIPT} bytes). Split into smaller scripts or use individual tools.`
|
|
196
191
|
}
|
|
197
|
-
|
|
192
|
+
// inline mode: pure node ESM — no prelude, nothing injected (TOOLS.md §12).
|
|
193
|
+
childArgs = ["--input-type=module", "--eval", code]
|
|
198
194
|
}
|
|
199
195
|
|
|
200
|
-
const { text, ok } = await runNode(childArgs, baseDir,
|
|
196
|
+
const { text, ok } = await runNode(childArgs, baseDir, timeoutMs, ctx.signal)
|
|
201
197
|
// Only filter successful output — never swallow an error report behind a filter.
|
|
202
198
|
if (!ok) return text
|
|
203
199
|
return args.filter ? applyFilter(text, args.filter) : text
|
|
204
200
|
},
|
|
205
|
-
}
|
|
201
|
+
}
|