@thincoder/core 0.9.1

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 (203) hide show
  1. package/abort-provenance.mjs +116 -0
  2. package/advisor/citations.mjs +139 -0
  3. package/advisor/compaction.mjs +174 -0
  4. package/advisor/convergence.mjs +80 -0
  5. package/advisor/history.mjs +77 -0
  6. package/advisor/loop.mjs +288 -0
  7. package/advisor/messages.mjs +299 -0
  8. package/advisor/notice.mjs +141 -0
  9. package/advisor/project-context.mjs +197 -0
  10. package/advisor/repos.mjs +150 -0
  11. package/advisor/run.mjs +190 -0
  12. package/advisor/truncate.mjs +57 -0
  13. package/advisor.mjs +281 -0
  14. package/agent/child-marks.mjs +24 -0
  15. package/agent/completion.mjs +145 -0
  16. package/agent/dispatch.mjs +493 -0
  17. package/agent/family-tools.mjs +174 -0
  18. package/agent/helpers.mjs +412 -0
  19. package/agent/post-turn.mjs +70 -0
  20. package/agent/record-results.mjs +174 -0
  21. package/agent/relay-prefix.mjs +39 -0
  22. package/agent/run-stages.mjs +244 -0
  23. package/agent/setup-reminders.mjs +199 -0
  24. package/agent/setup.mjs +234 -0
  25. package/agent/spawn-child.mjs +258 -0
  26. package/agent/suspension.mjs +240 -0
  27. package/agent/write-gate.mjs +87 -0
  28. package/agent-tools/advisor-async.mjs +481 -0
  29. package/agent-tools/advisor-settle.mjs +240 -0
  30. package/agent-tools/advisor.mjs +280 -0
  31. package/agent-tools/async-discard.mjs +143 -0
  32. package/agent-tools/async-settle.mjs +299 -0
  33. package/agent-tools/batch-segment.mjs +265 -0
  34. package/agent-tools/child-permission.mjs +45 -0
  35. package/agent-tools/consult.mjs +471 -0
  36. package/agent-tools/design-token.mjs +117 -0
  37. package/agent-tools/digest-budget.mjs +76 -0
  38. package/agent-tools/eng.mjs +102 -0
  39. package/agent-tools/escalate-async.mjs +302 -0
  40. package/agent-tools/goal.mjs +119 -0
  41. package/agent-tools/panel-blocks.mjs +24 -0
  42. package/agent-tools/parent-channel.mjs +231 -0
  43. package/agent-tools/plan.mjs +86 -0
  44. package/agent-tools/read-history.mjs +309 -0
  45. package/agent-tools/recent-changes.mjs +24 -0
  46. package/agent-tools/review-facts.mjs +31 -0
  47. package/agent-tools/settings.mjs +268 -0
  48. package/agent-tools/skill.mjs +63 -0
  49. package/agent-tools/spawn-gates.mjs +109 -0
  50. package/agent-tools/subagent-actions.mjs +496 -0
  51. package/agent-tools/subagent-async.mjs +456 -0
  52. package/agent-tools/subagent-panel.mjs +160 -0
  53. package/agent-tools/subagent-run.mjs +208 -0
  54. package/agent-tools/subagent-scheduler.mjs +446 -0
  55. package/agent-tools/subagent-spawn.mjs +478 -0
  56. package/agent-tools/subagent.mjs +419 -0
  57. package/agent-tools/task.mjs +87 -0
  58. package/agent-tools/timer.mjs +46 -0
  59. package/agent-tools/verify.mjs +295 -0
  60. package/agent-tools.mjs +23 -0
  61. package/agent.mjs +430 -0
  62. package/auto-think.mjs +115 -0
  63. package/compress-form.mjs +24 -0
  64. package/config-io.mjs +277 -0
  65. package/config-migrate.mjs +178 -0
  66. package/config-presets.mjs +49 -0
  67. package/config.mjs +419 -0
  68. package/context.mjs +495 -0
  69. package/conventions.mjs +223 -0
  70. package/embedding.mjs +120 -0
  71. package/escape.mjs +152 -0
  72. package/expand-home.mjs +16 -0
  73. package/explore-distill.mjs +152 -0
  74. package/generate-title.mjs +123 -0
  75. package/git/checkpoint.mjs +448 -0
  76. package/git/gitmem.mjs +100 -0
  77. package/history-window.mjs +179 -0
  78. package/hooks.mjs +108 -0
  79. package/i18n.mjs +106 -0
  80. package/index-bin.mjs +48 -0
  81. package/index-discover.mjs +176 -0
  82. package/ledger-cmd.mjs +209 -0
  83. package/ledger-db.mjs +86 -0
  84. package/ledger-surface.mjs +76 -0
  85. package/ledger.mjs +202 -0
  86. package/log.mjs +195 -0
  87. package/manifest.mjs +338 -0
  88. package/markdown.mjs +106 -0
  89. package/mcp/helpers.mjs +51 -0
  90. package/mcp/transport-http.mjs +248 -0
  91. package/mcp/transport-stdio.mjs +140 -0
  92. package/mcp/transport-ws.mjs +122 -0
  93. package/mcp.mjs +295 -0
  94. package/memory/code-index.mjs +219 -0
  95. package/memory/code-sync.mjs +427 -0
  96. package/memory/core.mjs +318 -0
  97. package/memory/delete.mjs +242 -0
  98. package/memory/docs.mjs +431 -0
  99. package/memory/file-walk.mjs +109 -0
  100. package/memory/origin.mjs +24 -0
  101. package/memory/scan.mjs +177 -0
  102. package/memory/schema.mjs +460 -0
  103. package/memory.mjs +21 -0
  104. package/model-ref.mjs +66 -0
  105. package/model-specs.mjs +277 -0
  106. package/package.json +32 -0
  107. package/peer-domains.mjs +265 -0
  108. package/peer-instances.mjs +178 -0
  109. package/permission.mjs +79 -0
  110. package/process-probe.mjs +315 -0
  111. package/prompt-files.mjs +113 -0
  112. package/prompt-overlays.mjs +78 -0
  113. package/prompts/advisor-design.md +43 -0
  114. package/prompts/advisor-round1.md +41 -0
  115. package/prompts/advisor-round2.md +46 -0
  116. package/prompts/advisor-round3.md +42 -0
  117. package/prompts/common.md +158 -0
  118. package/prompts/consult-base.md +19 -0
  119. package/prompts/discipline-engineering.md +123 -0
  120. package/prompts/discipline-normal.md +206 -0
  121. package/prompts/persona-coder.md +21 -0
  122. package/prompts/persona-eng-coder.md +41 -0
  123. package/prompts/persona-eng-designer.md +80 -0
  124. package/prompts/persona-engineering.md +160 -0
  125. package/prompts/persona-explore.md +15 -0
  126. package/prompts/persona-normal.md +35 -0
  127. package/prompts/persona-plan.md +27 -0
  128. package/provider/anthropic.mjs +225 -0
  129. package/provider/core.mjs +491 -0
  130. package/provider/errors.mjs +101 -0
  131. package/provider/google.mjs +257 -0
  132. package/provider/index.mjs +7 -0
  133. package/provider/list-models.mjs +163 -0
  134. package/provider/normalize.mjs +81 -0
  135. package/provider/rate.mjs +168 -0
  136. package/provider/responses.mjs +495 -0
  137. package/provider/retry.mjs +88 -0
  138. package/provider/sse.mjs +264 -0
  139. package/provider/wait-status.mjs +59 -0
  140. package/proxy.mjs +274 -0
  141. package/rules.mjs +53 -0
  142. package/session-gc.mjs +248 -0
  143. package/session-guard.mjs +59 -0
  144. package/session-lifecycle.mjs +305 -0
  145. package/session-migrate.mjs +48 -0
  146. package/session-rename.mjs +38 -0
  147. package/session-segments.mjs +99 -0
  148. package/session-slot-write.mjs +168 -0
  149. package/session-slots-manifest.mjs +264 -0
  150. package/session-slots.mjs +298 -0
  151. package/session-store.mjs +441 -0
  152. package/session.mjs +244 -0
  153. package/skills.mjs +234 -0
  154. package/text-budget.mjs +79 -0
  155. package/token-ttl.mjs +285 -0
  156. package/tool-docs/apply_patch.md +15 -0
  157. package/tool-docs/bash.md +38 -0
  158. package/tool-docs/delete.md +13 -0
  159. package/tool-docs/edit.md +30 -0
  160. package/tool-docs/execute.md +21 -0
  161. package/tool-docs/fetch.md +12 -0
  162. package/tool-docs/file_ops.md +17 -0
  163. package/tool-docs/get_current_time.md +8 -0
  164. package/tool-docs/git.md +54 -0
  165. package/tool-docs/glob.md +11 -0
  166. package/tool-docs/grep.md +19 -0
  167. package/tool-docs/hashline_edit.md +14 -0
  168. package/tool-docs/insert_after.md +15 -0
  169. package/tool-docs/lint.md +10 -0
  170. package/tool-docs/ls.md +12 -0
  171. package/tool-docs/lsp.md +10 -0
  172. package/tool-docs/process.md +10 -0
  173. package/tool-docs/question.md +16 -0
  174. package/tool-docs/read.md +20 -0
  175. package/tool-docs/read_image.md +8 -0
  176. package/tool-docs/tree.md +14 -0
  177. package/tool-docs/wait_for.md +22 -0
  178. package/tool-docs/websearch.md +16 -0
  179. package/tool-docs/write.md +11 -0
  180. package/tools/bash.mjs +276 -0
  181. package/tools/edit-batch.mjs +204 -0
  182. package/tools/edit-diff.mjs +388 -0
  183. package/tools/exec-run.mjs +43 -0
  184. package/tools/execute.mjs +243 -0
  185. package/tools/file.mjs +464 -0
  186. package/tools/git-checkpoint.mjs +143 -0
  187. package/tools/git-ext.mjs +173 -0
  188. package/tools/git.mjs +415 -0
  189. package/tools/glob-dialect.mjs +130 -0
  190. package/tools/index.mjs +76 -0
  191. package/tools/linter.mjs +120 -0
  192. package/tools/lsp.mjs +335 -0
  193. package/tools/ops.mjs +293 -0
  194. package/tools/patch.mjs +290 -0
  195. package/tools/question.mjs +26 -0
  196. package/tools/repomap.mjs +314 -0
  197. package/tools/search.mjs +248 -0
  198. package/tools/shared.mjs +467 -0
  199. package/tools/tree.mjs +81 -0
  200. package/tools/web.mjs +224 -0
  201. package/tools/write-path.mjs +191 -0
  202. package/traces/trace-store.mjs +303 -0
  203. package/undo-stack.mjs +47 -0
package/tools/web.mjs ADDED
@@ -0,0 +1,224 @@
1
+ import { DESC, truncate, stripTags, htmlToText, isPrivateHost } from "./shared.mjs";
2
+ import { URL } from "node:url";
3
+ import { proxyFetch } from "../proxy.mjs";
4
+
5
+ export const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
6
+ const FETCH_TIMEOUT = 15_000
7
+ // §14 D-TF3:网络失败错误文本追加 proxy 提示——纯文本提示,不自动路由(2026-08-31 裁定:proxy 显式传不自动应用)
8
+ const PROXY_HINT = "network failure — retry with proxy: 'http://host:port' if the target is blocked"
9
+
10
+ // ── Web search (Bing; direct by default, through proxy when configured and web toggle on) ──
11
+
12
+ function extractBing(html) {
13
+ const results = []
14
+ // RSS format: <item><title>..</title><link>..</link><description>..</description></item>
15
+ for (const m of html.matchAll(/<item>([\s\S]*?)<\/item>/gi)) {
16
+ const block = m[1]
17
+ const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1] ?? ""
18
+ const href = block.match(/<link>([\s\S]*?)<\/link>/)?.[1] ?? ""
19
+ const snippet = block.match(/<description>([\s\S]*?)<\/description>/)?.[1] ?? ""
20
+ if (!href) continue
21
+ results.push({ href, title: stripTags(title), snippet: stripTags(snippet) })
22
+ }
23
+ // Fallback: HTML b_algo blocks (older server-rendered pages)
24
+ if (results.length === 0) {
25
+ const blocks = html.split('<li class="b_algo"').slice(1)
26
+ for (const block of blocks) {
27
+ const link = block.match(/<h2[^>]*><a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/)
28
+ if (!link) continue
29
+ const snippet = block.match(/<p[^>]*>([\s\S]*?)<\/p>/)
30
+ results.push({ href: link[1], title: stripTags(link[2]), snippet: snippet ? stripTags(snippet[1]) : "" })
31
+ }
32
+ }
33
+ return results
34
+ }
35
+
36
+ function bingUrl(query, page) {
37
+ let u = `https://www.bing.com/search?q=${encodeURIComponent(query)}&format=rss&setlang=en`
38
+ if (page > 1) u += `&first=${(page - 1) * 10 + 1}`
39
+ return u
40
+ }
41
+
42
+ const ENGINES = [{ name: "bing", label: "Bing", url: bingUrl, extract: extractBing, ua: UA }]
43
+ const ENGINE_NAMES = ENGINES.map(e => e.name)
44
+
45
+ /** Structured search via Tavily (optional — config.websearch.apiKey). Returns
46
+ * { engine, results } or null to fall back to Bing HTML scraping.
47
+ * proxyUri: explicit per-call proxy (args.proxy) — never the config.json one. */
48
+ async function fetchTavily(query, limit, ctx, proxyUri) {
49
+ const apiKey = ctx?.agent?.config?.websearch?.apiKey
50
+ if (!apiKey) return null
51
+ const ctrl = new AbortController()
52
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT)
53
+ try {
54
+ const response = await proxyFetch("https://api.tavily.com/search", {
55
+ method: "POST",
56
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` },
57
+ body: JSON.stringify({ query, search_depth: "basic", max_results: limit, include_answer: false, include_raw_content: false }),
58
+ signal: ctrl.signal,
59
+ }, proxyUri)
60
+ if (!response.ok) return null
61
+ const data = await response.json()
62
+ const results = (Array.isArray(data.results) ? data.results : []).map((r) => ({
63
+ href: r.url, title: r.title ?? "", snippet: r.content ?? "", _engine: "tavily",
64
+ }))
65
+ return { engine: "tavily", results }
66
+ } catch { return null }
67
+ finally { clearTimeout(timer) }
68
+ }
69
+
70
+ async function fetchEngine(engine, query, page, proxyUri) {
71
+ const ctrl = new AbortController()
72
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT)
73
+ try {
74
+ const response = await proxyFetch(engine.url(query, page), {
75
+ headers: { "User-Agent": engine.ua, "Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8" },
76
+ signal: ctrl.signal,
77
+ }, proxyUri)
78
+ if (!response.ok) return null
79
+ const html = await response.text()
80
+ const results = engine.extract(html)
81
+ return { engine: engine.name, results }
82
+ } catch { return null }
83
+ finally { clearTimeout(timer) }
84
+ }
85
+
86
+ export const websearchTool = {
87
+ name: "websearch",
88
+ description: DESC("websearch"),
89
+ parameters: {
90
+ type: "object",
91
+ properties: {
92
+ query: { type: "string", description: "Search query" },
93
+ limit: { type: "number", description: "Max results (default 8, max 20)" },
94
+ engine: { type: "string", enum: ENGINE_NAMES, description: "Specific engine — \"bing\" (Bing). Omit to search all engines concurrently." },
95
+ page: { type: "number", description: "Page number for pagination (1-based, default 1). Only used when engine is specified." },
96
+ proxy: { type: "string", description: "http://host:port explicit proxy (optional) — use ONLY when passed; no proxy = direct. config.json proxy is NOT auto-applied (2026-08-31 ruling: fixed config breaks domestic sites)" },
97
+ },
98
+ required: ["query"],
99
+ },
100
+ readonly: true,
101
+ async execute(args, ctx) {
102
+ const limit = Math.min(args.limit ?? 8, 20)
103
+ const page = Math.max(1, args.page ?? 1)
104
+ // 2026-08-31 ruling: proxy is a PER-CALL decision (args.proxy), never the config.json
105
+ // fixed configuration. Model picks by target: github/foreign → pass proxy; gitee/domestic → omit.
106
+ const proxyUri = args.proxy ?? null
107
+ // Structured search first when a Tavily key is configured — stable, dated,
108
+ // no HTML scraping. Falls back to Bing silently.
109
+ const tavily = await fetchTavily(args.query, limit, ctx, proxyUri)
110
+ if (tavily && tavily.results.length > 0) {
111
+ return truncate(tavily.results.slice(0, limit).map((r, i) => `${i + 1}. [tavily] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
112
+ }
113
+ if (args.engine) {
114
+ const engine = ENGINES.find(e => e.name === args.engine)
115
+ if (!engine) return `Unknown engine '${args.engine}'. Available: ${ENGINE_NAMES.join(", ")}`
116
+ const fetched = await fetchEngine(engine, args.query, page, proxyUri)
117
+ if (!fetched || fetched.results.length === 0) return "(no results)"
118
+ return truncate(fetched.results.slice(0, limit).map((r, i) => `${i + 1}. [${engine.label}] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
119
+ }
120
+ const promises = ENGINES.map(e => fetchEngine(e, args.query, 1, proxyUri))
121
+ const fetched = (await Promise.all(promises)).filter(Boolean)
122
+ if (fetched.length === 0) return "(no results)"
123
+ const merged = [], indexes = fetched.map(() => 0)
124
+ let done = false
125
+ while (!done && merged.length < limit) {
126
+ done = true
127
+ for (let i = 0; i < fetched.length; i++) {
128
+ if (indexes[i] < fetched[i].results.length) {
129
+ merged.push({ ...fetched[i].results[indexes[i]], _engine: fetched[i].engine })
130
+ indexes[i]++; done = false
131
+ if (merged.length >= limit) break
132
+ }
133
+ }
134
+ }
135
+ return truncate(merged.slice(0, limit).map((r, i) => `${i + 1}. [${r._engine}] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
136
+ },
137
+ }
138
+
139
+ // ── Fetch tool (with proxy support) ──────
140
+
141
+ function isPrivateUrl(urlStr) {
142
+ let u; try { u = new URL(urlStr) } catch { return true }
143
+ return isPrivateHost(u.hostname)
144
+ }
145
+
146
+ // proxyFetch returns a native Response (Headers object, needs .get()) without proxy,
147
+ // but a Response-like with a plain lowercase-keyed Record through the CONNECT tunnel — handle both.
148
+ function headerOf(res, name) {
149
+ const h = res.headers
150
+ if (!h) return null
151
+ if (typeof h.get === "function") return h.get(name)
152
+ return h[name.toLowerCase()] ?? null
153
+ }
154
+
155
+ /** Validate a redirect target: resolve relative → absolute, http/https only, and
156
+ * SSRF-checked. Returns { target } on success or { error } (never follows into a
157
+ * private host — the redirect SSRF bypass). */
158
+ export function resolveRedirectTarget(loc, baseUrl) {
159
+ let target
160
+ try { target = new URL(loc, baseUrl).toString() } catch { return { error: "invalid redirect location" } }
161
+ if (!/^https?:\/\//.test(target)) return { error: "redirect target must be http/https" }
162
+ if (isPrivateUrl(target)) return { error: "redirect target is internal/private/metadata" }
163
+ return { target }
164
+ }
165
+
166
+ /** Heuristic: a fetch result that is a near-empty shell or a region-block page hides
167
+ * the real content. Flags the pattern so the model switches strategy instead of
168
+ * re-fetching blind (2026-08-31: 21-char MiniMax SPA shell + "App unavailable in
169
+ * region" Claude page both wasted an hour of round trips). */
170
+ export function detectSparseHtml(html, text) {
171
+ if (text.length >= 300) return ""
172
+ if (/unavailable in (your )?region|not available in (your )?region|app-unavailable|enable.?javascript|just a moment|attention required|cf-browser-verification/i.test(html)) {
173
+ return "\n\n[fetch hint: page looks region-blocked or JS-gated (body <300 chars). Try a search MCP tool (configured provider) or the site's .md/raw/mirror endpoint]"
174
+ }
175
+ if (/<div[^>]+id="(app|root)"[^>]*>\s*<\/div>|id="app"|id="root"/i.test(html)) {
176
+ return "\n\n[fetch hint: page is a JS-rendered SPA shell (body <300 chars) — content loads client-side. Try a search MCP tool or the site's .md/API endpoint]"
177
+ }
178
+ return ""
179
+ }
180
+
181
+ export const fetchTool = {
182
+ name: "fetch",
183
+ description: DESC("fetch"),
184
+ parameters: {
185
+ type: "object",
186
+ properties: {
187
+ url: { type: "string", description: "http/https URL" },
188
+ proxy: { type: "string", description: "http://host:port explicit proxy (optional) — use ONLY when passed; no proxy = direct. config.json proxy is NOT auto-applied (2026-08-31 ruling); pick per target (github/foreign sites need a proxy, gitee/domestic don't)" },
189
+ },
190
+ required: ["url"],
191
+ },
192
+ readonly: true,
193
+ async execute(args, _ctx) {
194
+ if (!/^https?:\/\//.test(args.url)) throw new Error("url must start with http:// or https://")
195
+ if (isPrivateUrl(args.url)) throw new Error("fetch blocked: internal/private/metadata addresses are not allowed")
196
+ try {
197
+ // 2026-08-31 ruling: per-call decision — args.proxy when passed, direct otherwise.
198
+ const proxyUri = args.proxy ?? null
199
+ const response = await proxyFetch(args.url, { headers: { "User-Agent": UA } }, proxyUri)
200
+ if (!response.ok) {
201
+ if ([301, 302, 307, 308].includes(response.status)) {
202
+ const loc = headerOf(response, "location")
203
+ if (loc) {
204
+ // SSRF-check the redirect target (resolve relative → absolute) before
205
+ // following — a 3xx must not bounce a public URL into a private host.
206
+ const r = resolveRedirectTarget(loc, args.url)
207
+ if (r.error) throw new Error(`fetch failed: ${r.error}`)
208
+ const r2 = await proxyFetch(r.target, { headers: { "User-Agent": UA } }, proxyUri)
209
+ if (!r2.ok) throw new Error(`fetch failed: HTTP ${r2.status}\n${PROXY_HINT}`)
210
+ const ct2 = headerOf(r2, "content-type") ?? ""
211
+ const b2 = await r2.text()
212
+ if (ct2.includes("text/html")) { const t = htmlToText(b2); return truncate(t + detectSparseHtml(b2, t)) }
213
+ return truncate(b2)
214
+ }
215
+ }
216
+ throw new Error(`fetch failed: HTTP ${response.status}\n${PROXY_HINT}`)
217
+ }
218
+ const ct = headerOf(response, "content-type") ?? ""
219
+ const body = await response.text()
220
+ if (ct.includes("text/html")) { const t = htmlToText(body); return truncate(t + detectSparseHtml(body, t)) }
221
+ return truncate(body)
222
+ } catch (e) { throw new Error(`fetch failed: ${e.cause?.code ?? e.message}\n${PROXY_HINT}`, { cause: e }) }
223
+ },
224
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * tools/write-path.mjs — 核内**单一写路径点**(编辑器编辑径注入缝——CORE-UNIFICATION §2.13.5)。
3
+ *
4
+ * 缺口(本档存在的原因):宿主侧对「编辑器里打开着的文件」有专门处理——先 `applyEdit`
5
+ * 再保存。不保存会让缓冲变脏而磁盘仍旧 ⇒ 下一次编辑撞上我们自己的 isDirty 护栏,外部
6
+ * 写入又与用户随后的保存互相竞写(split-brain 数据丢失)。核内原先写盘直调
7
+ * `node:fs/promises`、散在 5 个档的 8 个写点上 ⇒ **没有可注入的一跳** ⇒ 端侧接核后会
8
+ * 丢掉编辑器径。本模块就是那一跳:核内**工具面的 8 个编辑写点**全改调 `writeThroughPath`
9
+ * (模型面直写 `agent-tools/batch-segment.mjs` = 设计登记的 S2 前置门——不在本缝覆盖面;
10
+ * 口径与 `CORE-UNIFICATION.md` §2.13.5 落地收正同源)。
11
+ *
12
+ * 默认径 = CLI 语义(**零行为变**):`writeFile` + 记账。端侧
13
+ * `configureWritePath({ openDoc, applyEdit, isDirty })` 覆盖 ⇒ 编辑器径;**缺省不覆盖**。
14
+ * 核内**零端名分支**(契约 5——本档只认三个函数名,不出现任何端名)。
15
+ *
16
+ * 记账面(dirty-file tracking)**同源住本档**:核内写盘只此一处 ⇒ 记账面只此一处(Map/Set +
17
+ * 函数)。默认径的记账由本档代落——`meta.record` ⇒ `recordWrite`、`op="delete"` ⇒ `markDirty`;
18
+ * 调用方不再自行 `recordWrite` / `markDirty`(**不重复记账**)。**唯一例外 = apply_patch 的批级
19
+ * 记账**(两段式全部成功后由调用方一次 `markDirty`——批级语义,与改前逐字同)。`file.mjs` 只做
20
+ * 再导出,保既有 import 面稳定(模块拆分写优先纪律)。
21
+ */
22
+ import { writeFile, unlink, rename, cp, rm } from "node:fs/promises"
23
+
24
+ /** apply_patch 两段式的暂存后缀(单点定义——阶段一落暂存档、阶段二提交、失败清理均用它)。 */
25
+ export const TMP_SUFFIX = ".thincoder-tmp"
26
+
27
+ // ────────────────────────────────────────
28
+ // 记账面(read-before-insert 护栏的数据源)
29
+ // ────────────────────────────────────────
30
+ // insert_after 以行号为锚——最易漂移的定位方式。每个写点把文件标记为 dirty;
31
+ // insert_after 拒绝在 dirty 文件上运行,直到模型重新 read(拿到新行号)。
32
+ const dirtyPaths = new Set()
33
+ export function markDirty(abs) { dirtyPaths.add(abs) }
34
+ export function clearDirty(abs) { dirtyPaths.delete(abs) }
35
+ export function isDirty(abs) { return dirtyPaths.has(abs) }
36
+
37
+ // 写入工具记录受影响行范围——insert_after 精确判定:after_line 在未受影响区
38
+ // (< lastWrite.startLine)→ 行号未漂移 → 允许;受影响区内 → 拒绝(护栏保留);
39
+ // write 全文重写 → 全文件受影响,任何 after_line 拒绝。
40
+ const lastWrites = new Map() // abs → { type: 'write'|'edit'|'insert', startLine, shift }
41
+ export function recordWrite(abs, write) {
42
+ lastWrites.set(abs, write)
43
+ dirtyPaths.delete(abs) // 本 session 写入——等效于刚 read 过(快照在 lastWrites)
44
+ }
45
+ export function lastWriteOf(abs) { return lastWrites.get(abs) }
46
+ export function clearLastWrite(abs) { lastWrites.delete(abs) }
47
+
48
+ // ────────────────────────────────────────
49
+ // 注入面(端侧编辑器径)
50
+ // ────────────────────────────────────────
51
+
52
+ let injected = null
53
+
54
+ /**
55
+ * 覆盖默认写路径(**端装配层**调用;核内不调用——缺省不覆盖)。
56
+ *
57
+ * @param {{openDoc?: (abs: string) => any, isDirty?: (doc: any) => boolean,
58
+ * applyEdit?: (doc: any, content: string, meta: object) => Promise<void>}|null} impl
59
+ * · `openDoc(abs)` → doc | null 该路径是否被宿主打开(**每个写点都会问一次**——门禁面)
60
+ * · `isDirty(doc)` → boolean 打开且脏 ⇒ 拒写(split-brain 护栏,**所有写点一律适用**);
61
+ * **给了 `applyEdit` 就必须给 `isDirty`**(否则抛配置错——否则 = 静默退化为无护栏编辑器径)
62
+ * · `applyEdit(doc, content, meta)` → 端侧应用并落盘(内容写点专用——`applyEdit` + save);
63
+ * 返回 **null** = 未处理 ⇒ 回退默认径(§2.13.5 契约;返回其他值 = 已处理)
64
+ * 传 null / 非对象 ⇒ 撤销注入(回默认 fs 径)。
65
+ */
66
+ export function configureWritePath(impl) {
67
+ injected = impl && typeof impl === "object" ? impl : null
68
+ }
69
+
70
+ /** 撤销注入(测试与端装配生命周期用——缺省态 = 默认 fs 径)。 */
71
+ export function resetWritePath() { injected = null }
72
+
73
+ /** 脏缓冲拒写文案(**端中立**——与宿主侧现措辞同句;S2 接线后用户可见文案由本条统一)。 */
74
+ export const dirtyRefusalMessage = (abs) =>
75
+ `File has unsaved changes in the editor: ${abs}. Save or discard before allowing automated edits.`
76
+
77
+ /**
78
+ * 门禁单点(src 面与 dest 面共用——每面各问一次 `openDoc`):
79
+ * fail-closed(给了 `applyEdit` 却没给 `isDirty` ⇒ 脏态不可知 ⇒ 抛配置错,不静默退化为
80
+ * 无护栏编辑器径);打开且脏 ⇒ 抛端中立拒写文案(split-brain 护栏)。
81
+ * @returns doc | null(未打开 ⇒ null——该面无门禁)
82
+ */
83
+ function gateOpenDoc(impl, abs) {
84
+ const doc = impl.openDoc(abs)
85
+ if (!doc) return null
86
+ if (typeof impl.applyEdit === "function" && typeof impl.isDirty !== "function") {
87
+ throw new Error("writeThroughPath: injection provides applyEdit without isDirty — refusing to write to an open file whose dirty state is unknown")
88
+ }
89
+ if (typeof impl.isDirty === "function" && impl.isDirty(doc)) throw new Error(dirtyRefusalMessage(abs))
90
+ return doc
91
+ }
92
+
93
+ /**
94
+ * 试注入面:命中 ⇒ 编辑器径(`{ written: true, via: "editor" }`);未处理 ⇒ null(回默认)。
95
+ * 未打开(`openDoc` 返回假值)/ 无注入面 / 非内容写点 ⇒ 未处理。
96
+ * **dest 面门禁**(move / rename / **copy** 的覆盖面——父侧裁定补丁,copy 扩面同批):
97
+ * 路径操作会覆盖目标档 ⇒ 目标档与源档**同过门禁**(覆盖「编辑器打开且脏」的目标档 =
98
+ * 直接吞掉用户缓冲,与 src 面同源 split-brain 风险)⇒ 拒写。src 未打开**不豁免** dest 门
99
+ * (两面各问一次)。copy 不写 src、但覆 dest(`cp { force }`)⇒ 同过 dest 门。
100
+ */
101
+ async function tryInjectedPath(abs, content, meta) {
102
+ const impl = injected
103
+ if (!impl?.openDoc) return null
104
+ const op = meta.op ?? "write"
105
+ const doc = gateOpenDoc(impl, abs) // 每个写点都问一次(门禁面——见 configureWritePath)
106
+ if ((op === "move" || op === "rename" || op === "copy") && typeof meta.dest === "string" && meta.dest.length > 0) {
107
+ gateOpenDoc(impl, meta.dest) // dest 面过门禁(覆盖目标档——与 src 同源拒写判据)
108
+ }
109
+ // 编辑器径只承载**内容写点**(op="write");delete / 路径操作(copy/move/rename)与
110
+ // commit(默认径两段式的收尾)无端侧载体 ⇒ 门禁过后回默认径。
111
+ if (!doc || op !== "write") return null
112
+ if (typeof impl.applyEdit !== "function") return null
113
+ const handled = await impl.applyEdit(doc, content, meta)
114
+ if (handled === null) return null // 契约:null = 未处理 ⇒ 回退默认径(防“报成功但无落盘”)
115
+ if (meta.record) recordWrite(abs, meta.record) // 编辑器径同样是「我们写的」——记账同源
116
+ return { written: true, via: "editor" }
117
+ }
118
+
119
+ /**
120
+ * 默认径(无注入面 / 注入面未处理)——**CLI 语义,零行为变**。
121
+ *
122
+ * meta:
123
+ * · `op` "write"(默认)| "delete" | "copy" | "move" | "rename" | "commit"
124
+ * · `record` op="write":写后记账快照(recordWrite 第二参——受影响区)
125
+ * · `dest` op ∈ {copy, move, rename}:目标绝对路径
126
+ * · `stage` op="write":落到 `<abs>.thincoder-tmp`(**不提交、不记账**)——apply_patch
127
+ * 阶段一;提交由阶段二 `op="commit"` 收口(两段式原子保持)。**注入径例外**:
128
+ * 编辑器径无暂存档(即时提交)——此时 stage 写直接落目标档并返回
129
+ * `via:"editor"`,调用方须读 `via` 决定是否跳过 `op="commit"`(apply_patch 已如此)
130
+ */
131
+ async function defaultPath(abs, content, meta) {
132
+ const op = meta.op ?? "write"
133
+ switch (op) {
134
+ case "write": {
135
+ if (meta.stage) {
136
+ await writeFile(abs + TMP_SUFFIX, content, "utf8")
137
+ return { written: true, via: "staged" }
138
+ }
139
+ await writeFile(abs, content, "utf8")
140
+ if (meta.record) recordWrite(abs, meta.record)
141
+ return { written: true, via: "fs" }
142
+ }
143
+ case "commit": {
144
+ await rename(abs + TMP_SUFFIX, abs)
145
+ return { written: true, via: "committed" }
146
+ }
147
+ case "delete": {
148
+ await unlink(abs)
149
+ markDirty(abs)
150
+ return { written: true, via: "fs" }
151
+ }
152
+ case "copy": {
153
+ await cp(abs, meta.dest, { recursive: true, force: true })
154
+ return { written: true, via: "fs" }
155
+ }
156
+ case "move":
157
+ case "rename": {
158
+ // move 与 rename 共享 rename 系统调用;跨设备 move 回退 copy + rm(既有语义)。
159
+ try {
160
+ await rename(abs, meta.dest)
161
+ } catch (e) {
162
+ if (e?.code !== "EXDEV") throw e
163
+ await cp(abs, meta.dest, { recursive: true, force: true })
164
+ await rm(abs, { recursive: true, force: true })
165
+ }
166
+ return { written: true, via: "fs" }
167
+ }
168
+ default:
169
+ throw new Error(`writeThroughPath: unknown op "${op}"`)
170
+ }
171
+ }
172
+
173
+ /**
174
+ * 核内**单一写路径点**——全部工具写点经此落盘。
175
+ *
176
+ * @param {string} abs 目标绝对路径
177
+ * @param {string|null} content 写入内容(op="write" 必填;delete / copy / move / rename 传 null)
178
+ * @param {object} [meta] 见 defaultPath 的字段表
179
+ * @returns {Promise<{written: boolean, via: "editor"|"fs"|"staged"|"committed"}>}
180
+ * `via` = 实际落盘径("editor" = 注入面已提交;"staged" = 已落暂存待 `op="commit"`);
181
+ * `via:"editor"` 与 `meta.stage` 组合 = 编辑器径已直接提交目标档(无暂存档)
182
+ */
183
+ export async function writeThroughPath(abs, content, meta = {}) {
184
+ // `op="commit"` = 默认径两段式的收尾(暂存档 → 目标)——编辑器径是即时提交、不产生暂存
185
+ // 档 ⇒ 无端侧载体,不再问注入面(避免无意义的门禁调用;目标档的门禁已在 staging 问过)。
186
+ if ((meta.op ?? "write") !== "commit") {
187
+ const handled = await tryInjectedPath(abs, content, meta)
188
+ if (handled) return handled
189
+ }
190
+ return await defaultPath(abs, content, meta)
191
+ }