lonny-agent 0.2.4 → 0.2.7

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 (509) hide show
  1. package/.github/workflows/ci.yml +21 -0
  2. package/AGENTS.md +106 -0
  3. package/CHANGELOG.md +24 -0
  4. package/README.md +326 -326
  5. package/dist/agent/__tests__/session-restore.test.d.ts +2 -0
  6. package/dist/agent/__tests__/session-restore.test.d.ts.map +1 -0
  7. package/dist/agent/__tests__/session-restore.test.js +260 -0
  8. package/dist/agent/__tests__/session-restore.test.js.map +1 -0
  9. package/dist/agent/commands.d.ts +26 -0
  10. package/dist/agent/commands.d.ts.map +1 -0
  11. package/dist/agent/commands.js +253 -0
  12. package/dist/agent/commands.js.map +1 -0
  13. package/dist/agent/compaction.js +8 -1
  14. package/dist/agent/compaction.js.map +1 -1
  15. package/dist/agent/index.d.ts +2 -1
  16. package/dist/agent/index.d.ts.map +1 -1
  17. package/dist/agent/index.js +2 -2
  18. package/dist/agent/index.js.map +1 -1
  19. package/dist/agent/llm.d.ts +4 -0
  20. package/dist/agent/llm.d.ts.map +1 -1
  21. package/dist/agent/memory.d.ts +12 -0
  22. package/dist/agent/memory.d.ts.map +1 -0
  23. package/dist/agent/memory.js +51 -0
  24. package/dist/agent/memory.js.map +1 -0
  25. package/dist/agent/prompt-builder.d.ts +6 -1
  26. package/dist/agent/prompt-builder.d.ts.map +1 -1
  27. package/dist/agent/prompt-builder.js +100 -33
  28. package/dist/agent/prompt-builder.js.map +1 -1
  29. package/dist/agent/providers/google.d.ts.map +1 -1
  30. package/dist/agent/providers/google.js.map +1 -1
  31. package/dist/agent/providers/openai.d.ts +1 -0
  32. package/dist/agent/providers/openai.d.ts.map +1 -1
  33. package/dist/agent/providers/openai.js +59 -6
  34. package/dist/agent/providers/openai.js.map +1 -1
  35. package/dist/agent/session-chat.d.ts +3 -0
  36. package/dist/agent/session-chat.d.ts.map +1 -0
  37. package/dist/agent/session-chat.js +384 -0
  38. package/dist/agent/session-chat.js.map +1 -0
  39. package/dist/agent/session-display.d.ts +29 -0
  40. package/dist/agent/session-display.d.ts.map +1 -0
  41. package/dist/agent/session-display.js +179 -0
  42. package/dist/agent/session-display.js.map +1 -0
  43. package/dist/agent/session-persistence.d.ts +55 -0
  44. package/dist/agent/session-persistence.d.ts.map +1 -0
  45. package/dist/agent/session-persistence.js +101 -0
  46. package/dist/agent/session-persistence.js.map +1 -0
  47. package/dist/agent/session-utils.d.ts +5 -0
  48. package/dist/agent/session-utils.d.ts.map +1 -0
  49. package/dist/agent/session-utils.js +51 -0
  50. package/dist/agent/session-utils.js.map +1 -0
  51. package/dist/agent/session.d.ts +19 -17
  52. package/dist/agent/session.d.ts.map +1 -1
  53. package/dist/agent/session.js +234 -578
  54. package/dist/agent/session.js.map +1 -1
  55. package/dist/cli/index.d.ts +4 -0
  56. package/dist/cli/index.d.ts.map +1 -1
  57. package/dist/cli/index.js +67 -2
  58. package/dist/cli/index.js.map +1 -1
  59. package/dist/config/index.d.ts +2 -2
  60. package/dist/config/index.d.ts.map +1 -1
  61. package/dist/config/index.js +7 -1
  62. package/dist/config/index.js.map +1 -1
  63. package/dist/index.js +24 -3
  64. package/dist/index.js.map +1 -1
  65. package/dist/tools/__tests__/bash.test.js +169 -2
  66. package/dist/tools/__tests__/bash.test.js.map +1 -1
  67. package/dist/tools/__tests__/edit.test.js +1091 -340
  68. package/dist/tools/__tests__/edit.test.js.map +1 -1
  69. package/dist/tools/__tests__/fetch.test.js +1 -1
  70. package/dist/tools/__tests__/fetch.test.js.map +1 -1
  71. package/dist/tools/__tests__/glob.test.js +1 -1
  72. package/dist/tools/__tests__/glob.test.js.map +1 -1
  73. package/dist/tools/__tests__/grep.test.js +33 -1
  74. package/dist/tools/__tests__/grep.test.js.map +1 -1
  75. package/dist/tools/__tests__/ls.test.js +1 -1
  76. package/dist/tools/__tests__/ls.test.js.map +1 -1
  77. package/dist/tools/__tests__/read.test.js +1 -1
  78. package/dist/tools/__tests__/read.test.js.map +1 -1
  79. package/dist/tools/__tests__/registry.test.js +313 -0
  80. package/dist/tools/__tests__/registry.test.js.map +1 -1
  81. package/dist/tools/__tests__/sed.test.d.ts +2 -0
  82. package/dist/tools/__tests__/sed.test.d.ts.map +1 -0
  83. package/dist/tools/__tests__/sed.test.js +228 -0
  84. package/dist/tools/__tests__/sed.test.js.map +1 -0
  85. package/dist/tools/__tests__/write_plan.test.js +1 -1
  86. package/dist/tools/__tests__/write_plan.test.js.map +1 -1
  87. package/dist/tools/bash.d.ts.map +1 -1
  88. package/dist/tools/bash.js +711 -18
  89. package/dist/tools/bash.js.map +1 -1
  90. package/dist/tools/codebase/ast/__tests__/ast-edit-demo.d.ts +2 -0
  91. package/dist/tools/codebase/ast/__tests__/ast-edit-demo.d.ts.map +1 -0
  92. package/dist/tools/codebase/ast/__tests__/ast-edit-demo.js +7 -0
  93. package/dist/tools/codebase/ast/__tests__/ast-edit-demo.js.map +1 -0
  94. package/dist/tools/codebase/ast/__tests__/ast-edit-final.d.ts +6 -0
  95. package/dist/tools/codebase/ast/__tests__/ast-edit-final.d.ts.map +1 -0
  96. package/dist/tools/codebase/ast/__tests__/ast-edit-final.js +11 -0
  97. package/dist/tools/codebase/ast/__tests__/ast-edit-final.js.map +1 -0
  98. package/dist/tools/codebase/ast/__tests__/ast-edit-test.d.ts +2 -0
  99. package/dist/tools/codebase/ast/__tests__/ast-edit-test.d.ts.map +1 -0
  100. package/dist/tools/codebase/ast/__tests__/ast-edit-test.js +9 -0
  101. package/dist/tools/codebase/ast/__tests__/ast-edit-test.js.map +1 -0
  102. package/dist/tools/codebase/ast/__tests__/integration.test.d.ts +2 -0
  103. package/dist/tools/codebase/ast/__tests__/integration.test.d.ts.map +1 -0
  104. package/dist/tools/codebase/ast/__tests__/integration.test.js +415 -0
  105. package/dist/tools/codebase/ast/__tests__/integration.test.js.map +1 -0
  106. package/dist/tools/codebase/ast/__tests__/tree-sitter.test.d.ts +2 -0
  107. package/dist/tools/codebase/ast/__tests__/tree-sitter.test.d.ts.map +1 -0
  108. package/dist/tools/codebase/ast/__tests__/tree-sitter.test.js +414 -0
  109. package/dist/tools/codebase/ast/__tests__/tree-sitter.test.js.map +1 -0
  110. package/dist/tools/codebase/ast/adapter.d.ts +23 -0
  111. package/dist/tools/codebase/ast/adapter.d.ts.map +1 -0
  112. package/dist/tools/codebase/ast/adapter.js +11 -0
  113. package/dist/tools/codebase/ast/adapter.js.map +1 -0
  114. package/dist/tools/codebase/ast/tools.d.ts +3 -0
  115. package/dist/tools/codebase/ast/tools.d.ts.map +1 -0
  116. package/dist/tools/codebase/ast/tools.js +286 -0
  117. package/dist/tools/codebase/ast/tools.js.map +1 -0
  118. package/dist/tools/codebase/ast/tree-sitter-adapter.d.ts +3 -0
  119. package/dist/tools/codebase/ast/tree-sitter-adapter.d.ts.map +1 -0
  120. package/dist/tools/codebase/ast/tree-sitter-adapter.js +412 -0
  121. package/dist/tools/codebase/ast/tree-sitter-adapter.js.map +1 -0
  122. package/dist/tools/codebase/ast/tree-sitter-converters.d.ts +4 -0
  123. package/dist/tools/codebase/ast/tree-sitter-converters.d.ts.map +1 -0
  124. package/dist/tools/codebase/ast/tree-sitter-converters.js +447 -0
  125. package/dist/tools/codebase/ast/tree-sitter-converters.js.map +1 -0
  126. package/dist/tools/codebase/ast/types.d.ts +103 -0
  127. package/dist/tools/codebase/ast/types.d.ts.map +1 -0
  128. package/dist/tools/codebase/ast/types.js +2 -0
  129. package/dist/tools/codebase/ast/types.js.map +1 -0
  130. package/dist/tools/codebase/ast/wasm/tree-sitter-python.wasm +0 -0
  131. package/dist/tools/codebase/ast/wasm/tree-sitter-tsx.wasm +0 -0
  132. package/dist/tools/codebase/ast/wasm/tree-sitter-typescript.wasm +0 -0
  133. package/dist/tools/codebase/ast/wasm/tree-sitter.wasm +0 -0
  134. package/dist/tools/codebase/ast/wasm/web-tree-sitter.wasm +0 -0
  135. package/dist/tools/codebase/find.d.ts +3 -0
  136. package/dist/tools/codebase/find.d.ts.map +1 -0
  137. package/dist/tools/codebase/find.js +61 -0
  138. package/dist/tools/codebase/find.js.map +1 -0
  139. package/dist/tools/codebase/glob.d.ts +3 -0
  140. package/dist/tools/codebase/glob.d.ts.map +1 -0
  141. package/dist/tools/codebase/glob.js +37 -0
  142. package/dist/tools/codebase/glob.js.map +1 -0
  143. package/dist/tools/codebase/grep.d.ts +3 -0
  144. package/dist/tools/codebase/grep.d.ts.map +1 -0
  145. package/dist/tools/codebase/grep.js +169 -0
  146. package/dist/tools/codebase/grep.js.map +1 -0
  147. package/dist/tools/codebase/ls.d.ts +3 -0
  148. package/dist/tools/codebase/ls.d.ts.map +1 -0
  149. package/dist/tools/codebase/ls.js +39 -0
  150. package/dist/tools/codebase/ls.js.map +1 -0
  151. package/dist/tools/codebase/read.d.ts +10 -0
  152. package/dist/tools/codebase/read.d.ts.map +1 -0
  153. package/dist/tools/codebase/read.js +81 -0
  154. package/dist/tools/codebase/read.js.map +1 -0
  155. package/dist/tools/delete_memory.d.ts +3 -0
  156. package/dist/tools/delete_memory.d.ts.map +1 -0
  157. package/dist/tools/delete_memory.js +46 -0
  158. package/dist/tools/delete_memory.js.map +1 -0
  159. package/dist/tools/edit/diff-compute.d.ts +4 -0
  160. package/dist/tools/edit/diff-compute.d.ts.map +1 -0
  161. package/dist/tools/edit/diff-compute.js +18 -0
  162. package/dist/tools/edit/diff-compute.js.map +1 -0
  163. package/dist/tools/edit/diff-render.d.ts +18 -0
  164. package/dist/tools/edit/diff-render.d.ts.map +1 -0
  165. package/dist/tools/edit/diff-render.js +128 -0
  166. package/dist/tools/edit/diff-render.js.map +1 -0
  167. package/dist/tools/edit/edit.d.ts +4 -0
  168. package/dist/tools/edit/edit.d.ts.map +1 -0
  169. package/dist/tools/edit/edit.js +440 -0
  170. package/dist/tools/edit/edit.js.map +1 -0
  171. package/dist/tools/edit/matcher.d.ts +24 -0
  172. package/dist/tools/edit/matcher.d.ts.map +1 -0
  173. package/dist/tools/edit/matcher.js +66 -0
  174. package/dist/tools/edit/matcher.js.map +1 -0
  175. package/dist/tools/edit/parser.d.ts +10 -0
  176. package/dist/tools/edit/parser.d.ts.map +1 -0
  177. package/dist/tools/edit/parser.js +120 -0
  178. package/dist/tools/edit/parser.js.map +1 -0
  179. package/dist/tools/edit/types.d.ts +16 -0
  180. package/dist/tools/edit/types.d.ts.map +1 -0
  181. package/dist/tools/edit/types.js +2 -0
  182. package/dist/tools/edit/types.js.map +1 -0
  183. package/dist/tools/edit/write_plan.d.ts +5 -0
  184. package/dist/tools/edit/write_plan.d.ts.map +1 -0
  185. package/dist/tools/edit/write_plan.js +98 -0
  186. package/dist/tools/edit/write_plan.js.map +1 -0
  187. package/dist/tools/edit.d.ts +50 -9
  188. package/dist/tools/edit.d.ts.map +1 -1
  189. package/dist/tools/edit.js +394 -159
  190. package/dist/tools/edit.js.map +1 -1
  191. package/dist/tools/execute/bash/constants.d.ts +24 -0
  192. package/dist/tools/execute/bash/constants.d.ts.map +1 -0
  193. package/dist/tools/execute/bash/constants.js +88 -0
  194. package/dist/tools/execute/bash/constants.js.map +1 -0
  195. package/dist/tools/execute/bash/errors.d.ts +39 -0
  196. package/dist/tools/execute/bash/errors.d.ts.map +1 -0
  197. package/dist/tools/execute/bash/errors.js +249 -0
  198. package/dist/tools/execute/bash/errors.js.map +1 -0
  199. package/dist/tools/execute/bash/execution.d.ts +18 -0
  200. package/dist/tools/execute/bash/execution.d.ts.map +1 -0
  201. package/dist/tools/execute/bash/execution.js +207 -0
  202. package/dist/tools/execute/bash/execution.js.map +1 -0
  203. package/dist/tools/execute/bash/index.d.ts +3 -0
  204. package/dist/tools/execute/bash/index.d.ts.map +1 -0
  205. package/dist/tools/execute/bash/index.js +117 -0
  206. package/dist/tools/execute/bash/index.js.map +1 -0
  207. package/dist/tools/execute/bash/platform.d.ts +6 -0
  208. package/dist/tools/execute/bash/platform.d.ts.map +1 -0
  209. package/dist/tools/execute/bash/platform.js +64 -0
  210. package/dist/tools/execute/bash/platform.js.map +1 -0
  211. package/dist/tools/execute/bash/security.d.ts +12 -0
  212. package/dist/tools/execute/bash/security.d.ts.map +1 -0
  213. package/dist/tools/execute/bash/security.js +26 -0
  214. package/dist/tools/execute/bash/security.js.map +1 -0
  215. package/dist/tools/execute/bash/validation.d.ts +16 -0
  216. package/dist/tools/execute/bash/validation.d.ts.map +1 -0
  217. package/dist/tools/execute/bash/validation.js +57 -0
  218. package/dist/tools/execute/bash/validation.js.map +1 -0
  219. package/dist/tools/execute/bash.d.ts +3 -0
  220. package/dist/tools/execute/bash.d.ts.map +1 -0
  221. package/dist/tools/execute/bash.js +728 -0
  222. package/dist/tools/execute/bash.js.map +1 -0
  223. package/dist/tools/execute/git.d.ts +3 -0
  224. package/dist/tools/execute/git.d.ts.map +1 -0
  225. package/dist/tools/execute/git.js +183 -0
  226. package/dist/tools/execute/git.js.map +1 -0
  227. package/dist/tools/execute/task_complete.d.ts +7 -0
  228. package/dist/tools/execute/task_complete.d.ts.map +1 -0
  229. package/dist/tools/execute/task_complete.js +38 -0
  230. package/dist/tools/execute/task_complete.js.map +1 -0
  231. package/dist/tools/fetch.d.ts.map +1 -1
  232. package/dist/tools/fetch.js +2 -0
  233. package/dist/tools/fetch.js.map +1 -1
  234. package/dist/tools/find.d.ts.map +1 -1
  235. package/dist/tools/find.js +2 -0
  236. package/dist/tools/find.js.map +1 -1
  237. package/dist/tools/git.d.ts.map +1 -1
  238. package/dist/tools/git.js +2 -0
  239. package/dist/tools/git.js.map +1 -1
  240. package/dist/tools/glob.d.ts.map +1 -1
  241. package/dist/tools/glob.js +2 -0
  242. package/dist/tools/glob.js.map +1 -1
  243. package/dist/tools/grep.d.ts.map +1 -1
  244. package/dist/tools/grep.js +56 -30
  245. package/dist/tools/grep.js.map +1 -1
  246. package/dist/tools/install/clawhub.d.ts +26 -0
  247. package/dist/tools/install/clawhub.d.ts.map +1 -0
  248. package/dist/tools/install/clawhub.js +182 -0
  249. package/dist/tools/install/clawhub.js.map +1 -0
  250. package/dist/tools/install/constants.d.ts +5 -0
  251. package/dist/tools/install/constants.d.ts.map +1 -0
  252. package/dist/tools/install/constants.js +5 -0
  253. package/dist/tools/install/constants.js.map +1 -0
  254. package/dist/tools/install/install_skill.d.ts +3 -0
  255. package/dist/tools/install/install_skill.d.ts.map +1 -0
  256. package/dist/tools/install/install_skill.js +132 -0
  257. package/dist/tools/install/install_skill.js.map +1 -0
  258. package/dist/tools/install/npm.d.ts +8 -0
  259. package/dist/tools/install/npm.d.ts.map +1 -0
  260. package/dist/tools/install/npm.js +52 -0
  261. package/dist/tools/install/npm.js.map +1 -0
  262. package/dist/tools/install/skill-content.d.ts +9 -0
  263. package/dist/tools/install/skill-content.d.ts.map +1 -0
  264. package/dist/tools/install/skill-content.js +89 -0
  265. package/dist/tools/install/skill-content.js.map +1 -0
  266. package/dist/tools/install_skill.d.ts.map +1 -1
  267. package/dist/tools/install_skill.js +2 -0
  268. package/dist/tools/install_skill.js.map +1 -1
  269. package/dist/tools/list_memory.d.ts +3 -0
  270. package/dist/tools/list_memory.d.ts.map +1 -0
  271. package/dist/tools/list_memory.js +55 -0
  272. package/dist/tools/list_memory.js.map +1 -0
  273. package/dist/tools/ls.d.ts.map +1 -1
  274. package/dist/tools/ls.js +2 -0
  275. package/dist/tools/ls.js.map +1 -1
  276. package/dist/tools/memory/delete_memory.d.ts +3 -0
  277. package/dist/tools/memory/delete_memory.d.ts.map +1 -0
  278. package/dist/tools/memory/delete_memory.js +46 -0
  279. package/dist/tools/memory/delete_memory.js.map +1 -0
  280. package/dist/tools/memory/list_memory.d.ts +3 -0
  281. package/dist/tools/memory/list_memory.d.ts.map +1 -0
  282. package/dist/tools/memory/list_memory.js +55 -0
  283. package/dist/tools/memory/list_memory.js.map +1 -0
  284. package/dist/tools/memory/save_memory.d.ts +3 -0
  285. package/dist/tools/memory/save_memory.d.ts.map +1 -0
  286. package/dist/tools/memory/save_memory.js +45 -0
  287. package/dist/tools/memory/save_memory.js.map +1 -0
  288. package/dist/tools/read.d.ts.map +1 -1
  289. package/dist/tools/read.js +2 -0
  290. package/dist/tools/read.js.map +1 -1
  291. package/dist/tools/registry.d.ts +7 -8
  292. package/dist/tools/registry.d.ts.map +1 -1
  293. package/dist/tools/registry.js +114 -46
  294. package/dist/tools/registry.js.map +1 -1
  295. package/dist/tools/save_memory.d.ts +3 -0
  296. package/dist/tools/save_memory.d.ts.map +1 -0
  297. package/dist/tools/save_memory.js +45 -0
  298. package/dist/tools/save_memory.js.map +1 -0
  299. package/dist/tools/search.d.ts.map +1 -1
  300. package/dist/tools/search.js +2 -0
  301. package/dist/tools/search.js.map +1 -1
  302. package/dist/tools/sed.d.ts +4 -0
  303. package/dist/tools/sed.d.ts.map +1 -0
  304. package/dist/tools/sed.js +121 -0
  305. package/dist/tools/sed.js.map +1 -0
  306. package/dist/tools/tree.d.ts +12 -0
  307. package/dist/tools/tree.d.ts.map +1 -0
  308. package/dist/tools/tree.js +87 -0
  309. package/dist/tools/tree.js.map +1 -0
  310. package/dist/tools/types.d.ts +12 -0
  311. package/dist/tools/types.d.ts.map +1 -1
  312. package/dist/tools/web/fetch.d.ts +3 -0
  313. package/dist/tools/web/fetch.d.ts.map +1 -0
  314. package/dist/tools/web/fetch.js +49 -0
  315. package/dist/tools/web/fetch.js.map +1 -0
  316. package/dist/tools/web/search.d.ts +3 -0
  317. package/dist/tools/web/search.d.ts.map +1 -0
  318. package/dist/tools/web/search.js +134 -0
  319. package/dist/tools/web/search.js.map +1 -0
  320. package/dist/tools/write_plan.d.ts.map +1 -1
  321. package/dist/tools/write_plan.js +2 -0
  322. package/dist/tools/write_plan.js.map +1 -1
  323. package/dist/tui/commands.d.ts +4 -0
  324. package/dist/tui/commands.d.ts.map +1 -0
  325. package/dist/tui/commands.js +118 -0
  326. package/dist/tui/commands.js.map +1 -0
  327. package/dist/tui/components/colors.d.ts +36 -0
  328. package/dist/tui/components/colors.d.ts.map +1 -0
  329. package/dist/tui/components/colors.js +48 -0
  330. package/dist/tui/components/colors.js.map +1 -0
  331. package/dist/tui/components/header-bar.d.ts +26 -0
  332. package/dist/tui/components/header-bar.d.ts.map +1 -0
  333. package/dist/tui/components/header-bar.js +78 -0
  334. package/dist/tui/components/header-bar.js.map +1 -0
  335. package/dist/tui/components/index.d.ts +9 -0
  336. package/dist/tui/components/index.d.ts.map +1 -0
  337. package/dist/tui/components/index.js +9 -0
  338. package/dist/tui/components/index.js.map +1 -0
  339. package/dist/tui/components/landing-screen.d.ts +12 -0
  340. package/dist/tui/components/landing-screen.d.ts.map +1 -0
  341. package/dist/tui/components/landing-screen.js +53 -0
  342. package/dist/tui/components/landing-screen.js.map +1 -0
  343. package/dist/tui/components/pixel-logo.d.ts +5 -0
  344. package/dist/tui/components/pixel-logo.d.ts.map +1 -0
  345. package/dist/tui/components/pixel-logo.js +27 -0
  346. package/dist/tui/components/pixel-logo.js.map +1 -0
  347. package/dist/tui/components/plan-utils.d.ts +11 -0
  348. package/dist/tui/components/plan-utils.d.ts.map +1 -0
  349. package/dist/tui/components/plan-utils.js +62 -0
  350. package/dist/tui/components/plan-utils.js.map +1 -0
  351. package/dist/tui/components/plans-list.d.ts +18 -0
  352. package/dist/tui/components/plans-list.d.ts.map +1 -0
  353. package/dist/tui/components/plans-list.js +46 -0
  354. package/dist/tui/components/plans-list.js.map +1 -0
  355. package/dist/tui/components/rich-footer.d.ts +29 -0
  356. package/dist/tui/components/rich-footer.d.ts.map +1 -0
  357. package/dist/tui/components/rich-footer.js +124 -0
  358. package/dist/tui/components/rich-footer.js.map +1 -0
  359. package/dist/tui/components/todo-panel.d.ts +14 -0
  360. package/dist/tui/components/todo-panel.d.ts.map +1 -0
  361. package/dist/tui/components/todo-panel.js +83 -0
  362. package/dist/tui/components/todo-panel.js.map +1 -0
  363. package/dist/tui/components.d.ts.map +1 -1
  364. package/dist/tui/components.js +14 -7
  365. package/dist/tui/components.js.map +1 -1
  366. package/dist/tui/index.d.ts +2 -1
  367. package/dist/tui/index.d.ts.map +1 -1
  368. package/dist/tui/index.js +99 -505
  369. package/dist/tui/index.js.map +1 -1
  370. package/dist/tui/overlays.d.ts +28 -0
  371. package/dist/tui/overlays.d.ts.map +1 -0
  372. package/dist/tui/overlays.js +154 -0
  373. package/dist/tui/overlays.js.map +1 -0
  374. package/dist/tui/themes.d.ts +5 -0
  375. package/dist/tui/themes.d.ts.map +1 -0
  376. package/dist/tui/themes.js +38 -0
  377. package/dist/tui/themes.js.map +1 -0
  378. package/dist/web/index.d.ts.map +1 -1
  379. package/dist/web/index.js +62 -101
  380. package/dist/web/index.js.map +1 -1
  381. package/dist/web/public/app.js +5 -855
  382. package/dist/web/public/confirm.js +70 -0
  383. package/dist/web/public/index.html +121 -120
  384. package/dist/web/public/input.js +107 -0
  385. package/dist/web/public/messages.js +411 -0
  386. package/dist/web/public/sidebar.js +82 -0
  387. package/dist/web/public/state.js +72 -0
  388. package/dist/web/public/style.css +247 -70
  389. package/dist/web/public/utils.js +37 -0
  390. package/dist/web/public/websocket.js +267 -0
  391. package/dist/web/public/ws.js +19 -0
  392. package/dist/web/session-bridge.d.ts.map +1 -1
  393. package/dist/web/session-bridge.js +85 -10
  394. package/dist/web/session-bridge.js.map +1 -1
  395. package/docs/ast-tools.md +117 -0
  396. package/package.json +11 -5
  397. package/scripts/check-line-count.mjs +53 -0
  398. package/scripts/copy-ast-wasm.mjs +32 -0
  399. package/scripts/copy-native.mjs +24 -0
  400. package/scripts/copy-web.mjs +15 -0
  401. package/src/agent/__tests__/session-restore.test.ts +284 -0
  402. package/src/agent/commands.ts +274 -0
  403. package/src/agent/compaction.ts +9 -2
  404. package/src/agent/index.ts +7 -2
  405. package/src/agent/llm.ts +4 -0
  406. package/src/agent/memory.ts +60 -0
  407. package/src/agent/prompt-builder.ts +111 -33
  408. package/src/agent/providers/google.ts +0 -1
  409. package/src/agent/providers/openai.ts +79 -8
  410. package/src/agent/session-chat.ts +480 -0
  411. package/src/agent/session-display.ts +195 -0
  412. package/src/agent/session-persistence.ts +146 -0
  413. package/src/agent/session-utils.ts +54 -0
  414. package/src/agent/session.ts +269 -670
  415. package/src/cli/index.ts +73 -4
  416. package/src/config/index.ts +14 -7
  417. package/src/index.ts +30 -3
  418. package/src/tools/__tests__/bash.test.ts +188 -2
  419. package/src/tools/__tests__/edit.test.ts +1216 -356
  420. package/src/tools/__tests__/fetch.test.ts +1 -1
  421. package/src/tools/__tests__/glob.test.ts +1 -1
  422. package/src/tools/__tests__/grep.test.ts +36 -1
  423. package/src/tools/__tests__/ls.test.ts +1 -1
  424. package/src/tools/__tests__/read.test.ts +1 -1
  425. package/src/tools/__tests__/registry.test.ts +332 -0
  426. package/src/tools/__tests__/write_plan.test.ts +1 -1
  427. package/src/tools/codebase/ast/__tests__/integration.test.ts +452 -0
  428. package/src/tools/codebase/ast/__tests__/tree-sitter.test.ts +466 -0
  429. package/src/tools/codebase/ast/adapter.ts +44 -0
  430. package/src/tools/codebase/ast/tools.ts +322 -0
  431. package/src/tools/codebase/ast/tree-sitter-adapter.ts +468 -0
  432. package/src/tools/codebase/ast/tree-sitter-converters.ts +480 -0
  433. package/src/tools/codebase/ast/types.ts +102 -0
  434. package/src/tools/codebase/ast/wasm/tree-sitter-python.wasm +0 -0
  435. package/src/tools/codebase/ast/wasm/tree-sitter-tsx.wasm +0 -0
  436. package/src/tools/codebase/ast/wasm/tree-sitter-typescript.wasm +0 -0
  437. package/src/tools/codebase/ast/wasm/tree-sitter.wasm +0 -0
  438. package/src/tools/codebase/ast/wasm/web-tree-sitter.wasm +0 -0
  439. package/src/tools/{find.ts → codebase/find.ts} +4 -2
  440. package/src/tools/{glob.ts → codebase/glob.ts} +4 -2
  441. package/src/tools/{grep.ts → codebase/grep.ts} +61 -32
  442. package/src/tools/{ls.ts → codebase/ls.ts} +4 -2
  443. package/src/tools/{read.ts → codebase/read.ts} +5 -3
  444. package/src/tools/edit/diff-compute.ts +21 -0
  445. package/src/tools/edit/diff-render.ts +145 -0
  446. package/src/tools/edit/edit.ts +468 -0
  447. package/src/tools/edit/matcher.ts +70 -0
  448. package/src/tools/edit/parser.ts +131 -0
  449. package/src/tools/edit/types.ts +19 -0
  450. package/src/tools/{write_plan.ts → edit/write_plan.ts} +4 -2
  451. package/src/tools/execute/bash/constants.ts +93 -0
  452. package/src/tools/execute/bash/errors.ts +304 -0
  453. package/src/tools/execute/bash/execution.ts +211 -0
  454. package/src/tools/execute/bash/index.ts +131 -0
  455. package/src/tools/execute/bash/platform.ts +65 -0
  456. package/src/tools/execute/bash/security.ts +28 -0
  457. package/src/tools/execute/bash/validation.ts +78 -0
  458. package/src/tools/execute/git.ts +209 -0
  459. package/src/tools/execute/task_complete.ts +40 -0
  460. package/src/tools/install/clawhub.ts +222 -0
  461. package/src/tools/install/constants.ts +4 -0
  462. package/src/tools/install/install_skill.ts +150 -0
  463. package/src/tools/install/npm.ts +60 -0
  464. package/src/tools/install/skill-content.ts +108 -0
  465. package/src/tools/memory/delete_memory.ts +46 -0
  466. package/src/tools/memory/list_memory.ts +53 -0
  467. package/src/tools/memory/save_memory.ts +45 -0
  468. package/src/tools/registry.ts +121 -50
  469. package/src/tools/tree.ts +103 -0
  470. package/src/tools/types.ts +13 -0
  471. package/src/tools/{fetch.ts → web/fetch.ts} +4 -2
  472. package/src/tools/{search.ts → web/search.ts} +4 -2
  473. package/src/tui/commands.ts +135 -0
  474. package/src/tui/components/colors.ts +52 -0
  475. package/src/tui/components/header-bar.ts +88 -0
  476. package/src/tui/components/index.ts +8 -0
  477. package/src/tui/components/landing-screen.ts +67 -0
  478. package/src/tui/components/pixel-logo.ts +33 -0
  479. package/src/tui/components/plan-utils.ts +69 -0
  480. package/src/tui/components/plans-list.ts +55 -0
  481. package/src/tui/components/rich-footer.ts +146 -0
  482. package/src/tui/components/todo-panel.ts +95 -0
  483. package/src/tui/index.ts +110 -561
  484. package/src/tui/overlays.ts +203 -0
  485. package/src/tui/themes.ts +41 -0
  486. package/src/web/index.ts +66 -107
  487. package/src/web/public/app.js +5 -855
  488. package/src/web/public/confirm.js +70 -0
  489. package/src/web/public/index.html +121 -120
  490. package/src/web/public/input.js +107 -0
  491. package/src/web/public/messages.js +411 -0
  492. package/src/web/public/sidebar.js +82 -0
  493. package/src/web/public/state.js +72 -0
  494. package/src/web/public/style.css +247 -70
  495. package/src/web/public/utils.js +37 -0
  496. package/src/web/public/websocket.js +267 -0
  497. package/src/web/public/ws.js +19 -0
  498. package/src/web/session-bridge.ts +90 -12
  499. package/.claude/settings.local.json +0 -14
  500. package/.kilo/plans/1780064105789-mighty-pixel.md +0 -171
  501. package/.kilo/plans/1780293725888-quick-wizard.md +0 -62
  502. package/.lonny/plan-web-cwd-status.md +0 -38
  503. package/AGENT.md +0 -99
  504. package/src/tools/bash.ts +0 -39
  505. package/src/tools/edit.ts +0 -554
  506. package/src/tools/exec.ts +0 -348
  507. package/src/tools/git.ts +0 -76
  508. package/src/tools/install_skill.ts +0 -539
  509. package/src/tui/components.ts +0 -635
@@ -1,532 +1,1283 @@
1
1
  import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
2
3
  import * as path from 'node:path';
3
4
  import { afterAll, beforeAll, describe, expect, it } from 'vitest';
4
5
  import { FileReadTracker } from '../../diff/apply.js';
5
- import { createEditTool } from '../edit.js';
6
+ import { computeDiff } from '../edit/diff-compute.js';
7
+ import { escapeHtml, generateDiff, generateDiffWithContext, renderDiffTerminal, } from '../edit/diff-render.js';
8
+ import { createEditTool } from '../edit/edit.js';
9
+ import { findAllLinesTolerant, normalizeLine } from '../edit/matcher.js';
10
+ import { parseMarkdownEdit } from '../edit/parser.js';
6
11
  import { makeTempDir } from './helpers.js';
7
- describe('edit tool', () => {
12
+ // ── Minimal mock for FileReadTracker ─────────────────────────────────────
13
+ function makeApplier() {
14
+ const readSet = new Set();
15
+ return {
16
+ markRead(f) {
17
+ readSet.add(f);
18
+ },
19
+ checkModified(f) {
20
+ return readSet.has(f) ? null : `Warning: file was not read first.`;
21
+ },
22
+ };
23
+ }
24
+ // ── Tests for pure functions ─────────────────────────────────────────────
25
+ describe('normalizeLine', () => {
26
+ it('trims leading whitespace', () => {
27
+ expect(normalizeLine(' hello')).toBe('hello');
28
+ });
29
+ it('trims trailing whitespace', () => {
30
+ expect(normalizeLine('hello ')).toBe('hello');
31
+ });
32
+ it('collapses internal space runs', () => {
33
+ expect(normalizeLine('a b')).toBe('a b');
34
+ });
35
+ it('collapses internal tab runs', () => {
36
+ expect(normalizeLine('a\t\tb')).toBe('a b');
37
+ });
38
+ it('handles mixed spaces and tabs', () => {
39
+ expect(normalizeLine(' a \t b ')).toBe('a b');
40
+ });
41
+ it('reduces blank line with spaces to empty string', () => {
42
+ expect(normalizeLine(' ')).toBe('');
43
+ });
44
+ it('preserves single spaces', () => {
45
+ expect(normalizeLine('a b c')).toBe('a b c');
46
+ });
47
+ it('handles empty string', () => {
48
+ expect(normalizeLine('')).toBe('');
49
+ });
50
+ });
51
+ describe('findAllLinesTolerant', () => {
52
+ it('finds exact match', () => {
53
+ const result = findAllLinesTolerant('hello\nworld\nfoo\n', 'hello\nworld');
54
+ expect(result).toHaveLength(1);
55
+ expect(result[0].index).toBe(0);
56
+ expect(result[0].length).toBe('hello\nworld'.length);
57
+ });
58
+ it('finds match with trailing whitespace in content', () => {
59
+ const result = findAllLinesTolerant('hello \nworld\n', 'hello\nworld');
60
+ expect(result).toHaveLength(1);
61
+ // "hello \n" = 7 chars, "world" = 5 chars, total match = 12
62
+ expect(result[0].length).toBe(12);
63
+ });
64
+ it('finds match with leading whitespace in content', () => {
65
+ const result = findAllLinesTolerant(' hello\nworld\n', 'hello\nworld');
66
+ expect(result).toHaveLength(1);
67
+ // " hello\n" = 8 chars, "world" = 5, total = 13
68
+ expect(result[0].length).toBe(13);
69
+ });
70
+ it('finds match with extra internal spaces', () => {
71
+ const result = findAllLinesTolerant('foo bar\nbaz\n', 'foo bar\nbaz');
72
+ expect(result).toHaveLength(1);
73
+ });
74
+ it('returns empty array for empty oldString', () => {
75
+ expect(findAllLinesTolerant('abc', '')).toEqual([]);
76
+ });
77
+ it('returns empty when oldString longer than content', () => {
78
+ expect(findAllLinesTolerant('a', 'a\nb\nc')).toEqual([]);
79
+ });
80
+ it('returns empty when not found', () => {
81
+ expect(findAllLinesTolerant('abc\ndef\n', 'xyz')).toEqual([]);
82
+ });
83
+ it('finds multiple matches', () => {
84
+ const result = findAllLinesTolerant('a\nb\na\nb\n', 'a\nb');
85
+ expect(result).toHaveLength(2);
86
+ });
87
+ it('computes correct index for non-first match', () => {
88
+ const result = findAllLinesTolerant('x\na\nb\n', 'a\nb');
89
+ expect(result).toHaveLength(1);
90
+ expect(result[0].index).toBe(2); // "x\n" = 2 chars
91
+ });
92
+ it('computes correct length matching original whitespace', () => {
93
+ const result = findAllLinesTolerant('hello \nworld\n', 'hello\nworld');
94
+ expect(result).toHaveLength(1);
95
+ const match = contentSlice('hello \nworld\n', result[0]);
96
+ expect(match).toBe('hello \nworld');
97
+ });
98
+ it('handles CRLF content - correct position computation', () => {
99
+ const result = findAllLinesTolerant('hello\r\nworld\r\nfoo\r\n', 'hello\nworld');
100
+ expect(result).toHaveLength(1);
101
+ expect(result[0].index).toBe(0);
102
+ // 12 = 'hello\r' (6) + '\n' (1) + 'world' (5, trailing \r excluded)
103
+ expect(result[0].length).toBe(12);
104
+ const match = contentSlice('hello\r\nworld\r\nfoo\r\n', result[0]);
105
+ expect(match).toBe('hello\r\nworld');
106
+ });
107
+ it('handles mixed CRLF/LF content', () => {
108
+ const result = findAllLinesTolerant('hello\r\nworld\nfoo\r\n', 'hello\nworld');
109
+ expect(result).toHaveLength(1);
110
+ expect(result[0].index).toBe(0);
111
+ expect(result[0].length).toBe(12);
112
+ const match = contentSlice('hello\r\nworld\nfoo\r\n', result[0]);
113
+ expect(match).toBe('hello\r\nworld');
114
+ });
115
+ it('handles CRLF with whitespace normalization', () => {
116
+ const result = findAllLinesTolerant('hello \r\nworld\r\n', 'hello\nworld');
117
+ expect(result).toHaveLength(1);
118
+ // 13 = 'hello \r' (7) + '\n' (1) + 'world' (5, trailing \r excluded)
119
+ expect(result[0].length).toBe(13);
120
+ const match = contentSlice('hello \r\nworld\r\n', result[0]);
121
+ expect(match).toBe('hello \r\nworld');
122
+ });
123
+ });
124
+ function contentSlice(s, m) {
125
+ return s.slice(m.index, m.index + m.length);
126
+ }
127
+ describe('computeDiff', () => {
128
+ it('returns empty array for empty strings', () => {
129
+ expect(computeDiff('', '')).toEqual([]);
130
+ });
131
+ it('returns delete lines when new is empty', () => {
132
+ const result = computeDiff('a\nb', '');
133
+ expect(result).toHaveLength(2);
134
+ expect(result.every(l => l.type === 'delete')).toBe(true);
135
+ });
136
+ it('returns insert lines when old is empty', () => {
137
+ const result = computeDiff('', 'a\nb');
138
+ expect(result).toHaveLength(2);
139
+ expect(result.every(l => l.type === 'insert')).toBe(true);
140
+ });
141
+ it('returns equal lines for unchanged content', () => {
142
+ const result = computeDiff('hello\nworld', 'hello\nworld');
143
+ expect(result).toHaveLength(2);
144
+ expect(result.every(l => l.type === 'equal')).toBe(true);
145
+ });
146
+ it('produces mixed diff for changed content', () => {
147
+ const result = computeDiff('hello\nworld', 'hi\nworld');
148
+ // hello→hi should be delete+insert, world should be equal
149
+ expect(result).toHaveLength(3);
150
+ expect(result[0].type).toBe('delete');
151
+ expect(result[0].content).toBe('hello');
152
+ expect(result[1].type).toBe('insert');
153
+ expect(result[1].content).toBe('hi');
154
+ expect(result[2].type).toBe('equal');
155
+ expect(result[2].content).toBe('world');
156
+ });
157
+ });
158
+ describe('renderDiffTerminal', () => {
159
+ it('returns empty string for empty input', () => {
160
+ expect(renderDiffTerminal([])).toBe('');
161
+ });
162
+ it('renders delete lines in red with - prefix', () => {
163
+ const lines = computeDiff('old', '');
164
+ const output = renderDiffTerminal(lines);
165
+ expect(output).toContain('\x1b[38;2;255;80;80m');
166
+ expect(output).toContain('- 1 old');
167
+ expect(output).toContain('\x1b[0m');
168
+ });
169
+ it('renders insert lines in green with + prefix', () => {
170
+ const lines = computeDiff('', 'new');
171
+ const output = renderDiffTerminal(lines);
172
+ expect(output).toContain('\x1b[38;2;0;200;100m');
173
+ expect(output).toContain('+ 1 new');
174
+ });
175
+ it('renders equal lines in dim with space prefix', () => {
176
+ const lines = computeDiff('same', 'same');
177
+ const output = renderDiffTerminal(lines);
178
+ expect(output).toContain('\x1b[38;2;100;100;100m');
179
+ expect(output).toContain(' same');
180
+ expect(output).toContain('\x1b[0m');
181
+ });
182
+ });
183
+ describe('generateDiff', () => {
184
+ it('returns terminal-colored unified diff with line numbers', () => {
185
+ const output = generateDiff('old', 'new');
186
+ expect(output).toContain('\x1b[38;2;255;80;80m');
187
+ expect(output).toContain('- 1 old');
188
+ expect(output).toContain('\x1b[38;2;0;200;100m');
189
+ expect(output).toContain('+ 1 new');
190
+ expect(output).toContain('1');
191
+ });
192
+ it('returns terminal-colored unified diff with custom start line number', () => {
193
+ const output = generateDiff('old', 'new', 10);
194
+ expect(output).toContain('- 10 old');
195
+ expect(output).toContain('+ 10 new');
196
+ });
197
+ });
198
+ describe('generateDiffWithContext', () => {
199
+ it('returns context line before match', () => {
200
+ const output = generateDiffWithContext('keep\nreplace\nkeep', 'replace', 'REPLACE', 5, 7);
201
+ expect(output).toContain('keep');
202
+ expect(output).toContain('REPLACE');
203
+ });
204
+ it('returns no context before when match starts at first line', () => {
205
+ const output = generateDiffWithContext('line1\nline2', 'line1', 'LINE1', 0, 5);
206
+ expect(output).toContain('LINE1');
207
+ const dimCount = (output.match(/\x1b\[38;2;100;100;100m/g) || []).length;
208
+ expect(dimCount).toBe(1);
209
+ });
210
+ it('returns no context after when match ends at last line', () => {
211
+ const output = generateDiffWithContext('line1\nline2', 'line2', 'LINE2', 6, 5);
212
+ expect(output).toContain('LINE2');
213
+ const dimCount = (output.match(/\x1b\[38;2;100;100;100m/g) || []).length;
214
+ expect(dimCount).toBe(1);
215
+ });
216
+ it('handles matchIndex at exact end of content (append behavior)', () => {
217
+ const output = generateDiffWithContext('line1\nline2', '', 'appended', 11, 0);
218
+ expect(output).toContain('appended');
219
+ // Should show line 2 context (the last line)
220
+ expect(output).toContain('line2');
221
+ });
222
+ it('handles match spanning only the last line with no trailing newline', () => {
223
+ const output = generateDiffWithContext('first\nlast', 'last', 'LAST', 6, 4);
224
+ expect(output).toContain('LAST');
225
+ expect(output).toContain('first');
226
+ // No context after since match is on the last line
227
+ const dimCount = (output.match(/\x1b\[38;2;100;100;100m/g) || []).length;
228
+ expect(dimCount).toBe(1);
229
+ });
230
+ it('handles fullContent with no trailing newline', () => {
231
+ const output = generateDiffWithContext('line1\nline2\nline3', 'line2', 'LINE2', 6, 5);
232
+ expect(output).toContain('LINE2');
233
+ expect(output).toContain('line1');
234
+ expect(output).toContain('line3');
235
+ });
236
+ it('handles match on single-line content with no trailing newline', () => {
237
+ const output = generateDiffWithContext('onlyline', 'onlyline', 'REPLACED', 0, 8);
238
+ expect(output).toContain('REPLACED');
239
+ // No context dim lines, only the diff
240
+ const dimCount = (output.match(/\x1b\[38;2;100;100;100m/g) || []).length;
241
+ expect(dimCount).toBe(0);
242
+ });
243
+ });
244
+ describe('escapeHtml', () => {
245
+ it('escapes ampersands', () => {
246
+ expect(escapeHtml('a&b')).toBe('a&b');
247
+ });
248
+ it('escapes less-than', () => {
249
+ expect(escapeHtml('<tag>')).toBe('&lt;tag&gt;');
250
+ });
251
+ it('escapes greater-than', () => {
252
+ expect(escapeHtml('a > b')).toBe('a &gt; b');
253
+ });
254
+ it('escapes double quotes', () => {
255
+ expect(escapeHtml('say "hello"')).toBe('say &quot;hello&quot;');
256
+ });
257
+ it('handles empty string', () => {
258
+ expect(escapeHtml('')).toBe('');
259
+ });
260
+ it('handles string with no special chars', () => {
261
+ expect(escapeHtml('hello world')).toBe('hello world');
262
+ });
263
+ });
264
+ describe('parseMarkdownEdit', () => {
265
+ it('parses single edit block', () => {
266
+ const input = '```edit\nfile: src/test.ts\nold: |\n hello\nnew: |\n world\n```';
267
+ const edits = parseMarkdownEdit(input);
268
+ expect(edits).toHaveLength(1);
269
+ expect(edits[0].file_path).toBe('src/test.ts');
270
+ // Pipe format preserves leading whitespace
271
+ expect(edits[0].old_string).toBe(' hello');
272
+ expect(edits[0].new_string).toBe(' world');
273
+ });
274
+ it('parses create file (empty old)', () => {
275
+ const input = '```edit\nfile: src/new.ts\nold:\nnew: |\n const x = 1\n```';
276
+ const edits = parseMarkdownEdit(input);
277
+ expect(edits).toHaveLength(1);
278
+ expect(edits[0].file_path).toBe('src/new.ts');
279
+ expect(edits[0].old_string).toBe('');
280
+ // Pipe format preserves leading whitespace
281
+ expect(edits[0].new_string).toBe(' const x = 1');
282
+ });
283
+ it('parses create file with pipe (old: | empty content)', () => {
284
+ const input = [
285
+ '```edit',
286
+ 'file: src/new-pipe.ts',
287
+ 'old: |',
288
+ 'new: |',
289
+ ' const x = 1',
290
+ '```',
291
+ ].join('\n');
292
+ const edits = parseMarkdownEdit(input);
293
+ expect(edits).toHaveLength(1);
294
+ expect(edits[0].file_path).toBe('src/new-pipe.ts');
295
+ expect(edits[0].old_string).toBe('');
296
+ // Pipe format preserves leading whitespace
297
+ expect(edits[0].new_string).toBe(' const x = 1');
298
+ });
299
+ it('parses multiple edit blocks', () => {
300
+ const input = [
301
+ '```edit',
302
+ 'file: a.ts',
303
+ 'old: |',
304
+ ' foo',
305
+ 'new: |',
306
+ ' bar',
307
+ '```',
308
+ '```edit',
309
+ 'file: b.ts',
310
+ 'old: |',
311
+ ' baz',
312
+ 'new: |',
313
+ ' qux',
314
+ '```',
315
+ ].join('\n');
316
+ const edits = parseMarkdownEdit(input);
317
+ expect(edits).toHaveLength(2);
318
+ expect(edits[0].file_path).toBe('a.ts');
319
+ // Pipe format preserves leading whitespace
320
+ expect(edits[0].old_string).toBe(' foo');
321
+ expect(edits[0].new_string).toBe(' bar');
322
+ expect(edits[1].file_path).toBe('b.ts');
323
+ expect(edits[1].old_string).toBe(' baz');
324
+ expect(edits[1].new_string).toBe(' qux');
325
+ });
326
+ it('returns empty array when no edit blocks found', () => {
327
+ expect(parseMarkdownEdit('no code blocks here')).toEqual([]);
328
+ });
329
+ it('returns empty array when block has no file path', () => {
330
+ const input = '```edit\nold: |\n a\nnew: |\n b\n```';
331
+ expect(parseMarkdownEdit(input)).toEqual([]);
332
+ });
333
+ it('parses edit block without pipe (single line)', () => {
334
+ const input = '```edit\nfile: a.ts\nold: hello\nnew: world\n```';
335
+ const edits = parseMarkdownEdit(input);
336
+ expect(edits).toHaveLength(1);
337
+ // Non-pipe format: "old: hello" �?content after "old: " includes the leading space
338
+ expect(edits[0].old_string).toBe(' hello');
339
+ expect(edits[0].new_string).toBe(' world');
340
+ });
341
+ it('parses old with pipe-newline format', () => {
342
+ const input = '```edit\nfile: a.ts\nold: |\n multi\n line\nnew: |\n replaced\n```';
343
+ const edits = parseMarkdownEdit(input);
344
+ expect(edits).toHaveLength(1);
345
+ // Pipe format preserves leading whitespace (only strips the first \n)
346
+ expect(edits[0].old_string).toBe(' multi\n line');
347
+ expect(edits[0].new_string).toBe(' replaced');
348
+ });
349
+ it('handles extra whitespace after ```edit', () => {
350
+ const input = '```edit \nfile: a.ts\nold: |\n x\nnew: |\n y\n```';
351
+ const edits = parseMarkdownEdit(input);
352
+ expect(edits).toHaveLength(1);
353
+ expect(edits[0].file_path).toBe('a.ts');
354
+ });
355
+ it('ignores non-edit code blocks', () => {
356
+ const input = '```typescript\nconst x = 1\n```';
357
+ expect(parseMarkdownEdit(input)).toEqual([]);
358
+ });
359
+ it('handles mixed content with edit blocks', () => {
360
+ const input = [
361
+ 'some text before',
362
+ '',
363
+ '```edit',
364
+ 'file: a.ts',
365
+ 'old: |',
366
+ ' hello',
367
+ 'new: |',
368
+ ' hi',
369
+ '```',
370
+ 'some text after',
371
+ ].join('\n');
372
+ const edits = parseMarkdownEdit(input);
373
+ expect(edits).toHaveLength(1);
374
+ expect(edits[0].file_path).toBe('a.ts');
375
+ });
376
+ it('handles early-``` closing (new: outside the block)', () => {
377
+ // Model may close ``` before new: �?common mistake
378
+ const input = [
379
+ '```edit',
380
+ 'file: a.ts',
381
+ 'old: |',
382
+ ' hello',
383
+ '```',
384
+ 'new: |',
385
+ ' world',
386
+ '```',
387
+ ].join('\n');
388
+ const edits = parseMarkdownEdit(input);
389
+ expect(edits).toHaveLength(1);
390
+ expect(edits[0].file_path).toBe('a.ts');
391
+ expect(edits[0].old_string).toBe(' hello');
392
+ expect(edits[0].new_string).toBe(' world');
393
+ });
394
+ it('handles raw file:/old:/new: without ``` markers', () => {
395
+ // Model outputs edit without ```edit / ``` wrappers
396
+ const input = 'file: a.ts\nold: |\n hello\nnew: |\n world\n';
397
+ const edits = parseMarkdownEdit(input);
398
+ expect(edits).toHaveLength(1);
399
+ expect(edits[0].file_path).toBe('a.ts');
400
+ expect(edits[0].old_string).toBe(' hello');
401
+ expect(edits[0].new_string).toBe(' world');
402
+ });
403
+ it('handles edit block without any ``` markers (raw file:/old:/new: content)', () => {
404
+ const input = 'file: a.ts\nold: |\n hello\nnew: |\n world\n';
405
+ const edits = parseMarkdownEdit(input);
406
+ expect(edits).toHaveLength(1);
407
+ expect(edits[0].file_path).toBe('a.ts');
408
+ expect(edits[0].old_string).toBe(' hello');
409
+ expect(edits[0].new_string).toBe(' world');
410
+ });
411
+ it('handles old:| then new: (no pipe on new)', () => {
412
+ const input = '```edit\nfile: a.ts\nold: |\n hello\nnew:\n world\n```';
413
+ const edits = parseMarkdownEdit(input);
414
+ expect(edits).toHaveLength(1);
415
+ expect(edits[0].file_path).toBe('a.ts');
416
+ expect(edits[0].new_string).toBe(' world');
417
+ });
418
+ it('handles |2 pipe-with-digit syntax', () => {
419
+ const input = '```edit\nfile: a.ts\nold: |2\n hello\n world\nnew: |1\n hi\n```';
420
+ const edits = parseMarkdownEdit(input);
421
+ expect(edits).toHaveLength(1);
422
+ expect(edits[0].file_path).toBe('a.ts');
423
+ expect(edits[0].old_string).toBe(' hello\n world');
424
+ expect(edits[0].new_string).toBe(' hi');
425
+ });
426
+ it('handles backticks inside edit content', () => {
427
+ const input = '```edit\nfile: a.ts\nold: |\n some `code` here\nnew: |\n replaced `code`\n```';
428
+ const edits = parseMarkdownEdit(input);
429
+ expect(edits).toHaveLength(1);
430
+ expect(edits[0].file_path).toBe('a.ts');
431
+ expect(edits[0].old_string).toBe(' some `code` here');
432
+ expect(edits[0].new_string).toBe(' replaced `code`');
433
+ });
434
+ it('handles old_string with trailing whitespace line', () => {
435
+ const input = '```edit\nfile: a.ts\nold: |\n hello\n \nnew: |\n hi\n```';
436
+ const edits = parseMarkdownEdit(input);
437
+ expect(edits).toHaveLength(1);
438
+ expect(edits[0].old_string).toBe(' hello\n ');
439
+ });
440
+ it('handles new_string with empty content (deletion)', () => {
441
+ const input = '```edit\nfile: a.ts\nold: |\n remove me\nnew:\n```';
442
+ const edits = parseMarkdownEdit(input);
443
+ expect(edits).toHaveLength(1);
444
+ expect(edits[0].old_string).toBe(' remove me');
445
+ expect(edits[0].new_string).toBe('');
446
+ });
447
+ });
448
+ // ── Markdown format integration tests ────────────────────────────────────
449
+ describe('edit tool �?markdown format', () => {
8
450
  let tmpDir;
9
451
  let applier;
10
452
  beforeAll(() => {
11
453
  tmpDir = makeTempDir();
12
454
  fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'line one\nline two\nline three\n');
13
- fs.writeFileSync(path.join(tmpDir, 'b.txt'), 'foo\nbar\nbaz\n');
14
- applier = new FileReadTracker();
455
+ applier = makeApplier();
456
+ applier.markRead(path.join(tmpDir, 'a.txt'));
15
457
  });
16
458
  afterAll(() => {
17
459
  fs.rmSync(tmpDir, { recursive: true, force: true });
18
460
  });
19
461
  const tool = () => createEditTool(applier, tmpDir);
20
- describe('single edit mode', () => {
21
- beforeAll(() => {
22
- fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'line one\nline two\nline three\n');
23
- fs.writeFileSync(path.join(tmpDir, 'dup.txt'), 'abc\ndef\nabc\n');
24
- });
25
- it('replaces an exact string', async () => {
462
+ it('replaces exact string via markdown format', async () => {
463
+ const input = '```edit\nfile: a.txt\nold: |\n line two\nnew: |\n line TWO\n```';
464
+ const r = await tool().execute({ content: input });
465
+ expect(r.success).toBe(true);
466
+ expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toContain('line TWO');
467
+ });
468
+ it('replaces multi-line via markdown format', async () => {
469
+ // Use a fresh file to avoid cross-test contamination
470
+ const file = path.join(tmpDir, 'multi-md.txt');
471
+ fs.writeFileSync(file, 'line one\nline two\nline three\n');
472
+ applier.markRead(file);
473
+ const input = [
474
+ '```edit',
475
+ 'file: multi-md.txt',
476
+ 'old: |',
477
+ ' line one',
478
+ ' line two',
479
+ 'new: |',
480
+ ' line 1',
481
+ ' line 2',
482
+ '```',
483
+ ].join('\n');
484
+ const r = await tool().execute({ content: input });
485
+ expect(r.success).toBe(true);
486
+ // Pipe format preserves leading whitespace from the block
487
+ const content = fs.readFileSync(file, 'utf8');
488
+ expect(content).toContain(' line 1');
489
+ expect(content).toContain(' line 2');
490
+ });
491
+ it('creates file via markdown format', async () => {
492
+ const input = '```edit\nfile: markdown-new.txt\nold:\nnew: |\n created via markdown\n```';
493
+ const r = await tool().execute({ content: input });
494
+ expect(r.success).toBe(true);
495
+ // Pipe format preserves leading whitespace from the block
496
+ expect(fs.readFileSync(path.join(tmpDir, 'markdown-new.txt'), 'utf8')).toBe(' created via markdown');
497
+ });
498
+ it('reports parse error for malformed markdown', async () => {
499
+ const r = await tool().execute({ content: 'not a valid edit block' });
500
+ expect(r.success).toBe(false);
501
+ expect(r.error).toContain('Failed to parse');
502
+ });
503
+ it('applies multiple markdown blocks', async () => {
504
+ fs.writeFileSync(path.join(tmpDir, 'multi-a.txt'), 'foo\nbar\n');
505
+ fs.writeFileSync(path.join(tmpDir, 'multi-b.txt'), 'baz\nqux\n');
506
+ applier.markRead(path.join(tmpDir, 'multi-a.txt'));
507
+ applier.markRead(path.join(tmpDir, 'multi-b.txt'));
508
+ const input = [
509
+ '```edit',
510
+ 'file: multi-a.txt',
511
+ 'old: |',
512
+ ' foo',
513
+ 'new: |',
514
+ ' FOO',
515
+ '```',
516
+ '```edit',
517
+ 'file: multi-b.txt',
518
+ 'old: |',
519
+ ' baz',
520
+ 'new: |',
521
+ ' BAZ',
522
+ '```',
523
+ ].join('\n');
524
+ const r = await tool().execute({ content: input });
525
+ expect(r.success).toBe(true);
526
+ expect(fs.readFileSync(path.join(tmpDir, 'multi-a.txt'), 'utf8')).toContain('FOO');
527
+ expect(fs.readFileSync(path.join(tmpDir, 'multi-b.txt'), 'utf8')).toContain('BAZ');
528
+ });
529
+ });
530
+ // ── Advanced edge cases ──────────────────────────────────────────────────
531
+ describe('edit tool �?edge cases', () => {
532
+ let tmpDir;
533
+ let applier;
534
+ beforeAll(() => {
535
+ tmpDir = makeTempDir();
536
+ applier = makeApplier();
537
+ });
538
+ afterAll(() => {
539
+ fs.rmSync(tmpDir, { recursive: true, force: true });
540
+ });
541
+ const tool = () => createEditTool(applier, tmpDir);
542
+ describe('special characters', () => {
543
+ it('handles unicode characters', async () => {
544
+ const file = path.join(tmpDir, 'unicode.txt');
545
+ fs.writeFileSync(file, 'héllo wörld\n');
546
+ applier.markRead(file);
26
547
  const r = await tool().execute({
27
- edits: [{ file_path: 'a.txt', old_string: 'line two', new_string: 'line TWO' }],
548
+ edits: [{ file_path: 'unicode.txt', old_string: 'héllo wörld', new_string: 'HELLO WORLD' }],
28
549
  });
29
550
  expect(r.success).toBe(true);
30
- expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toContain('line TWO');
551
+ expect(fs.readFileSync(file, 'utf8')).toBe('HELLO WORLD\n');
31
552
  });
32
- it('replaces multi-line string', async () => {
553
+ it('handles emoji characters', async () => {
554
+ const file = path.join(tmpDir, 'emoji.txt');
555
+ fs.writeFileSync(file, 'hello 👍 world\n');
556
+ applier.markRead(file);
33
557
  const r = await tool().execute({
34
558
  edits: [
35
- {
36
- file_path: 'a.txt',
37
- old_string: 'line one\nline TWO',
38
- new_string: 'line 1\nline 2',
39
- },
559
+ { file_path: 'emoji.txt', old_string: 'hello 👍 world', new_string: 'hello world' },
40
560
  ],
41
561
  });
42
562
  expect(r.success).toBe(true);
43
- const content = fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8');
44
- expect(content).toContain('line 1');
45
- expect(content).toContain('line 2');
563
+ expect(fs.readFileSync(file, 'utf8')).toBe('hello world\n');
46
564
  });
47
- it('reports old_string not found', async () => {
565
+ it('handles Chinese characters', async () => {
566
+ const file = path.join(tmpDir, 'chinese.txt');
567
+ fs.writeFileSync(file, '你好世界\n');
568
+ applier.markRead(file);
48
569
  const r = await tool().execute({
49
- edits: [{ file_path: 'a.txt', old_string: 'nonexistent', new_string: 'x' }],
570
+ edits: [{ file_path: 'chinese.txt', old_string: '你好世界', new_string: 'Hello World' }],
50
571
  });
51
- expect(r.success).toBe(false);
52
- expect(r.error).toContain('not found');
53
- });
54
- it('reports duplicate old_string', async () => {
55
- const r = await tool().execute({
56
- edits: [{ file_path: 'dup.txt', old_string: 'abc', new_string: 'xyz' }],
57
- });
58
- expect(r.success).toBe(false);
59
- expect(r.error).toContain('MULTIPLE times');
60
- });
61
- it('auto-corrects flat file_path/old_string/new_string into edits array', async () => {
62
- // The tool auto-corrects { file_path, old_string, new_string }
63
- // into { edits: [{ file_path, old_string, new_string }] }
64
- // So the error should be about file not found, not 'edits' missing
65
- const r = await tool().execute({ file_path: 'x', old_string: 'y', new_string: 'z' });
66
- expect(r.success).toBe(false);
67
- expect(r.error).toContain('not found');
572
+ expect(r.success).toBe(true);
573
+ expect(fs.readFileSync(file, 'utf8')).toBe('Hello World\n');
68
574
  });
69
- it('reports file not found', async () => {
575
+ it('handles tab characters', async () => {
576
+ const file = path.join(tmpDir, 'tabs.txt');
577
+ fs.writeFileSync(file, 'a\tb\nc\td\n');
578
+ applier.markRead(file);
70
579
  const r = await tool().execute({
71
- edits: [{ file_path: 'nonexistent.txt', old_string: 'x', new_string: 'y' }],
580
+ edits: [{ file_path: 'tabs.txt', old_string: 'a\tb', new_string: 'A\tB' }],
72
581
  });
73
- expect(r.success).toBe(false);
74
- expect(r.error).toContain('not found');
582
+ expect(r.success).toBe(true);
583
+ expect(fs.readFileSync(file, 'utf8')).toBe('A\tB\nc\td\n');
75
584
  });
76
585
  });
77
- describe('validation', () => {
78
- beforeAll(() => {
79
- fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'test content\n');
80
- });
81
- it('rejects edit missing old_string', async () => {
586
+ describe('empty and boundary files', () => {
587
+ it('edits a file with single line', async () => {
588
+ const file = path.join(tmpDir, 'single.txt');
589
+ fs.writeFileSync(file, 'only line\n');
590
+ applier.markRead(file);
82
591
  const r = await tool().execute({
83
- edits: [{ file_path: 'a.txt', new_string: 'replacement' }],
592
+ edits: [{ file_path: 'single.txt', old_string: 'only line', new_string: 'replaced line' }],
84
593
  });
85
- expect(r.success).toBe(false);
86
- expect(r.error).toContain('missing');
87
- expect(r.error).toContain('old_string');
88
- });
89
- it('rejects edit missing new_string', async () => {
90
- const r = await tool().execute({
91
- edits: [{ file_path: 'a.txt', old_string: 'original' }],
92
- });
93
- expect(r.success).toBe(false);
94
- expect(r.error).toContain('missing');
95
- expect(r.error).toContain('new_string');
594
+ expect(r.success).toBe(true);
595
+ expect(fs.readFileSync(file, 'utf8')).toBe('replaced line\n');
96
596
  });
97
- it('rejects edit missing file_path', async () => {
597
+ it('edits a file with no trailing newline', async () => {
598
+ const file = path.join(tmpDir, 'no-nl.txt');
599
+ fs.writeFileSync(file, 'line one\nline two');
600
+ applier.markRead(file);
98
601
  const r = await tool().execute({
99
- edits: [{ old_string: 'a', new_string: 'b' }],
602
+ edits: [{ file_path: 'no-nl.txt', old_string: 'line two', new_string: 'line TWO' }],
100
603
  });
101
- expect(r.success).toBe(false);
102
- expect(r.error).toContain('missing');
103
- expect(r.error).toContain('file_path');
604
+ expect(r.success).toBe(true);
605
+ expect(fs.readFileSync(file, 'utf8')).toBe('line one\nline TWO');
104
606
  });
105
- it('reports all missing fields in one error', async () => {
607
+ it('appends to end of file', async () => {
608
+ const file = path.join(tmpDir, 'append.txt');
609
+ fs.writeFileSync(file, 'line one\n');
610
+ applier.markRead(file);
106
611
  const r = await tool().execute({
107
612
  edits: [
108
- { file_path: 'a.txt', old_string: 'a', new_string: 'b' },
109
- { file_path: 'a.txt', new_string: 'c' },
110
- { file_path: 'a.txt', old_string: 'd' },
613
+ {
614
+ file_path: 'append.txt',
615
+ old_string: 'line one',
616
+ new_string: 'line one\nline two',
617
+ },
111
618
  ],
112
619
  });
113
- expect(r.success).toBe(false);
114
- // Should report BOTH errors, not just the first
115
- expect(r.error).toContain('2 of 3');
116
- expect(r.error).toContain('edit #2');
117
- expect(r.error).toContain('edit #3');
118
- // Should mention not to split across edits
119
- expect(r.error).toContain('COMPLETE');
120
- });
121
- it('reports which fields each malformed edit has', async () => {
122
- const r = await tool().execute({
123
- edits: [{ file_path: 'a.txt', new_string: 'c' }],
124
- });
125
- expect(r.success).toBe(false);
126
- expect(r.error).toContain('has: file_path, new_string');
127
- });
128
- it('includes rawInput in the error message', async () => {
129
- const r = await tool().execute({
130
- edits: [{ file_path: 'a.txt', new_string: 'c' }],
131
- });
132
- expect(r.success).toBe(false);
133
- expect(r.error).toContain('a.txt');
134
- expect(r.error).toContain('new_string');
135
- });
136
- });
137
- describe('auto-correction', () => {
138
- it('wraps top-level file_path/old_string/new_string into edits array', async () => {
139
- const r = await tool().execute({
140
- file_path: 'nonexistent.ts',
141
- old_string: 'x',
142
- new_string: 'y',
143
- });
144
- // Should be auto-corrected so the error is about file not found, not about missing edits
145
- expect(r.success).toBe(false);
146
- expect(r.error).toContain('not found');
620
+ expect(r.success).toBe(true);
621
+ expect(fs.readFileSync(file, 'utf8')).toBe('line one\nline two\n');
147
622
  });
148
- it('wraps flat file_path + old_string (missing new_string) with default empty new_string', async () => {
623
+ it('replaces entire file content', async () => {
624
+ const file = path.join(tmpDir, 'full-replace.txt');
625
+ fs.writeFileSync(file, 'old content\n');
626
+ applier.markRead(file);
149
627
  const r = await tool().execute({
150
- file_path: path.join(tmpDir, 'new.txt'),
151
- old_string: '',
628
+ edits: [
629
+ {
630
+ file_path: 'full-replace.txt',
631
+ old_string: 'old content',
632
+ new_string: 'brand new content',
633
+ },
634
+ ],
152
635
  });
153
- // Auto-corrected to { edits: [{ file_path, old_string: '', new_string: '' }] }
154
- // This creates a file with empty content
155
- expect(r.success).toBe(true);
156
- expect(fs.existsSync(path.join(tmpDir, 'new.txt'))).toBe(true);
157
- expect(fs.readFileSync(path.join(tmpDir, 'new.txt'), 'utf8')).toBe('');
158
- fs.unlinkSync(path.join(tmpDir, 'new.txt'));
159
- });
160
- it('handles edits passed directly as an array (not wrapped)', async () => {
161
- // @ts-expect-error — runtime accepts array, TS expects object
162
- const r = await tool().execute([
163
- { file_path: 'a.txt', old_string: 'test', new_string: 'TEST' },
164
- ]);
165
636
  expect(r.success).toBe(true);
637
+ expect(fs.readFileSync(file, 'utf8')).toBe('brand new content\n');
166
638
  });
167
- it('handles file_path + new_string (no old_string) at top level as create', async () => {
639
+ it('rejects empty old_string on existing non-empty file', async () => {
640
+ const file = path.join(tmpDir, 'empty-old-on-existing.txt');
641
+ fs.writeFileSync(file, 'some content\n');
642
+ applier.markRead(file);
168
643
  const r = await tool().execute({
169
- file_path: path.join(tmpDir, 'auto-create.txt'),
170
- new_string: 'auto created',
644
+ edits: [
645
+ {
646
+ file_path: 'empty-old-on-existing.txt',
647
+ old_string: '',
648
+ new_string: 'should not replace',
649
+ },
650
+ ],
171
651
  });
172
- expect(r.success).toBe(true);
173
- expect(fs.readFileSync(path.join(tmpDir, 'auto-create.txt'), 'utf8')).toBe('auto created');
174
- fs.unlinkSync(path.join(tmpDir, 'auto-create.txt'));
652
+ expect(r.success).toBe(false);
653
+ expect(r.error).toContain('already exists');
175
654
  });
176
- it('handles only file_path at top level as empty file creation', async () => {
655
+ it('rejects create file when file already exists with empty content', async () => {
656
+ const file = path.join(tmpDir, 'empty-existing-file.txt');
657
+ fs.writeFileSync(file, '');
658
+ applier.markRead(file);
177
659
  const r = await tool().execute({
178
- file_path: path.join(tmpDir, 'empty-file.txt'),
660
+ edits: [
661
+ {
662
+ file_path: 'empty-existing-file.txt',
663
+ old_string: '',
664
+ new_string: 'add content',
665
+ },
666
+ ],
179
667
  });
180
- expect(r.success).toBe(true);
181
- expect(fs.existsSync(path.join(tmpDir, 'empty-file.txt'))).toBe(true);
182
- expect(fs.readFileSync(path.join(tmpDir, 'empty-file.txt'), 'utf8')).toBe('');
183
- fs.unlinkSync(path.join(tmpDir, 'empty-file.txt'));
184
- });
185
- it('rejects nonsensical top-level keys with helpful error', async () => {
186
- const r = await tool().execute({ foo: 'bar' });
187
668
  expect(r.success).toBe(false);
188
- expect(r.error).toContain('edits');
189
- expect(r.error).toContain('array');
669
+ expect(r.error).toContain('already exists');
190
670
  });
191
671
  });
192
- describe('batch mode', () => {
193
- beforeAll(() => {
194
- fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'one\ntwo\nthree\nfour\n');
195
- fs.writeFileSync(path.join(tmpDir, 'b.txt'), 'foo\nbar\nbaz\n');
196
- });
197
- it('applies multiple edits to one file', async () => {
672
+ describe('file path edge cases', () => {
673
+ it('creates file in subdirectory that does not exist', async () => {
198
674
  const r = await tool().execute({
199
675
  edits: [
200
- { file_path: 'a.txt', old_string: 'one', new_string: 'ONE' },
201
- { file_path: 'a.txt', old_string: 'three', new_string: 'THREE' },
676
+ {
677
+ file_path: 'deep/nested/dir/new-file.txt',
678
+ old_string: '',
679
+ new_string: 'created in nested dir',
680
+ },
202
681
  ],
203
682
  });
204
683
  expect(r.success).toBe(true);
205
- const content = fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8');
206
- expect(content).toContain('ONE');
207
- expect(content).toContain('THREE');
208
- expect(content).toContain('two');
684
+ expect(fs.readFileSync(path.join(tmpDir, 'deep/nested/dir/new-file.txt'), 'utf8')).toBe('created in nested dir');
209
685
  });
210
- it('applies edits across multiple files', async () => {
686
+ it('handles file path with special characters', async () => {
211
687
  const r = await tool().execute({
212
688
  edits: [
213
- { file_path: 'a.txt', old_string: 'ONE', new_string: 'one' },
214
- { file_path: 'b.txt', old_string: 'foo\nbar', new_string: 'FOO\nBAR' },
689
+ {
690
+ file_path: 'file-with-dashes_and_underscores.txt',
691
+ old_string: '',
692
+ new_string: 'special path',
693
+ },
215
694
  ],
216
695
  });
217
696
  expect(r.success).toBe(true);
218
- expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toContain('one');
219
- expect(fs.readFileSync(path.join(tmpDir, 'b.txt'), 'utf8')).toContain('FOO');
220
- expect(fs.readFileSync(path.join(tmpDir, 'b.txt'), 'utf8')).toContain('BAR');
697
+ expect(fs.readFileSync(path.join(tmpDir, 'file-with-dashes_and_underscores.txt'), 'utf8')).toBe('special path');
221
698
  });
222
- it('rolls back all changes on failure', async () => {
223
- const original = fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8');
699
+ it('rejects path traversal with Windows drive letter', async () => {
224
700
  const r = await tool().execute({
225
701
  edits: [
226
- { file_path: 'a.txt', old_string: 'THREE', new_string: 'three' },
227
- { file_path: 'a.txt', old_string: 'zzz_nonexistent_zzz', new_string: 'FAIL' },
702
+ {
703
+ file_path: 'C:\\Windows\\system32\\drivers\\etc\\hosts',
704
+ old_string: '',
705
+ new_string: 'should not write',
706
+ },
228
707
  ],
229
708
  });
230
709
  expect(r.success).toBe(false);
231
- expect(r.error).toContain('rolled back');
232
- expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toBe(original);
710
+ expect(r.error).toContain('traversal');
233
711
  });
234
- it('rolls back multi-file on any failure', async () => {
235
- const origA = fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8');
236
- const origB = fs.readFileSync(path.join(tmpDir, 'b.txt'), 'utf8');
712
+ });
713
+ describe('path traversal security', () => {
714
+ it('rejects path traversal with ../', async () => {
237
715
  const r = await tool().execute({
238
716
  edits: [
239
- { file_path: 'a.txt', old_string: 'four', new_string: 'FOUR' },
240
- { file_path: 'missing.txt', old_string: 'x', new_string: 'y' },
717
+ {
718
+ file_path: '../../outside-cwd.txt',
719
+ old_string: 'hello',
720
+ new_string: 'world',
721
+ },
241
722
  ],
242
723
  });
243
724
  expect(r.success).toBe(false);
244
- expect(fs.readFileSync(path.join(tmpDir, 'a.txt'), 'utf8')).toBe(origA);
245
- expect(fs.readFileSync(path.join(tmpDir, 'b.txt'), 'utf8')).toBe(origB);
246
- });
247
- it('rejects empty edits array', async () => {
248
- const r = await tool().execute({ edits: [] });
249
- expect(r.success).toBe(false);
250
- expect(r.error).toContain('empty');
725
+ expect(r.error).toContain('traversal');
251
726
  });
252
- });
253
- describe('diff output', () => {
254
- beforeAll(() => {
255
- fs.writeFileSync(path.join(tmpDir, 'diff-test.txt'), 'line one\nline two\nline three\n');
256
- });
257
- it('shows green + for new file creation (no red - lines)', async () => {
258
- const r = await tool().execute({
259
- edits: [{ file_path: 'diff-new.txt', old_string: '', new_string: 'hello\nworld' }],
260
- });
261
- expect(r.success).toBe(true);
262
- // Should contain green ANSI code for added lines
263
- expect(r.output).toContain('\x1b[38;2;0;200;100m'); // green
264
- expect(r.output).toContain('hello');
265
- expect(r.output).toContain('world');
266
- // Should NOT contain red ANSI code (no removed lines)
267
- expect(r.output).not.toContain('\x1b[38;2;255;80;80m');
268
- expect(r.output).toContain('Created');
269
- });
270
- it('shows red - for removed lines and green + for added lines on replacement', async () => {
727
+ it('rejects path traversal with deep nested ../', async () => {
271
728
  const r = await tool().execute({
272
729
  edits: [
273
730
  {
274
- file_path: 'diff-test.txt',
275
- old_string: 'line two',
276
- new_string: 'line TWO',
731
+ file_path: 'subdir/../../../outside-cwd.txt',
732
+ old_string: '',
733
+ new_string: 'should not create',
277
734
  },
278
735
  ],
279
736
  });
280
- expect(r.success).toBe(true);
281
- // Should contain red ANSI code for removed content
282
- expect(r.output).toContain('\x1b[38;2;255;80;80m');
283
- expect(r.output).toContain('line two');
284
- // Should contain green ANSI code for added content
285
- expect(r.output).toContain('\x1b[38;2;0;200;100m');
286
- expect(r.output).toContain('line TWO');
287
- expect(r.output).toContain('Edited');
737
+ expect(r.success).toBe(false);
738
+ expect(r.error).toContain('traversal');
288
739
  });
289
- it('shows multi-line diff correctly', async () => {
740
+ it('rejects absolute path', async () => {
290
741
  const r = await tool().execute({
291
742
  edits: [
292
743
  {
293
- file_path: 'diff-test.txt',
294
- old_string: 'line one\nline TWO',
295
- new_string: 'line 1\nline 2',
744
+ file_path: '/etc/passwd',
745
+ old_string: '',
746
+ new_string: 'should not write',
296
747
  },
297
748
  ],
298
749
  });
299
- expect(r.success).toBe(true);
300
- // Removed lines (red)
301
- expect(r.output).toContain('\x1b[38;2;255;80;80mline one');
302
- expect(r.output).toContain('\x1b[38;2;255;80;80mline TWO');
303
- // Added lines (green)
304
- expect(r.output).toContain('\x1b[38;2;0;200;100mline 1');
305
- expect(r.output).toContain('\x1b[38;2;0;200;100mline 2');
750
+ expect(r.success).toBe(false);
751
+ expect(r.error).toContain('traversal');
306
752
  });
307
- it('handles CRLF in old_string (Windows compatibility)', async () => {
308
- fs.writeFileSync(path.join(tmpDir, 'crlf-test.txt'), 'a\\r\\nb\\r\\nc\\r\\n');
753
+ it('allows normal paths inside cwd', async () => {
309
754
  const r = await tool().execute({
310
755
  edits: [
311
756
  {
312
- file_path: 'crlf-test.txt',
313
- // Simulate AI sending old_string with \\r\\n (CRLF) on Windows
314
- old_string: 'b\\r\\n',
315
- new_string: 'B\\r\\n',
757
+ file_path: 'safe-file.txt',
758
+ old_string: '',
759
+ new_string: 'safe content',
316
760
  },
317
761
  ],
318
762
  });
319
763
  expect(r.success).toBe(true);
320
- const content = fs.readFileSync(path.join(tmpDir, 'crlf-test.txt'), 'utf8');
321
- expect(content).toContain('B');
322
- // Should show the normalized diff (with \\n, not \\r\\n)
323
- expect(r.output).toContain('\x1b[38;2;255;80;80mb');
324
- expect(r.output).toContain('\x1b[38;2;0;200;100mB');
325
764
  });
326
- it('does not show empty red line when old_string is empty', async () => {
765
+ it('rejects path traversal via markdown format', async () => {
766
+ const input = '```edit\nfile: ../../escape.txt\nold:\nnew: |\n hacked\n```';
767
+ const r = await tool().execute({ content: input });
768
+ expect(r.success).toBe(false);
769
+ expect(r.error).toContain('traversal');
770
+ });
771
+ it('rejects path traversal via symlink (if supported)', async () => {
772
+ let canSymlink = false;
773
+ const externalDir = path.join(os.tmpdir(), 'lonny-symlink-target-' + Date.now());
774
+ const symLink = path.join(tmpDir, 'symlink-inside');
775
+ try {
776
+ fs.mkdirSync(externalDir, { recursive: true });
777
+ fs.writeFileSync(path.join(externalDir, 'malicious.txt'), 'evil');
778
+ fs.symlinkSync(externalDir, symLink, 'junction');
779
+ canSymlink = fs.existsSync(symLink);
780
+ }
781
+ catch {
782
+ canSymlink = false;
783
+ }
784
+ if (!canSymlink)
785
+ return;
327
786
  const r = await tool().execute({
328
- edits: [{ file_path: 'empty-old.txt', old_string: '', new_string: 'only added' }],
787
+ edits: [
788
+ {
789
+ file_path: 'symlink-inside/malicious.txt',
790
+ old_string: 'evil',
791
+ new_string: 'hacked',
792
+ },
793
+ ],
329
794
  });
330
- expect(r.success).toBe(true);
331
- // Should have green colored line
332
- expect(r.output).toContain('\x1b[38;2;0;200;100monly added');
333
- // Count red-start markers — should be 0 (no removed content)
334
- const redCount = (r.output.match(/\x1b\[38;2;255;80;80m/g) || []).length;
335
- expect(redCount).toBe(0);
795
+ expect(r.success).toBe(false);
796
+ expect(r.error).toContain('traversal');
797
+ // Cleanup
798
+ try {
799
+ fs.rmSync(externalDir, { recursive: true, force: true });
800
+ }
801
+ catch { }
336
802
  });
337
803
  });
338
- describe('create mode', () => {
339
- const createdDir = () => path.join(tmpDir, 'created-test');
340
- afterAll(() => {
341
- fs.rmSync(createdDir(), { recursive: true, force: true });
342
- });
343
- it('creates a new file with empty old_string', async () => {
804
+ describe('error message quality', () => {
805
+ it('includes proximity hint when old_string not found', async () => {
806
+ const file = path.join(tmpDir, 'proximity.txt');
807
+ fs.writeFileSync(file, 'keep this line\nfind me\nkeep that line\n');
808
+ applier.markRead(file);
344
809
  const r = await tool().execute({
345
810
  edits: [
346
811
  {
347
- file_path: 'created-test/new.ts',
348
- old_string: '',
349
- new_string: 'const x = 1\nexport { x }',
812
+ file_path: 'proximity.txt',
813
+ old_string: 'find me\nnonexistent',
814
+ new_string: 'x',
350
815
  },
351
816
  ],
352
817
  });
353
- expect(r.success).toBe(true);
354
- const content = fs.readFileSync(path.join(createdDir(), 'new.ts'), 'utf8');
355
- expect(content).toBe('const x = 1\nexport { x }');
818
+ expect(r.success).toBe(false);
819
+ // Should include proximity info
820
+ expect(r.error).toContain('Near line');
821
+ expect(r.error).toContain('find me');
356
822
  });
357
- it('creates a file in batch mode', async () => {
823
+ it('includes file content preview when no close match found', async () => {
824
+ const file = path.join(tmpDir, 'preview.txt');
825
+ fs.writeFileSync(file, 'alpha\nbeta\ngamma\n');
826
+ applier.markRead(file);
358
827
  const r = await tool().execute({
359
828
  edits: [
360
- { file_path: 'created-test/a.ts', old_string: '', new_string: '// a' },
361
- { file_path: 'created-test/b.ts', old_string: '', new_string: '// b' },
829
+ {
830
+ file_path: 'preview.txt',
831
+ old_string: 'zzz_nonexistent_zzz',
832
+ new_string: 'x',
833
+ },
362
834
  ],
363
835
  });
364
- expect(r.success).toBe(true);
365
- expect(fs.readFileSync(path.join(createdDir(), 'a.ts'), 'utf8')).toBe('// a');
366
- expect(fs.readFileSync(path.join(createdDir(), 'b.ts'), 'utf8')).toBe('// b');
836
+ expect(r.success).toBe(false);
837
+ // Should include file content preview
838
+ expect(r.error).toContain('File content');
839
+ expect(r.error).toContain('alpha');
840
+ expect(r.error).toContain('beta');
367
841
  });
368
- it('rejects create when file already exists', async () => {
842
+ it('includes read warning when file not read first', async () => {
843
+ // Don't call markRead �?simulate stale content
844
+ const file = path.join(tmpDir, 'no-read.txt');
845
+ fs.writeFileSync(file, 'content\n');
369
846
  const r = await tool().execute({
370
847
  edits: [
371
848
  {
372
- file_path: 'created-test/new.ts',
373
- old_string: '',
849
+ file_path: 'no-read.txt',
850
+ old_string: 'zzz',
374
851
  new_string: 'x',
375
852
  },
376
853
  ],
377
854
  });
378
855
  expect(r.success).toBe(false);
379
- expect(r.error).toContain('already exists');
856
+ expect(r.error).toContain('was not read first');
380
857
  });
381
- it('rolls back on create failure in batch', async () => {
858
+ it('includes rawInput in error for easy debugging', async () => {
382
859
  const r = await tool().execute({
383
860
  edits: [
384
- { file_path: 'created-test/rollback-1.ts', old_string: '', new_string: 'keep me' },
385
- { file_path: 'created-test/rollback-1.ts', old_string: '', new_string: 'duplicate' },
861
+ {
862
+ file_path: 'nonexistent.txt',
863
+ old_string: 'hello',
864
+ new_string: 'world',
865
+ },
386
866
  ],
387
867
  });
388
868
  expect(r.success).toBe(false);
389
- expect(fs.existsSync(path.join(createdDir(), 'rollback-1.ts'))).toBe(false);
869
+ expect(r.error).toContain('nonexistent.txt');
870
+ expect(r.error).toContain('hello');
871
+ expect(r.error).toContain('world');
390
872
  });
391
873
  });
392
- describe('whitespace tolerance', () => {
393
- const wsDir = () => path.join(tmpDir, 'ws-test');
394
- const f = (name) => path.join(wsDir(), name);
395
- beforeAll(() => {
396
- fs.mkdirSync(wsDir(), { recursive: true });
397
- });
398
- const tool = () => createEditTool(applier, wsDir());
399
- it('handles trailing space in file (AI omits trailing space)', async () => {
400
- fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n');
874
+ describe('output format', () => {
875
+ it('prefixes output with "Edited" for modifications', async () => {
876
+ const file = path.join(tmpDir, 'output-test.txt');
877
+ fs.writeFileSync(file, 'original\n');
878
+ applier.markRead(file);
401
879
  const r = await tool().execute({
402
- edits: [
403
- { file_path: 'trailing.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
404
- ],
880
+ edits: [{ file_path: 'output-test.txt', old_string: 'original', new_string: 'modified' }],
405
881
  });
406
882
  expect(r.success).toBe(true);
407
- expect(fs.readFileSync(f('trailing.txt'), 'utf8')).toBe('HELLO\nWORLD\n');
408
- // Should indicate whitespace-normalized in output
409
- expect(r.output).toContain('whitespace-normalized');
883
+ expect(r.output).toContain('Edited');
410
884
  });
411
- it('handles trailing space in old_string (AI adds extra trailing space)', async () => {
412
- fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n');
885
+ it('prefixes output with "Created" for new files', async () => {
413
886
  const r = await tool().execute({
414
- edits: [
415
- { file_path: 'trailing.txt', old_string: 'hello \nworld', new_string: 'HELLO\nWORLD' },
416
- ],
887
+ edits: [{ file_path: 'output-create.txt', old_string: '', new_string: 'new file' }],
417
888
  });
418
889
  expect(r.success).toBe(true);
419
- expect(fs.readFileSync(f('trailing.txt'), 'utf8')).toBe('HELLO\nWORLD\n');
890
+ expect(r.output).toContain('Created');
420
891
  });
421
- it('handles leading space in file (AI omits leading indent)', async () => {
422
- fs.writeFileSync(f('leading.txt'), ' hello\nworld\n');
892
+ it('includes line count for created files', async () => {
423
893
  const r = await tool().execute({
424
894
  edits: [
425
- { file_path: 'leading.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
895
+ {
896
+ file_path: 'output-count.txt',
897
+ old_string: '',
898
+ new_string: 'line1\nline2\nline3',
899
+ },
426
900
  ],
427
901
  });
428
902
  expect(r.success).toBe(true);
429
- // Leading spaces in the original file become part of the matched block
430
- // and are preserved in the result
431
- expect(fs.readFileSync(f('leading.txt'), 'utf8')).toBe(' HELLO\nWORLD\n');
903
+ expect(r.output).toContain('3 lines');
432
904
  });
433
- it('handles leading space in old_string (AI adds extra indent)', async () => {
434
- fs.writeFileSync(f('leading.txt'), ' hello\nworld\n');
905
+ });
906
+ describe('concurrent edits to same line area', () => {
907
+ it('applies edits bottom-to-top within same file', async () => {
908
+ const file = path.join(tmpDir, 'concurrent.txt');
909
+ fs.writeFileSync(file, 'first\nsecond\nthird\n');
910
+ applier.markRead(file);
435
911
  const r = await tool().execute({
436
912
  edits: [
437
- { file_path: 'leading.txt', old_string: ' hello\nworld', new_string: 'HELLO\nWORLD' },
913
+ { file_path: 'concurrent.txt', old_string: 'first', new_string: 'FIRST' },
914
+ { file_path: 'concurrent.txt', old_string: 'third', new_string: 'THIRD' },
438
915
  ],
439
916
  });
440
917
  expect(r.success).toBe(true);
441
- // The file's leading spaces are part of the matched block, so they
442
- // get replaced along with the rest of the matched text
443
- expect(fs.readFileSync(f('leading.txt'), 'utf8')).toBe('HELLO\nWORLD\n');
918
+ const content = fs.readFileSync(file, 'utf8');
919
+ expect(content).toBe('FIRST\nsecond\nTHIRD\n');
444
920
  });
445
- it('handles extra internal spaces in file (AI sends single space)', async () => {
446
- fs.writeFileSync(f('internal.txt'), 'foo bar\nbaz\n');
921
+ it('applies edits bottom-to-top to avoid line offset issues', async () => {
922
+ const file = path.join(tmpDir, 'offset.txt');
923
+ fs.writeFileSync(file, 'a\nb\nc\n');
924
+ applier.markRead(file);
925
+ // If applied top-to-bottom: after replacing "a" with "A1\nA2",
926
+ // "c" would be at line 4, not line 2
447
927
  const r = await tool().execute({
448
928
  edits: [
449
- { file_path: 'internal.txt', old_string: 'foo bar\nbaz', new_string: 'FOO BAR\nBAZ' },
929
+ { file_path: 'offset.txt', old_string: 'a', new_string: 'A1\nA2' },
930
+ { file_path: 'offset.txt', old_string: 'c', new_string: 'C' },
450
931
  ],
451
932
  });
452
933
  expect(r.success).toBe(true);
453
- expect(fs.readFileSync(f('internal.txt'), 'utf8')).toBe('FOO BAR\nBAZ\n');
934
+ const content = fs.readFileSync(file, 'utf8');
935
+ expect(content).toBe('A1\nA2\nb\nC\n');
454
936
  });
455
- it('handles extra internal spaces in old_string (AI adds double space)', async () => {
456
- fs.writeFileSync(f('internal.txt'), 'foo bar\nbaz\n');
937
+ });
938
+ describe('CRLF handling', () => {
939
+ it('handles CRLF in file content', async () => {
940
+ const file = path.join(tmpDir, 'crlf-content.txt');
941
+ fs.writeFileSync(file, 'hello\r\nworld\r\n');
942
+ applier.markRead(file);
457
943
  const r = await tool().execute({
458
944
  edits: [
459
- { file_path: 'internal.txt', old_string: 'foo bar\nbaz', new_string: 'FOO BAR\nBAZ' },
945
+ { file_path: 'crlf-content.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
460
946
  ],
461
947
  });
462
948
  expect(r.success).toBe(true);
463
- expect(fs.readFileSync(f('internal.txt'), 'utf8')).toBe('FOO BAR\nBAZ\n');
949
+ const content = fs.readFileSync(file, 'utf8');
950
+ expect(content).toBe('HELLO\nWORLD\n');
464
951
  });
465
- it('handles blank line with spaces matching empty blank line', async () => {
466
- fs.writeFileSync(f('blank.txt'), 'foo\n \nbar\n');
952
+ });
953
+ describe('rollback behavior', () => {
954
+ it('restores original file content on failure', async () => {
955
+ const file = path.join(tmpDir, 'rollback-restore.txt');
956
+ fs.writeFileSync(file, 'original content\n');
957
+ applier.markRead(file);
958
+ const original = fs.readFileSync(file, 'utf8');
467
959
  const r = await tool().execute({
468
- edits: [{ file_path: 'blank.txt', old_string: 'foo\n\nbar', new_string: 'FOO\n\nBAR' }],
960
+ edits: [
961
+ {
962
+ file_path: 'rollback-restore.txt',
963
+ old_string: 'original content',
964
+ new_string: 'changed',
965
+ },
966
+ { file_path: 'rollback-restore.txt', old_string: 'nonexistent', new_string: 'fail' },
967
+ ],
469
968
  });
470
- expect(r.success).toBe(true);
471
- // The blank line with spaces is part of the matched block and gets
472
- // replaced entirely by the new_string's blank line
473
- expect(fs.readFileSync(f('blank.txt'), 'utf8')).toBe('FOO\n\nBAR\n');
969
+ expect(r.success).toBe(false);
970
+ expect(r.error).toContain('rolled back');
971
+ // File should be restored to original
972
+ expect(fs.readFileSync(file, 'utf8')).toBe(original);
474
973
  });
475
- it('handles mixed whitespace across multiple lines', async () => {
476
- fs.writeFileSync(f('mixed.txt'), ' a \nb \nc\n');
974
+ it('deletes newly created file on rollback', async () => {
477
975
  const r = await tool().execute({
478
- edits: [{ file_path: 'mixed.txt', old_string: 'a\nb\nc', new_string: 'A\nB\nC' }],
976
+ edits: [
977
+ { file_path: 'rollback-new.txt', old_string: '', new_string: 'new file' },
978
+ { file_path: 'rollback-new.txt', old_string: '', new_string: 'duplicate' },
979
+ ],
479
980
  });
480
- expect(r.success).toBe(true);
481
- expect(fs.readFileSync(f('mixed.txt'), 'utf8')).toBe('A\nB\nC\n');
981
+ expect(r.success).toBe(false);
982
+ expect(fs.existsSync(path.join(tmpDir, 'rollback-new.txt'))).toBe(false);
482
983
  });
483
- it('still fails when text content differs (not just whitespace)', async () => {
484
- fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n');
984
+ });
985
+ describe('multi-file scenarios', () => {
986
+ beforeAll(() => {
987
+ fs.writeFileSync(path.join(tmpDir, 'mf-a.txt'), 'edit me\n');
988
+ fs.writeFileSync(path.join(tmpDir, 'mf-b.txt'), 'edit me too\n');
989
+ applier.markRead(path.join(tmpDir, 'mf-a.txt'));
990
+ applier.markRead(path.join(tmpDir, 'mf-b.txt'));
991
+ });
992
+ it('edits multiple existing files in one call', async () => {
485
993
  const r = await tool().execute({
486
- edits: [{ file_path: 'trailing.txt', old_string: 'hello\nmundo', new_string: 'x\ny' }],
994
+ edits: [
995
+ { file_path: 'mf-a.txt', old_string: 'edit me', new_string: 'edited' },
996
+ { file_path: 'mf-b.txt', old_string: 'edit me too', new_string: 'edited too' },
997
+ ],
487
998
  });
488
- expect(r.success).toBe(false);
489
- expect(r.error).toContain('not found');
999
+ expect(r.success).toBe(true);
1000
+ expect(fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8')).toBe('edited\n');
1001
+ expect(fs.readFileSync(path.join(tmpDir, 'mf-b.txt'), 'utf8')).toBe('edited too\n');
490
1002
  });
491
- it('still fails for completely wrong old_string', async () => {
492
- fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n');
1003
+ it('mixes create and edit across files', async () => {
493
1004
  const r = await tool().execute({
494
- edits: [{ file_path: 'trailing.txt', old_string: 'zzz\nzzz', new_string: 'x\ny' }],
1005
+ edits: [
1006
+ { file_path: 'mf-a.txt', old_string: 'edited', new_string: 'edited again' },
1007
+ { file_path: 'mf-create-new.txt', old_string: '', new_string: 'newly created' },
1008
+ ],
495
1009
  });
496
- expect(r.success).toBe(false);
497
- expect(r.error).toContain('not found');
1010
+ expect(r.success).toBe(true);
1011
+ expect(fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8')).toBe('edited again\n');
1012
+ expect(fs.readFileSync(path.join(tmpDir, 'mf-create-new.txt'), 'utf8')).toBe('newly created');
498
1013
  });
499
- it('reports duplicate in whitespace-normalized mode', async () => {
500
- fs.writeFileSync(f('duplicate.txt'), 'a \nb\na \nb\n');
1014
+ it('rolls back mixed create+edit on failure', async () => {
1015
+ const origA = fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8');
501
1016
  const r = await tool().execute({
502
- edits: [{ file_path: 'duplicate.txt', old_string: 'a\nb', new_string: 'X\nY' }],
1017
+ edits: [
1018
+ { file_path: 'mf-a.txt', old_string: 'edited again', new_string: 'changed' },
1019
+ { file_path: 'mf-rollback-new.txt', old_string: '', new_string: 'will be deleted' },
1020
+ { file_path: 'mf-nonexistent.txt', old_string: 'nope', new_string: 'x' },
1021
+ ],
503
1022
  });
504
1023
  expect(r.success).toBe(false);
505
- expect(r.error).toContain('MULTIPLE times');
506
- expect(r.error).toContain('whitespace-normalized');
1024
+ expect(r.error).toContain('rolled back');
1025
+ // Existing file restored
1026
+ expect(fs.readFileSync(path.join(tmpDir, 'mf-a.txt'), 'utf8')).toBe(origA);
1027
+ // Created file should be deleted
1028
+ expect(fs.existsSync(path.join(tmpDir, 'mf-rollback-new.txt'))).toBe(false);
507
1029
  });
508
- it('prefers exact match when available', async () => {
509
- fs.writeFileSync(f('exact-prefer.txt'), 'hello\nworld\n');
1030
+ it('creates multiple files in subdirectories', async () => {
510
1031
  const r = await tool().execute({
511
1032
  edits: [
512
- { file_path: 'exact-prefer.txt', old_string: 'hello\nworld', new_string: 'HELLO\nWORLD' },
1033
+ { file_path: 'multi-dir/sub1/x.ts', old_string: '', new_string: '// x' },
1034
+ { file_path: 'multi-dir/sub2/y.ts', old_string: '', new_string: '// y' },
513
1035
  ],
514
1036
  });
515
1037
  expect(r.success).toBe(true);
516
- expect(fs.readFileSync(f('exact-prefer.txt'), 'utf8')).toBe('HELLO\nWORLD\n');
517
- // Exact match should NOT have the whitespace-normalized note
518
- expect(r.output).not.toContain('whitespace-normalized');
1038
+ expect(fs.readFileSync(path.join(tmpDir, 'multi-dir/sub1/x.ts'), 'utf8')).toBe('// x');
1039
+ expect(fs.readFileSync(path.join(tmpDir, 'multi-dir/sub2/y.ts'), 'utf8')).toBe('// y');
519
1040
  });
520
- it('provides context snippet when old_string not found', async () => {
521
- fs.writeFileSync(f('trailing.txt'), 'hello \nworld\n');
522
- const r = await tool().execute({
523
- edits: [{ file_path: 'trailing.txt', old_string: 'nonexistent_line_xyz', new_string: 'x' }],
524
- });
1041
+ it('creates multiple files with markdown format', async () => {
1042
+ const input = [
1043
+ '```edit',
1044
+ 'file: md-multi-1.txt',
1045
+ 'old:',
1046
+ 'new: |',
1047
+ ' first file',
1048
+ '```',
1049
+ '```edit',
1050
+ 'file: md-multi-2.txt',
1051
+ 'old:',
1052
+ 'new: |',
1053
+ ' second file',
1054
+ '```',
1055
+ ].join('\n');
1056
+ const r = await tool().execute({ content: input });
1057
+ expect(r.success).toBe(true);
1058
+ expect(fs.readFileSync(path.join(tmpDir, 'md-multi-1.txt'), 'utf8')).toBe(' first file');
1059
+ expect(fs.readFileSync(path.join(tmpDir, 'md-multi-2.txt'), 'utf8')).toBe(' second file');
1060
+ });
1061
+ it('rolls back multi-file via markdown format on failure', async () => {
1062
+ const file = path.join(tmpDir, 'md-rollback-target.txt');
1063
+ fs.writeFileSync(file, 'preserve me\n');
1064
+ applier.markRead(file);
1065
+ const input = [
1066
+ '```edit',
1067
+ 'file: md-rollback-target.txt',
1068
+ 'old: |',
1069
+ ' preserve me',
1070
+ 'new: |',
1071
+ ' changed',
1072
+ '```',
1073
+ '```edit',
1074
+ 'file: md-nonexistent.txt',
1075
+ 'old: |',
1076
+ ' not there',
1077
+ 'new: |',
1078
+ ' fail',
1079
+ '```',
1080
+ ].join('\n');
1081
+ const r = await tool().execute({ content: input });
525
1082
  expect(r.success).toBe(false);
526
- expect(r.error).toContain('not found');
527
- // Should contain a snippet with the file's actual content
528
- expect(r.error).toContain('hello');
1083
+ expect(r.error).toContain('rolled back');
1084
+ // Target file should be restored
1085
+ expect(fs.readFileSync(file, 'utf8')).toBe('preserve me\n');
529
1086
  });
530
1087
  });
531
1088
  });
1089
+ // ── Model-generated edit format integration tests ──────────────────────
1090
+ // These tests call a real LLM (DeepSeek-V4-Flash via ~/.lonny/config.json)
1091
+ // to generate edit format content, then feed it into the edit tool.
1092
+ const LONNY_CONFIG_PATH = path.join(os.homedir(), '.lonny', 'config.json');
1093
+ function loadLonnyConfig() {
1094
+ try {
1095
+ return JSON.parse(fs.readFileSync(LONNY_CONFIG_PATH, 'utf-8'));
1096
+ }
1097
+ catch {
1098
+ return {};
1099
+ }
1100
+ }
1101
+ const lonnyConfig = loadLonnyConfig();
1102
+ const hasApiKey = !!lonnyConfig.apiKey;
1103
+ /** Call the model with a prompt and return the full text response (non-streaming). */
1104
+ async function callModel(prompt, signal) {
1105
+ const { default: OpenAI } = await import('openai');
1106
+ const client = new OpenAI({
1107
+ apiKey: lonnyConfig.apiKey,
1108
+ baseURL: lonnyConfig.baseUrl,
1109
+ });
1110
+ const response = await client.chat.completions.create({
1111
+ model: lonnyConfig.model || 'deepseek-v4-flash',
1112
+ messages: [{ role: 'user', content: prompt }],
1113
+ stream: false,
1114
+ max_tokens: 4096,
1115
+ }, signal ? { signal } : undefined);
1116
+ return response.choices[0]?.message?.content || '';
1117
+ }
1118
+ describe.runIf(hasApiKey)('model-generated edit format', () => {
1119
+ const MODEL_TIMEOUT = 60_000; // Model API calls can take 10-30s
1120
+ let tmpDir;
1121
+ let applier;
1122
+ beforeAll(() => {
1123
+ tmpDir = makeTempDir();
1124
+ applier = new FileReadTracker();
1125
+ });
1126
+ afterAll(() => {
1127
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1128
+ });
1129
+ const tool = () => createEditTool(applier, tmpDir);
1130
+ it('model replaces exact string in a file', { timeout: MODEL_TIMEOUT }, async () => {
1131
+ const file = path.join(tmpDir, 'simple.txt');
1132
+ fs.writeFileSync(file, 'hello world\nfoo bar\n');
1133
+ applier.markRead(file);
1134
+ const prompt = [
1135
+ 'Output ONLY an edit code block (```edit ... ```) that replaces "hello world" with "hello universe" in file "simple.txt".',
1136
+ '',
1137
+ 'The EXACT format is:',
1138
+ '```edit',
1139
+ 'file: simple.txt',
1140
+ 'old: |',
1141
+ ' hello world',
1142
+ 'new: |',
1143
+ ' hello universe',
1144
+ '```',
1145
+ '',
1146
+ 'Do NOT add quotes, do NOT add backticks around the text inside old/new. Output ONLY the code block.',
1147
+ ].join('\n');
1148
+ const modelOutput = await callModel(prompt);
1149
+ const edits = parseMarkdownEdit(modelOutput);
1150
+ expect(edits.length).toBeGreaterThanOrEqual(1);
1151
+ const result = await tool().execute({ content: modelOutput });
1152
+ expect(result.success).toBe(true);
1153
+ const content = fs.readFileSync(file, 'utf8');
1154
+ expect(content).toContain('hello universe');
1155
+ expect(content).toContain('foo bar');
1156
+ });
1157
+ it('model replaces multi-line text in a file', { timeout: MODEL_TIMEOUT }, async () => {
1158
+ const file = path.join(tmpDir, 'multiline.txt');
1159
+ fs.writeFileSync(file, 'line A\nline B\nline C\nline D\n');
1160
+ applier.markRead(file);
1161
+ const prompt = [
1162
+ 'Output ONLY an edit code block that replaces:',
1163
+ '',
1164
+ ' line B',
1165
+ ' line C',
1166
+ '',
1167
+ 'with:',
1168
+ '',
1169
+ ' line X',
1170
+ ' line Y',
1171
+ '',
1172
+ `in file "multiline.txt".`,
1173
+ '',
1174
+ 'Format:',
1175
+ '```edit',
1176
+ 'file: multiline.txt',
1177
+ 'old: |',
1178
+ ' line B',
1179
+ ' line C',
1180
+ 'new: |',
1181
+ ' line X',
1182
+ ' line Y',
1183
+ '```',
1184
+ '',
1185
+ 'IMPORTANT: old: must contain EXACTLY "line B" and "line C" on separate lines with 2-space indent.',
1186
+ 'Do NOT add quotes around the text. Output ONLY the code block.',
1187
+ ].join('\n');
1188
+ const modelOutput = await callModel(prompt);
1189
+ const edits = parseMarkdownEdit(modelOutput);
1190
+ expect(edits.length).toBeGreaterThanOrEqual(1);
1191
+ const result = await tool().execute({ content: modelOutput });
1192
+ expect(result.success).toBe(true);
1193
+ const content = fs.readFileSync(file, 'utf8');
1194
+ expect(content).toContain('line X');
1195
+ expect(content).toContain('line Y');
1196
+ });
1197
+ it('model creates a new file', { timeout: MODEL_TIMEOUT }, async () => {
1198
+ const prompt = [
1199
+ 'Output ONLY an edit code block that CREATES a new file called "fresh.txt" with content "hello world".',
1200
+ '',
1201
+ 'Format (note: old: has NO content - this means create):',
1202
+ '```edit',
1203
+ 'file: fresh.txt',
1204
+ 'old:',
1205
+ 'new: |',
1206
+ ' hello world',
1207
+ '```',
1208
+ '',
1209
+ 'Output ONLY the code block.',
1210
+ ].join('\n');
1211
+ const modelOutput = await callModel(prompt);
1212
+ const edits = parseMarkdownEdit(modelOutput);
1213
+ expect(edits.length).toBeGreaterThanOrEqual(1);
1214
+ expect(edits[0].file_path).toBe('fresh.txt');
1215
+ const result = await tool().execute({ content: modelOutput });
1216
+ expect(result.success).toBe(true);
1217
+ expect(fs.readFileSync(path.join(tmpDir, 'fresh.txt'), 'utf8')).toContain('hello world');
1218
+ });
1219
+ it('model edits a file with special characters (double quotes)', {
1220
+ timeout: MODEL_TIMEOUT,
1221
+ }, async () => {
1222
+ const file = path.join(tmpDir, 'quotes.txt');
1223
+ fs.writeFileSync(file, 'alpha\nbeta\ngamma\n');
1224
+ applier.markRead(file);
1225
+ const prompt = [
1226
+ 'Output ONLY an edit code block that replaces "beta" with "BETA" in file "quotes.txt".',
1227
+ '',
1228
+ '```edit',
1229
+ 'file: quotes.txt',
1230
+ 'old: |',
1231
+ ' beta',
1232
+ 'new: |',
1233
+ ' BETA',
1234
+ '```',
1235
+ '',
1236
+ 'Output ONLY the code block. No extra text.',
1237
+ ].join('\n');
1238
+ const modelOutput = await callModel(prompt);
1239
+ const edits = parseMarkdownEdit(modelOutput);
1240
+ expect(edits.length).toBeGreaterThanOrEqual(1);
1241
+ const result = await tool().execute({ content: modelOutput });
1242
+ expect(result.success).toBe(true);
1243
+ const content = fs.readFileSync(file, 'utf8');
1244
+ expect(content).toContain('BETA');
1245
+ expect(content).toContain('alpha');
1246
+ expect(content).toContain('gamma');
1247
+ });
1248
+ it('model generates edit block that round-trips successfully', {
1249
+ timeout: MODEL_TIMEOUT,
1250
+ }, async () => {
1251
+ const file = path.join(tmpDir, 'roundtrip.txt');
1252
+ fs.writeFileSync(file, 'first\nsecond\nthird\nfourth\n');
1253
+ applier.markRead(file);
1254
+ const prompt = [
1255
+ 'Output ONLY an edit code block that replaces "second" with "SECOND" in file "roundtrip.txt".',
1256
+ 'Include 1 line of context before and after to ensure uniqueness.',
1257
+ '',
1258
+ 'Format:',
1259
+ '```edit',
1260
+ 'file: roundtrip.txt',
1261
+ 'old: |',
1262
+ ' first',
1263
+ ' second',
1264
+ ' third',
1265
+ 'new: |',
1266
+ ' first',
1267
+ ' SECOND',
1268
+ ' third',
1269
+ '```',
1270
+ '',
1271
+ 'Output ONLY the code block.',
1272
+ ].join('\n');
1273
+ const modelOutput = await callModel(prompt);
1274
+ const edits = parseMarkdownEdit(modelOutput);
1275
+ expect(edits.length).toBeGreaterThanOrEqual(1);
1276
+ const result = await tool().execute({ content: modelOutput });
1277
+ expect(result.success).toBe(true);
1278
+ const content = fs.readFileSync(file, 'utf8');
1279
+ expect(content).toContain('SECOND');
1280
+ expect(content).toContain('fourth');
1281
+ });
1282
+ });
532
1283
  //# sourceMappingURL=edit.test.js.map