thincoder 0.12.62 → 0.12.63

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 (228) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +13 -12
  3. package/bin/thincoder.mjs +53 -27
  4. package/package.json +6 -5
  5. package/src/acp/bridge.mjs +35 -15
  6. package/src/acp/client-caps.mjs +86 -0
  7. package/src/acp/ext.mjs +86 -0
  8. package/src/acp/handlers-session.mjs +240 -0
  9. package/src/acp/handlers-slots.mjs +196 -0
  10. package/src/acp/login.mjs +48 -0
  11. package/src/acp/session.mjs +6 -4
  12. package/src/acp.mjs +67 -379
  13. package/src/cli/distill-command.mjs +3 -3
  14. package/src/cli/make-agent.mjs +59 -17
  15. package/src/cli/memory-command.mjs +3 -3
  16. package/src/cli/permission.mjs +4 -48
  17. package/src/cli/setup-wizard.mjs +1 -1
  18. package/src/completions.mjs +3 -1
  19. package/src/crash-reports.mjs +1 -1
  20. package/src/distill.mjs +4 -4
  21. package/src/heap-watch.mjs +1 -1
  22. package/src/prompt-injections.mjs +20 -0
  23. package/src/tui/agent-turn.mjs +40 -9
  24. package/src/tui/cmd-advisor.mjs +5 -5
  25. package/src/tui/cmd-config.mjs +8 -8
  26. package/src/tui/cmd-eng.mjs +25 -9
  27. package/src/tui/cmd-mcp.mjs +9 -8
  28. package/src/tui/cmd-model.mjs +1 -1
  29. package/src/tui/cmd-new.mjs +6 -5
  30. package/src/tui/cmd-reindex.mjs +1 -1
  31. package/src/tui/cmd-restore.mjs +2 -2
  32. package/src/tui/cmd-session.mjs +24 -1
  33. package/src/tui/cmd-skills.mjs +1 -1
  34. package/src/tui/cmd-think.mjs +22 -9
  35. package/src/tui/config-helpers.mjs +1 -1
  36. package/src/tui/display-budget.mjs +33 -11
  37. package/src/tui/index.mjs +9 -9
  38. package/src/tui/interaction.mjs +16 -7
  39. package/src/tui/key-modes.mjs +9 -4
  40. package/src/tui/ledger-surface.mjs +26 -10
  41. package/src/tui/model-catalog.mjs +4 -4
  42. package/src/tui/model-picker.mjs +8 -7
  43. package/src/tui/mouse.mjs +11 -6
  44. package/src/tui/pickers.mjs +15 -2
  45. package/src/tui/render-conversation.mjs +1 -1
  46. package/src/tui/render-frame.mjs +10 -5
  47. package/src/tui/render-loop.mjs +1 -1
  48. package/src/tui/render-segments.mjs +3 -1
  49. package/src/tui/slash-commands.mjs +1 -1
  50. package/src/tui/startup.mjs +14 -14
  51. package/src/tui/subagent-blocks.mjs +20 -3
  52. package/src/tui/subagent-freeze.mjs +73 -2
  53. package/src/tui/suspension-drive.mjs +46 -22
  54. package/src/tui/tool-events.mjs +11 -8
  55. package/src/tui/wizard.mjs +3 -3
  56. package/src/abort-provenance.mjs +0 -116
  57. package/src/advisor/citations.mjs +0 -139
  58. package/src/advisor/compaction.mjs +0 -174
  59. package/src/advisor/convergence.mjs +0 -80
  60. package/src/advisor/history.mjs +0 -77
  61. package/src/advisor/loop.mjs +0 -293
  62. package/src/advisor/messages.mjs +0 -299
  63. package/src/advisor/project-context.mjs +0 -194
  64. package/src/advisor/repos.mjs +0 -150
  65. package/src/advisor/run.mjs +0 -293
  66. package/src/advisor/truncate.mjs +0 -57
  67. package/src/advisor.mjs +0 -290
  68. package/src/agent/completion.mjs +0 -146
  69. package/src/agent/dispatch.mjs +0 -489
  70. package/src/agent/helpers.mjs +0 -384
  71. package/src/agent/post-turn.mjs +0 -70
  72. package/src/agent/record-results.mjs +0 -174
  73. package/src/agent/relay-prefix.mjs +0 -39
  74. package/src/agent/run-stages.mjs +0 -244
  75. package/src/agent/setup-reminders.mjs +0 -69
  76. package/src/agent/setup.mjs +0 -354
  77. package/src/agent/spawn-child.mjs +0 -243
  78. package/src/agent-tools/advisor-async.mjs +0 -346
  79. package/src/agent-tools/advisor-settle.mjs +0 -231
  80. package/src/agent-tools/advisor.mjs +0 -260
  81. package/src/agent-tools/async-settle.mjs +0 -204
  82. package/src/agent-tools/batch-segment.mjs +0 -195
  83. package/src/agent-tools/consult.mjs +0 -473
  84. package/src/agent-tools/design-token.mjs +0 -117
  85. package/src/agent-tools/digest-budget.mjs +0 -76
  86. package/src/agent-tools/eng.mjs +0 -67
  87. package/src/agent-tools/escalate-async.mjs +0 -295
  88. package/src/agent-tools/goal.mjs +0 -119
  89. package/src/agent-tools/plan.mjs +0 -81
  90. package/src/agent-tools/read-history.mjs +0 -309
  91. package/src/agent-tools/recent-changes.mjs +0 -24
  92. package/src/agent-tools/review-streak.mjs +0 -93
  93. package/src/agent-tools/settings.mjs +0 -265
  94. package/src/agent-tools/skill.mjs +0 -47
  95. package/src/agent-tools/subagent-actions.mjs +0 -482
  96. package/src/agent-tools/subagent-async.mjs +0 -434
  97. package/src/agent-tools/subagent-panel.mjs +0 -160
  98. package/src/agent-tools/subagent-run.mjs +0 -205
  99. package/src/agent-tools/subagent-scheduler.mjs +0 -392
  100. package/src/agent-tools/subagent-spawn.mjs +0 -459
  101. package/src/agent-tools/subagent.mjs +0 -404
  102. package/src/agent-tools/task.mjs +0 -87
  103. package/src/agent-tools/timer.mjs +0 -46
  104. package/src/agent-tools/verify.mjs +0 -271
  105. package/src/agent-tools.mjs +0 -17
  106. package/src/agent.mjs +0 -417
  107. package/src/auto-think.mjs +0 -115
  108. package/src/config-migrate.mjs +0 -70
  109. package/src/config.mjs +0 -496
  110. package/src/context.mjs +0 -392
  111. package/src/conventions.mjs +0 -223
  112. package/src/embedding.mjs +0 -120
  113. package/src/escape.mjs +0 -152
  114. package/src/expand-home.mjs +0 -16
  115. package/src/explore-distill.mjs +0 -155
  116. package/src/generate-title.mjs +0 -88
  117. package/src/git/checkpoint.mjs +0 -448
  118. package/src/git/gitmem.mjs +0 -100
  119. package/src/hooks.mjs +0 -97
  120. package/src/ledger.mjs +0 -227
  121. package/src/log.mjs +0 -195
  122. package/src/markdown.mjs +0 -106
  123. package/src/mcp/helpers.mjs +0 -51
  124. package/src/mcp/transport-http.mjs +0 -248
  125. package/src/mcp/transport-stdio.mjs +0 -140
  126. package/src/mcp/transport-ws.mjs +0 -122
  127. package/src/mcp.mjs +0 -295
  128. package/src/memory/code-index.mjs +0 -219
  129. package/src/memory/code-sync.mjs +0 -415
  130. package/src/memory/core.mjs +0 -299
  131. package/src/memory/delete.mjs +0 -236
  132. package/src/memory/docs.mjs +0 -419
  133. package/src/memory/file-walk.mjs +0 -109
  134. package/src/memory/scan.mjs +0 -95
  135. package/src/memory/schema.mjs +0 -452
  136. package/src/memory.mjs +0 -21
  137. package/src/model-ref.mjs +0 -66
  138. package/src/model-specs.mjs +0 -179
  139. package/src/peer-domains.mjs +0 -265
  140. package/src/peer-instances.mjs +0 -231
  141. package/src/prompt-overlays.mjs +0 -82
  142. package/src/prompts/advisor-design.md +0 -41
  143. package/src/prompts/advisor-round1.md +0 -41
  144. package/src/prompts/advisor-round2.md +0 -46
  145. package/src/prompts/advisor-round3.md +0 -42
  146. package/src/prompts/common.md +0 -115
  147. package/src/prompts/consult-base.md +0 -19
  148. package/src/prompts/discipline-engineering.md +0 -258
  149. package/src/prompts/discipline-normal.md +0 -185
  150. package/src/prompts/persona-coder.md +0 -21
  151. package/src/prompts/persona-eng-coder.md +0 -37
  152. package/src/prompts/persona-eng-designer.md +0 -60
  153. package/src/prompts/persona-engineering.md +0 -55
  154. package/src/prompts/persona-explore.md +0 -15
  155. package/src/prompts/persona-normal.md +0 -27
  156. package/src/prompts/persona-plan.md +0 -26
  157. package/src/provider/anthropic.mjs +0 -225
  158. package/src/provider/core.mjs +0 -476
  159. package/src/provider/errors.mjs +0 -101
  160. package/src/provider/google.mjs +0 -257
  161. package/src/provider/index.mjs +0 -7
  162. package/src/provider/list-models.mjs +0 -93
  163. package/src/provider/normalize.mjs +0 -81
  164. package/src/provider/rate.mjs +0 -108
  165. package/src/provider/responses.mjs +0 -495
  166. package/src/provider/retry.mjs +0 -88
  167. package/src/provider/sse.mjs +0 -264
  168. package/src/proxy.mjs +0 -261
  169. package/src/rules.mjs +0 -53
  170. package/src/session-gc.mjs +0 -221
  171. package/src/session-guard.mjs +0 -59
  172. package/src/session-migrate.mjs +0 -48
  173. package/src/session-rename.mjs +0 -38
  174. package/src/session-segments.mjs +0 -100
  175. package/src/session-slots.mjs +0 -492
  176. package/src/session-store.mjs +0 -441
  177. package/src/session.mjs +0 -492
  178. package/src/skills.mjs +0 -153
  179. package/src/text-budget.mjs +0 -46
  180. package/src/token-ttl.mjs +0 -274
  181. package/src/tools/apply_patch.md +0 -15
  182. package/src/tools/bash.md +0 -37
  183. package/src/tools/bash.mjs +0 -268
  184. package/src/tools/checklist-sync.mjs +0 -181
  185. package/src/tools/checklist.md +0 -13
  186. package/src/tools/checklist.mjs +0 -299
  187. package/src/tools/delete.md +0 -13
  188. package/src/tools/edit-batch.mjs +0 -191
  189. package/src/tools/edit-diff.mjs +0 -348
  190. package/src/tools/edit.md +0 -30
  191. package/src/tools/execute.md +0 -21
  192. package/src/tools/execute.mjs +0 -228
  193. package/src/tools/fetch.md +0 -12
  194. package/src/tools/file.mjs +0 -469
  195. package/src/tools/file_ops.md +0 -17
  196. package/src/tools/get_current_time.md +0 -8
  197. package/src/tools/git-checkpoint.mjs +0 -143
  198. package/src/tools/git-ext.mjs +0 -173
  199. package/src/tools/git.md +0 -54
  200. package/src/tools/git.mjs +0 -356
  201. package/src/tools/glob-dialect.mjs +0 -130
  202. package/src/tools/glob.md +0 -11
  203. package/src/tools/grep.md +0 -19
  204. package/src/tools/hashline_edit.md +0 -14
  205. package/src/tools/index.mjs +0 -36
  206. package/src/tools/insert_after.md +0 -15
  207. package/src/tools/lint.md +0 -10
  208. package/src/tools/linter.mjs +0 -128
  209. package/src/tools/ls.md +0 -12
  210. package/src/tools/lsp.md +0 -10
  211. package/src/tools/lsp.mjs +0 -316
  212. package/src/tools/ops.mjs +0 -299
  213. package/src/tools/patch.mjs +0 -282
  214. package/src/tools/process.md +0 -10
  215. package/src/tools/question.md +0 -16
  216. package/src/tools/question.mjs +0 -26
  217. package/src/tools/read.md +0 -20
  218. package/src/tools/read_image.md +0 -8
  219. package/src/tools/repomap.mjs +0 -314
  220. package/src/tools/search.mjs +0 -236
  221. package/src/tools/shared.mjs +0 -446
  222. package/src/tools/tree.md +0 -14
  223. package/src/tools/tree.mjs +0 -66
  224. package/src/tools/wait_for.md +0 -22
  225. package/src/tools/web.mjs +0 -224
  226. package/src/tools/websearch.md +0 -16
  227. package/src/tools/write.md +0 -11
  228. package/src/traces/trace-store.mjs +0 -355
@@ -1,469 +0,0 @@
1
- import {
2
- DESC,
3
- truncate,
4
- MAX_READ_LINES,
5
- gitDiffOne,
6
- autoSyntaxCheck,
7
- resolveInCwd,
8
- resolveExternal,
9
- normalizeEOL,
10
- detectFileEol,
11
- joinWithEol,
12
- majorityEol,
13
- FFFD_WARNING,
14
- } from "./shared.mjs";
15
- import { applyEditBatch } from "./edit-batch.mjs";
16
- import { runSingleEdit } from "./edit-diff.mjs";
17
- import { specForModel } from "../config.mjs";
18
- import { createHash } from "node:crypto";
19
- import { mkdir, readFile, stat, writeFile, unlink } from "node:fs/promises";
20
- import { join, relative, dirname } from "node:path";
21
-
22
- const MAX_FILE_READ_BYTES = 10_000_000
23
- const MAX_IMAGE_BYTES = 15_000_000
24
- // DUAL-END-TRUNCATION (F-1, C 方案——2026-09-09):read 双端尾行数——窗口截断大文件时
25
- // 返回 头 N 行(N = 请求窗口,默认 MAX_READ_LINES)+ 省略注 + 尾 M 行(本常量)。M 与
26
- // 窗口合计(2500 行)为"整读上限"——K=0(尾区与窗口相接)时整印剩余、不产生假省略注。
27
- export const READ_TAIL_LINES = 500
28
-
29
- // ────────────────────────────────────────
30
- // Dirty-file tracking (read-before-insert guard)
31
- // ────────────────────────────────────────
32
- // insert_after anchors on LINE NUMBERS — the most drift-prone addressing.
33
- // Every write tool marks the file dirty; insert_after refuses to run on a
34
- // dirty file until the agent reads it again (fresh line numbers). This turns
35
- // the "read after edit" discipline into a structural guarantee: a stale
36
- // after_line can never silently land in the wrong place again.
37
- const dirtyPaths = new Set()
38
- export function markDirty(abs) { dirtyPaths.add(abs) }
39
- export function clearDirty(abs) { dirtyPaths.delete(abs) }
40
- export function isDirty(abs) { return dirtyPaths.has(abs) }
41
-
42
- // 2026-08-31 工具顺手度优化(用户批准):写入工具记录受影响行范围——insert_after
43
- // 精确判定:after_line 在未受影响区(< lastWrite.startLine)→ 行号未漂移 → 允许
44
- // (消掉"我写的文件被当外部修改、必须重 read"的摩擦);受影响区内 → 拒绝(护栏保留);
45
- // write 全文重写 → 全文件受影响,任何 after_line 拒绝。
46
- const lastWrites = new Map() // abs → { type: 'write'|'edit'|'insert', startLine, shift }
47
- export function recordWrite(abs, write) {
48
- lastWrites.set(abs, write)
49
- dirtyPaths.delete(abs) // 本 session 写入——等效于刚 read 过(快照在 lastWrites)
50
- }
51
- export function lastWriteOf(abs) { return lastWrites.get(abs) }
52
- export function clearLastWrite(abs) { lastWrites.delete(abs) }
53
-
54
- /** 2026-08-31 工具顺手度(用户批准"可以啊"):写入工具返回带上下文窗口——
55
- * 模型拿到的不只是"inserted at L395",而是"L395 这行是什么内容"——下次再操作时
56
- * 能自检"我的行号 vs 实际内容"是否匹配,匹配不上 = 行号漂了,先 read——
57
- * 死循环就断了(根因:模型对行号锚点的"新鲜度"没有感知——数字本身不携带语义)。
58
- * write 全文重写跳过(无行号锚点——模型刚写的知道内容)。
59
- * edit-batch.mjs(数组形态)复用——导出仅供内部模块,非公共 API。 */
60
- export async function appendWriteContext(abs, writeLine, baseResult) {
61
- const content = normalizeEOL(await readFile(abs, "utf8"))
62
- const lines = content.split("\n")
63
- const start = Math.max(1, writeLine - 3)
64
- const end = Math.min(lines.length, writeLine + 3)
65
- const ctxLines = []
66
- for (let i = start; i <= end; i++) {
67
- const marker = i === writeLine ? "→" : " "
68
- ctxLines.push(`${marker} L${i}\t${lines[i - 1]}`)
69
- }
70
- return `${baseResult}\ncontext (L${start}-L${end}):\n${ctxLines.join("\n")}`
71
- }
72
-
73
- export const readTool = {
74
- name: "read",
75
- description: DESC("read"),
76
- parameters: {
77
- type: "object",
78
- properties: {
79
- path: { type: "string", description: "File path (relative to cwd or absolute)" },
80
- filePath: { type: "string", description: "Alias for path (supported for API compatibility)" },
81
- offset: { type: "number", description: "1-based line number to start from" },
82
- limit: { type: "number", description: `Max lines to return (default ${MAX_READ_LINES})` },
83
- 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)." },
84
- 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." },
85
- },
86
- required: ["path"],
87
- },
88
- readonly: true,
89
- async execute(args, ctx) {
90
- const p = args.path ?? args.filePath
91
- const abs = args.allowExternal ? resolveExternal(ctx, p) : resolveInCwd(ctx, p)
92
- // Large file guard: check size first, reject reading entire file if >10MB (offset/limit only affect the returned slice, not buffering)
93
- const st = await stat(abs).catch(() => null)
94
- 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.`)
95
- const content = normalizeEOL(await readFile(abs, "utf8"))
96
- // A read refreshes the agent's view — line numbers are fresh again.
97
- clearDirty(abs)
98
- clearLastWrite(abs) // 2026-08-31:read 同时清写入快照(新视图以 read 为准)
99
- const lines = content.split("\n")
100
- const offset = Math.max(1, args.offset ?? 1)
101
- const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
102
- const windowEnd = offset - 1 + limit // index just past the requested window
103
- const render = (slice, startLn) => slice.map((l, i) => {
104
- const ln = startLn + i
105
- if (args.hashes) return `${ln}\t[${hashLine(l)}] ${l}`
106
- return `${ln}\t${l}`
107
- }).join("\n")
108
- // DUAL-END-TRUNCATION (F-1, C 方案——DUAL-END-TRUNCATION.md 2026-09-09):
109
- // 大文件(> MAX_READ_LINES 行)窗口截断时返回 头(请求窗口)+ 中段省略注 +
110
- // 文件真实尾部(末 READ_TAIL_LINES 行——现尾注升级为真实尾行内容——offload 产物
111
- // 的尾端结论可见)。≤ 阈值文件任何窗口走旧头向路径(字节零变化);窗口覆盖全文件
112
- // 也走旧路径。与 hashes 模式照常交互(头尾行 hash 照算)。
113
- if (windowEnd >= lines.length || lines.length <= MAX_READ_LINES) {
114
- const suffix = windowEnd < lines.length ? `\n... (${lines.length} lines total, use offset to continue)` : ""
115
- return truncate(render(lines.slice(offset - 1, windowEnd), offset) + suffix)
116
- }
117
- const head = render(lines.slice(offset - 1, windowEnd), offset)
118
- // 尾区 = 文件末 READ_TAIL_LINES 行;尾区起点落在窗口内(重叠)→ 尾区从窗口后开始
119
- // —— 任何行不打印两次;剩余全被覆盖(K=0)→ 整印余段且无假省略注。
120
- const tailStart = Math.max(windowEnd, lines.length - READ_TAIL_LINES)
121
- const middle = tailStart - windowEnd
122
- const tail = render(lines.slice(tailStart), tailStart + 1)
123
- return middle > 0
124
- ? `${head}\n…(truncated: ${middle} lines in middle, use offset to continue)\n${tail}`
125
- : `${head}\n${tail}`
126
- },
127
- }
128
-
129
- // ---------------------------------------------------------------- read_image
130
-
131
- // Raster formats only — every mainstream vision API (Kimi, Anthropic, OpenAI, Gemini)
132
- // rejects svg/bmp. svg is served as text source below; bmp is refused with a hint.
133
- const IMAGE_EXTENSIONS = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" }
134
- const MAX_SVG_CHARS = 100_000
135
-
136
- export const readImageTool = {
137
- name: "read_image",
138
- description: DESC("read_image"),
139
- parameters: {
140
- type: "object",
141
- properties: {
142
- path: { type: "string", description: "Path to image file (relative to cwd or absolute). Supports png, jpg, gif, webp. svg files are returned as text source (no vision API accepts svg)." },
143
- },
144
- required: ["path"],
145
- },
146
- readonly: true,
147
- multimodal: true, // returns JSON { text, images } — agent loop converts to multimodal user message
148
- /** Returns JSON: { text, images }, for the agent layer to convert into multimodal user messages */
149
- async execute(args, ctx) {
150
- const abs = resolveInCwd(ctx, args.path)
151
- const ext = abs.slice(abs.lastIndexOf(".") + 1).toLowerCase()
152
-
153
- // SVG is text markup — return the source directly. Works with text-only models too
154
- // (no vision gate), and never poisons history with an image part the API will 400 on.
155
- if (ext === "svg") {
156
- const st = await stat(abs).catch(() => null)
157
- if (st && st.size > MAX_IMAGE_BYTES) throw new Error(`Image too large: ${Math.round(st.size / 1_000_000)}MB (max 15MB)`)
158
- const src = normalizeEOL(await readFile(abs, "utf8"))
159
- return `[read_image: ${args.path} (svg source, ${src.length} chars — no vision API accepts image/svg+xml, showing markup instead)]\n` +
160
- truncate(src, MAX_SVG_CHARS)
161
- }
162
-
163
- // Vision capability gate: injecting an image into a text-only model's history poisons the whole
164
- // conversation (every subsequent request 400s on the image part). Refuse before reading the file.
165
- // F-3(IMAGE-DOWNGRADE-VISION——2026-09-09):软引导句逐字锚(设计档照抄——CLI 无 extension
166
- // 式自动降级——模型收到错误后自选 spawn 视觉渠道子代理路径)。
167
- const model = ctx.agent?.provider?.model
168
- if (model && !specForModel(model).multimodal) {
169
- throw new Error(
170
- `Model "${model}" does not support image input — read_image is unavailable with this provider. ` +
171
- `Verify visual output programmatically (file size, dimensions, pixel checks via code) or ask the user to switch to a vision-capable provider.\n` +
172
- `模型不支持图像——可 spawn 一个视觉模型子代理(subagent model 参数指视觉渠道)用 read_image 读图`
173
- )
174
- }
175
- const mime = IMAGE_EXTENSIONS[ext]
176
- if (!mime) {
177
- const hint = ext === "bmp" ? " Convert it to PNG first (no mainstream vision API accepts BMP)." : ""
178
- throw new Error(`Unsupported image format: .${ext}. Supported: ${Object.keys(IMAGE_EXTENSIONS).join(", ")}, svg (as text source).${hint}`)
179
- }
180
- // Check size before reading — prevent huge images from blowing up memory (20MB base64 ≈ 15MB raw)
181
- const imgStat = await stat(abs).catch(() => null)
182
- if (imgStat && imgStat.size > MAX_IMAGE_BYTES) throw new Error(`Image too large: ${Math.round(imgStat.size / 1_000_000)}MB (max 15MB)`)
183
- const buf = await readFile(abs) // raw buffer, no encoding
184
- const b64 = buf.toString("base64")
185
- const bytes = buf.length
186
- const result = JSON.stringify({
187
- text: `[read_image: ${args.path} (${mime}, ${bytes} bytes)]`,
188
- images: [{ type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } }],
189
- })
190
- // Paste-created temp files: delete after use, no litter
191
- const basename = abs.includes("/") ? abs.slice(abs.lastIndexOf("/") + 1) : abs.slice(abs.lastIndexOf("\\") + 1)
192
- if (basename.startsWith(".thincoder-paste-")) {
193
- try { await unlink(abs) } catch { /* can't delete, so be it */ }
194
- }
195
- return result
196
- },
197
- }
198
-
199
- // ---------------------------------------------------------------- write
200
-
201
- export const writeTool = {
202
- name: "write",
203
- description: DESC("write"),
204
- parameters: {
205
- type: "object",
206
- properties: {
207
- path: { type: "string", description: "File path (relative to cwd or absolute)" },
208
- content: { type: "string", description: "Full content to write" },
209
- },
210
- required: ["path", "content"],
211
- },
212
- readonly: false,
213
- touchedPaths(args) { return args.path ? [args.path] : [] },
214
- async execute(args, ctx) {
215
- const abs = resolveInCwd(ctx, args.path)
216
- await mkdir(dirname(abs), { recursive: true })
217
- const st = await stat(abs).catch(() => null)
218
- if (st?.isDirectory()) throw new Error(`Path is a directory: ${args.path}`)
219
- // EOL semantics: overwriting an existing file restores ITS original EOL style (F1);
220
- // a new file follows the directory's majority style, defaulting to LF (F2).
221
- const prev = st ? await readFile(abs, "utf8").catch(() => null) : null
222
- const eol = prev != null ? detectFileEol(prev) : majorityEol(dirname(abs))
223
- const content = eol === "\r\n" ? normalizeEOL(args.content).replace(/\n/g, "\r\n") : args.content
224
- await writeFile(abs, content, "utf8")
225
- recordWrite(abs, { type: "write", startLine: 1, shift: 0 }) // 全文重写——全文件受影响
226
- const diff = gitDiffOne(ctx.cwd, abs)
227
- return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
228
- },
229
- }
230
-
231
- // ---------------------------------------------------------------- edit
232
-
233
- export const editTool = {
234
- name: "edit",
235
- description: DESC("edit"),
236
- parameters: {
237
- type: "object",
238
- properties: {
239
- path: { type: "string", description: "File path — single form: required; with the edits array: optional top-level default for entries without their own path" },
240
- old_string: { type: "string", description: "Exact text to replace (tolerant matching: exact → whitespace-only variant → fuzzy match at ≥90% line equality after whitespace/indent/quote normalization). Mutually exclusive with line/startLine/endLine" },
241
- new_string: { type: "string", description: "Replacement text — content-based edits require it (empty is an explicit error); with line-based targeting (line/startLine/endLine) give it to replace the line/range, or OMIT it to delete (an explicit empty string is an error — omission is the delete signal)" },
242
- line: { type: "integer", description: "1-based line number — replace that single line with new_string, or OMIT new_string to DELETE the line; no old_string needed (mutually exclusive with old_string and startLine/endLine)" },
243
- startLine: { type: "integer", description: "1-based first line of the range to replace with new_string (inclusive — requires endLine; mutually exclusive with old_string); OMIT new_string to DELETE the range" },
244
- endLine: { type: "integer", description: "1-based last line of the range to replace with new_string (inclusive — requires startLine; mutually exclusive with old_string); OMIT new_string to DELETE the range" },
245
- replace_all: { type: "boolean", description: "Replace all occurrences (default false)" },
246
- edits: {
247
- type: "array",
248
- 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/line/startLine/endLine — provide each change's targeting (old_string, or line / startLine+endLine) inside its edits entry; new_string replaces when given, and line-targeted entries may omit it to DELETE the line/range.",
249
- items: {
250
- type: "object",
251
- properties: {
252
- path: { type: "string" },
253
- old_string: { type: "string" },
254
- new_string: { type: "string" },
255
- line: { type: "integer" },
256
- startLine: { type: "integer" },
257
- endLine: { type: "integer" },
258
- replace_all: { type: "boolean" },
259
- },
260
- },
261
- },
262
- },
263
- required: [],
264
- },
265
- readonly: false,
266
- touchedPaths(args) {
267
- // 2026-09-05 用户裁定:顶层 path = 批默认——仅当有条目缺 path 时计入(条目全带 path
268
- // 时顶层 path 不实际使用——不虚报进 _touchedFiles)
269
- if (args.edits) {
270
- const out = args.edits.map((e) => e.path).filter(Boolean)
271
- if (args.path && args.edits.some((e) => !e.path)) out.push(args.path)
272
- return out
273
- }
274
- return args.path ? [args.path] : []
275
- },
276
- async execute(args, ctx) {
277
- // 2026-08-31 工具顺手度(用户批准):数组形态——一次多文件原子替换
278
- // (应用逻辑在 edit-batch.mjs——2026-09-01 拆出,500 行硬限,先例 git-ext.mjs)
279
- if (args.edits) return applyEditBatch(args, ctx)
280
-
281
- // 单文件(现状路径)——执行体整段迁出至 edit-diff.mjs(EDIT.md §6——
282
- // 行级 LCS 判定:零重叠→替换即删 / 一般 diff→LCS / 空 new→显式报错)
283
- return runSingleEdit(args, ctx)
284
- },
285
- }
286
-
287
- // ---------------------------------------------------------------- insert_after
288
-
289
- export const insertAfterTool = {
290
- name: "insert_after",
291
- description: DESC("insert_after"),
292
- parameters: {
293
- type: "object",
294
- properties: {
295
- path: { type: "string", description: "File path" },
296
- after_line: { type: "number", description: "Line number to insert after (1-based). Takes priority over after_regex." },
297
- after_regex: { type: "string", description: "JavaScript regex to find the line to insert after (must match exactly one line)" },
298
- content: { type: "string", description: "Text to insert (with leading newline if you need a blank line)" },
299
- },
300
- required: ["path", "content"],
301
- },
302
- readonly: false,
303
- touchedPaths(args) { return args.path ? [args.path] : [] },
304
- async execute(args, ctx) {
305
- const abs = resolveInCwd(ctx, args.path)
306
- // Read-before-insert guard: after_line anchors are line numbers, and any
307
- // write since the last read made them stale. Refuse instead of silently
308
- // inserting at a drifted position (the failure mode that corrupted test
309
- // structure repeatedly). after_regex callers get the same gate — a stale
310
- // target line is just as wrong, and the rule is simpler to reason about.
311
- // 2026-08-31 工具顺手度(用户批准):判定精确化——本 session 写入工具记录受影响区
312
- // (lastWrite),after_line 在未受影响区(<= startLine)→ 行号未漂移 → 允许
313
- // (消掉"我写的文件被当外部修改"的摩擦);受影响区内/write 全文重写 → 拒绝。
314
- const lw = lastWriteOf(abs)
315
- if (lw && args.after_line != null) {
316
- if (lw.type === "write") {
317
- throw new Error(
318
- `${args.path} 刚被 write 全文重写(was modified since your last read)——任何行号都可能漂移,必须重 read。`
319
- )
320
- }
321
- if (args.after_line > lw.startLine) {
322
- throw new Error(
323
- `${args.path} 的 after_line ${args.after_line} 在上次写入(L${lw.startLine})之后——` +
324
- `行号已漂移 ${lw.shift >= 0 ? "+" : ""}${lw.shift},请用新行号或先 read。`
325
- )
326
- }
327
- // after_line <= startLine → 行号未漂移 → 允许
328
- } else if (isDirty(abs)) {
329
- throw new Error(
330
- `${args.path} was modified since your last read — line numbers may be stale.\n` +
331
- `Read the file again (read tool) to refresh line numbers, then retry insert_after.`
332
- )
333
- }
334
- const raw = await readFile(abs, "utf8") // original bytes — EOL detection needs the file's real line endings
335
- const text = normalizeEOL(raw)
336
- const lines = text.split("\n")
337
-
338
- let targetLine
339
- if (args.after_line != null) {
340
- targetLine = args.after_line
341
- if (!Number.isInteger(targetLine)) {
342
- throw new Error(`after_line must be an integer, got: ${args.after_line}`)
343
- }
344
- if (targetLine < 1 || targetLine > lines.length) {
345
- throw new Error(`after_line ${targetLine} out of range (file has ${lines.length} lines)`)
346
- }
347
- } else if (args.after_regex) {
348
- let regex
349
- try {
350
- regex = new RegExp(args.after_regex)
351
- } catch (e) {
352
- throw new Error(`after_regex /${args.after_regex}/ is not a valid JavaScript regex: ${e.message}`, { cause: e })
353
- }
354
- const matches = []
355
- for (let i = 0; i < lines.length; i++) {
356
- if (regex.test(lines[i])) matches.push(i + 1)
357
- }
358
- if (matches.length === 0) throw new Error(`after_regex /${args.after_regex}/ matched no lines in ${args.path}`)
359
- if (matches.length > 1) throw new Error(`after_regex /${args.after_regex}/ matched ${matches.length} lines (${matches.slice(0, 5).join(", ")}${matches.length > 5 ? "…" : ""}); use a more specific pattern or after_line instead`)
360
- targetLine = matches[0]
361
- } else {
362
- throw new Error("Either after_line or after_regex is required")
363
- }
364
-
365
- lines.splice(targetLine, 0, normalizeEOL(args.content))
366
- // Write back in the file's ORIGINAL EOL style (review R9#2: same bug class as
367
- // edit — a CRLF file must not silently become LF here either).
368
- const updated = joinWithEol(lines, raw)
369
- await writeFile(abs, updated, "utf8")
370
- recordWrite(abs, { type: "insert", startLine: targetLine, shift: normalizeEOL(args.content).split("\n").length })
371
- const diff = gitDiffOne(ctx.cwd, abs)
372
- const baseResult = `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
373
- return await appendWriteContext(abs, targetLine + 1, baseResult)
374
- },
375
- }
376
-
377
- // ---------------------------------------------------------------- hashline_edit
378
-
379
- /**
380
- * Compute a 12-char hex SHA256 hash for a line (exact content, no trimming).
381
- * Used by both read (hashes=true) and hashline_edit for hash-based matching.
382
- */
383
- export function hashLine(content) {
384
- return createHash("sha256").update(content).digest("hex").slice(0, 12)
385
- }
386
-
387
- export const hashlineEditTool = {
388
- name: "hashline_edit",
389
- description: DESC("hashline_edit"),
390
- parameters: {
391
- type: "object",
392
- properties: {
393
- path: { type: "string", description: "File path" },
394
- old_hashes: { type: "array", items: { type: "string" }, description: "SHA256 hashes (12 chars) of the lines to replace. Read the file with hashes=true first to obtain these hashes. Single line: pass 1 hash; multiple lines: pass the exact sequence of hashes." },
395
- new_content: { type: "string", description: "Replacement text (can span multiple lines)" },
396
- },
397
- required: ["path", "old_hashes", "new_content"],
398
- },
399
- readonly: false,
400
- touchedPaths(args) { return args.path ? [args.path] : [] },
401
- async execute(args, ctx) {
402
- const abs = resolveInCwd(ctx, args.path)
403
- if (!args.old_hashes?.length) throw new Error("old_hashes must not be empty — read the file with hashes=true to get line hashes")
404
- const raw = await readFile(abs, "utf8")
405
- const content = normalizeEOL(raw)
406
- // Encoding-corruption probe: U+FFFD means the file is not clean UTF-8 — hash
407
- // addressing may be unreliable. Warn (never block).
408
- const corrupted = content.includes("\uFFFD")
409
- const lines = content.split("\n")
410
- const fileHashes = lines.map((l) => hashLine(l))
411
- const target = args.old_hashes
412
-
413
- // Sliding-window match: find all occurrences of the hash sequence.
414
- // When multiple matches are found (e.g. empty lines), report positions so the
415
- // model can include more context lines (adjacent lines with unique hashes).
416
- const matches = []
417
- for (let i = 0; i <= fileHashes.length - target.length; i++) {
418
- let match = true
419
- for (let j = 0; j < target.length; j++) {
420
- if (fileHashes[i + j] !== target[j]) { match = false; break }
421
- }
422
- if (match) matches.push(i)
423
- }
424
-
425
- if (matches.length === 0) {
426
- // Help the model recover: show the current file hashes for context
427
- const maxShow = Math.min(fileHashes.length, 50)
428
- const hashDump = fileHashes.slice(0, maxShow).map((h, i) => `${h} L${i + 1}: ${lines[i].slice(0, 80)}`).join("\n")
429
- const preview = target.join(" ")
430
- throw new Error(
431
- `Hash sequence not found in ${args.path}: ${preview} — for fresh hashes, re-read the file with hashes=true\n` +
432
- `The file may have been modified since you last read it. Current hashes (first ${maxShow} lines):\n${hashDump}` +
433
- (corrupted ? `\n${FFFD_WARNING}` : "")
434
- )
435
- }
436
-
437
- if (matches.length > 1) {
438
- const ctx = 2 // lines of surrounding context
439
- const detail = matches.map((m) => {
440
- const start = Math.max(0, m - ctx)
441
- const end = Math.min(lines.length, m + target.length + ctx)
442
- const preview = lines.slice(start, end).map((l, i) => {
443
- const ln = start + i + 1
444
- const marker = m <= ln - 1 && ln - 1 < m + target.length ? ">" : " "
445
- return `${marker} L${ln}: ${l.slice(0, 80)}`
446
- }).join("\n")
447
- return ` Match at line ${m + 1} (${target.length} line(s)):\n${preview}`
448
- }).join("\n\n")
449
- throw new Error(
450
- `Hash sequence matches ${matches.length} positions in ${args.path} — ambiguous.\n` +
451
- `Include more surrounding lines (unique-hash lines before/after the target) to disambiguate.\n\n` +
452
- `All matches with surrounding context:\n\n${detail}`
453
- )
454
- }
455
-
456
- const pos = matches[0]
457
- // Replace: remove old lines, insert new lines at the same position
458
- const newLines = normalizeEOL(args.new_content).split("\n") // normalize: CRLF in new_content would join into \r\r\n
459
- lines.splice(pos, target.length, ...newLines)
460
- // Write back in the file's original EOL style (same rule as edit / apply_patch).
461
- const updated = joinWithEol(lines, raw)
462
- await writeFile(abs, updated, "utf8")
463
- recordWrite(abs, { type: "edit", startLine: pos + 1, shift: newLines.length - target.length })
464
- const diff = gitDiffOne(ctx.cwd, abs)
465
- const baseResult = `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}${corrupted ? `\n${FFFD_WARNING}` : ""}`
466
- return await appendWriteContext(abs, pos + 1, baseResult)
467
- },
468
- }
469
-
@@ -1,17 +0,0 @@
1
- Move, copy, or rename a file/directory.
2
-
3
- **Route to file_ops instead of bash:**
4
- - `mv a b` → file_ops action=move
5
- - `cp a b` / `copy` → file_ops action=copy
6
- - `ren a b` / `rename a b` → file_ops action=rename
7
-
8
- Parameters:
9
- - action (required): move | copy | rename
10
- - source (required): source path, relative to cwd or absolute
11
- - dest (required): destination path
12
-
13
- Notes:
14
- - Paths resolve relative to cwd — no directory restriction (same boundary as bash; the approval gate is the guard).
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`.
17
- - Returns `Moved|Copied|Renamed <source> -> <dest>` or `Error: ...` (same-path and missing-path errors).
@@ -1,8 +0,0 @@
1
- Get the current date, time, weekday, and timezone.
2
-
3
- **Route to get_current_time instead of bash:**
4
- - `date` / `time` → get_current_time
5
-
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).
@@ -1,143 +0,0 @@
1
- /**
2
- * git-checkpoint.mjs — git 工具 checkpoint action 子系统(CHECKPOINT.md F2/F6)。
3
- * git.mjs 的 checkpoint case 委托到这里:list/create/rewind/cat/versions + F6 懒清理 +
4
- * F2/D7 提示行 + 文件树格式化。CLI 与 VS Code 两端同构(镜像,修改须两端同批)。
5
- */
6
- import { runGit } from "./shared.mjs"
7
- import { escapeXml } from "../agent/helpers.mjs"
8
- import {
9
- createCheckpoint,
10
- listCheckpoints,
11
- rewind,
12
- listFileVersions,
13
- catFile,
14
- isGitRepo,
15
- deleteCheckpointsForCwd,
16
- } from "../git/checkpoint.mjs"
17
-
18
- /** F6 lazy fallback (CHECKPOINT.md D3): an EXTERNAL git commit (via bash / IDE — not the git
19
- * tool) leaves this cwd's checkpoints as pre-commit state. Compare HEAD commit time
20
- * (epoch SECONDS from %ct) against the newest snapshot's meta.time (ms): `%ct × 1000` aligns
21
- * both to ms. HEAD newer → every snapshot predates the commit → clear all. All-or-nothing:
22
- * any snapshot NEWER than HEAD (e.g. a manual create after the external commit) skips the
23
- * whole clear. Best-effort — never blocks the checkpoint op. */
24
- export async function lazyClearIfCommitted(cwd) {
25
- try {
26
- const cps = await listCheckpoints(cwd)
27
- if (cps.length === 0) return
28
- const headSec = runGit(cwd, ["log", "-1", "--format=%ct"])
29
- const headMs = Number.parseInt(headSec, 10) * 1000
30
- if (!Number.isFinite(headMs) || headMs <= 0) return
31
- const newest = cps[0] // listCheckpoints returns newest → oldest
32
- if (headMs > newest.time) await deleteCheckpointsForCwd(cwd)
33
- } catch {
34
- // best-effort (NF7 philosophy) — a lazy-clear failure must not break list/create
35
- }
36
- }
37
-
38
- /** checkpoint case 主入口(git 工具 checkpoint action 的全部子动作)。 */
39
- export async function executeCheckpointAction(args, ctx) {
40
- const { checkpointAction: sub, checkpointId: id, path } = args
41
- if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
42
-
43
- if (!sub) return "checkpoint: missing checkpointAction — use: list | create | rewind | cat | versions"
44
-
45
- // F6 lazy fallback (list/create entry): an EXTERNAL git commit (HEAD time newer
46
- // than the newest snapshot) means every snapshot predates a safety baseline —
47
- // clear them. All-or-nothing: if any snapshot is newer than HEAD, skip entirely.
48
- if (sub === "list" || sub === "create") await lazyClearIfCommitted(ctx.cwd)
49
-
50
- if (sub === "create") {
51
- const cp = await createCheckpoint(ctx.cwd)
52
- return `Checkpoint ${cp.id} created (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked)`
53
- }
54
- if (sub === "versions") {
55
- if (!path) throw new Error("path is required for versions — the file whose history you want")
56
- const versions = await listFileVersions(ctx.cwd, path)
57
- if (versions.length === 0) return `No snapshot copies of "${path}" found (it was never part of an auto/protection snapshot).`
58
- return (
59
- `Historical versions of "${path}" (${versions.length}, newest first):\n` +
60
- versions.map((v) =>
61
- ` ${v.snapshotId} ${new Date(v.time).toISOString()} ${v.size}B sha:${v.sha} (${v.source})` +
62
- (v.sha === versions[versions.indexOf(v) - 1]?.sha ? " ← same content as previous" : "")
63
- ).join("\n") +
64
- `\nRestore a version: checkpointAction=rewind checkpointId=<snapshotId> path="${path}"`
65
- )
66
- }
67
- if (sub === "rewind") {
68
- if (!id) throw new Error("checkpointId is required for rewind — use checkpointAction=list to see snapshot ids")
69
- if (!path) throw new Error("path is required for rewind — full restore is disabled (as dangerous as `git checkout -- .`). Restore files individually. Use checkpointAction=versions path=<file> to list a file's historical versions.")
70
- const s = await rewind(ctx.cwd, id, { path })
71
- return `Restored "${path}" (${s.type}) from checkpoint ${id}.\n(The pre-restore state was snapshotted first — you can restore again to go back.)`
72
- }
73
- if (sub === "cat") {
74
- if (!id) throw new Error("checkpointId is required for cat — use checkpointAction=list to see snapshot ids")
75
- if (!path) throw new Error("path is required for cat — specify which file to read")
76
- return await catFile(ctx.cwd, id, path)
77
- }
78
- if (sub === "list") {
79
- const cps = await listCheckpoints(ctx.cwd)
80
- if (cps.length === 0) return "(no checkpoints yet)"
81
-
82
- // Specific id: show the file tree within that snapshot
83
- if (id) {
84
- const cp = cps.find((c) => c.id === id)
85
- if (!cp) throw new Error(`checkpoint ${id} not found`)
86
- return formatFileTree(cp)
87
- }
88
-
89
- // Overview: list of all snapshots (file names are XML-escaped: they are
90
- // untrusted input that flows back into the model's context). F2/D7: fixed
91
- // hint line at the tail — the recovery entry for a snapshot after an accident.
92
- return cps.map((c) => {
93
- const parts = [`${c.id} ${new Date(c.time).toISOString()}`]
94
- if (c.tracked.length) parts.push(`${c.tracked.length} tracked: ${c.tracked.map(escapeXml).join(", ")}`)
95
- if (c.untracked.length) parts.push(`${c.untracked.length} untracked: ${c.untracked.map(escapeXml).join(", ")}`)
96
- return parts.join(" ")
97
- }).join("\n") + "\n(意外丢弃改动?checkpointAction=rewind 可恢复操作前状态)"
98
- }
99
- throw new Error(`Unknown checkpoint action: ${sub}. Use: list | create | rewind | cat | versions`)
100
- }
101
-
102
- /** Format a checkpoint's file list as a directory tree (directories first, indented display) */
103
- function formatFileTree(cp) {
104
- // File names are XML-escaped: untrusted input that flows back into the model's context
105
- const all = [
106
- ...(cp.tracked ?? []).map((f) => ({ path: escapeXml(f), type: "" })),
107
- ...(cp.untracked ?? []).map((f) => ({ path: escapeXml(f), type: " (untracked)" })),
108
- ]
109
- if (all.length === 0) return "(empty checkpoint)"
110
-
111
- all.sort((a, b) => a.path.localeCompare(b.path))
112
-
113
- const tree = new Map()
114
- for (const { path, type } of all) {
115
- const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "."
116
- if (!tree.has(dir)) tree.set(dir, [])
117
- tree.get(dir).push({ name: path.slice(dir === "." ? 0 : dir.length + 1), type })
118
- }
119
-
120
- const lines = []
121
- const dirs = [...tree.keys()].sort()
122
- for (const dir of dirs) {
123
- if (dir !== "." && !lines.includes(dir + "/")) {
124
- const parts = dir.split("/")
125
- for (let i = 1; i <= parts.length; i++) {
126
- const prefix = parts.slice(0, i).join("/") + "/"
127
- if (!lines.includes(prefix)) lines.push(prefix)
128
- }
129
- }
130
- }
131
- for (const dir of dirs) {
132
- if (dir !== ".") {
133
- for (const { name, type } of tree.get(dir)) {
134
- lines.push(` ${dir}/${name}${type}`)
135
- }
136
- }
137
- }
138
- for (const { name, type } of tree.get(".") ?? []) {
139
- lines.push(name + type)
140
- }
141
-
142
- return lines.join("\n")
143
- }