berry-agent 0.1.0-alpha.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 (401) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -0
  3. package/dist/.api-emit.stamp +1 -0
  4. package/dist/.build-meta.json +6 -0
  5. package/dist/agent/events.js +1 -0
  6. package/dist/agent/index.js +2 -0
  7. package/dist/agent/loop.js +139 -0
  8. package/dist/agent/queue.js +119 -0
  9. package/dist/agent/stream.js +62 -0
  10. package/dist/agent/tools-batch.js +221 -0
  11. package/dist/agent/types.js +8 -0
  12. package/dist/api/berry-agent-llm.d.ts +18 -0
  13. package/dist/api/berry-agent.d.ts +9 -0
  14. package/dist/api/surface.json +1632 -0
  15. package/dist/api/tsconfig.paths.json +12 -0
  16. package/dist/api/typebox-compile.d.ts +2 -0
  17. package/dist/api/typebox-value.d.ts +2 -0
  18. package/dist/api/typebox.d.ts +2 -0
  19. package/dist/browser/cdp.js +256 -0
  20. package/dist/browser/codes.js +31 -0
  21. package/dist/browser/discover.js +107 -0
  22. package/dist/browser/engine.js +169 -0
  23. package/dist/browser/index.js +17 -0
  24. package/dist/browser/install.js +342 -0
  25. package/dist/browser/page.js +321 -0
  26. package/dist/browser/service.js +158 -0
  27. package/dist/browser/tools.js +198 -0
  28. package/dist/browser/types.js +81 -0
  29. package/dist/channels/ask-queue.js +82 -0
  30. package/dist/channels/commands.js +129 -0
  31. package/dist/channels/engine/cell.js +143 -0
  32. package/dist/channels/engine/diff.js +143 -0
  33. package/dist/channels/engine/engine.js +416 -0
  34. package/dist/channels/engine/index.js +8 -0
  35. package/dist/channels/engine/input-keys.js +86 -0
  36. package/dist/channels/engine/input.js +596 -0
  37. package/dist/channels/engine/memory-io.js +71 -0
  38. package/dist/channels/engine/process-io.js +38 -0
  39. package/dist/channels/engine/types.js +17 -0
  40. package/dist/channels/engine/width.js +122 -0
  41. package/dist/channels/index.js +15 -0
  42. package/dist/channels/registry.js +56 -0
  43. package/dist/channels/sdk/admit.js +29 -0
  44. package/dist/channels/sdk/backend.js +102 -0
  45. package/dist/channels/sdk/cursor.js +56 -0
  46. package/dist/channels/sdk/index.js +20 -0
  47. package/dist/channels/sdk/jsonl.js +208 -0
  48. package/dist/channels/sdk/protocol.js +24 -0
  49. package/dist/channels/sdk/schema.js +91 -0
  50. package/dist/channels/sdk/wire-core.js +446 -0
  51. package/dist/channels/service.js +169 -0
  52. package/dist/channels/tui/autocomplete/autocomplete.js +42 -0
  53. package/dist/channels/tui/autocomplete/file-mentions.js +106 -0
  54. package/dist/channels/tui/autocomplete/popup.js +148 -0
  55. package/dist/channels/tui/autocomplete/provider.js +7 -0
  56. package/dist/channels/tui/autocomplete/token.js +68 -0
  57. package/dist/channels/tui/backend/ansi-rows.js +211 -0
  58. package/dist/channels/tui/backend/main-screen.js +207 -0
  59. package/dist/channels/tui/backend/osc.js +91 -0
  60. package/dist/channels/tui/backend/transcript.js +230 -0
  61. package/dist/channels/tui/backend/tui-backend.js +915 -0
  62. package/dist/channels/tui/editor/editor-model.js +543 -0
  63. package/dist/channels/tui/editor/editor-view.js +140 -0
  64. package/dist/channels/tui/editor/editor.js +275 -0
  65. package/dist/channels/tui/editor/undo-stack.js +35 -0
  66. package/dist/channels/tui/editor/visual-lines.js +126 -0
  67. package/dist/channels/tui/editor/word-nav.js +106 -0
  68. package/dist/channels/tui/history/history-viewer.js +403 -0
  69. package/dist/channels/tui/index.js +3 -0
  70. package/dist/channels/tui/layout.js +167 -0
  71. package/dist/channels/tui/markdown/blocks.js +111 -0
  72. package/dist/channels/tui/markdown/inline.js +75 -0
  73. package/dist/channels/tui/markdown/markdown.js +182 -0
  74. package/dist/channels/tui/memory/memory-viewer.js +540 -0
  75. package/dist/channels/tui/overlay/alt-screen.js +105 -0
  76. package/dist/channels/tui/overlay/overlay.js +57 -0
  77. package/dist/channels/tui/overlay/select-confirm.js +156 -0
  78. package/dist/channels/tui/panels/todo-panel.js +54 -0
  79. package/dist/channels/tui/panels/tool-progress-panel.js +93 -0
  80. package/dist/channels/tui/scroll/scroll-view.js +254 -0
  81. package/dist/channels/tui/status/status-line.js +76 -0
  82. package/dist/channels/tui/text.js +38 -0
  83. package/dist/channels/tui/theme.js +41 -0
  84. package/dist/channels/types.js +11 -0
  85. package/dist/channels/ui-core.js +209 -0
  86. package/dist/checkpoint/capture.js +45 -0
  87. package/dist/checkpoint/codes.js +33 -0
  88. package/dist/checkpoint/command.js +82 -0
  89. package/dist/checkpoint/gate.js +48 -0
  90. package/dist/checkpoint/index.js +20 -0
  91. package/dist/checkpoint/restore.js +172 -0
  92. package/dist/checkpoint/store.js +247 -0
  93. package/dist/checkpoint/types.js +4 -0
  94. package/dist/checkpoint/walk.js +146 -0
  95. package/dist/compaction/ccr-tools.js +112 -0
  96. package/dist/compaction/ccr.js +84 -0
  97. package/dist/compaction/codes.js +21 -0
  98. package/dist/compaction/index.js +18 -0
  99. package/dist/compaction/policy.js +200 -0
  100. package/dist/compaction/service.js +422 -0
  101. package/dist/compaction/slots.js +121 -0
  102. package/dist/compaction/types.js +10 -0
  103. package/dist/context/codes.js +40 -0
  104. package/dist/context/events.js +160 -0
  105. package/dist/context/index.js +13 -0
  106. package/dist/context/logger.js +136 -0
  107. package/dist/context/scope.js +182 -0
  108. package/dist/context/workspace.js +104 -0
  109. package/dist/contracts/agent-events.d.ts +73 -0
  110. package/dist/contracts/agent-events.js +16 -0
  111. package/dist/contracts/api.d.ts +249 -0
  112. package/dist/contracts/api.js +299 -0
  113. package/dist/contracts/approval.d.ts +37 -0
  114. package/dist/contracts/approval.js +9 -0
  115. package/dist/contracts/env-ref.d.ts +41 -0
  116. package/dist/contracts/env-ref.js +32 -0
  117. package/dist/contracts/errors.d.ts +56 -0
  118. package/dist/contracts/errors.js +266 -0
  119. package/dist/contracts/events.d.ts +70 -0
  120. package/dist/contracts/events.js +329 -0
  121. package/dist/contracts/index.d.ts +32 -0
  122. package/dist/contracts/index.js +26 -0
  123. package/dist/contracts/llm.d.ts +275 -0
  124. package/dist/contracts/llm.js +12 -0
  125. package/dist/contracts/messages.d.ts +63 -0
  126. package/dist/contracts/messages.js +65 -0
  127. package/dist/contracts/redact.d.ts +46 -0
  128. package/dist/contracts/redact.js +223 -0
  129. package/dist/contracts/tools.d.ts +197 -0
  130. package/dist/contracts/tools.js +33 -0
  131. package/dist/contracts/types.d.ts +302 -0
  132. package/dist/contracts/types.js +50 -0
  133. package/dist/contracts/ui.d.ts +122 -0
  134. package/dist/contracts/ui.js +15 -0
  135. package/dist/conversation/agent-service.js +61 -0
  136. package/dist/conversation/approval-wiring.js +82 -0
  137. package/dist/conversation/backoff.js +38 -0
  138. package/dist/conversation/codes.js +47 -0
  139. package/dist/conversation/control-tools.js +102 -0
  140. package/dist/conversation/control.js +210 -0
  141. package/dist/conversation/driver.js +922 -0
  142. package/dist/conversation/index.js +12 -0
  143. package/dist/conversation/model-visible.js +169 -0
  144. package/dist/conversation/open-tools.js +126 -0
  145. package/dist/conversation/reseed.js +132 -0
  146. package/dist/conversation/sessions.js +215 -0
  147. package/dist/conversation/todo.js +203 -0
  148. package/dist/conversation/types.js +35 -0
  149. package/dist/conversation/wiring.js +204 -0
  150. package/dist/credentials/codes.js +59 -0
  151. package/dist/credentials/commands.js +186 -0
  152. package/dist/credentials/env-ref.js +34 -0
  153. package/dist/credentials/index.js +28 -0
  154. package/dist/credentials/migration.js +18 -0
  155. package/dist/credentials/oauth.js +266 -0
  156. package/dist/credentials/refresh.js +152 -0
  157. package/dist/credentials/secrets.js +118 -0
  158. package/dist/credentials/types.js +30 -0
  159. package/dist/exec/bash.js +279 -0
  160. package/dist/exec/codes.js +37 -0
  161. package/dist/exec/env.js +99 -0
  162. package/dist/exec/environment.js +39 -0
  163. package/dist/exec/git-guard.js +623 -0
  164. package/dist/exec/index.js +24 -0
  165. package/dist/exec/registry.js +120 -0
  166. package/dist/exec/spawn.js +281 -0
  167. package/dist/exec/tail.js +77 -0
  168. package/dist/exec/types.js +5 -0
  169. package/dist/goal/codes.js +33 -0
  170. package/dist/goal/command.js +175 -0
  171. package/dist/goal/fold.js +121 -0
  172. package/dist/goal/gates.js +122 -0
  173. package/dist/goal/index.js +22 -0
  174. package/dist/goal/migration.js +48 -0
  175. package/dist/goal/service.js +501 -0
  176. package/dist/goal/todo-tool.js +174 -0
  177. package/dist/goal/types.js +1 -0
  178. package/dist/goal/update-tool.js +46 -0
  179. package/dist/host/approval-cmd.js +276 -0
  180. package/dist/host/assembly.js +1146 -0
  181. package/dist/host/boot-failures.js +104 -0
  182. package/dist/host/budget-advisory.js +74 -0
  183. package/dist/host/budget-broadcast.js +50 -0
  184. package/dist/host/builtins.js +54 -0
  185. package/dist/host/cli.js +544 -0
  186. package/dist/host/codes.js +120 -0
  187. package/dist/host/config-schema.js +195 -0
  188. package/dist/host/conversation-stack.js +654 -0
  189. package/dist/host/core-plugins.js +1685 -0
  190. package/dist/host/credentials-cmd.js +42 -0
  191. package/dist/host/disclosure.js +30 -0
  192. package/dist/host/dispatch.js +105 -0
  193. package/dist/host/doors-cmd.js +154 -0
  194. package/dist/host/dump-config.js +44 -0
  195. package/dist/host/hook-dispatch-guard.js +27 -0
  196. package/dist/host/import-gate.js +153 -0
  197. package/dist/host/index.js +54 -0
  198. package/dist/host/issue-session.js +241 -0
  199. package/dist/host/loader.js +372 -0
  200. package/dist/host/main.js +161 -0
  201. package/dist/host/manifest.js +381 -0
  202. package/dist/host/mcp-entry.js +53 -0
  203. package/dist/host/plugin-boot.js +952 -0
  204. package/dist/host/plugin-context.js +672 -0
  205. package/dist/host/plugin-install.js +480 -0
  206. package/dist/host/plugin-reload.js +124 -0
  207. package/dist/host/plugin-store.js +462 -0
  208. package/dist/host/plugin-tools.js +359 -0
  209. package/dist/host/plugin-uninstall.js +257 -0
  210. package/dist/host/plugins-cmd.js +302 -0
  211. package/dist/host/plugins-command.js +130 -0
  212. package/dist/host/plugins-config.js +195 -0
  213. package/dist/host/prompt-sections.js +133 -0
  214. package/dist/host/run-entry.js +544 -0
  215. package/dist/host/runtime.js +209 -0
  216. package/dist/host/scheduler-tick.js +294 -0
  217. package/dist/host/serve-daemon.js +501 -0
  218. package/dist/host/serve-entry.js +279 -0
  219. package/dist/host/session-anchor.js +41 -0
  220. package/dist/host/sessions-cmd.js +219 -0
  221. package/dist/host/sessions-face.js +47 -0
  222. package/dist/host/settings-store.js +112 -0
  223. package/dist/host/signals.js +69 -0
  224. package/dist/host/single-instance.js +105 -0
  225. package/dist/host/subagent-factory.js +291 -0
  226. package/dist/host/testkit/harness.js +283 -0
  227. package/dist/host/testkit/index.js +14 -0
  228. package/dist/host/testkit/matrix.js +302 -0
  229. package/dist/host/tool-policy-store.js +177 -0
  230. package/dist/host/triggers.js +212 -0
  231. package/dist/host/tui-entry.js +216 -0
  232. package/dist/host/webui-bridge.js +165 -0
  233. package/dist/issue/codes.js +38 -0
  234. package/dist/issue/filter.js +146 -0
  235. package/dist/issue/github.js +116 -0
  236. package/dist/issue/index.js +17 -0
  237. package/dist/issue/mount.js +70 -0
  238. package/dist/issue/poll.js +58 -0
  239. package/dist/issue/service.js +341 -0
  240. package/dist/issue/tools.js +70 -0
  241. package/dist/issue/types.js +34 -0
  242. package/dist/issue/webhook.js +126 -0
  243. package/dist/llm/codes.js +77 -0
  244. package/dist/llm/complete.js +196 -0
  245. package/dist/llm/events.js +15 -0
  246. package/dist/llm/index.js +24 -0
  247. package/dist/llm/inflight.js +65 -0
  248. package/dist/llm/model-id.js +58 -0
  249. package/dist/llm/provider-face.d.ts +32 -0
  250. package/dist/llm/provider-face.js +34 -0
  251. package/dist/llm/recovery.js +146 -0
  252. package/dist/llm/runtime.js +46 -0
  253. package/dist/llm/stream-fn.js +147 -0
  254. package/dist/lsp/codes.js +31 -0
  255. package/dist/lsp/connection.js +147 -0
  256. package/dist/lsp/frame.js +110 -0
  257. package/dist/lsp/index.js +16 -0
  258. package/dist/lsp/inject.js +58 -0
  259. package/dist/lsp/instance.js +264 -0
  260. package/dist/lsp/service.js +522 -0
  261. package/dist/lsp/tools.js +53 -0
  262. package/dist/lsp/types.js +201 -0
  263. package/dist/mcp/bridge.js +222 -0
  264. package/dist/mcp/codes.js +21 -0
  265. package/dist/mcp/index.js +19 -0
  266. package/dist/mcp/jsonrpc.js +230 -0
  267. package/dist/mcp/service.js +125 -0
  268. package/dist/mcp/tools.js +145 -0
  269. package/dist/mcp/types.js +108 -0
  270. package/dist/memory/cite.js +131 -0
  271. package/dist/memory/codes.js +50 -0
  272. package/dist/memory/command.js +71 -0
  273. package/dist/memory/consolidate.js +219 -0
  274. package/dist/memory/cycle.js +109 -0
  275. package/dist/memory/dao.js +878 -0
  276. package/dist/memory/diff.js +238 -0
  277. package/dist/memory/extract.js +181 -0
  278. package/dist/memory/fts.js +58 -0
  279. package/dist/memory/index.js +42 -0
  280. package/dist/memory/inject.js +354 -0
  281. package/dist/memory/merge.js +178 -0
  282. package/dist/memory/migration.js +132 -0
  283. package/dist/memory/pollution.js +59 -0
  284. package/dist/memory/port.js +385 -0
  285. package/dist/memory/review.js +201 -0
  286. package/dist/memory/scan.js +89 -0
  287. package/dist/memory/tools.js +508 -0
  288. package/dist/memory/types.js +140 -0
  289. package/dist/obs/codes.js +38 -0
  290. package/dist/obs/db.js +115 -0
  291. package/dist/obs/index.js +20 -0
  292. package/dist/obs/rollup.js +61 -0
  293. package/dist/obs/service.js +363 -0
  294. package/dist/obs/session-tools.js +282 -0
  295. package/dist/obs/session-view.js +225 -0
  296. package/dist/obs/tool.js +97 -0
  297. package/dist/obs/types.js +1 -0
  298. package/dist/persist/audit.js +63 -0
  299. package/dist/persist/aux.js +39 -0
  300. package/dist/persist/codes.js +35 -0
  301. package/dist/persist/index.js +30 -0
  302. package/dist/persist/load-history.js +50 -0
  303. package/dist/persist/migrations.js +37 -0
  304. package/dist/persist/paths.js +108 -0
  305. package/dist/persist/persistence.js +229 -0
  306. package/dist/persist/schema.js +99 -0
  307. package/dist/persist/secret-box.js +95 -0
  308. package/dist/persist/store.js +706 -0
  309. package/dist/persist/write-behind.js +238 -0
  310. package/dist/safety/approval.js +171 -0
  311. package/dist/safety/bwrap.js +123 -0
  312. package/dist/safety/codes.js +57 -0
  313. package/dist/safety/danger.js +505 -0
  314. package/dist/safety/gate.js +226 -0
  315. package/dist/safety/index.js +24 -0
  316. package/dist/safety/presets.js +73 -0
  317. package/dist/safety/roots.js +139 -0
  318. package/dist/safety/sandbox.js +214 -0
  319. package/dist/safety/seatbelt.js +88 -0
  320. package/dist/safety/sensitive.js +34 -0
  321. package/dist/safety/tool-policy.js +203 -0
  322. package/dist/safety/types.js +13 -0
  323. package/dist/scheduler/codes.js +54 -0
  324. package/dist/scheduler/cron-backend.js +135 -0
  325. package/dist/scheduler/engine.js +280 -0
  326. package/dist/scheduler/gates.js +60 -0
  327. package/dist/scheduler/index.js +25 -0
  328. package/dist/scheduler/migration.js +22 -0
  329. package/dist/scheduler/runner.js +171 -0
  330. package/dist/scheduler/schedule.js +193 -0
  331. package/dist/scheduler/service.js +283 -0
  332. package/dist/scheduler/tick.js +139 -0
  333. package/dist/scheduler/types.js +1 -0
  334. package/dist/sdk/http.js +865 -0
  335. package/dist/sdk/index.js +22 -0
  336. package/dist/sdk/mcp.js +289 -0
  337. package/dist/sdk/plugin-route-registry.js +126 -0
  338. package/dist/sdk/plugin-routes.js +149 -0
  339. package/dist/sdk/security.js +116 -0
  340. package/dist/sdk/types.js +32 -0
  341. package/dist/session/budget.js +159 -0
  342. package/dist/session/codes.js +40 -0
  343. package/dist/session/derive.js +180 -0
  344. package/dist/session/event-data.js +1 -0
  345. package/dist/session/fork.js +72 -0
  346. package/dist/session/import-gates.js +148 -0
  347. package/dist/session/index.js +18 -0
  348. package/dist/session/recover.js +103 -0
  349. package/dist/session/session.js +279 -0
  350. package/dist/session/snapshot.js +107 -0
  351. package/dist/skills/agents.js +242 -0
  352. package/dist/skills/codes.js +51 -0
  353. package/dist/skills/discovery.js +266 -0
  354. package/dist/skills/frontmatter.js +215 -0
  355. package/dist/skills/index.js +28 -0
  356. package/dist/skills/load.js +118 -0
  357. package/dist/skills/manage.js +201 -0
  358. package/dist/skills/registry.js +138 -0
  359. package/dist/skills/render.js +124 -0
  360. package/dist/skills/sections.js +171 -0
  361. package/dist/skills/types.js +21 -0
  362. package/dist/subagent/codes.js +50 -0
  363. package/dist/subagent/declarative.js +55 -0
  364. package/dist/subagent/index.js +19 -0
  365. package/dist/subagent/notify.js +51 -0
  366. package/dist/subagent/provide.js +7 -0
  367. package/dist/subagent/registry.js +149 -0
  368. package/dist/subagent/service.js +338 -0
  369. package/dist/subagent/surface.js +44 -0
  370. package/dist/subagent/tool.js +153 -0
  371. package/dist/subagent/types.js +25 -0
  372. package/dist/tools/apply-patch.js +164 -0
  373. package/dist/tools/codes.js +109 -0
  374. package/dist/tools/fs.js +501 -0
  375. package/dist/tools/index.js +17 -0
  376. package/dist/tools/observed.js +99 -0
  377. package/dist/tools/pipeline.js +176 -0
  378. package/dist/tools/protected-read.js +54 -0
  379. package/dist/tools/registry.js +250 -0
  380. package/dist/tools/search.js +451 -0
  381. package/dist/tools/worktree.js +327 -0
  382. package/dist/web/codes.js +37 -0
  383. package/dist/web/gate.js +43 -0
  384. package/dist/web/hygiene.js +195 -0
  385. package/dist/web/index.js +16 -0
  386. package/dist/web/service.js +207 -0
  387. package/dist/web/ssrf-guard.js +44 -0
  388. package/dist/web/tool.js +76 -0
  389. package/dist/web/types.js +10 -0
  390. package/dist/webui/assets/index-B-t2O8gh.js +22 -0
  391. package/dist/webui/assets/index-DOlgSZ9Q.css +2 -0
  392. package/dist/webui/index.html +14 -0
  393. package/dist/webui/index.js +14 -0
  394. package/dist/webui/server.js +549 -0
  395. package/dist/webui/types.js +38 -0
  396. package/examples/README.md +26 -0
  397. package/examples/minimal-code-plugin/entry.js +43 -0
  398. package/examples/minimal-code-plugin/package.json +12 -0
  399. package/examples/pure-skill-pack/package.json +14 -0
  400. package/examples/pure-skill-pack/skills/markdown-table/SKILL.md +32 -0
  401. package/package.json +86 -0
@@ -0,0 +1,501 @@
1
+ /**
2
+ * L2 tools — fs 工具族(04 §7 fs 族段:read / write / edit / ls 四件;检索
3
+ * 族 find/grep 挂批 7b)。
4
+ *
5
+ * 三道防线的关系(04 §7——三关全过才落盘):
6
+ * - fence(containment):写/删目标 canonical 化后必须在可写根内。根经
7
+ * writableRoots provider 注入(safety 件的推导函数——本批未落,缺省
8
+ * workspace + 系统临时目录过渡);进程内 canonicalize-then-contain 是
9
+ * 防误操作护栏,非 security boundary;
10
+ * - 观察态 CAS(observed.ts):未读拒写 + 指纹守卫——「写的内容是否基于
11
+ * 最新观察」;
12
+ * - 补丁定位(apply-patch.ts):context 行锚匹配——「改的位置是否还在」。
13
+ *
14
+ * 编码纪律(04 §7,本仓裁决:UTF-8 严格、无逃生参数):文本读 = strict
15
+ * TextDecoder(fatal)+ UTF-8 BOM 剥离照读;lossy 即 FS_DECODE_NON_UTF8
16
+ * 拒(绝不静默 mojibake 进上下文)。ACP 决策树/本地码页转码/encoding 参数
17
+ * 挂真实需求另裁(蓝本独有,规范未纳)。
18
+ *
19
+ * edit 两阶段编排:补丁全部操作先「解析 + fence + CAS + 定位」校验并计算
20
+ * 目标内容,全过才顺序落盘——语义错误(定位失败/未读/CAS 冲突/非 UTF-8)
21
+ * 全部前置暴露,非原子窗口(跨文件顺序应用、无回滚——规范声明语义)只剩
22
+ * 物理写失败一种。
23
+ *
24
+ * 写串行链(04 §7 写串行链条):全部写路径(write 全段 / edit 两阶段全段)
25
+ * 经 per-canonical-path **模块级**链互斥——多会话并发写同一物理文件时,后
26
+ * 到写者的 stat→CAS 在前驱落盘后才跑,观察指纹必然过期被拒,不再有「两写
27
+ * 者都过 CAS、后写静默覆盖先写」的丢失更新。链与实例无关(物理文件系统
28
+ * 只有一块——挂实例即漏互斥)。
29
+ */
30
+ import { basename, dirname, extname, isAbsolute, join, resolve as resolvePath, sep } from 'node:path';
31
+ import { tmpdir } from 'node:os';
32
+ import { open, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises';
33
+ import { Type } from 'typebox';
34
+ import { BaseError } from '../contracts/index.js';
35
+ import { canonicalWorkspaceRoot } from '../context/index.js';
36
+ import { addLinesToContent, applyUpdateLines, parseApplyPatch } from './apply-patch.js';
37
+ import { ObservedFiles, requireObservedForEdit, resolveWriteIntent, statVersion } from './observed.js';
38
+ import { assertInodeNotProtected, rejectProtectedReadPath } from './protected-read.js';
39
+ /**
40
+ * 图片扩展名 → MIME(read 图片分支识别表)。按扩展名识别不做魔数嗅探:
41
+ * 工具语义是「读给模型看」,伪图片由模型侧自然暴露——不为它加嗅探器。
42
+ */
43
+ const IMAGE_MIME_BY_EXT = {
44
+ ['.png']: 'image/png',
45
+ ['.jpg']: 'image/jpeg',
46
+ ['.jpeg']: 'image/jpeg',
47
+ ['.gif']: 'image/gif',
48
+ ['.webp']: 'image/webp',
49
+ };
50
+ /** 纯文本结果快捷构造(details 可选进结构化明细面) */
51
+ function textResult(text, details) {
52
+ return { content: [{ type: 'text', text }], ...(details !== undefined ? { details } : {}) };
53
+ }
54
+ /**
55
+ * canonical 化绝对路径:在场路径走 realpath(解析符号链);不在场路径回退
56
+ * 「最近在场祖先 realpath + 拼回尾部段」——保证 fence 比较双方都是真实位
57
+ * 置:可写根内 symlink 指向根外的逃逸在 contain 检查处暴露(canonical 化
58
+ * 后即出根)。递归上溯至文件系统根兜底(根自身无父)。
59
+ */
60
+ export async function canonicalize(abs) {
61
+ try {
62
+ return await realpath(abs);
63
+ }
64
+ catch {
65
+ const parent = dirname(abs);
66
+ if (parent === abs)
67
+ return abs; // 到达文件系统根
68
+ const canonicalParent = await canonicalize(parent);
69
+ return join(canonicalParent, basename(abs));
70
+ }
71
+ }
72
+ /**
73
+ * child 是否位于 root 内:相等或隔分隔符的前缀(防 /root 与 /root-evil 误
74
+ * 判)。root 为文件系统根 sep(全盘可写形态的根)时任意绝对路径皆命中——
75
+ * 特判与 safety 件(未落)将来的同款判定同语义,不 cross-import 防成环。
76
+ */
77
+ function isInside(child, root) {
78
+ const prefix = root === sep ? sep : root + sep;
79
+ return child === root || child.startsWith(prefix);
80
+ }
81
+ /** 当前盘上指纹(`${size}:${mtimeMs}`);文件不在返回 undefined */
82
+ async function currentVersion(abs) {
83
+ try {
84
+ const s = await stat(abs);
85
+ return statVersion(s.size, s.mtimeMs);
86
+ }
87
+ catch (err) {
88
+ if (err.code === 'ENOENT')
89
+ return undefined;
90
+ throw err; // EACCES 等真实 I/O 错误照常上抛(工具失败面)
91
+ }
92
+ }
93
+ /* ------------------------------------------------------------------ */
94
+ /* 写串行链(04 §7 写串行链条——per-canonical-path 模块级全局互斥) */
95
+ /* ------------------------------------------------------------------ */
96
+ /**
97
+ * per-canonical-path 写链尾登记(**模块级**——跨 createFsTools 实例共享:
98
+ * 多会话各持一套 fs 族、子代理每子一套,但物理文件系统只有一块;链粒度 =
99
+ * 物理路径,不挂任何实例/注册表——挂实例即漏互斥)。键 = canonical 绝对
100
+ * 路径;值 = 最近写操作的占位 promise(已 settle 的旧值等价「空闲」,故
101
+ * 值等价自清不影响语义)。
102
+ */
103
+ const writeChains = new Map();
104
+ /**
105
+ * 写操作互斥段(「同步原子段安装占位链尾」形态——互斥安装零 await):
106
+ *
107
+ * 1. 同步原子段(零 await):捕获全部涉及路径的当前链尾 + 将自身**占位**
108
+ * 安装为各路径新链尾(多路径共享同一占位对象——edit 跨文件时的全序锚);
109
+ * 2. 等待前驱(Promise.all——等待边恒指向安装更早者,图无环无死锁);
110
+ * 3. 执行操作本体;
111
+ * 4. settle 占位(无论成败——锁即释放,操作错误原样上抛)+ 值等价自清
112
+ * (某路径链尾仍是本占位才删键,防 Map 随路径集合无界增长)。
113
+ *
114
+ * 互斥原理:执行期各路径链尾恒为本操作占位——并发者在自己的原子段读到的
115
+ * 是「链尾已占」而非「已 settle 的旧值」,必然排在本操作之后。
116
+ *
117
+ * @param paths 本次操作涉及的 canonical 路径全集(write 单路径;edit 多路径)
118
+ * @param op 操作本体(互斥段内执行——覆盖 stat→CAS→物理写→观察回填全段)
119
+ */
120
+ export async function serializeWrites(paths, op) {
121
+ // 占位 promise:resolver 手持,settle 时机完全归本函数的 finally
122
+ let release;
123
+ const placeholder = new Promise((resolve) => {
124
+ release = resolve;
125
+ });
126
+ // 同步原子段:先捕获前驱再安装占位——两步之间零 await,并发者不可能插入。
127
+ // 前驱两源(树级互斥〔04 §7 worktree 条补钉③〕):① 自身键在飞写;
128
+ // ② **树级在飞占位**——键为本次路径的祖先目录(worktree create/clean 经
129
+ // serializeTreeWrites 以树根入链):树操作在飞时,其域内文件写等待树操作。
130
+ // 键域分隔符边界判据(isUnderPath)保证文件键之间永不误配:一键是另一键
131
+ // 的祖先目录前缀时,两者不可能同时是普通文件路径(物理上矛盾)。
132
+ const priors = [];
133
+ for (const p of paths) {
134
+ const own = writeChains.get(p);
135
+ if (own !== undefined)
136
+ priors.push(own);
137
+ for (const [key, ph] of writeChains) {
138
+ if (key !== p && isUnderPath(p, key))
139
+ priors.push(ph);
140
+ }
141
+ }
142
+ for (const p of paths)
143
+ writeChains.set(p, placeholder);
144
+ try {
145
+ await Promise.all(priors);
146
+ return await op();
147
+ }
148
+ finally {
149
+ release(); // settle 占位:等待者放行(与本操作成败无关)
150
+ // 值等价自清:链尾仍指本占位才删(并发者已装上自己的占位时不动他者)
151
+ for (const p of paths) {
152
+ if (writeChains.get(p) === placeholder)
153
+ writeChains.delete(p);
154
+ }
155
+ }
156
+ }
157
+ /** child 是否落在 ancestor 目录域内(分隔符边界——`/a/bc` 不算 `/a/b` 下) */
158
+ function isUnderPath(child, ancestor) {
159
+ const prefix = ancestor.endsWith(sep) ? ancestor : ancestor + sep;
160
+ return child.startsWith(prefix);
161
+ }
162
+ /**
163
+ * 树级写互斥(04 §7 worktree 条补钉③——create/clean 以 worktree 根 canonical
164
+ * 路径入链的执法体)。与 serializeWrites 的差异在**前缀域**:等待集 = 所有链键
165
+ * 落在 root 下(含 root 自身)的在途写;阻塞集 = 在飞期间任何链键落在 root 下
166
+ * 的文件级 serializeWrites(其前驱扫描会看到 root 占位——isUnderPath 祖先向)
167
+ * 及并发的其他树级操作(同键竞争)。扫描与安装同一同步原子段(零 await)——
168
+ * JS 单线程保证两向握手无交错窗口。树根占位值等价自清同 serializeWrites。
169
+ */
170
+ export async function serializeTreeWrites(root, op) {
171
+ let release;
172
+ const placeholder = new Promise((resolve) => {
173
+ release = resolve;
174
+ });
175
+ // 同步原子段:等待集(root 自身 + root 下全部在飞文件写)+ 安装占位
176
+ const priors = [];
177
+ for (const [key, ph] of writeChains) {
178
+ if (key === root || isUnderPath(key, root))
179
+ priors.push(ph);
180
+ }
181
+ writeChains.set(root, placeholder);
182
+ try {
183
+ await Promise.all(priors);
184
+ return await op();
185
+ }
186
+ finally {
187
+ release();
188
+ if (writeChains.get(root) === placeholder)
189
+ writeChains.delete(root);
190
+ }
191
+ }
192
+ /**
193
+ * 互斥段内写目标漂移重验:写链只互斥宿主写者——链外共享写者(外部进程对
194
+ * workspace 的直接 OS 写权)不受链约束,可在链外 canonicalize〔T0〕→ 段
195
+ * 内物理写〔T1〕窗口把任一父组件 swap 成符号链,writeFile/rm 跟随即宿主
196
+ * 全权写出 fence 外(fence 只在链外验过一次,对 T1 真实落点不再过问)。
197
+ * 修法 = 物理写前重跑 canonicalize 与链外定键比对,漂移即拒(fail-closed)。
198
+ * 调用形态约束:重验完成与物理写之间零 await——重验是物理写前的最后一跳,
199
+ * 残窗收敛至 realpath 走查与 open 提交之间的指令级窗(治本 = 父目录 fd 锚
200
+ * 定或 temp+rename,挂真实攻击面拉动)。
201
+ *
202
+ * @param abs 用户拼写路径(重跑 canonicalize 的输入——与 T0 定键同源)
203
+ * @param canonical 链外推导定键(T0 值)——比对基准
204
+ */
205
+ export const assertTargetStable = async (abs, canonical) => {
206
+ const nowCanonical = await canonicalize(abs);
207
+ if (nowCanonical !== canonical) {
208
+ throw new BaseError('FS_WRITE_TARGET_DRIFTED', `[FS_WRITE_TARGET_DRIFTED] 写目标在互斥段内漂移:${abs} 现规范化 ${nowCanonical} ≠ 定键 ${canonical}(疑似父组件被符号链交换——拒绝落盘;请重新执行写操作)`);
209
+ }
210
+ };
211
+ /* ---------------- 编码纪律:UTF-8 严格解码 ---------------- */
212
+ /** UTF-8 BOM 字节序列(EF BB BF——在场即剥离照读,不进内容) */
213
+ const UTF8_BOM = '';
214
+ /**
215
+ * 严格 UTF-8 解码(04 §7 编码纪律):fatal TextDecoder——任何非法字节序
216
+ * 列 lossy 即抛 FS_DECODE_NON_UTF8(绝不产 U+FFFD 乱码进上下文);UTF-8
217
+ * BOM 剥离照读。无本地码页回退、无逃生参数(挂真实需求另裁)。
218
+ */
219
+ function decodeUtf8Strict(raw) {
220
+ const decoder = new TextDecoder('utf-8', { fatal: true });
221
+ let text;
222
+ try {
223
+ text = decoder.decode(raw);
224
+ }
225
+ catch {
226
+ throw new BaseError('FS_DECODE_NON_UTF8', '[FS_DECODE_NON_UTF8] 文件非 UTF-8 编码(严格解码 lossy)——本仓文本面只认 UTF-8;如需处理其他编码文件请在 bash 侧转档后读写');
227
+ }
228
+ // BOM 剥离(首字符 U+FEFF)——BOM 是传输层标记不是内容
229
+ return text.startsWith(UTF8_BOM) ? text.slice(UTF8_BOM.length) : text;
230
+ }
231
+ /** 终态文本保头截断(至多 maxBytes 字节;截点落在多字节字符中间时回退到
232
+ * 该字符起点——丢一个不完整字符,不产 U+FFFD 尾巴) */
233
+ function headUtf8(buf, maxBytes) {
234
+ let end = Math.min(buf.length, maxBytes);
235
+ while (end > 0 && (buf[end] & 0xc0) === 0x80)
236
+ end--;
237
+ return buf.subarray(0, end).toString('utf8');
238
+ }
239
+ /**
240
+ * 组装 fs 工具族(read / write / edit / ls)。观察表本函数创建并在族内共
241
+ * 享——「读过什么」是工具族级状态(per-driver 一套:装配层把族注册进驱
242
+ * 动层,观察态随会话生命周期)。
243
+ */
244
+ export function createFsTools(opts = {}) {
245
+ const workspace = opts.workspace ?? (() => canonicalWorkspaceRoot());
246
+ const writableRoots = opts.writableRoots ?? (() => [workspace(), tmpdir()]); // 过渡缺省(不随档位);host 装配批换 safety.createRootsProvider
247
+ const maxReadBytes = opts.maxReadBytes ?? 256 * 1024;
248
+ const maxImageBytes = opts.maxImageBytes ?? 5 * 1024 * 1024;
249
+ const protectedReadFiles = opts.protectedReadFiles ?? (() => []);
250
+ const observed = new ObservedFiles();
251
+ /** 用户给出路径 → 绝对路径(相对路径锚 workspace;isAbsolute 直 resolve) */
252
+ const resolveTarget = (p) => (isAbsolute(p) ? resolvePath(p) : resolvePath(workspace(), p));
253
+ /**
254
+ * 读侧 open-handle 守卫读(04 §7 定形④——2026-09-08 P0①):open 一次解析
255
+ * 路径,fstat 钉住「真正打开的那个 inode」过 inode 判后从句柄读全量——
256
+ * 一判收口硬链别名(canonical 路径不同而 inode 相同)与 TOCTOU 换靶
257
+ * (canonicalize 与 open 之间路径组件被换)两攻击面。read 显式读与 edit
258
+ * 阶段一隐式内容读同律过此门。
259
+ */
260
+ const readGuarded = async (abs) => {
261
+ const fh = await open(abs, 'r');
262
+ try {
263
+ const fst = await fh.stat();
264
+ await assertInodeNotProtected(abs, fst, protectedReadFiles);
265
+ return await fh.readFile();
266
+ }
267
+ finally {
268
+ await fh.close();
269
+ }
270
+ };
271
+ /**
272
+ * 写路径 fence:canonical 化后必须在某可写根内(根同样 canonical 化后
273
+ * 比对)。只拦写/删——读任意位置允许(coding 场景读系统文件是常态)。
274
+ * @returns canonical 化后的写目标(链键 + 物理写目标同源)
275
+ */
276
+ const assertWritable = async (abs) => {
277
+ const canonical = await canonicalize(abs);
278
+ for (const root of writableRoots()) {
279
+ const canonicalRoot = await canonicalize(resolvePath(root));
280
+ if (isInside(canonical, canonicalRoot))
281
+ return canonical;
282
+ }
283
+ throw new BaseError('FS_OUTSIDE_WRITABLE_ROOTS', `[FS_OUTSIDE_WRITABLE_ROOTS] 写目标不在可写根内:${abs}(可写根:${writableRoots().join('、')})`);
284
+ };
285
+ /* ---------------- read:观察登记的唯一天然入口 ---------------- */
286
+ const readTool = {
287
+ name: 'read',
288
+ effect: 'read',
289
+ description: '读取文件内容。文本按 UTF-8 严格解码(带 BOM 自动剥离;非 UTF-8 报错,转档请走 bash)。图片文件(png/jpg/jpeg/gif/webp)返回 image 内容块可直接看图(上限 5MiB)。读取即登记观察态:后续 write/edit 必须基于本观察(文件被改动过会被版本守卫拒绝)。文件不存在时报错,但同样登记「不存在」观察(之后 write 创建该路径即合法)。',
290
+ parameters: Type.Object({
291
+ path: Type.String({ description: '文件路径(相对路径锚工作区根)' }),
292
+ }),
293
+ execute: async (args) => {
294
+ const abs = resolveTarget(args.path);
295
+ // 读侧 carve-out 路径判(04 §7——2026-09-08 P0①):deny 先于存在性检查
296
+ // (缺席同拒同文案——不暴露敏感件存在性差异),且不登记观察(拒读不构
297
+ // 成「看过」)。canonical 化即剥符号链别名(读 /alias→secret.key 同拒)
298
+ const canonical = await canonicalize(abs);
299
+ rejectProtectedReadPath(canonical, protectedReadFiles);
300
+ const version = await currentVersion(abs);
301
+ if (version === undefined) {
302
+ // 不在 = 错误 + 登记 absent 观察(调用失败但观察语义成立:模型看过「这里没有文件」)
303
+ observed.observeAbsent(abs);
304
+ throw new BaseError('FS_NOT_FOUND', `[FS_NOT_FOUND] 文件不存在:${abs}`);
305
+ }
306
+ // 图片分支:按扩展名识别 → image 块(base64 + mimeType)。不走文本
307
+ // 截断护栏——图片自有界(管道输出护栏「只钳文本」同口径)
308
+ const imageMime = IMAGE_MIME_BY_EXT[extname(abs).toLowerCase()];
309
+ if (imageMime !== undefined) {
310
+ // open-handle 守卫读:改扩展名不改判据(inode 判与内容无关——04 §7)
311
+ const raw = await readGuarded(abs);
312
+ if (raw.byteLength > maxImageBytes) {
313
+ // 超限 = 可预期输入问题:isError 结果面拒绝(模型可自纠——压缩/裁剪
314
+ // 后重读或放弃);不 throw 不截断(base64 截断 = 损坏图片无意义)
315
+ return {
316
+ content: [
317
+ {
318
+ type: 'text',
319
+ text: `图片过大:${abs}(${raw.byteLength} 字节 > 上限 ${maxImageBytes} 字节)。请压缩或裁剪后重读。`,
320
+ },
321
+ ],
322
+ isError: true,
323
+ details: { path: abs, bytes: raw.byteLength, limit: maxImageBytes, image: true, rejected: 'too-large' },
324
+ };
325
+ }
326
+ observed.observePresent(abs, version);
327
+ return {
328
+ content: [
329
+ { type: 'text', text: `${abs}(图片 ${imageMime},${raw.byteLength} 字节)` },
330
+ { type: 'image', data: raw.toString('base64'), mimeType: imageMime },
331
+ ],
332
+ details: { path: abs, bytes: raw.byteLength, mimeType: imageMime, image: true },
333
+ };
334
+ }
335
+ // 文本分支:字节原样读入(open-handle 守卫——同过 inode 判)→ 严格
336
+ // UTF-8 解码(lossy 即拒——绝不 mojibake)
337
+ const raw = await readGuarded(abs);
338
+ const text = decodeUtf8Strict(raw);
339
+ // 截断护栏:保头 maxBytes 字节(UTF-8 安全截点)+ 非静默注记
340
+ const truncated = Buffer.byteLength(text, 'utf8') > maxReadBytes;
341
+ const content = truncated ? headUtf8(Buffer.from(text, 'utf8'), maxReadBytes) : text;
342
+ observed.observePresent(abs, version);
343
+ const truncNote = truncated ? `\n…(已截断至 ${maxReadBytes} 字节,完整内容请分段读取)` : '';
344
+ return textResult(`${content}${truncNote}`, {
345
+ path: abs,
346
+ bytes: Buffer.byteLength(text, 'utf8'),
347
+ truncated,
348
+ });
349
+ },
350
+ };
351
+ /* ---------------- write:按观察态分派 create/replace ---------------- */
352
+ const writeTool = {
353
+ name: 'write',
354
+ effect: 'write',
355
+ description: '写文件(整体替换内容)。运行时按观察态自动分派:从未读过且已存在 → 拒绝(先 read);读过 → 仅当读取后未被修改才允许替换(版本守卫);读时不存在 → 创建合法。写入成功即更新观察。写入一律按 UTF-8 落盘。',
356
+ parameters: Type.Object({
357
+ path: Type.String({ description: '目标文件路径(相对路径锚工作区根)' }),
358
+ content: Type.String({ description: '完整文件内容(整体替换,非追加)' }),
359
+ }),
360
+ execute: async (args) => {
361
+ const abs = resolveTarget(args.path);
362
+ // 键推导先行:fence + canonical 化在链外完成——链键与物理写目标同为
363
+ // 本操作定死的 canonical 路径(writeFile 落真实位置而非符号链拼写;
364
+ // 观察键维持用户拼写 abs——read/write 同拼写一致,跨拼写别名是既有语义)
365
+ const canonical = await assertWritable(abs);
366
+ return serializeWrites([canonical], async () => {
367
+ const current = await currentVersion(canonical);
368
+ // CAS 分派:未读→create-if-absent;absent 观察→create;present→指纹守卫
369
+ const intent = resolveWriteIntent(observed.get(abs), current === undefined ? undefined : { version: current });
370
+ // 段内漂移重验:重验完成与物理写零 await 相接——swap 窗口收口
371
+ await assertTargetStable(abs, canonical);
372
+ await writeFile(canonical, args.content, 'utf8');
373
+ // 写后回填观察:刚写入的内容即最新事实版本(立即 stat 产指纹,紧随
374
+ // 的再次写不需重读)
375
+ const after = await currentVersion(canonical);
376
+ if (after !== undefined)
377
+ observed.observePresent(abs, after);
378
+ return textResult(`已写入 ${abs}(${intent.kind === 'create-if-absent' ? '新建' : '替换'},${Buffer.byteLength(args.content, 'utf8')} 字节)`, {
379
+ path: abs,
380
+ kind: intent.kind,
381
+ bytes: Buffer.byteLength(args.content, 'utf8'),
382
+ });
383
+ });
384
+ },
385
+ };
386
+ /* ---------------- edit:apply_patch 补丁(两阶段:全检后写) ---------------- */
387
+ const editTool = {
388
+ name: 'edit',
389
+ effect: 'write',
390
+ description: '按 apply_patch 补丁格式编辑文件(一次补丁可改多文件:Update File / Add File / Delete File)。Update/Delete 的目标必须先 read 过;全部校验通过后才落盘(跨文件顺序应用,非原子)。只接受 UTF-8 文件。',
391
+ parameters: Type.Object({
392
+ patch: Type.String({
393
+ description: 'apply_patch 格式补丁文本,形如:\n*** Begin Patch\n*** Update File: path\n context\n-old\n+new\n*** Add File: new.txt\n+content\n*** Delete File: old.txt\n*** End Patch',
394
+ }),
395
+ }),
396
+ execute: async (args) => {
397
+ const ops = parseApplyPatch(args.patch);
398
+ // 键推导先行:逐 op fence + canonical 化在链外完成——本补丁涉及的全
399
+ // 部 canonical 路径即链键全集(Map 去重;fence 每文件单独过——补丁
400
+ // 夹带根外目标逐个暴露)
401
+ const targets = new Map();
402
+ for (const op of ops) {
403
+ const abs = resolveTarget(op.path);
404
+ const canonical = await assertWritable(abs);
405
+ // 读侧 carve-out 路径判(04 §7 定形⑤——2026-09-08 P0①):edit 的隐式
406
+ // 内容读(update 前置读)同过保护面;fence/canonicalize 先于内容读,
407
+ // 路径判紧随 fence——三 op 全拒(update 隐式读 / add 造敏感件 / delete
408
+ // 篡改敏感件同面,敏感件归 persist 自管不归 fs 工具族)
409
+ rejectProtectedReadPath(canonical, protectedReadFiles);
410
+ targets.set(canonical, { op, abs });
411
+ }
412
+ // 两阶段全段入链:阶段一的读-CAS-算内容与阶段二的顺序落盘在同一互
413
+ // 斥段内(阶段间窗口的并发写会让「已校验内容」过期——全段互斥才闭合)
414
+ return serializeWrites([...targets.keys()], async () => {
415
+ /** 阶段一产物:通过全部校验、目标内容已就绪的待应用操作 */
416
+ const planned = [];
417
+ for (const [canonical, { op, abs }] of targets) {
418
+ const current = await currentVersion(canonical);
419
+ const currentRef = current === undefined ? undefined : { version: current };
420
+ if (op.kind === 'update') {
421
+ // 编辑守卫:必须已读(present)且指纹一致;内容在阶段一就算好
422
+ //(定位失败前置暴露——不留到半途落盘才发现)
423
+ requireObservedForEdit(observed.get(abs), currentRef);
424
+ // 前置读同 read 口径严格 UTF-8 + open-handle 守卫(04 §7 定形④⑤
425
+ //——隐式读同过 inode 判:防补丁路径硬链/换靶读到敏感件);非
426
+ // UTF-8 一律拒改(防转码回写毁档);改写通道 = read 后 write 全文
427
+ // 替换(按 UTF-8 落盘)
428
+ const raw = await readGuarded(canonical);
429
+ const text = decodeUtf8Strict(raw);
430
+ planned.push({ op, abs, canonical, content: applyUpdateLines(abs, text, op.lines) });
431
+ }
432
+ else if (op.kind === 'add') {
433
+ if (currentRef !== undefined) {
434
+ throw new BaseError('FS_PATCH_FAILED', `[FS_PATCH_FAILED] *** Add File: ${abs} 目标已存在——修改已有文件请用 Update File`);
435
+ }
436
+ planned.push({ op, abs, canonical, content: addLinesToContent(op.lines) });
437
+ }
438
+ else {
439
+ // 删除守卫与 update 同款:删之前必须读过(知道删的是什么)
440
+ requireObservedForEdit(observed.get(abs), currentRef);
441
+ planned.push({ op, abs, canonical });
442
+ }
443
+ }
444
+ /* 阶段二:顺序应用(无回滚——语义错误已在阶段一全部暴露,只剩物理
445
+ 写失败;物理写走 canonical,观察回填走用户拼写——与 write 同口径) */
446
+ const summary = [];
447
+ /** 结构化操作账(消费面 = 后续诊断注入等按 op 分型的面) */
448
+ const operations = [];
449
+ for (const item of planned) {
450
+ // 段内漂移重验:每个物理写(writeFile/rm)前逐项重验——与物理写
451
+ // 零 await 相接,多文件补丁不因前项耗时给后项留窗
452
+ await assertTargetStable(item.abs, item.canonical);
453
+ if (item.op.kind === 'delete') {
454
+ await rm(item.canonical);
455
+ summary.push(`deleted ${item.op.path}`);
456
+ operations.push({ op: 'delete', path: item.canonical });
457
+ continue;
458
+ }
459
+ await writeFile(item.canonical, item.content, 'utf8');
460
+ const after = await currentVersion(item.canonical);
461
+ if (after !== undefined)
462
+ observed.observePresent(item.abs, after);
463
+ summary.push(`${item.op.kind === 'add' ? 'added' : 'updated'} ${item.op.path}`);
464
+ operations.push({ op: item.op.kind, path: item.canonical });
465
+ }
466
+ return textResult(`补丁已应用(${summary.length} 个操作):\n${summary.join('\n')}`, {
467
+ operations,
468
+ });
469
+ });
470
+ },
471
+ };
472
+ /* ---------------- ls:目录列举(不登记观察——不构成内容观察) ---------------- */
473
+ const lsTool = {
474
+ name: 'ls',
475
+ effect: 'read',
476
+ description: '列出目录内容(名称 + 类型,目录带尾斜杠)。缺省列工作区根。',
477
+ parameters: Type.Object({
478
+ path: Type.Optional(Type.String({ description: '目录路径(缺省工作区根)' })),
479
+ }),
480
+ execute: async (args) => {
481
+ const abs = resolveTarget(args.path ?? '.');
482
+ let entries;
483
+ try {
484
+ entries = await readdir(abs, { withFileTypes: true });
485
+ }
486
+ catch (err) {
487
+ if (err.code === 'ENOENT') {
488
+ throw new BaseError('FS_NOT_FOUND', `[FS_NOT_FOUND] 目录不存在:${abs}`);
489
+ }
490
+ throw err;
491
+ }
492
+ entries.sort((a, b) => a.name.localeCompare(b.name));
493
+ const lines = entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
494
+ return textResult(lines.length > 0 ? lines.join('\n') : '(空目录)', {
495
+ path: abs,
496
+ count: entries.length,
497
+ });
498
+ },
499
+ };
500
+ return { tools: [readTool, writeTool, editTool, lsTool], observed };
501
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * tools 件公开面(三段管道 + 两层注册表 + fs 工具族;04 篇 §7)。
3
+ *
4
+ * 单向 DAG:tools → contracts + context(02 篇 §4 模块表 L2 席)。conversation
5
+ * /safety/exec 等后续件经本面消费:safety 守门行订阅 tools_pre_execute、
6
+ * conversation 经注册表 agentToolsFor 取 loop 工具面、exec/bash 复用写串行
7
+ * 链。错误码注册(codes.ts)随本面引入生效——与 llm/session 的 codes.ts 同
8
+ * 款纪律(写入点文件实际 import 注册才发生)。
9
+ */
10
+ import './codes.js';
11
+ export { createToolPipeline, OUTPUT_GUARD_BYTES } from './pipeline.js';
12
+ export { createToolRegistry, scanToolDescription, toAgentTool, TOOL_TIMEOUT_FLOOR_MS } from './registry.js';
13
+ export { ObservedFiles, statVersion, resolveWriteIntent, requireObservedForEdit } from './observed.js';
14
+ export { parseApplyPatch, applyUpdateLines, addLinesToContent } from './apply-patch.js';
15
+ export { createFsTools, canonicalize, serializeWrites, serializeTreeWrites, assertTargetStable } from './fs.js';
16
+ export { createSearchTools, globToRegExp } from './search.js';
17
+ export { createWorktreeService, createWorktreeTools, GIT_METADATA_COMMANDS } from './worktree.js';
@@ -0,0 +1,99 @@
1
+ /**
2
+ * L2 tools — fs 观察态 CAS(04 §7 fs 族:先读后写结构纪律的判定核心)。
3
+ *
4
+ * 模型对文件的写意图按「观察态 × 当前盘上状态」自动分派(运行时判定,非
5
+ * 模型显式传版本号——模型只须遵守「先 read 后写」工作流):
6
+ *
7
+ * 从未读过(无记录) → create-if-absent:目标已在盘上 = 覆盖从未见过的
8
+ * 内容,FS_NOT_OBSERVED 拒(先 read 观察);
9
+ * 读过且在(present) → replace-if-version:当前 stat 指纹与观察指纹一
10
+ * 致才放行替换,不符 FS_VERSION_CONFLICT(丢失更
11
+ * 新守卫);读后文件消失也 FS_VERSION_CONFLICT;
12
+ * 读过且不在(absent) → create-if-absent:读时不存在、此刻却出现 = 他方
13
+ * 并发创建,FS_VERSION_CONFLICT。
14
+ *
15
+ * edit/delete 额外守卫 requireObservedForEdit:补丁编辑必须基于 present 观
16
+ * 察(未读/读时不在都拒)——补丁是「对已见内容的增量改动」。
17
+ *
18
+ * 与 fence 正交:fence(可写根 containment)管「允不允许写这里」;CAS 管
19
+ * 「写的内容是否基于最新观察」——两关全过才落盘。
20
+ *
21
+ * 观察键语义(04 §7 CAS 段):登记键 = 用户拼写 abs(resolveTarget 直出
22
+ * ——模型用它拼写过的路径是它心智中的同一文件);物理写目标 = canonical
23
+ * 键(跨拼写别名在盘上本就同一物——指纹比对的物证面)。
24
+ */
25
+ import { BaseError } from '../contracts/index.js';
26
+ /** 由 stat 产版本指纹——size 与 mtimeMs 组合:内容变更或同尺寸重写都会动 mtime */
27
+ export function statVersion(size, mtimeMs) {
28
+ return `${size}:${mtimeMs}`;
29
+ }
30
+ /**
31
+ * 按「观察态 × 当前盘上指纹」分派写意图(判定核心——fs 工具族 write 面
32
+ * 的第一道门;fence 是另一道,先后正交)。
33
+ *
34
+ * @param observed 观察记录(undefined = 从未读过该路径)
35
+ * @param current 当前盘上指纹(undefined = 此刻文件不存在)
36
+ * @returns 分派出的写意图(拒绝时抛 BaseError——码进 message 首缀)
37
+ */
38
+ export function resolveWriteIntent(observed, current) {
39
+ if (observed === undefined) {
40
+ // 从未读过:在场文件 = 未见过的内容,拒绝覆盖(防盲写)
41
+ if (current !== undefined) {
42
+ throw new BaseError('FS_NOT_OBSERVED', '[FS_NOT_OBSERVED] 目标已存在但从未读取过——拒绝盲写:先 read 观察后再写');
43
+ }
44
+ return { kind: 'create-if-absent' };
45
+ }
46
+ if (observed.state === 'absent') {
47
+ // 读时不在:合法意图是创建;此刻在场 = 他方并发创建,冲突
48
+ if (current !== undefined) {
49
+ throw new BaseError('FS_VERSION_CONFLICT', '[FS_VERSION_CONFLICT] 读取时目标不存在、现在却已存在(他方并发创建):重新 read 后再写');
50
+ }
51
+ return { kind: 'create-if-absent' };
52
+ }
53
+ // present:指纹一致才替换
54
+ if (current === undefined) {
55
+ throw new BaseError('FS_VERSION_CONFLICT', '[FS_VERSION_CONFLICT] 读取后目标已被删除:重新 read 确认意图');
56
+ }
57
+ if (current.version !== observed.version) {
58
+ throw new BaseError('FS_VERSION_CONFLICT', `[FS_VERSION_CONFLICT] 目标在读取后被修改(观察 ${observed.version ?? '?'} ≠ 当前 ${current.version}):重新 read 最新版后再写`);
59
+ }
60
+ return { kind: 'replace-if-version', expectedVersion: current.version };
61
+ }
62
+ /**
63
+ * edit/delete 意图守卫:必须已读过且在(present)才可动增量面——补丁编辑
64
+ * 的前提是「对已见内容做改动」。指纹校验复用 resolveWriteIntent 的 present
65
+ * 分支(本函数只补「必须已读」这道门)。
66
+ */
67
+ export function requireObservedForEdit(observed, current) {
68
+ if (observed === undefined || observed.state === 'absent') {
69
+ throw new BaseError('FS_NOT_OBSERVED', `[FS_NOT_OBSERVED] 编辑前必须先 read 目标(观察态 ${observed?.state ?? '未读'}不满足补丁编辑前提)`);
70
+ }
71
+ return resolveWriteIntent(observed, current);
72
+ }
73
+ /**
74
+ * 观察态登记簿:absPath → 观察记录(fs 工具族 per-driver 持有一份,随会话
75
+ * 生命周期;driver 层注册的工具闭包内可达——「会话态工具面」存在理由之一)。
76
+ */
77
+ export class ObservedFiles {
78
+ /** 登记表本体(Map——clear 语义需要;无遍历需求故不暴露只读视图) */
79
+ files = new Map();
80
+ /**
81
+ * 登记「读到内容」。写成功后的观察回填同走此面(写完即最新观察——写后
82
+ * 立即 stat 产指纹登记,紧随的再次写不需重读)。
83
+ */
84
+ observePresent(path, version) {
85
+ this.files.set(path, { state: 'present', version });
86
+ }
87
+ /** 登记「读时不存在」(「这里没有文件」也是观察——后续 create 合法) */
88
+ observeAbsent(path) {
89
+ this.files.set(path, { state: 'absent' });
90
+ }
91
+ /** 取观察记录(未读过返回 undefined——写意图分派的入参) */
92
+ get(path) {
93
+ return this.files.get(path);
94
+ }
95
+ /** 清空登记簿(测试重置面;产码无清空消费者——观察态随 driver 生命周期自然终结) */
96
+ clear() {
97
+ this.files.clear();
98
+ }
99
+ }