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.
Files changed (129) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +25 -3
  4. package/package.json +3 -7
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +38 -3
  7. package/src/advisor/run.mjs +91 -53
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +156 -39
  10. package/src/agent/helpers.mjs +46 -4
  11. package/src/agent/setup.mjs +102 -19
  12. package/src/agent/spawn-child.mjs +28 -1
  13. package/src/agent-tools/advisor.mjs +43 -11
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +4 -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 +565 -128
  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 +182 -81
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +27 -1
  31. package/src/cli/memory-command.mjs +28 -7
  32. package/src/cli/permission.mjs +8 -1
  33. package/src/config.mjs +125 -8
  34. package/src/context.mjs +115 -34
  35. package/src/distill.mjs +19 -1
  36. package/src/escape.mjs +82 -27
  37. package/src/log.mjs +195 -0
  38. package/src/mcp/transport-http.mjs +13 -1
  39. package/src/mcp.mjs +52 -7
  40. package/src/memory/code-sync.mjs +1 -1
  41. package/src/memory/core.mjs +204 -10
  42. package/src/memory/docs.mjs +197 -62
  43. package/src/memory.mjs +1 -1
  44. package/src/model-specs.mjs +38 -1
  45. package/src/prompts/advisor-design.md +46 -0
  46. package/src/prompts/advisor-round1.md +49 -2
  47. package/src/prompts/advisor-round2.md +47 -0
  48. package/src/prompts/advisor-round3.md +47 -0
  49. package/src/prompts/coder.md +22 -0
  50. package/src/prompts/consult-base.md +13 -0
  51. package/src/prompts/discipline.md +25 -6
  52. package/src/prompts/eng-coder.md +2 -2
  53. package/src/prompts/engineering-sub.md +23 -1
  54. package/src/prompts/engineering.md +157 -50
  55. package/src/prompts/explore.md +1 -2
  56. package/src/prompts/main.md +11 -5
  57. package/src/prompts/methodology-template.md +14 -0
  58. package/src/prompts/system.md +5 -2
  59. package/src/provider/anthropic.mjs +7 -5
  60. package/src/provider/core.mjs +104 -28
  61. package/src/provider/google.mjs +57 -24
  62. package/src/provider/normalize.mjs +1 -1
  63. package/src/provider/rate.mjs +0 -2
  64. package/src/provider/responses.mjs +8 -13
  65. package/src/provider/sse.mjs +20 -0
  66. package/src/session.mjs +15 -0
  67. package/src/tools/apply_patch.md +5 -1
  68. package/src/tools/bash.md +3 -3
  69. package/src/tools/delete.md +1 -0
  70. package/src/tools/edit-batch.mjs +92 -0
  71. package/src/tools/edit-diff.mjs +265 -0
  72. package/src/tools/edit.md +11 -6
  73. package/src/tools/execute.md +8 -8
  74. package/src/tools/execute.mjs +31 -35
  75. package/src/tools/file.mjs +26 -114
  76. package/src/tools/file_ops.md +3 -2
  77. package/src/tools/get_current_time.md +3 -1
  78. package/src/tools/git.md +1 -1
  79. package/src/tools/git.mjs +8 -16
  80. package/src/tools/hashline_edit.md +2 -0
  81. package/src/tools/index.mjs +3 -2
  82. package/src/tools/insert_after.md +2 -1
  83. package/src/tools/lint.md +3 -1
  84. package/src/tools/linter.mjs +9 -37
  85. package/src/tools/lsp.md +4 -1
  86. package/src/tools/patch.mjs +84 -13
  87. package/src/tools/pdf-parse-text.mjs +497 -0
  88. package/src/tools/pdf-parse-xref.mjs +499 -0
  89. package/src/tools/pdf.mjs +155 -0
  90. package/src/tools/question.md +2 -1
  91. package/src/tools/read.md +1 -0
  92. package/src/tools/read_pdf.md +21 -0
  93. package/src/tools/repomap.mjs +1 -1
  94. package/src/tools/shared.mjs +11 -32
  95. package/src/tools/system.mjs +6 -21
  96. package/src/tools/tree.md +2 -1
  97. package/src/tools/web.mjs +5 -3
  98. package/src/tools/websearch.md +2 -1
  99. package/src/tools/write.md +2 -0
  100. package/src/traces/trace-store.mjs +224 -0
  101. package/src/tui/agent-turn.mjs +387 -24
  102. package/src/tui/clipboard.mjs +17 -6
  103. package/src/tui/cmd-config.mjs +29 -9
  104. package/src/tui/cmd-eng.mjs +1 -0
  105. package/src/tui/cmd-extract.mjs +1 -1
  106. package/src/tui/cmd-mcp-form.mjs +197 -0
  107. package/src/tui/cmd-mcp.mjs +264 -114
  108. package/src/tui/cmd-think.mjs +1 -1
  109. package/src/tui/index.mjs +49 -95
  110. package/src/tui/interaction.mjs +41 -3
  111. package/src/tui/key-handler.mjs +105 -143
  112. package/src/tui/key-modes.mjs +215 -0
  113. package/src/tui/layout.mjs +22 -1
  114. package/src/tui/mouse.mjs +41 -1
  115. package/src/tui/pickers.mjs +73 -7
  116. package/src/tui/render-conversation.mjs +13 -161
  117. package/src/tui/render-frame.mjs +45 -20
  118. package/src/tui/render-loop.mjs +4 -1
  119. package/src/tui/render-segments.mjs +165 -0
  120. package/src/tui/render.mjs +4 -4
  121. package/src/tui/startup.mjs +40 -2
  122. package/src/tui/subagent-blocks.mjs +404 -111
  123. package/src/tui/subagent-panel.mjs +88 -13
  124. package/src/tui/tool-args.mjs +10 -2
  125. package/src/tui/tool-events.mjs +172 -95
  126. package/src/tui/update-notice.mjs +72 -0
  127. package/src/tui/wizard.mjs +36 -6
  128. package/src/agent-tools/escalate.mjs +0 -179
  129. package/src/tools/exec-prelude.mjs +0 -84
@@ -10,16 +10,13 @@ import {
10
10
  detectFileEol,
11
11
  joinWithEol,
12
12
  majorityEol,
13
- findCandidates,
14
13
  FFFD_WARNING,
15
14
  } from "./shared.mjs";
15
+ import { applyEditBatch } from "./edit-batch.mjs";
16
+ import { runSingleEdit } from "./edit-diff.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
- async function appendWriteContext(abs, writeLine, baseResult) {
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)
@@ -75,16 +73,18 @@ export const readTool = {
75
73
  type: "object",
76
74
  properties: {
77
75
  path: { type: "string", description: "File path (relative to cwd or absolute)" },
76
+ filePath: { type: "string", description: "Alias for path (supported for API compatibility)" },
78
77
  offset: { type: "number", description: "1-based line number to start from" },
79
78
  limit: { type: "number", description: `Max lines to return (default ${MAX_READ_LINES})` },
80
- allowExternal: { type: "boolean", description: "Allow reading files outside the working directory. Only set true when the user explicitly provided an external path — never use this to explore beyond cwd on your own." },
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)." },
81
80
  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
81
  },
83
82
  required: ["path"],
84
83
  },
85
84
  readonly: true,
86
85
  async execute(args, ctx) {
87
- 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)
88
88
  // Large file guard: check size first, reject reading entire file if >10MB (offset/limit only affect the returned slice, not buffering)
89
89
  const st = await stat(abs).catch(() => null)
90
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.`)
@@ -216,13 +216,13 @@ export const editTool = {
216
216
  parameters: {
217
217
  type: "object",
218
218
  properties: {
219
- 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" },
220
220
  old_string: { type: "string", description: "Exact text to replace" },
221
221
  new_string: { type: "string", description: "Replacement text" },
222
222
  replace_all: { type: "boolean", description: "Replace all occurrences (default false)" },
223
223
  edits: {
224
224
  type: "array",
225
- description: "2026-08-31 工具顺手度:一次多文件原子替换——任一失败全不写(先全量检查所有替换可执行)。与 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.",
226
226
  items: {
227
227
  type: "object",
228
228
  properties: {
@@ -231,7 +231,7 @@ export const editTool = {
231
231
  new_string: { type: "string" },
232
232
  replace_all: { type: "boolean" },
233
233
  },
234
- required: ["path", "old_string", "new_string"],
234
+ required: ["old_string", "new_string"],
235
235
  },
236
236
  },
237
237
  },
@@ -239,111 +239,23 @@ export const editTool = {
239
239
  },
240
240
  readonly: false,
241
241
  touchedPaths(args) {
242
- 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
+ }
243
249
  return args.path ? [args.path] : []
244
250
  },
245
251
  async execute(args, ctx) {
246
252
  // 2026-08-31 工具顺手度(用户批准):数组形态——一次多文件原子替换
247
- if (args.edits) {
248
- if (!Array.isArray(args.edits) || args.edits.length === 0) {
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
- }
253
+ // (应用逻辑在 edit-batch.mjs——2026-09-01 拆出,500 行硬限,先例 git-ext.mjs)
254
+ if (args.edits) return applyEditBatch(args, ctx)
293
255
 
294
- // 单文件(现状路径)
295
- const abs = resolveInCwd(ctx, args.path)
296
- if (!args.old_string) {
297
- throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
298
- }
299
- const raw = await readFile(abs, "utf8")
300
- const content = normalizeEOL(raw)
301
- const occurrences = content.split(args.old_string).length - 1
302
- if (occurrences === 0) {
303
- // Give clues to help the model locate: first-line preview + common causes
304
- const preview = args.old_string.slice(0, 100).split("\n")[0]
305
- // Similarity candidates (LCS, line-level, top 3, score ≥ 0.5) — turns the
306
- // "not found" black box into a pointer at the most likely intended line.
307
- // Multi-line old_string: only its first line is scored (marked accordingly).
308
- const cands = findCandidates(content.split("\n"), args.old_string)
309
- let candText = ""
310
- if (cands.length > 0) {
311
- const header = args.old_string.includes("\n")
312
- ? ` similar lines (old_string line 1: "${args.old_string.split("\n")[0].slice(0, 80)}"):`
313
- : " similar lines:"
314
- candText = "\n" + header + "\n" + cands.map((c) => ` L${c.line}: ${c.preview} (${Math.round(c.score * 100)}%)`).join("\n")
315
- }
316
- throw new Error(
317
- `old_string not found in ${args.path}\n` +
318
- ` searched: "${preview}${args.old_string.length > 100 ? "…" : ""}"\n` +
319
- (lastWriteOf(abs)?.type === "write"
320
- ? ` hints: this file was modified since your last read (write 全文重写后内容全变) — re-read it to refresh your copy of the content, then retry\n`
321
- : isDirty(abs)
322
- ? ` 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`
323
- : ` hints: whitespace mismatch? file already changed? try reading the file first\n`) +
324
- candText
325
- )
326
- }
327
- if (occurrences > 1 && !args.replace_all) {
328
- throw new Error(`old_string matches ${occurrences} times in ${args.path}; provide more context or set replace_all`)
329
- }
330
- const updated = args.replace_all
331
- ? content.split(args.old_string).join(args.new_string)
332
- // Functional replacement: avoid $-substitution patterns in new_string (match string / backreference) being expanded
333
- : content.replace(args.old_string, () => args.new_string)
334
- // Write back in the file's ORIGINAL EOL style (first-newline rule) — a CRLF
335
- // file must not come back as LF (that rewrites every line in the diff).
336
- // normalizeEOL first: new_string may carry \r\n (e.g. pasted from a raw CRLF
337
- // read); without normalizing, split leaves stray \r and CRLF join makes \r\r\n.
338
- await writeFile(abs, joinWithEol(normalizeEOL(updated).split("\n"), raw), "utf8")
339
- // 2026-08-31 工具顺手度:记录受影响区(替换首行 + 行数差)——insert_after 精确判定
340
- const matchIdx = content.indexOf(args.old_string)
341
- const editStartLine = matchIdx >= 0 ? content.slice(0, matchIdx).split("\n").length : 1
342
- const lineShift = args.new_string.split("\n").length - args.old_string.split("\n").length
343
- recordWrite(abs, { type: "edit", startLine: editStartLine, shift: lineShift })
344
- const diff = gitDiffOne(ctx.cwd, abs)
345
- const baseResult = `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
346
- 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)
347
259
  },
348
260
  }
349
261
 
@@ -491,7 +403,7 @@ export const hashlineEditTool = {
491
403
  const hashDump = fileHashes.slice(0, maxShow).map((h, i) => `${h} L${i + 1}: ${lines[i].slice(0, 80)}`).join("\n")
492
404
  const preview = target.join(" ")
493
405
  throw new Error(
494
- `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` +
495
407
  `The file may have been modified since you last read it. Current hashes (first ${maxShow} lines):\n${hashDump}` +
496
408
  (corrupted ? `\n${FFFD_WARNING}` : "")
497
409
  )
@@ -11,6 +11,7 @@ Parameters:
11
11
  - dest (required): destination path
12
12
 
13
13
  Notes:
14
- - Paths are confined to the working directory (same safety as write/edit) bash has NO directory confinement.
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).
package/src/tools/git.md CHANGED
@@ -36,7 +36,7 @@ Run a git command. Only works inside a git repository.
36
36
 
37
37
  Parameters:
38
38
  - action (required): diff / status / log / show / checkpoint / add / rm / commit / push / tag / branch / checkout / restore / stash / fetch / pull / reset / revert / merge / cherry-pick / ls-remote / clone / init / rebase / remote / clean / switch / apply / worktree / archive / blame / mv
39
- - workdir: run git in this workspace subdirectory (monorepo / multi-repo). Confined to the workspace. Default: cwd
39
+ - workdir: run git in this subdirectory (monorepo / multi-repo). Path relative to cwd — no directory restriction. Default: cwd
40
40
  - config: (network actions push/fetch/pull/ls-remote/clone) git -c overrides, e.g. ["http.proxy=http://10.2.2.112:3128"] for blocked remotes
41
41
  - path: (diff/log/add/commit/checkout/restore/rm/apply/archive/blame/mv/worktree) file or directory to scope / stage / restore
42
42
  - ref: (show/diff/checkout/reset/revert/merge/cherry-pick/tag:create/branch:create/rebase/worktree:add/archive) commit/branch/ref; (push/pull/fetch) the branch or tag (space-separated for multiple)
package/src/tools/git.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  runGit
5
5
  } from "./shared.mjs";
6
6
  import { execFileSync } from "node:child_process";
7
- import { resolve, relative, isAbsolute, sep } from "node:path";
7
+ import { resolve } from "node:path";
8
8
  import { filterLines, runGitStrict, validateRef, gitConfigArgs, snapshotBefore, executeExtAction } from "./git-ext.mjs";
9
9
  import { executeCheckpointAction } from "./git-checkpoint.mjs";
10
10
 
@@ -23,20 +23,12 @@ function runGitRaw(cwd, cmdArgs, config = []) {
23
23
 
24
24
 
25
25
 
26
- /** True when `abs` is inside `root` (handles `..` and cross-drive, which relative()
27
- * returns as an absolute path on Windows). */
28
- function isInside(root, abs) {
29
- const rel = relative(root, abs)
30
- if (isAbsolute(rel)) return false
31
- return rel !== ".." && !rel.startsWith(".." + sep)
32
- }
33
-
34
- /** Resolve workdir relative to cwd, asserting it stays within the workspace. */
26
+ /** Resolve workdir relative to cwd no boundary assertion
27
+ * (§10.1 2026-09-02: workspace confinement removed; git itself is not
28
+ * directory-limited — same boundary as bash). */
35
29
  function resolveBaseDir(cwd, workdir) {
36
30
  if (!workdir || typeof workdir !== "string") return cwd
37
- const abs = resolve(cwd, workdir)
38
- if (!isInside(cwd, abs)) throw new Error(`workdir escapes the workspace: ${workdir}`)
39
- return abs
31
+ return resolve(cwd, workdir)
40
32
  }
41
33
 
42
34
 
@@ -59,7 +51,7 @@ export const gitTool = {
59
51
  // write-op params
60
52
  name: { type: "string", description: "(branch/tag) The branch or tag name (create/delete/switch)" },
61
53
  remote: { type: "string", description: "(push/fetch/pull) Remote name (e.g. origin). Default: current upstream" },
62
- workdir: { type: "string", description: "Run git in this workspace subdirectory (monorepo / multi-repo). Confined to the workspace. Default: cwd" },
54
+ workdir: { type: "string", description: "Run git in this subdirectory (monorepo / multi-repo). Path relative to cwd — no directory restriction. Default: cwd" },
63
55
  config: { type: "array", items: { type: "string" }, description: "(network actions: push/fetch/pull/ls-remote) git -c overrides, e.g. [\"http.proxy=http://10.2.2.112:3128\"] for blocked remotes" },
64
56
  tags: { type: "boolean", description: "(push) Also push all tags (--tags)" },
65
57
  mode: { type: "string", enum: ["soft", "mixed", "hard"], description: "(reset) reset mode — hard snapshots the tree first + needs confirmation(操作前自动快照,checkpointAction=rewind 恢复)" },
@@ -82,8 +74,8 @@ export const gitTool = {
82
74
  },
83
75
  readonly: false,
84
76
  async execute(args, ctx) {
85
- // workdir: run git in a workspace subdirectory (monorepo / multi-repo). Shadow ctx.cwd so
86
- // every action + snapshotBefore + checkpoint resolves against the workdir, confined to the workspace.
77
+ // workdir: run git in a subdirectory (monorepo / multi-repo). Shadow ctx.cwd so
78
+ // every action + snapshotBefore + checkpoint resolves against the workdir.
87
79
  if (args.workdir) ctx = { ...ctx, cwd: resolveBaseDir(ctx.cwd, args.workdir) }
88
80
  // git -c overrides (proxy etc.) — only network actions need them; passing to every
89
81
  // action would be harmless but noisy. cfgArgs stays [] for local ops.
@@ -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
@@ -1,8 +1,10 @@
1
1
  Run the appropriate linter/checker for a file. Auto-detects based on file extension and project config.
2
2
  Without 'full', runs a fast node --check (JS/TS syntax only, catches parse errors in milliseconds).
3
- With 'full', runs the language-aware cascade: eslint → tsc –noEmit → node --check (JS/TS/TSX); ruff (Python); cargo check (Rust); go vet (Go).
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)
@@ -1,7 +1,7 @@
1
1
  import { DESC, resolveInCwd } from "./shared.mjs"
2
2
  import { execFileSync } from "node:child_process"
3
3
  import { existsSync } from "node:fs"
4
- import { join, relative } from "node:path"
4
+ import { join } from "node:path"
5
5
 
6
6
  export const lintTool = {
7
7
  name: "lint",
@@ -26,7 +26,8 @@ export const lintTool = {
26
26
  return nodeCheckResult(abs)
27
27
  }
28
28
 
29
- // Full cascade: language-aware (eslint → tsc → node --check, etc.)
29
+ // Full cascade: language-aware (tsc → node --check, ruff, cargo, go vet —
30
+ // third-party linter cascade removed 2026-09-02, TOOLS.md §10.2: zero-dependency lint)
30
31
  const ext = abs.split(".").pop()?.toLowerCase()
31
32
  const checkers = LANG_CHECKERS[ext]
32
33
  if (!checkers) return nodeCheckResult(abs) // fall back to node --check
@@ -54,33 +55,7 @@ function nodeCheckResult(abs) {
54
55
  }
55
56
  }
56
57
 
57
- // ─── Full-check cascade checkers ──────────────────────
58
-
59
- async function eslintCheck(file, { cwd, existsSync, execFileSync, join, relative }) {
60
- let dir = file.split(/[\\/]/).slice(0, -1).join("/") || "."
61
- while (true) {
62
- for (const cfg of [".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yaml", ".eslintrc.yml", "eslint.config.js", "eslint.config.mjs"]) {
63
- if (existsSync(join(cwd, dir, cfg))) {
64
- try {
65
- const cfgDir = join(cwd, dir)
66
- const relPath = relative(cfgDir, file)
67
- execFileSync("npx", ["eslint", "--no-color", "--format", "compact", relPath], {
68
- cwd: cfgDir, encoding: "utf8", timeout: 30000, stdio: ["ignore", "pipe", "pipe"],
69
- })
70
- return "✓ eslint: no issues"
71
- } catch (e) {
72
- const stdout = (e.stdout || "").trim()
73
- if (stdout) return stdout
74
- return `✗ eslint: ${(e.stderr || e.message).slice(0, 500)}`
75
- }
76
- }
77
- }
78
- const parent = dir.split("/").slice(0, -1).join("/")
79
- if (!parent || parent === dir) break
80
- dir = parent
81
- }
82
- return null
83
- }
58
+ // ─── Full-check cascade checkers (third-party linter branch removed 2026-09-02, TOOLS.md §10.2) ──────
84
59
 
85
60
  async function tscCheck(file, { cwd, existsSync, execFileSync, join }) {
86
61
  if (!existsSync(join(cwd, "tsconfig.json"))) return null
@@ -142,14 +117,11 @@ async function goVet(file, { cwd, execFileSync }) {
142
117
  }
143
118
 
144
119
  const LANG_CHECKERS = {
145
- js: [eslintCheck],
146
- mjs: [eslintCheck],
147
- cjs: [eslintCheck],
148
- jsx: [eslintCheck],
149
- ts: [eslintCheck, tscCheck],
150
- tsx: [eslintCheck, tscCheck],
151
- mts: [eslintCheck, tscCheck],
152
- cts: [eslintCheck, tscCheck],
120
+ // js/mjs/cjs/jsx fall back to node --check (no entry — third-party linter cascade removed 2026-09-02, TOOLS.md §10.2)
121
+ ts: [tscCheck],
122
+ tsx: [tscCheck],
123
+ mts: [tscCheck],
124
+ cts: [tscCheck],
153
125
  py: [ruffCheck],
154
126
  rs: [cargoCheck],
155
127
  go: [goVet],
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; --- / +++ headers per file, @@ -old,count +new,count @@ hunks. Use --- /dev/null to create a file." },
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
  },