thincoder 0.12.58 → 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.
Files changed (114) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +8 -0
  4. package/package.json +1 -1
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +34 -1
  7. package/src/advisor/run.mjs +89 -51
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +91 -14
  10. package/src/agent/helpers.mjs +35 -4
  11. package/src/agent/setup.mjs +90 -19
  12. package/src/agent/spawn-child.mjs +25 -0
  13. package/src/agent-tools/advisor.mjs +24 -2
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +2 -1
  16. package/src/agent-tools/goal.mjs +11 -1
  17. package/src/agent-tools/read-history.mjs +160 -0
  18. package/src/agent-tools/settings.mjs +162 -0
  19. package/src/agent-tools/skill.mjs +2 -1
  20. package/src/agent-tools/subagent-actions.mjs +432 -0
  21. package/src/agent-tools/subagent-async.mjs +427 -0
  22. package/src/agent-tools/subagent-scheduler.mjs +319 -0
  23. package/src/agent-tools/subagent.mjs +467 -193
  24. package/src/agent-tools/task.mjs +4 -3
  25. package/src/agent-tools/timer.mjs +9 -4
  26. package/src/agent-tools/verify.mjs +161 -49
  27. package/src/agent-tools.mjs +1 -0
  28. package/src/agent.mjs +161 -125
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +2 -1
  31. package/src/cli/permission.mjs +8 -1
  32. package/src/config.mjs +5 -0
  33. package/src/context.mjs +87 -27
  34. package/src/distill.mjs +19 -1
  35. package/src/escape.mjs +6 -4
  36. package/src/log.mjs +195 -0
  37. package/src/memory/code-sync.mjs +1 -1
  38. package/src/memory/core.mjs +126 -0
  39. package/src/memory/docs.mjs +196 -87
  40. package/src/memory.mjs +1 -1
  41. package/src/model-specs.mjs +15 -1
  42. package/src/prompts/advisor-design.md +46 -0
  43. package/src/prompts/advisor-round1.md +49 -2
  44. package/src/prompts/advisor-round2.md +47 -0
  45. package/src/prompts/advisor-round3.md +47 -0
  46. package/src/prompts/coder.md +22 -0
  47. package/src/prompts/consult-base.md +13 -0
  48. package/src/prompts/discipline.md +10 -5
  49. package/src/prompts/eng-coder.md +2 -2
  50. package/src/prompts/engineering-sub.md +23 -1
  51. package/src/prompts/engineering.md +106 -56
  52. package/src/prompts/explore.md +1 -2
  53. package/src/prompts/main.md +11 -6
  54. package/src/prompts/methodology-template.md +14 -0
  55. package/src/prompts/system.md +4 -2
  56. package/src/provider/core.mjs +56 -2
  57. package/src/tools/apply_patch.md +3 -1
  58. package/src/tools/bash.md +1 -1
  59. package/src/tools/delete.md +1 -0
  60. package/src/tools/edit-batch.mjs +31 -43
  61. package/src/tools/edit-diff.mjs +265 -0
  62. package/src/tools/edit.md +10 -8
  63. package/src/tools/execute.md +7 -7
  64. package/src/tools/execute.mjs +24 -20
  65. package/src/tools/file.mjs +18 -68
  66. package/src/tools/file_ops.md +2 -1
  67. package/src/tools/get_current_time.md +3 -1
  68. package/src/tools/hashline_edit.md +2 -0
  69. package/src/tools/index.mjs +3 -2
  70. package/src/tools/insert_after.md +2 -1
  71. package/src/tools/lint.md +2 -0
  72. package/src/tools/lsp.md +4 -1
  73. package/src/tools/patch.mjs +84 -13
  74. package/src/tools/pdf-parse-text.mjs +497 -0
  75. package/src/tools/pdf-parse-xref.mjs +499 -0
  76. package/src/tools/pdf.mjs +155 -0
  77. package/src/tools/question.md +2 -1
  78. package/src/tools/read.md +1 -0
  79. package/src/tools/read_pdf.md +21 -0
  80. package/src/tools/repomap.mjs +1 -1
  81. package/src/tools/shared.mjs +4 -12
  82. package/src/tools/system.mjs +6 -21
  83. package/src/tools/tree.md +2 -1
  84. package/src/tools/web.mjs +5 -3
  85. package/src/tools/websearch.md +2 -1
  86. package/src/tools/write.md +2 -0
  87. package/src/traces/trace-store.mjs +224 -0
  88. package/src/tui/agent-turn.mjs +385 -22
  89. package/src/tui/clipboard.mjs +15 -4
  90. package/src/tui/cmd-config.mjs +29 -9
  91. package/src/tui/cmd-extract.mjs +1 -1
  92. package/src/tui/cmd-mcp.mjs +9 -0
  93. package/src/tui/cmd-think.mjs +1 -1
  94. package/src/tui/index.mjs +29 -95
  95. package/src/tui/interaction.mjs +13 -2
  96. package/src/tui/key-handler.mjs +105 -155
  97. package/src/tui/key-modes.mjs +215 -0
  98. package/src/tui/layout.mjs +22 -1
  99. package/src/tui/mouse.mjs +40 -0
  100. package/src/tui/pickers.mjs +11 -3
  101. package/src/tui/render-conversation.mjs +13 -161
  102. package/src/tui/render-frame.mjs +27 -10
  103. package/src/tui/render-loop.mjs +4 -1
  104. package/src/tui/render-segments.mjs +165 -0
  105. package/src/tui/startup.mjs +36 -0
  106. package/src/tui/subagent-blocks.mjs +322 -144
  107. package/src/tui/subagent-panel.mjs +88 -13
  108. package/src/tui/tool-args.mjs +10 -2
  109. package/src/tui/tool-events.mjs +132 -100
  110. package/src/tui/update-notice.mjs +72 -0
  111. package/src/tui/wizard.mjs +36 -6
  112. package/src/agent-tools/escalate.mjs +0 -179
  113. package/src/agent-tools/subagent-check.mjs +0 -107
  114. package/src/tools/exec-prelude.mjs +0 -84
@@ -10,30 +10,34 @@
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 `import()`-s exec-prelude.mjs first for readFile/writeFile/glob/grep/
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.
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.
17
18
  *
18
19
  * Parameters:
19
20
  * code — JS to run inline (top-level await and import() supported). Use this OR scriptFile.
20
- * scriptFile — run a .mjs/.js file with node (self-contained, no prelude). Use this OR code.
21
+ * scriptFile — run a .mjs/.js file with node (self-contained, imports what it needs). Use this OR code.
21
22
  * nodeArgs — (scriptFile) extra node flags before the script (e.g. --test, --check); eval-like flags rejected
22
23
  * workdir — run in this sub-directory (no directory restriction)
23
24
  * filter — return only output lines matching this regex (case-insensitive)
24
- * timeoutMs — timeout (default 30s, max 60s)
25
+ * timeoutMs — timeout (default 30s, max 600000ms)
25
26
  */
26
27
  import { spawn } from "node:child_process"
27
- import { dirname, resolve } from "node:path"
28
- import { fileURLToPath, pathToFileURL } from "node:url"
28
+ import { resolve } from "node:path"
29
29
  import { DESC } from "./shared.mjs"
30
30
 
31
31
  const MAX_SCRIPT = 50_000
32
32
  const MAX_OUTPUT = 50_000
33
33
  const DEFAULT_TIMEOUT = 30_000
34
34
 
35
- const __dirname = dirname(fileURLToPath(import.meta.url))
36
- const PRELUDE_URL = pathToFileURL(resolve(__dirname, "exec-prelude.mjs")).href
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`
37
41
 
38
42
  /** Resolve workdir relative to cwd — no boundary assertion
39
43
  * (§10.1 2026-09-02: workspace confinement removed; the child node process is
@@ -56,11 +60,10 @@ function applyFilter(output, filter) {
56
60
 
57
61
  /** Spawn node with the given args, capture stdout/stderr, enforce timeout/abort.
58
62
  * Resolves { text, ok } — ok=false on non-zero exit / timeout / abort. */
59
- function runNode(childArgs, baseDir, root, timeoutMs, signal) {
63
+ function runNode(childArgs, baseDir, timeoutMs, signal) {
60
64
  return new Promise((resolvePromise) => {
61
65
  const child = spawn(process.execPath, childArgs, {
62
66
  cwd: baseDir,
63
- env: { ...process.env, THINCODER_EXEC_ROOT: root },
64
67
  stdio: ["ignore", "pipe", "pipe"],
65
68
  windowsHide: true,
66
69
  })
@@ -80,7 +83,7 @@ function runNode(childArgs, baseDir, root, timeoutMs, signal) {
80
83
  const kill = () => { try { child.kill("SIGKILL") } catch { /* already gone */ } }
81
84
  // After kill, wait for "close" (child fully reaped) before settling — settling
82
85
  // early races the caller deleting the cwd dir while the child still holds it.
83
- const armKick = () => { kickTimer = setTimeout(() => settle(mode === "abort" ? "(stopped)" : `Error: script timed out after ${timeoutMs}ms`, false), 3000) }
86
+ const armKick = () => { kickTimer = setTimeout(() => settle(mode === "abort" ? "(stopped)" : timeoutErrorText(timeoutMs), false), 3000) }
84
87
  const onAbort = () => { if (mode) return; mode = "abort"; kill(); armKick() }
85
88
 
86
89
  timer = setTimeout(() => { if (!mode) { mode = "timeout"; kill(); armKick() } }, timeoutMs)
@@ -100,7 +103,7 @@ function runNode(childArgs, baseDir, root, timeoutMs, signal) {
100
103
  child.on("error", (e) => settle(`Error: failed to start node: ${e.message}`, false))
101
104
  child.on("close", (code) => {
102
105
  if (mode === "abort") return settle("(stopped)", false)
103
- if (mode === "timeout") return settle(`Error: script timed out after ${timeoutMs}ms`, false)
106
+ if (mode === "timeout") return settle(timeoutErrorText(timeoutMs), false)
104
107
  const out = outBuf.trimEnd()
105
108
  const err = errBuf.trim()
106
109
  if (code === 0) {
@@ -132,11 +135,11 @@ export const executeTool = {
132
135
  properties: {
133
136
  code: {
134
137
  type: "string",
135
- description: "JavaScript code to execute (top-level await and dynamic import() supported). Use provided globals: readFile/writeFile/glob/grep/log, plus native require/process/console/fetch/import. Use this OR scriptFile.",
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.",
136
139
  },
137
140
  scriptFile: {
138
141
  type: "string",
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>`.",
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>`.",
140
143
  },
141
144
  nodeArgs: {
142
145
  type: "array",
@@ -174,7 +177,7 @@ export const executeTool = {
174
177
  if (args.scriptFile) {
175
178
  if (args.code?.trim()) return "Error: pass code OR scriptFile, not both"
176
179
  // 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.
180
+ // a real node process imports what it needs. No directory restriction.
178
181
  const scriptAbs = resolve(baseDir, args.scriptFile)
179
182
  let nodeArgs
180
183
  try { nodeArgs = validateNodeArgs(args.nodeArgs) }
@@ -186,12 +189,13 @@ export const executeTool = {
186
189
  if (code.length > MAX_SCRIPT) {
187
190
  return `Error: script too large (${code.length} > ${MAX_SCRIPT} bytes). Split into smaller scripts or use individual tools.`
188
191
  }
189
- childArgs = ["--input-type=module", "--eval", `await import(${JSON.stringify(PRELUDE_URL)});\n${code}`]
192
+ // inline mode: pure node ESM — no prelude, nothing injected (TOOLS.md §12).
193
+ childArgs = ["--input-type=module", "--eval", code]
190
194
  }
191
195
 
192
- const { text, ok } = await runNode(childArgs, baseDir, ctx.cwd, timeoutMs, ctx.signal)
196
+ const { text, ok } = await runNode(childArgs, baseDir, timeoutMs, ctx.signal)
193
197
  // Only filter successful output — never swallow an error report behind a filter.
194
198
  if (!ok) return text
195
199
  return args.filter ? applyFilter(text, args.filter) : text
196
200
  },
197
- }
201
+ }
@@ -10,10 +10,10 @@ import {
10
10
  detectFileEol,
11
11
  joinWithEol,
12
12
  majorityEol,
13
- findCandidates,
14
13
  FFFD_WARNING,
15
14
  } from "./shared.mjs";
16
15
  import { applyEditBatch } from "./edit-batch.mjs";
16
+ import { runSingleEdit } from "./edit-diff.mjs";
17
17
  import { specForModel } from "../config.mjs";
18
18
  import { createHash } from "node:crypto";
19
19
  import { mkdir, readFile, stat, writeFile, unlink } from "node:fs/promises";
@@ -73,6 +73,7 @@ export const readTool = {
73
73
  type: "object",
74
74
  properties: {
75
75
  path: { type: "string", description: "File path (relative to cwd or absolute)" },
76
+ filePath: { type: "string", description: "Alias for path (supported for API compatibility)" },
76
77
  offset: { type: "number", description: "1-based line number to start from" },
77
78
  limit: { type: "number", description: `Max lines to return (default ${MAX_READ_LINES})` },
78
79
  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)." },
@@ -82,7 +83,8 @@ export const readTool = {
82
83
  },
83
84
  readonly: true,
84
85
  async execute(args, ctx) {
85
- const abs = args.allowExternal ? resolveExternal(ctx, args.path) : resolveInCwd(ctx, args.path)
86
+ const p = args.path ?? args.filePath
87
+ const abs = args.allowExternal ? resolveExternal(ctx, p) : resolveInCwd(ctx, p)
86
88
  // Large file guard: check size first, reject reading entire file if >10MB (offset/limit only affect the returned slice, not buffering)
87
89
  const st = await stat(abs).catch(() => null)
88
90
  if (st && st.size > MAX_FILE_READ_BYTES) throw new Error(`File too large (${Math.round(st.size / 1_000_000)}MB > 10MB limit). Use bash with head/tail or grep for targeted extraction.`)
@@ -214,13 +216,13 @@ export const editTool = {
214
216
  parameters: {
215
217
  type: "object",
216
218
  properties: {
217
- path: { type: "string", description: "File path" },
219
+ path: { type: "string", description: "File path — single form: required; with the edits array: optional top-level default for entries without their own path" },
218
220
  old_string: { type: "string", description: "Exact text to replace" },
219
221
  new_string: { type: "string", description: "Replacement text" },
220
222
  replace_all: { type: "boolean", description: "Replace all occurrences (default false)" },
221
223
  edits: {
222
224
  type: "array",
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.",
225
+ 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. A top-level path is allowed — it defaults entries without their own path (entry paths win). Mutually exclusive with top-level old_string/new_string — provide each change's old/new inside its edits entry.",
224
226
  items: {
225
227
  type: "object",
226
228
  properties: {
@@ -229,7 +231,7 @@ export const editTool = {
229
231
  new_string: { type: "string" },
230
232
  replace_all: { type: "boolean" },
231
233
  },
232
- required: ["path", "old_string", "new_string"],
234
+ required: ["old_string", "new_string"],
233
235
  },
234
236
  },
235
237
  },
@@ -237,7 +239,13 @@ export const editTool = {
237
239
  },
238
240
  readonly: false,
239
241
  touchedPaths(args) {
240
- if (args.edits) return args.edits.map((e) => e.path).filter(Boolean)
242
+ // 2026-09-05 用户裁定:顶层 path = 批默认——仅当有条目缺 path 时计入(条目全带 path
243
+ // 时顶层 path 不实际使用——不虚报进 _touchedFiles)
244
+ if (args.edits) {
245
+ const out = args.edits.map((e) => e.path).filter(Boolean)
246
+ if (args.path && args.edits.some((e) => !e.path)) out.push(args.path)
247
+ return out
248
+ }
241
249
  return args.path ? [args.path] : []
242
250
  },
243
251
  async execute(args, ctx) {
@@ -245,67 +253,9 @@ export const editTool = {
245
253
  // (应用逻辑在 edit-batch.mjs——2026-09-01 拆出,500 行硬限,先例 git-ext.mjs)
246
254
  if (args.edits) return applyEditBatch(args, ctx)
247
255
 
248
- // 单文件(现状路径)
249
- const abs = resolveInCwd(ctx, args.path)
250
- if (!args.old_string) {
251
- throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
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
- }
261
- const raw = await readFile(abs, "utf8")
262
- const content = normalizeEOL(raw)
263
- const occurrences = content.split(args.old_string).length - 1
264
- if (occurrences === 0) {
265
- // Give clues to help the model locate: first-line preview + common causes
266
- const preview = args.old_string.slice(0, 100).split("\n")[0]
267
- // Similarity candidates (LCS, line-level, top 3, score ≥ 0.5) — turns the
268
- // "not found" black box into a pointer at the most likely intended line.
269
- // Multi-line old_string: only its first line is scored (marked accordingly).
270
- const cands = findCandidates(content.split("\n"), args.old_string)
271
- let candText = ""
272
- if (cands.length > 0) {
273
- const header = args.old_string.includes("\n")
274
- ? ` similar lines (old_string line 1: "${args.old_string.split("\n")[0].slice(0, 80)}"):`
275
- : " similar lines:"
276
- candText = "\n" + header + "\n" + cands.map((c) => ` L${c.line}: ${c.preview} (${Math.round(c.score * 100)}%)`).join("\n")
277
- }
278
- throw new Error(
279
- `old_string not found in ${args.path}\n` +
280
- ` searched: "${preview}${args.old_string.length > 100 ? "…" : ""}"\n` +
281
- (lastWriteOf(abs)?.type === "write"
282
- ? ` hints: this file was modified since your last read (write 全文重写后内容全变) — re-read it to refresh your copy of the content, then retry\n`
283
- : isDirty(abs)
284
- ? ` 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`
285
- : ` hints: whitespace mismatch? file already changed? try reading the file first\n`) +
286
- candText
287
- )
288
- }
289
- if (occurrences > 1 && !args.replace_all) {
290
- throw new Error(`old_string matches ${occurrences} times in ${args.path}; provide more context or set replace_all`)
291
- }
292
- const updated = args.replace_all
293
- ? content.split(args.old_string).join(args.new_string)
294
- // Functional replacement: avoid $-substitution patterns in new_string (match string / backreference) being expanded
295
- : content.replace(args.old_string, () => args.new_string)
296
- // Write back in the file's ORIGINAL EOL style (first-newline rule) — a CRLF
297
- // file must not come back as LF (that rewrites every line in the diff).
298
- // normalizeEOL first: new_string may carry \r\n (e.g. pasted from a raw CRLF
299
- // read); without normalizing, split leaves stray \r and CRLF join makes \r\r\n.
300
- await writeFile(abs, joinWithEol(normalizeEOL(updated).split("\n"), raw), "utf8")
301
- // 2026-08-31 工具顺手度:记录受影响区(替换首行 + 行数差)——insert_after 精确判定
302
- const matchIdx = content.indexOf(args.old_string)
303
- const editStartLine = matchIdx >= 0 ? content.slice(0, matchIdx).split("\n").length : 1
304
- const lineShift = args.new_string.split("\n").length - args.old_string.split("\n").length
305
- recordWrite(abs, { type: "edit", startLine: editStartLine, shift: lineShift })
306
- const diff = gitDiffOne(ctx.cwd, abs)
307
- const baseResult = `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
308
- return await appendWriteContext(abs, editStartLine, baseResult)
256
+ // 单文件(现状路径)——执行体整段迁出至 edit-diff.mjs(TOOLS.md §15 D15.1——
257
+ // 行级 LCS 判定:零重叠→插入 / 一般 diff→LCS / 空 new→显式报错)
258
+ return runSingleEdit(args, ctx)
309
259
  },
310
260
  }
311
261
 
@@ -453,7 +403,7 @@ export const hashlineEditTool = {
453
403
  const hashDump = fileHashes.slice(0, maxShow).map((h, i) => `${h} L${i + 1}: ${lines[i].slice(0, 80)}`).join("\n")
454
404
  const preview = target.join(" ")
455
405
  throw new Error(
456
- `Hash sequence not found in ${args.path}: ${preview}\n` +
406
+ `Hash sequence not found in ${args.path}: ${preview} — for fresh hashes, re-read the file with hashes=true\n` +
457
407
  `The file may have been modified since you last read it. Current hashes (first ${maxShow} lines):\n${hashDump}` +
458
408
  (corrupted ? `\n${FFFD_WARNING}` : "")
459
409
  )
@@ -13,4 +13,5 @@ Parameters:
13
13
  Notes:
14
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
- - To create a directory, use `write` (creates parent dirs) or `bash mkdir`.
16
+ - To create a directory, use `write` (creates parent dirs) or `bash mkdir`.
17
+ - Returns `Moved|Copied|Renamed <source> -> <dest>` or `Error: ...` (same-path and missing-path errors).
@@ -3,4 +3,6 @@ Get the current date, time, weekday, and timezone.
3
3
  **Route to get_current_time instead of bash:**
4
4
  - `date` / `time` → get_current_time
5
5
 
6
- Use it whenever a task depends on the current time or date (deadlines, freshness, timestamps) rather than shelling out.
6
+ Use it whenever a task depends on the current time or date (deadlines, freshness, timestamps) rather than shelling out.
7
+
8
+ Returns `Date` / `Timezone` / `Weekday` / `Local` lines (UTC + local timezone).
@@ -10,3 +10,5 @@ Notes:
10
10
  - Hashes are position-independent: they identify lines by content, not by line number (which changes after edits)
11
11
  - If the hash sequence isn't found, the error will include the current file's hashes so you can retry with corrected values
12
12
  - Prefer this over edit when: 1) the file may have mixed whitespace/encoding, 2) you want to edit a block of lines with a single call
13
+ - Replacement text replaces the lines identified by the hashes — content not present in new_content is deleted. For a new line after a known line, use insert_after. For a single simple string swap, use edit.
14
+ - use the most recent read of the file as the source of old_string / line numbers / hashes — re-read after the file changed
@@ -2,6 +2,7 @@
2
2
  export { toOpenAISchema } from "./shared.mjs";
3
3
 
4
4
  import { readTool, writeTool, editTool, insertAfterTool, readImageTool, hashlineEditTool } from "./file.mjs";
5
+ import { readPdfTool } from "./pdf.mjs";
5
6
  import { applyPatchTool, deleteTool } from "./patch.mjs";
6
7
  import { bashTool, globTool, grepTool, lsTool } from "./system.mjs";
7
8
  import { websearchTool, fetchTool } from "./web.mjs";
@@ -15,7 +16,7 @@ import { treeTool } from "./tree.mjs";
15
16
 
16
17
  export const builtinTools = [
17
18
  readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
18
- readImageTool, bashTool, globTool, grepTool,
19
+ readImageTool, readPdfTool, bashTool, globTool, grepTool,
19
20
  websearchTool, lsTool, fetchTool, deleteTool,
20
21
  gitTool, questionTool,
21
22
  checklistTool, lintTool, lspTool, executeTool,
@@ -25,7 +26,7 @@ export const builtinTools = [
25
26
 
26
27
  export {
27
28
  readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
28
- readImageTool, bashTool, globTool, grepTool,
29
+ readImageTool, readPdfTool, bashTool, globTool, grepTool,
29
30
  websearchTool, lsTool, fetchTool, deleteTool,
30
31
  gitTool, questionTool,
31
32
  checklistTool, lintTool, lspTool, executeTool,
@@ -8,7 +8,8 @@ Parameters:
8
8
 
9
9
  Notes:
10
10
  - Either after_line or after_regex is required; if both are given, after_line wins.
11
- - Use this instead of `edit` when you're adding a new function, import, or block — no need to fabricate surrounding context for exact matching.
11
+ - Use this instead of edit when you're adding a new line — a checklist item, a doc heading, a line of prose, a function, an import, or a block — no need to fabricate surrounding context for exact matching.
12
12
  - The inserted content becomes its own line; it's equivalent to `lines.splice(targetLine, 0, content)`.
13
13
  - Returns a diff of the change.
14
14
  - **Read-before-insert guard**: if the file was modified by any write tool (write/edit/insert_after/hashline_edit/apply_patch/delete) since your last `read`, this tool REFUSES with an error — line numbers may be stale. Read the file again, then retry. This prevents after_line from silently landing at a drifted position.
15
+ - 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/lint.md CHANGED
@@ -3,6 +3,8 @@ Without 'full', runs a fast node --check (JS/TS syntax only, catches parse error
3
3
  With 'full', runs the language-aware cascade: tsc –noEmit (TS); ruff (Python); cargo check (Rust); go vet (Go). JS/JSX files fall back to node --check; TS uses tsc --noEmit (requires tsconfig.json).
4
4
  Use the fast default after every write/edit; use 'full' before declaring a task complete.
5
5
 
6
+ Returns the check result: `Syntax OK: <path>` / `Syntax error in <path>: <message>` (or the language checker's output — `✓ no issues` or the failure text).
7
+
6
8
  Parameters:
7
9
  - path: File to check (default: most recently modified file)
8
10
  - full: Run the full language-aware cascade instead of just node --check (default false)
package/src/tools/lsp.md CHANGED
@@ -1,7 +1,10 @@
1
- LSP code intelligence: go to definition, find references, hover info, document symbols, diagnostics. Use this to understand code structure without grep-guessing function locations or type shapes.
1
+ LSP code intelligence: go to definition, find references, hover info, document symbols, diagnostics. Use this to understand code structure without grep-guessing function locations or type shapes. Find files with glob / repo_outline — use lsp for definition / references / diagnostics
2
2
 
3
3
  Parameters:
4
4
  - subcommand (required): LSP operation — "definition" | "references" | "hover" | "symbols" | "diagnostics"
5
5
  - uri (required): Target file path (relative to project root)
6
6
  - line: 1-based line number (for definition/references/hover)
7
7
  - character: 1-based character offset (for definition/references/hover)
8
+
9
+ Notes:
10
+ - Returns the requested LSP result: definition location, references, hover info, document symbols, or diagnostics — or an error message.
@@ -15,8 +15,23 @@ import { relative, dirname } from "node:path";
15
15
  /**
16
16
  * Parse a unified diff: returns [{ path, isNew, hunks: [{ ops: [{type:" "|"-"|"+", text}] }] }]
17
17
  * Consume hunk lines by the line counts in the @@ header — LLMs often strip context blank lines to pure empty lines,
18
- * so we use counts rather than first characters to determine hunk boundaries
18
+ * so we use counts rather than first characters to determine hunk boundaries.
19
+ * D15.6: a bare "@@" header (no coordinates) is accepted — the hunk body runs until the next hunk/file header
20
+ * ("@@" / "--- " / "+++ " / "diff " / "index "), purely located by its ops.
19
21
  */
22
+ /**
23
+ * P15.10(2026-09-05 用户裁定——「符合模型直觉」):文件头判定——
24
+ * 完整头(`--- x` 后随 `+++ `)任意老路径形态均认(git 规范);
25
+ * 容缺头(`+++ b/<path>` 配对行省略——模型单文件补丁自然形态)仅认 a//b/ 前缀——
26
+ * newPath 推导 = oldPath;`/dev/null` 容缺仍拒(新文件名从 --- 侧不可推导——parsePatch 内特报);
27
+ * 其他 `--- x` = 普通删行内容(行首标记 - + 内容 `-- x`)——不是文件头——hunk 体不得误断。
28
+ */
29
+ function isFileHeader(line, nextLine) {
30
+ if (!line.startsWith("--- ")) return false
31
+ if (nextLine?.startsWith("+++ ")) return true
32
+ return /^[ab]\//.test(line.slice(4).trim())
33
+ }
34
+
20
35
  function parsePatch(patch) {
21
36
  // Patch text often comes from CRLF terminals/model output; trailing \r mixed into hunk content breaks context matching, strip uniformly
22
37
  const lines = patch.replace(/\r(?=\n|$)/g, "").split("\n")
@@ -29,18 +44,69 @@ function parsePatch(patch) {
29
44
  if (line.startsWith("--- ")) {
30
45
  const oldPath = line.slice(4).trim()
31
46
  const plus = lines[i + 1]
32
- if (!plus?.startsWith("+++ ")) throw new Error(`Malformed patch: expected "+++" line after "${line}"`)
33
- const newPath = plus.slice(4).trim()
34
- if (newPath === "/dev/null") throw new Error("Deleting files via patch is not supported — use the delete tool")
35
- cur = { path: stripPrefix(newPath), isNew: oldPath === "/dev/null", hunks: [] }
36
- files.push(cur)
37
- i += 2
38
- continue
47
+ if (plus?.startsWith("+++ ")) {
48
+ const newPath = plus.slice(4).trim()
49
+ if (newPath === "/dev/null") throw new Error("Deleting files via patch is not supported — use the delete tool")
50
+ cur = { path: stripPrefix(newPath), isNew: oldPath === "/dev/null", hunks: [] }
51
+ files.push(cur)
52
+ i += 2
53
+ continue
54
+ }
55
+ // P15.10:容缺头——`--- a/<path>`(或 b/ 前缀)后直接跟 hunk = 对同路径的修改。
56
+ if (/^[ab]\//.test(oldPath)) {
57
+ cur = { path: stripPrefix(oldPath), isNew: false, hunks: [] }
58
+ files.push(cur)
59
+ i += 1
60
+ continue
61
+ }
62
+ if (oldPath === "/dev/null") {
63
+ throw new Error(`"--- /dev/null" needs a "+++ b/<path>" line naming the new file — the --- side does not carry the file name`)
64
+ }
65
+ throw new Error(`Malformed patch: expected "+++" line after "${line}"`)
39
66
  }
40
67
  if (line.startsWith("@@")) {
41
68
  if (!cur) throw new Error("Malformed patch: hunk header before any file header")
42
69
  const m = line.match(/^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/)
43
- if (!m) throw new Error(`Malformed patch: bad hunk header "${line}" (need @@ -old,count +new,count @@)`)
70
+ if (!m) {
71
+ // D15.6 (TOOLS.md §15):坐标裸 "@@" 头——hunk 完全靠操作行定位。
72
+ // 无行数可用:操作行以 " " / "-" / "+" 开头(空行宽容为上下文行),
73
+ // 直到下一个 hunk 头 / 文件头 "@@"/"--- "/"+++ "/"diff "/"index " 为止。
74
+ if (!/^@@(?: @@)?\s*$/.test(line)) {
75
+ throw new Error(`Malformed patch: bad hunk header "${line}" (need @@ -old,count +new,count @@ or bare @@)`)
76
+ }
77
+ const hunk = { ops: [] }
78
+ i++
79
+ while (i < lines.length) {
80
+ const hl = lines[i]
81
+ if (hl.startsWith("@") || isFileHeader(hl, lines[i + 1]) || hl.startsWith("+++ ") || hl.startsWith("diff ") || hl.startsWith("index ")) break
82
+ if (hl.startsWith("\\")) { i++; continue } // ""
83
+ // 宽容空行=上下文行——但 patch 文本末尾(或 hunk 之间/文件头之前)的 "" 是
84
+ // 分隔产物而非内容:仅当后继仍是操作行时才当作上下文消费。
85
+ // 复评 #1(2026-09-04):文件头 "--- "/"+++ " 以 -/+ 开头会被 op 前缀判定误收——
86
+ // 文件头前的分隔空行不得吞成幽灵上下文行(会把 - 锚序列尾部拼上 ""——跨文件
87
+ // 零上下文 hunk 因此误报 not-found)。
88
+ const next = lines[i + 1]
89
+ if (hl === "" && (next == null || !/^[ +\-\\]/.test(next) || isFileHeader(next, lines[i + 2]) || next.startsWith("+++ "))) break
90
+ const tag = hl === "" ? " " : hl[0]
91
+ if (tag !== " " && tag !== "-" && tag !== "+") break // metadata / file section end
92
+ hunk.ops.push({ type: tag, text: hl === "" ? "" : hl.slice(1) })
93
+ i++
94
+ }
95
+ if (hunk.ops.length === 0) throw new Error(`Malformed patch: empty coordinate-less hunk "${line.trim()}"`)
96
+ const ctxCount = hunk.ops.filter((o) => o.type === " ").length
97
+ // §15.3 (TOOLS.md D15.10.1——2026-09-04):context<2 且含 ≥1 个 - 行 → 接受——定位锚 = hunk 内
98
+ // 匹配行序列(空格上下文行 + - 行——按出现序)连续——唯一匹配即应用(applyHunks 既有锚匹配域——
99
+ // 多匹配 / not-found 语义不变)。0 上下文与 1 上下文同待遇(评审 #4a)。
100
+ // 纯 +(无 - 锚)且 context<2 仍拒——插入位置不可判——报错引导加锚(NF15.8c)。
101
+ const removedCount = hunk.ops.filter((o) => o.type === "-").length
102
+ if (ctxCount < 2 && removedCount === 0) {
103
+ throw new Error(
104
+ `Coordinate-less hunk ${cur.hunks.length + 1} in ${cur.path} has ${ctxCount} context line(s) — add more context lines`,
105
+ )
106
+ }
107
+ cur.hunks.push(hunk)
108
+ continue
109
+ }
44
110
  let oldNeed = m[1] == null ? 1 : Number(m[1])
45
111
  let newNeed = m[2] == null ? 1 : Number(m[2])
46
112
  const hunk = { ops: [] }
@@ -62,8 +128,10 @@ function parsePatch(patch) {
62
128
  }
63
129
  i++ // skip diff --git / index / blank lines and other metadata
64
130
  }
65
- if (files.length === 0) throw new Error("No file changes found in patch (need --- / +++ headers)")
66
- return files
131
+ // P15.10:容缺/完整空段头(头后无任何 hunk)过滤——不虚报 touchedPaths、不触发无谓 read+write
132
+ const withHunks = files.filter((f) => f.hunks.length > 0)
133
+ if (withHunks.length === 0) throw new Error("No file changes found in patch (need --- / +++ headers)")
134
+ return withHunks
67
135
  }
68
136
 
69
137
  /** Apply hunks sequentially onto an in-memory line array; any failure throws (caller guarantees nothing is written to disk). Ignores trailing \r when comparing; context lines retain original bytes */
@@ -84,7 +152,10 @@ function applyHunks(fileLines, hunks, eol, path) {
84
152
  const preview = oldSeq.slice(0, 3).join(" ⏎ ")
85
153
  throw new Error(`Hunk ${h + 1} in ${path} does not apply — context not found: "${preview}${oldSeq.length > 3 ? "…" : ""}". Read the file first and regenerate the patch from actual content.`)
86
154
  }
87
- if (matches.length > 1) throw new Error(`Hunk ${h + 1} in ${path} matches ${matches.length} locations — add more context lines to make it unique`)
155
+ if (matches.length > 1) {
156
+ const preview = oldSeq.slice(0, 3).join(" ⏎ ")
157
+ throw new Error(`Hunk ${h + 1} in ${path} matches ${matches.length} locations — add more context lines to make it unique. Anchor: "${preview}${oldSeq.length > 3 ? "…" : ""}"`)
158
+ }
88
159
  const pos = matches[0]
89
160
  const out = []
90
161
  let src = pos
@@ -103,7 +174,7 @@ export const applyPatchTool = {
103
174
  parameters: {
104
175
  type: "object",
105
176
  properties: {
106
- patch: { type: "string", description: "Unified diff. May span multiple files (multiple --- / +++ header pairs — including creating MULTIPLE new files via --- /dev/null); --- / +++ headers per file, @@ -old,count +new,count @@ hunks." },
177
+ patch: { type: "string", description: "Unified diff. May span multiple files (multiple --- / +++ header pairs — including creating MULTIPLE new files via --- /dev/null); --- / +++ headers per file, @@ -old,count +new,count @@ hunks (a bare @@ header is also accepted — coordinate-less hunks are located by their anchor lines: context lines plus the removed (-) lines, matched as a contiguous sequence — a unique match applies; a zero/one-context hunk is accepted only when it removes (-) at least one line and that anchor sequence is unique, while anchor-free pure-+ (insert) hunks need at least 2 context lines). The +++ b/<path> pair may be omitted for existing files — a lone --- a/<path> (or --- b/<path>) header followed directly by hunks applies to that path (new files still need --- /dev/null + +++ b/<path>)." },
107
178
  },
108
179
  required: ["patch"],
109
180
  },