rovecode 0.4.0-beta.3 → 0.4.0

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 (432) hide show
  1. package/README.md +57 -72
  2. package/THIRD_PARTY_NOTICES.md +0 -44
  3. package/bin/rovecode.ts +21 -0
  4. package/package.json +16 -38
  5. package/src/account/keys.ts +97 -0
  6. package/src/account/login.ts +158 -0
  7. package/src/account/provision.ts +47 -0
  8. package/src/account/store.ts +63 -0
  9. package/src/acp/server.ts +373 -0
  10. package/src/cli/account-cmd.ts +116 -0
  11. package/src/cli/connect.ts +244 -0
  12. package/src/cli/context-cmd.ts +199 -0
  13. package/src/cli/dispatch.ts +109 -0
  14. package/src/cli/doctor.ts +324 -0
  15. package/src/cli/export.ts +278 -0
  16. package/src/cli/help.ts +240 -0
  17. package/src/cli/is-tui-invocation.ts +8 -0
  18. package/src/cli/main.ts +599 -0
  19. package/src/cli/market-cmd.ts +658 -0
  20. package/src/cli/mcp-market-cmd.ts +299 -0
  21. package/src/cli/output.ts +382 -0
  22. package/src/cli/repl.ts +172 -0
  23. package/src/cli/resume.ts +32 -0
  24. package/src/cli/run-limits.ts +78 -0
  25. package/src/cli/runtime.ts +792 -0
  26. package/src/cli/setup.ts +187 -0
  27. package/src/cli/update-cmd.ts +78 -0
  28. package/src/cli/workflow-cmd.ts +100 -0
  29. package/src/coding/checkpoints.ts +270 -0
  30. package/src/coding/diff.ts +136 -0
  31. package/src/coding/files.ts +339 -0
  32. package/src/coding/hashline.ts +319 -0
  33. package/src/coding/lsp.ts +406 -0
  34. package/src/coding/repomap-cache.ts +99 -0
  35. package/src/coding/repomap-files.ts +110 -0
  36. package/src/coding/repomap.ts +392 -0
  37. package/src/core/compaction.ts +399 -0
  38. package/src/core/config.ts +289 -0
  39. package/src/core/context-report.ts +228 -0
  40. package/src/core/context.ts +60 -0
  41. package/src/core/count-remote.ts +107 -0
  42. package/src/core/execpolicy-rules.ts +196 -0
  43. package/src/core/execpolicy.ts +385 -0
  44. package/src/core/executor.ts +397 -0
  45. package/src/core/guardrails.ts +400 -0
  46. package/src/core/hooks.ts +398 -0
  47. package/src/core/images.ts +230 -0
  48. package/src/core/intro.ts +236 -0
  49. package/src/core/loop.ts +621 -0
  50. package/src/core/modes.ts +372 -0
  51. package/src/core/orchestrator.ts +207 -0
  52. package/src/core/reflection.ts +165 -0
  53. package/src/core/sandbox-config.ts +167 -0
  54. package/src/core/session-images.ts +73 -0
  55. package/src/core/session.ts +398 -0
  56. package/src/core/settings.ts +98 -0
  57. package/src/core/stuck-detector.ts +273 -0
  58. package/src/core/tasks.ts +374 -0
  59. package/src/core/token-scale.ts +108 -0
  60. package/src/core/tool-output-budget.ts +166 -0
  61. package/src/core/tools.ts +288 -0
  62. package/src/core/types.ts +330 -0
  63. package/src/core/update-check.ts +171 -0
  64. package/src/core/update.ts +158 -0
  65. package/src/core/usage.ts +204 -0
  66. package/src/core/validate.ts +121 -0
  67. package/src/core/verify-gate.ts +159 -0
  68. package/src/core/verify.ts +237 -0
  69. package/src/core/voice.ts +158 -0
  70. package/src/core/win-job.ts +183 -0
  71. package/src/design/audit.ts +797 -0
  72. package/src/design/direction.ts +190 -0
  73. package/src/design/rules.ts +157 -0
  74. package/src/eval/bench.ts +150 -0
  75. package/src/eval/gauntlet-runner.ts +218 -0
  76. package/src/eval/gauntlet.ts +226 -0
  77. package/src/eval/grader.ts +186 -0
  78. package/src/eval/record.ts +202 -0
  79. package/src/eval/redact.ts +141 -0
  80. package/src/eval/replay.ts +147 -0
  81. package/src/eval/trajectory.ts +373 -0
  82. package/src/index.ts +17 -0
  83. package/src/market/catalogs/mcp-docs.json +111 -0
  84. package/src/market/catalogs/plugins.json +111 -0
  85. package/src/market/catalogs/skills.json +478 -0
  86. package/src/market/clone.ts +72 -0
  87. package/src/market/context-cost.ts +121 -0
  88. package/src/market/digest.ts +106 -0
  89. package/src/market/index.ts +22 -0
  90. package/src/market/install.ts +578 -0
  91. package/src/market/manifest.ts +187 -0
  92. package/src/market/prereq.ts +145 -0
  93. package/src/market/registry.ts +363 -0
  94. package/src/market/resolve.ts +111 -0
  95. package/src/market/types.ts +236 -0
  96. package/src/market/validate.ts +227 -0
  97. package/src/mcp/client.ts +431 -0
  98. package/src/mcp/config.ts +239 -0
  99. package/src/mcp/local-package.ts +211 -0
  100. package/src/mcp/market-catalog.ts +84 -0
  101. package/src/mcp/market-install.ts +289 -0
  102. package/src/mcp/market.ts +0 -0
  103. package/src/mcp/tools.ts +131 -0
  104. package/src/mcp/trust.ts +49 -0
  105. package/src/memory/blocks.ts +175 -0
  106. package/src/memory/recall.ts +355 -0
  107. package/src/memory/store.ts +105 -0
  108. package/src/memory/tools.ts +99 -0
  109. package/src/plugins/cli.ts +123 -0
  110. package/src/plugins/discover.ts +108 -0
  111. package/src/plugins/index.ts +50 -0
  112. package/src/plugins/init.ts +140 -0
  113. package/src/plugins/install.ts +184 -0
  114. package/src/plugins/load.ts +149 -0
  115. package/src/plugins/manifest.ts +106 -0
  116. package/src/plugins/state.ts +83 -0
  117. package/src/providers/auth.ts +293 -0
  118. package/src/providers/cache.ts +223 -0
  119. package/src/providers/catalog-local.ts +160 -0
  120. package/src/providers/catalog.ts +408 -0
  121. package/src/providers/middleware-context.ts +86 -0
  122. package/src/providers/middleware.ts +373 -0
  123. package/src/providers/profile-glm53.ts +111 -0
  124. package/src/providers/profile-sonnet5-persona.ts +65 -0
  125. package/src/providers/profile-sonnet5-voice.ts +23 -0
  126. package/src/providers/profiles.ts +156 -0
  127. package/src/providers/provider-config.ts +311 -0
  128. package/src/providers/registry.ts +302 -0
  129. package/src/providers/response-validation.ts +80 -0
  130. package/src/providers/retry.ts +234 -0
  131. package/src/providers/router.ts +294 -0
  132. package/src/providers/sse.ts +26 -0
  133. package/src/providers/stream-errors.ts +117 -0
  134. package/src/providers/stream.ts +569 -0
  135. package/src/providers/thinking.ts +189 -0
  136. package/src/providers/wire-messages.ts +129 -0
  137. package/src/sdk/client.ts +225 -0
  138. package/src/sdk/index.ts +3 -0
  139. package/src/server/dashboard.ts +144 -0
  140. package/src/server/http.ts +343 -0
  141. package/src/server/openapi.ts +246 -0
  142. package/src/sextant/card-hits.ts +102 -0
  143. package/src/sextant/card-keys.ts +55 -0
  144. package/src/sextant/context-source.ts +157 -0
  145. package/src/sextant/draw-agents.ts +273 -0
  146. package/src/sextant/draw-code.ts +388 -0
  147. package/src/sextant/draw-context.ts +222 -0
  148. package/src/sextant/draw-frame.ts +164 -0
  149. package/src/sextant/draw-market.ts +573 -0
  150. package/src/sextant/draw-messages.ts +386 -0
  151. package/src/sextant/draw-pet.ts +230 -0
  152. package/src/sextant/draw-plan.ts +159 -0
  153. package/src/sextant/draw-tabs.ts +85 -0
  154. package/src/sextant/draw-util.ts +65 -0
  155. package/src/sextant/engine.ts +230 -0
  156. package/src/sextant/frame-hits.ts +25 -0
  157. package/src/sextant/frame.ts +101 -0
  158. package/src/sextant/git-status.ts +197 -0
  159. package/src/sextant/grid.ts +59 -0
  160. package/src/sextant/input.ts +119 -0
  161. package/src/sextant/keys.ts +488 -0
  162. package/src/sextant/layout.ts +86 -0
  163. package/src/sextant/local-commands.ts +156 -0
  164. package/src/sextant/market-source.ts +287 -0
  165. package/src/sextant/mentions.ts +141 -0
  166. package/src/sextant/message-hits.ts +26 -0
  167. package/src/sextant/model.ts +387 -0
  168. package/src/sextant/overlays.ts +451 -0
  169. package/src/sextant/panel-hits.ts +38 -0
  170. package/src/sextant/pet.ts +399 -0
  171. package/src/sextant/screen.ts +324 -0
  172. package/src/sextant/scroll-hits.ts +66 -0
  173. package/src/sextant/scrollbar.ts +82 -0
  174. package/src/sextant/selection.ts +123 -0
  175. package/src/sextant/sextant-bridge.ts +174 -0
  176. package/src/sextant/sextant-cards.ts +142 -0
  177. package/src/sextant/sextant-diff-base.ts +63 -0
  178. package/src/sextant/sextant-files.ts +154 -0
  179. package/src/sextant/sextant-frame-loop.ts +314 -0
  180. package/src/sextant/sextant-renderer.ts +478 -0
  181. package/src/sextant/sextant-repo.ts +131 -0
  182. package/src/sextant/theme.ts +66 -0
  183. package/src/sextant/tool-rows.ts +189 -0
  184. package/src/sextant/types.ts +473 -0
  185. package/src/skills/index.ts +306 -0
  186. package/src/skills/tools.ts +69 -0
  187. package/src/skills/versioned.ts +227 -0
  188. package/src/telemetry/otel.ts +353 -0
  189. package/src/telemetry/otlp.ts +68 -0
  190. package/src/tools/ask-user.ts +156 -0
  191. package/src/tools/design.ts +151 -0
  192. package/src/tools/evalcell.ts +338 -0
  193. package/src/tools/html-text.ts +139 -0
  194. package/src/tools/provider.ts +149 -0
  195. package/src/tools/task.ts +216 -0
  196. package/src/tools/todo.ts +320 -0
  197. package/src/tools/webfetch.ts +331 -0
  198. package/src/tui/app.ts +608 -0
  199. package/src/tui/attach.ts +127 -0
  200. package/src/tui/checkpoints-cmd.ts +70 -0
  201. package/src/tui/clipboard-image.ts +81 -0
  202. package/src/tui/commands.ts +277 -0
  203. package/src/tui/cost.ts +108 -0
  204. package/src/tui/info-cmd.ts +144 -0
  205. package/src/tui/mcp-cmd.ts +128 -0
  206. package/src/tui/modes-cmd.ts +45 -0
  207. package/src/tui/overlays.ts +97 -0
  208. package/src/tui/pi-renderer.ts +424 -0
  209. package/src/tui/providers-cmd.ts +366 -0
  210. package/src/tui/renderer.ts +101 -0
  211. package/src/tui/replay-marker.ts +29 -0
  212. package/src/tui/session-cmd.ts +146 -0
  213. package/src/tui/sextant-attach.ts +68 -0
  214. package/src/tui/sextant-io.ts +184 -0
  215. package/src/tui/sextant-smoke.ts +110 -0
  216. package/src/tui/smoke.ts +72 -0
  217. package/src/tui/theme.ts +59 -0
  218. package/src/tui/todo-label.ts +7 -0
  219. package/src/workflow/engine.ts +266 -0
  220. package/tsconfig.json +30 -0
  221. package/vendor/pi-tui/LICENSE +21 -0
  222. package/vendor/pi-tui/PATCHES.md +12 -0
  223. package/vendor/pi-tui/PROVENANCE.md +12 -0
  224. package/vendor/pi-tui/README.upstream.md +854 -0
  225. package/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  226. package/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  227. package/vendor/pi-tui/src/alt-screen-search.ts +158 -0
  228. package/vendor/pi-tui/src/autocomplete.ts +827 -0
  229. package/vendor/pi-tui/src/components/alt-screen-flash.ts +52 -0
  230. package/vendor/pi-tui/src/components/box.ts +138 -0
  231. package/vendor/pi-tui/src/components/cancellable-loader.ts +41 -0
  232. package/vendor/pi-tui/src/components/editor.ts +2364 -0
  233. package/vendor/pi-tui/src/components/h-stack.ts +45 -0
  234. package/vendor/pi-tui/src/components/image.ts +128 -0
  235. package/vendor/pi-tui/src/components/input.ts +448 -0
  236. package/vendor/pi-tui/src/components/loader.ts +93 -0
  237. package/vendor/pi-tui/src/components/markdown.ts +1016 -0
  238. package/vendor/pi-tui/src/components/scroll-view.ts +217 -0
  239. package/vendor/pi-tui/src/components/select-list.ts +230 -0
  240. package/vendor/pi-tui/src/components/settings-list.ts +277 -0
  241. package/vendor/pi-tui/src/components/spacer.ts +29 -0
  242. package/vendor/pi-tui/src/components/stack.ts +155 -0
  243. package/vendor/pi-tui/src/components/text.ts +108 -0
  244. package/vendor/pi-tui/src/components/truncated-text.ts +66 -0
  245. package/vendor/pi-tui/src/components/v-stack.ts +34 -0
  246. package/vendor/pi-tui/src/editor-component.ts +75 -0
  247. package/vendor/pi-tui/src/fuzzy.ts +138 -0
  248. package/vendor/pi-tui/src/index.ts +149 -0
  249. package/vendor/pi-tui/src/keybindings.ts +321 -0
  250. package/vendor/pi-tui/src/keys.ts +1402 -0
  251. package/vendor/pi-tui/src/kill-ring.ts +47 -0
  252. package/vendor/pi-tui/src/latex.ts +1381 -0
  253. package/vendor/pi-tui/src/layout-node.ts +52 -0
  254. package/vendor/pi-tui/src/layout.ts +411 -0
  255. package/vendor/pi-tui/src/native-modifiers.ts +60 -0
  256. package/vendor/pi-tui/src/native-module-path.ts +32 -0
  257. package/vendor/pi-tui/src/stdin-buffer.ts +445 -0
  258. package/vendor/pi-tui/src/terminal-colors.ts +74 -0
  259. package/vendor/pi-tui/src/terminal-image.ts +701 -0
  260. package/vendor/pi-tui/src/terminal.ts +554 -0
  261. package/vendor/pi-tui/src/tui-alt-screen.ts +1379 -0
  262. package/vendor/pi-tui/src/tui-main-screen.ts +655 -0
  263. package/vendor/pi-tui/src/tui.ts +1264 -0
  264. package/vendor/pi-tui/src/undo-stack.ts +29 -0
  265. package/vendor/pi-tui/src/utils.ts +1327 -0
  266. package/vendor/pi-tui/src/word-navigation.ts +118 -0
  267. package/vendor/pi-tui/test/test-themes.ts +39 -0
  268. package/vendor/pi-tui/test/virtual-terminal.ts +219 -0
  269. package/CHANGELOG.md +0 -527
  270. package/bin/rovecode.js +0 -24
  271. package/dist/cli/app-j6gn14w3.js +0 -2
  272. package/dist/cli/ask-user-cwstt8fz.js +0 -2
  273. package/dist/cli/auth-login-9bbp9915.js +0 -2
  274. package/dist/cli/auth-m8p9grty.js +0 -2
  275. package/dist/cli/bench-16zqdms5.js +0 -9
  276. package/dist/cli/catalog-1xchffa4.js +0 -2
  277. package/dist/cli/cli-1n1zb64f.js +0 -2
  278. package/dist/cli/client-2t9gjkck.js +0 -2
  279. package/dist/cli/commands-exafvm2b.js +0 -2
  280. package/dist/cli/connect-6zde0kn3.js +0 -2
  281. package/dist/cli/context-cmd-5t43wgqt.js +0 -2
  282. package/dist/cli/context-report-kt01pw8y.js +0 -2
  283. package/dist/cli/count-remote-ap7x3vh6.js +0 -2
  284. package/dist/cli/design-ne5zszyh.js +0 -2
  285. package/dist/cli/dispatch-2r5myxye.js +0 -2
  286. package/dist/cli/doctor-ws4fh4tn.js +0 -3
  287. package/dist/cli/executor-bdrjn634.js +0 -2
  288. package/dist/cli/export-1mxb9g5p.js +0 -2
  289. package/dist/cli/files-g104xghh.js +0 -2
  290. package/dist/cli/gauntlet-07xrjpj7.js +0 -2
  291. package/dist/cli/gauntlet-runner-xvy64436.js +0 -10
  292. package/dist/cli/gauntlet-wave3-jm91yt5w.js +0 -5
  293. package/dist/cli/gauntlet-wave4-r13py7p1.js +0 -14
  294. package/dist/cli/hashline-znvrat11.js +0 -2
  295. package/dist/cli/http-xafw6fsh.js +0 -143
  296. package/dist/cli/index-1sgjm25y.js +0 -2
  297. package/dist/cli/init-g2m0tn4m.js +0 -51
  298. package/dist/cli/install-avaqjjqq.js +0 -2
  299. package/dist/cli/loop-mmpfft01.js +0 -2
  300. package/dist/cli/main-0904f6ps.js +0 -5
  301. package/dist/cli/main-0ab9fc26.js +0 -9
  302. package/dist/cli/main-0jys2ccn.js +0 -3
  303. package/dist/cli/main-0mtcdbs7.js +0 -3
  304. package/dist/cli/main-0z1w2zsg.js +0 -3
  305. package/dist/cli/main-1dchs7xv.js +0 -18
  306. package/dist/cli/main-1ereejm1.js +0 -3
  307. package/dist/cli/main-1k1kw6b5.js +0 -3
  308. package/dist/cli/main-27y4sm2k.js +0 -38
  309. package/dist/cli/main-2wwjex5j.js +0 -58
  310. package/dist/cli/main-2yeveeve.js +0 -6
  311. package/dist/cli/main-2yfck9b5.js +0 -3
  312. package/dist/cli/main-2zmzgkwh.js +0 -3
  313. package/dist/cli/main-351pz3z7.js +0 -7
  314. package/dist/cli/main-3gjqfh7a.js +0 -6
  315. package/dist/cli/main-3nf3kgve.js +0 -3
  316. package/dist/cli/main-3pjrb2hd.js +0 -3
  317. package/dist/cli/main-3rxcvgna.js +0 -19
  318. package/dist/cli/main-4b3jgy66.js +0 -19
  319. package/dist/cli/main-4wndhjdc.js +0 -7
  320. package/dist/cli/main-4xcmvxnk.js +0 -3
  321. package/dist/cli/main-5tbz0wbz.js +0 -4
  322. package/dist/cli/main-5ywnwthm.js +0 -3
  323. package/dist/cli/main-6b62vkz0.js +0 -14
  324. package/dist/cli/main-6dnk69vp.js +0 -3
  325. package/dist/cli/main-6genrmhs.js +0 -136
  326. package/dist/cli/main-73g7eff4.js +0 -15
  327. package/dist/cli/main-7c5thhjd.js +0 -5
  328. package/dist/cli/main-7rn6bqje.js +0 -3
  329. package/dist/cli/main-80haw7qk.js +0 -4
  330. package/dist/cli/main-875s60s2.js +0 -4
  331. package/dist/cli/main-8kjxbpw4.js +0 -8
  332. package/dist/cli/main-90ds1z4e.js +0 -10
  333. package/dist/cli/main-9etavkew.js +0 -3
  334. package/dist/cli/main-a9njrkk1.js +0 -3
  335. package/dist/cli/main-aecrjq2d.js +0 -12
  336. package/dist/cli/main-ck9asesq.js +0 -9
  337. package/dist/cli/main-cta9racd.js +0 -4
  338. package/dist/cli/main-ddv7j2ag.js +0 -3
  339. package/dist/cli/main-dfreez27.js +0 -10
  340. package/dist/cli/main-f7rw7des.js +0 -3
  341. package/dist/cli/main-ggcn7rd7.js +0 -5
  342. package/dist/cli/main-gzkmycnv.js +0 -3
  343. package/dist/cli/main-hq51jg8v.js +0 -18
  344. package/dist/cli/main-jft389w9.js +0 -8
  345. package/dist/cli/main-k1eqkg83.js +0 -3
  346. package/dist/cli/main-k2y8a2aw.js +0 -9
  347. package/dist/cli/main-kcpbykxz.js +0 -4
  348. package/dist/cli/main-kd488vje.js +0 -22
  349. package/dist/cli/main-kh32yvgk.js +0 -5
  350. package/dist/cli/main-kqxnqjnv.js +0 -25
  351. package/dist/cli/main-kyn0xnsg.js +0 -3
  352. package/dist/cli/main-m1kk6fp5.js +0 -21
  353. package/dist/cli/main-mv40pcr2.js +0 -4
  354. package/dist/cli/main-n0t3973w.js +0 -3
  355. package/dist/cli/main-nqveez48.js +0 -4
  356. package/dist/cli/main-pknhvrmj.js +0 -3
  357. package/dist/cli/main-pn1w7a7j.js +0 -3
  358. package/dist/cli/main-prxxs70n.js +0 -4
  359. package/dist/cli/main-q3vsesf9.js +0 -3
  360. package/dist/cli/main-qsevpgsv.js +0 -3
  361. package/dist/cli/main-rdgdw24b.js +0 -25
  362. package/dist/cli/main-rfth4tbm.js +0 -16
  363. package/dist/cli/main-rg0wn0xf.js +0 -5
  364. package/dist/cli/main-sdmxhtv8.js +0 -4
  365. package/dist/cli/main-skbp13js.js +0 -18
  366. package/dist/cli/main-t4xnd213.js +0 -7
  367. package/dist/cli/main-vqak588n.js +0 -4
  368. package/dist/cli/main-w2n1303f.js +0 -9
  369. package/dist/cli/main-wbrdspr2.js +0 -5
  370. package/dist/cli/main-wsrg79c1.js +0 -7
  371. package/dist/cli/main-x4r0fne4.js +0 -5
  372. package/dist/cli/main-xea2f3tn.js +0 -6
  373. package/dist/cli/main-xg704a3c.js +0 -3
  374. package/dist/cli/main-xvnrabfp.js +0 -16
  375. package/dist/cli/main-xy53xf0r.js +0 -4
  376. package/dist/cli/main-y1fqy60y.js +0 -3
  377. package/dist/cli/main-yn8cd281.js +0 -34
  378. package/dist/cli/main-yr0ksc0h.js +0 -4
  379. package/dist/cli/main-z2ex2vyf.js +0 -4
  380. package/dist/cli/main-z3aayzvq.js +0 -3
  381. package/dist/cli/main-zaqh35jg.js +0 -3
  382. package/dist/cli/main-zc2e8e46.js +0 -4
  383. package/dist/cli/main-zzrfw6cf.js +0 -13
  384. package/dist/cli/main.js +0 -280
  385. package/dist/cli/market-cmd-e14kmx9n.js +0 -5
  386. package/dist/cli/mcp-login-wq7ktdek.js +0 -2
  387. package/dist/cli/mcp-market-cmd-9mg3jecy.js +0 -2
  388. package/dist/cli/notify-b7qc0cjb.js +0 -2
  389. package/dist/cli/oauth-z8whcgfx.js +0 -2
  390. package/dist/cli/output-b3ewj3ps.js +0 -16
  391. package/dist/cli/profiles-6mr5he5e.js +0 -2
  392. package/dist/cli/provider-config-g7j42q8x.js +0 -2
  393. package/dist/cli/provider-jr1y8vvm.js +0 -2
  394. package/dist/cli/registry-s8yk86g0.js +0 -2
  395. package/dist/cli/registry-t6p8d4mn.js +0 -2
  396. package/dist/cli/repl-bajwe1mh.js +0 -11
  397. package/dist/cli/resume-rwn9nz7y.js +0 -2
  398. package/dist/cli/run-flags-nah7ndpt.js +0 -2
  399. package/dist/cli/runtime-n7gafzhb.js +0 -2
  400. package/dist/cli/sandbox-config-emdy18x4.js +0 -2
  401. package/dist/cli/server-b0nvs2bn.js +0 -5
  402. package/dist/cli/session-arg-y75wd4kj.js +0 -2
  403. package/dist/cli/session-j62evmjq.js +0 -2
  404. package/dist/cli/sessions-cmd-tsnwz0ns.js +0 -7
  405. package/dist/cli/settings-df10wfez.js +0 -2
  406. package/dist/cli/setup-jzvv72fg.js +0 -2
  407. package/dist/cli/sextant-smoke-37m81ke6.js +0 -5
  408. package/dist/cli/skills-cmd-gjxnxnhx.js +0 -2
  409. package/dist/cli/smoke-p7748apt.js +0 -8
  410. package/dist/cli/start-chat-s4st3mm0.js +0 -12
  411. package/dist/cli/stream-gmeyewds.js +0 -2
  412. package/dist/cli/task-gh0kkp3n.js +0 -2
  413. package/dist/cli/tasks-z1kfpe8e.js +0 -2
  414. package/dist/cli/thinking-0eqkrz6t.js +0 -2
  415. package/dist/cli/todo-5brcrt9m.js +0 -2
  416. package/dist/cli/tools-7pzm0vj9.js +0 -2
  417. package/dist/cli/tools-s635p6s8.js +0 -2
  418. package/dist/cli/trust-cmd-cjav8zgm.js +0 -2
  419. package/dist/cli/update-check-pt31bm2f.js +0 -2
  420. package/dist/cli/update-cmd-tk131s9t.js +0 -2
  421. package/dist/cli/voice-56nabd8d.js +0 -2
  422. package/dist/cli/webfetch-xd8q596m.js +0 -2
  423. package/dist/cli/websearch-5hkf98k1.js +0 -2
  424. package/dist/cli/workflow-cmd-cy3cvzjp.js +0 -4
  425. package/dist/cli/workspace-q10g5z3e.js +0 -2
  426. package/dist/lib/index.js +0 -62
  427. package/dist/lib/models-index.json +0 -1
  428. package/dist/lib/plugins.js +0 -55
  429. package/dist/lib/providers.js +0 -17
  430. package/dist/lib/public-api.js +0 -20
  431. package/dist/lib/sdk.js +0 -360
  432. /package/{dist/cli → src/providers}/models-index.json +0 -0
@@ -0,0 +1,355 @@
1
+ /** Cross-session recall (port #17): tokenized full-text search over
2
+ * `.rovecode/sessions/<id>/entries.jsonl` message text, exposed as a `recall` tool.
3
+ *
4
+ * Ported from hermes-agent's session search (MIT), adapted from SQLite FTS5 to an
5
+ * in-memory inverted index over the JSONL session tree:
6
+ * - Index = tokenized message content, maintained incrementally; hermes keeps an
7
+ * external-content FTS5 table in sync via insert triggers + high-water/progress
8
+ * markers so only unindexed rows are (re)indexed (hermes_state_common.py:637-684,
9
+ * hermes_state_search.py:280-346 fts_rebuild_step). Here the incremental unit is
10
+ * the session FILE, keyed by mtime (+size guard): only changed files re-index.
11
+ * - Ranking tiers = hermes's routing: exact tokenized FTS5 match is the primary
12
+ * path, substring (trigram) matching is the fallback tier
13
+ * (hermes_state_search.py:1467-1489 _describe_search_path, 1846-1885 routing);
14
+ * within a tier hermes orders by BM25 `ORDER BY rank` with timestamp tiebreaks
15
+ * (hermes_state_search.py:1791-1798). Here: exact-term count desc, then weighted
16
+ * term frequency, then recency. Query terms are implicitly ANDed, matching FTS5
17
+ * (tools/session_search_tool.py:807-809).
18
+ * - Partial matches need terms >=3 chars, mirroring trigram eligibility
19
+ * (hermes_state_search.py:1322-1337 _trigram_eligible_tokens).
20
+ * - Hits are snippet + metadata only, never full content
21
+ * (hermes_state_search.py:1694-1701, 1827-1844); preview = 120-char window
22
+ * starting 40 before the first match, the LIKE-fallback snippet shape
23
+ * (hermes_state_search.py:1585-1595).
24
+ * - Result budget: limit clamped like hermes's max(1, min(limit, 10))
25
+ * (tools/session_search_tool.py:1040-1046).
26
+ * - NO LLM anywhere in the search path (tools/session_search_tool.py:25-33 — the
27
+ * historical "summary mode" was removed upstream); summarization is an optional
28
+ * injected fn here, off by default.
29
+ * - Trust boundary: previews get the same per-line injection neutralization as
30
+ * BlockStore (blocks.ts:24-27) — threat lines render as [BLOCKED]; disk is never
31
+ * rewritten. The query echo in tool output is bounded to MAX_QUERY_CHARS.
32
+ */
33
+
34
+ import { readdirSync, readFileSync, statSync } from "node:fs";
35
+ import { join } from "node:path";
36
+ import type { Tool, ToolContext, ToolOutput } from "../core/types.ts";
37
+
38
+ /** Ranked hit — the bar's exact shape. Preview only; full text stays on disk. */
39
+ export interface RecallHit { sessionId: string; entryId: string; preview: string; timestamp: number }
40
+
41
+ /** Optional post-search summarizer (LLM or otherwise) — injected, never required. */
42
+ export type SummarizeFn = (query: string, hits: RecallHit[]) => Promise<string> | string;
43
+
44
+ export interface RefreshStats { scanned: number; indexed: number; removed: number }
45
+
46
+ const MAX_RESULTS = 10; // hermes limit ceiling (session_search_tool.py:1046)
47
+ const DEFAULT_RESULTS = 5;
48
+ const PREVIEW_WINDOW = 120; // hermes LIKE-fallback snippet width (hermes_state_search.py:1587)
49
+ const PREVIEW_LEAD = 40; // window starts 40 chars before the match (same line)
50
+ const MIN_PARTIAL_TERM = 3; // trigram eligibility (hermes_state_search.py:1322-1337)
51
+ const MAX_QUERY_CHARS = 512; // bounded adversarial input (hermes MAX_FTS5_QUERY_CHARS, hermes_state_search.py:1199-1201)
52
+ const EXACT_WEIGHT = 2;
53
+ const PARTIAL_WEIGHT = 1;
54
+
55
+ /** unicode61-style tokenization (hermes's base FTS5 index): case-fold, split on
56
+ * anything that is not a letter or digit. */
57
+ export function tokenize(text: string): string[] {
58
+ return text.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((t) => t.length > 0);
59
+ }
60
+
61
+ interface Doc {
62
+ sessionId: string;
63
+ entryId: string;
64
+ text: string;
65
+ timestamp: number;
66
+ tokens: Map<string, number>; // token -> tf (kept for O(tokens) removal on re-index)
67
+ }
68
+
69
+ interface FileState { mtimeMs: number; size: number; docKeys: string[] }
70
+
71
+ /** One session-entry line, already filtered to message text. */
72
+ function parseLine(line: string): { entryId: string; text: string; timestamp: number } | null {
73
+ let raw: unknown;
74
+ try { raw = JSON.parse(line); } catch { return null; } // malformed lines: skip, never throw (session.ts reload pattern)
75
+ if (!raw || typeof raw !== "object") return null;
76
+ const w = raw as { id?: unknown; createdAt?: unknown; entry?: unknown };
77
+ if (typeof w.id !== "string") return null;
78
+ const e = w.entry;
79
+ // events (kind:"event") and non-message shapes are not "message text" — skip
80
+ if (!e || typeof e !== "object" || !("role" in e)) return null;
81
+ const parts = (e as { parts?: unknown }).parts;
82
+ if (!Array.isArray(parts)) return null;
83
+ const texts: string[] = [];
84
+ for (const p of parts) {
85
+ if (p && typeof p === "object"
86
+ && (p as { kind?: unknown }).kind === "text"
87
+ && typeof (p as { text?: unknown }).text === "string") {
88
+ texts.push((p as { text: string }).text);
89
+ }
90
+ }
91
+ const text = texts.join(" ").trim();
92
+ if (!text) return null; // tool_call/tool_result-only messages carry no text parts
93
+ return { entryId: w.id, text, timestamp: typeof w.createdAt === "number" ? w.createdAt : 0 };
94
+ }
95
+
96
+ /** Recalled transcript text sits at the SAME trust level as BlockStore markdown:
97
+ * text a past model/user wrote, re-entering a live context. Local copy of the
98
+ * blocks.ts:24-27 neutralization — any line matching the injection pattern
99
+ * renders as [BLOCKED]; the raw text on disk is never rewritten. */
100
+ const THREAT = /(?:ignore previous|disregard above|system prompt)/i;
101
+ function neutralize(text: string): string {
102
+ return text.split("\n").map((l) => (THREAT.test(l) ? "[BLOCKED]" : l)).join("\n");
103
+ }
104
+
105
+ /** Slicing by UTF-16 unit can strand half of a surrogate pair at either cut —
106
+ * drop a leading low / trailing high orphan so emitted text stays well-formed. */
107
+ function trimOrphanSurrogates(s: string): string {
108
+ const head = s.charCodeAt(0); // NaN on empty: both range checks are false
109
+ if (head >= 0xdc00 && head <= 0xdfff) s = s.slice(1);
110
+ const tail = s.charCodeAt(s.length - 1);
111
+ if (tail >= 0xd800 && tail <= 0xdbff) s = s.slice(0, -1);
112
+ return s;
113
+ }
114
+
115
+ /** Single-line preview: 120-char window starting 40 before the first matched term
116
+ * (hermes_state_search.py:1585-1595). Ellipses mark clipping. */
117
+ function makePreview(text: string, terms: string[]): string {
118
+ const flat = text.replace(/\s+/g, " ").trim();
119
+ const lower = flat.toLowerCase();
120
+ let pos = -1;
121
+ for (const t of terms) {
122
+ const i = lower.indexOf(t);
123
+ if (i !== -1 && (pos === -1 || i < pos)) pos = i;
124
+ }
125
+ const start = pos === -1 ? 0 : Math.max(0, pos - PREVIEW_LEAD);
126
+ const clip = trimOrphanSurrogates(flat.slice(start, start + PREVIEW_WINDOW));
127
+ return (start > 0 ? "…" : "") + clip + (start + PREVIEW_WINDOW < flat.length ? "…" : "");
128
+ }
129
+
130
+ /** Inverted index over every session's entries.jsonl. Incremental: a file is
131
+ * re-read only when its mtime (or size — appends always grow JSONL) changed;
132
+ * deleted session dirs drop out. Session identity = the DIRECTORY name, matching
133
+ * session.ts listSessions (a tampered meta.json must not redirect recall). */
134
+ export class RecallIndex {
135
+ private docs = new Map<string, Doc>(); // docKey -> doc
136
+ private postings = new Map<string, Map<string, number>>(); // token -> docKey -> tf
137
+ private files = new Map<string, FileState>(); // sessionId -> file state
138
+
139
+ constructor(private readonly root: string) {}
140
+
141
+ /** Stat every session file; (re)index changed/new ones, drop vanished ones. */
142
+ refresh(): RefreshStats {
143
+ const stats: RefreshStats = { scanned: 0, indexed: 0, removed: 0 };
144
+ let names: string[] = [];
145
+ try { names = readdirSync(this.root); } catch { /* missing root = empty index */ }
146
+ const live = new Set<string>();
147
+ for (const name of names) {
148
+ const file = join(this.root, name, "entries.jsonl");
149
+ let st: { mtimeMs: number; size: number };
150
+ try { const s = statSync(file); st = { mtimeMs: s.mtimeMs, size: s.size }; }
151
+ catch { continue; } // foreign dir / no entries yet: skip, never throw
152
+ live.add(name);
153
+ stats.scanned++;
154
+ const prev = this.files.get(name);
155
+ if (prev && prev.mtimeMs === st.mtimeMs && prev.size === st.size) continue; // unchanged: keyed by mtime
156
+ this.dropSession(name);
157
+ this.indexFile(name, file, st);
158
+ stats.indexed++;
159
+ }
160
+ for (const name of [...this.files.keys()]) {
161
+ if (!live.has(name)) { this.dropSession(name); stats.removed++; }
162
+ }
163
+ return stats;
164
+ }
165
+
166
+ private dropSession(sessionId: string): void {
167
+ const prev = this.files.get(sessionId);
168
+ if (!prev) return;
169
+ for (const key of prev.docKeys) {
170
+ const doc = this.docs.get(key);
171
+ if (doc) {
172
+ for (const token of doc.tokens.keys()) {
173
+ const posting = this.postings.get(token);
174
+ if (posting) { posting.delete(key); if (posting.size === 0) this.postings.delete(token); }
175
+ }
176
+ }
177
+ this.docs.delete(key);
178
+ }
179
+ this.files.delete(sessionId);
180
+ }
181
+
182
+ private indexFile(sessionId: string, file: string, st: { mtimeMs: number; size: number }): void {
183
+ const docKeys: string[] = [];
184
+ let content = "";
185
+ try { content = readFileSync(file, "utf8"); } catch { /* raced deletion: index as empty */ }
186
+ for (const line of content.split("\n")) {
187
+ if (!line.trim()) continue;
188
+ const parsed = parseLine(line);
189
+ if (!parsed) continue;
190
+ // length-prefixed doc key: immune to separator-content collisions — session
191
+ // "s" + entry "0000e1" and session "s0000" + entry "e1" must stay distinct.
192
+ // The previous separator was the literal 4-char string "0000" (bytes 0x30
193
+ // 0x30 0x30 0x30 — an intended U+0000 NUL escape written without the
194
+ // backslash-u), so exactly that pair collided and the second doc was
195
+ // silently dropped by the duplicate-key guard below.
196
+ const key = `${sessionId.length}:${sessionId}:${parsed.entryId}`;
197
+ if (this.docs.has(key)) continue; // duplicate-id guard (session.ts corruption class)
198
+ const tokens = new Map<string, number>();
199
+ for (const t of tokenize(parsed.text)) tokens.set(t, (tokens.get(t) ?? 0) + 1);
200
+ if (tokens.size === 0) continue;
201
+ this.docs.set(key, { sessionId, entryId: parsed.entryId, text: parsed.text, timestamp: parsed.timestamp, tokens });
202
+ docKeys.push(key);
203
+ for (const [token, tf] of tokens) {
204
+ let posting = this.postings.get(token);
205
+ if (!posting) { posting = new Map(); this.postings.set(token, posting); }
206
+ posting.set(key, tf);
207
+ }
208
+ }
209
+ this.files.set(sessionId, { mtimeMs: st.mtimeMs, size: st.size, docKeys });
210
+ }
211
+
212
+ /** Ranked search. Terms are ANDed (FTS5 implicit AND). Tiering: docs with more
213
+ * exact-term matches ALWAYS outrank docs matching only partially (hermes's
214
+ * exact-FTS5-before-trigram routing as a rank, not a fallback); within a tier,
215
+ * weighted tf desc, then timestamp desc, then key for determinism. */
216
+ search(query: string, limit: number, excludeSession?: string): RecallHit[] {
217
+ this.refresh();
218
+ const terms = [...new Set(tokenize(query.slice(0, MAX_QUERY_CHARS)))];
219
+ if (terms.length === 0) return [];
220
+
221
+ let cands: Map<string, { exact: number; weighted: number }> | null = null;
222
+ for (const term of terms) {
223
+ const matched = new Map<string, { ex: number; part: number }>();
224
+ const exact = this.postings.get(term);
225
+ if (exact) for (const [key, tf] of exact) matched.set(key, { ex: tf, part: 0 });
226
+ if (term.length >= MIN_PARTIAL_TERM) { // substring tier, trigram-eligible terms only
227
+ for (const [token, posting] of this.postings) {
228
+ if (token === term || !token.includes(term)) continue;
229
+ for (const [key, tf] of posting) {
230
+ const m = matched.get(key) ?? { ex: 0, part: 0 };
231
+ m.part += tf;
232
+ matched.set(key, m);
233
+ }
234
+ }
235
+ }
236
+ const next = new Map<string, { exact: number; weighted: number }>();
237
+ for (const [key, m] of matched) {
238
+ const prev = cands === null ? { exact: 0, weighted: 0 } : cands.get(key);
239
+ if (prev === undefined) continue; // AND: term missing from doc drops it
240
+ next.set(key, {
241
+ exact: prev.exact + (m.ex > 0 ? 1 : 0),
242
+ weighted: prev.weighted + EXACT_WEIGHT * m.ex + PARTIAL_WEIGHT * m.part,
243
+ });
244
+ }
245
+ cands = next;
246
+ if (cands.size === 0) return [];
247
+ }
248
+
249
+ const ranked = [...(cands ?? new Map<string, { exact: number; weighted: number }>())]
250
+ .flatMap(([key, cand]) => {
251
+ const doc = this.docs.get(key);
252
+ // exclude the live session: recall is CROSS-session (hermes skips the
253
+ // current lineage, session_search_tool.py:852-860)
254
+ return doc && doc.sessionId !== excludeSession ? [{ key, cand, doc }] : [];
255
+ })
256
+ .sort((a, b) =>
257
+ b.cand.exact - a.cand.exact
258
+ || b.cand.weighted - a.cand.weighted
259
+ || b.doc.timestamp - a.doc.timestamp
260
+ || (a.key < b.key ? -1 : 1));
261
+
262
+ return ranked.slice(0, Math.max(0, limit)).map(({ doc }) => ({
263
+ sessionId: doc.sessionId,
264
+ entryId: doc.entryId,
265
+ // neutralized HERE so every consumer — tool output, data.hits, the injected
266
+ // summarizer — sees the scanned view, never the verbatim transcript line
267
+ preview: neutralize(makePreview(doc.text, terms)),
268
+ timestamp: doc.timestamp,
269
+ }));
270
+ }
271
+ }
272
+
273
+ export interface RecallToolOptions {
274
+ /** injected summarizer; absent = raw hits only (upstream removed its LLM summary mode) */
275
+ summarize?: SummarizeFn;
276
+ /** result-budget ceiling; defaults to hermes's 10 */
277
+ maxResults?: number;
278
+ }
279
+
280
+ /** Build the `recall` tool over a sessions root (one lazy index per tool instance).
281
+ *
282
+ * kind "read", NOT "memory": recall only READS session files from disk — it never
283
+ * mutates memory. core/tools.ts actionFor() maps "read" -> "file.read" but
284
+ * "memory" -> "memory.write"; gating a pure read behind a write action would let
285
+ * memory-write policies silently grant history reads AND lock recall out of
286
+ * read-only rule sets. With no `path` arg, describeResource() falls back to the
287
+ * tool name, so policy can target `file.read recall` precisely; deny-by-default
288
+ * still applies when no rule matches (core/tools.ts evaluatePermissions). */
289
+ export function recallTool(sessionsRoot: string, opts: RecallToolOptions = {}): Tool {
290
+ const index = new RecallIndex(sessionsRoot);
291
+ const ceiling = Math.max(1, opts.maxResults ?? MAX_RESULTS);
292
+ return {
293
+ schema: {
294
+ name: "recall",
295
+ description:
296
+ "Search past session transcripts (cross-session recall). Full-text over prior conversation " +
297
+ "message text — no LLM. Terms are ANDed; exact word matches rank above partial (substring) " +
298
+ `matches. Returns up to ${ceiling} hits: sessionId, entryId, timestamp, and a short preview. ` +
299
+ "Use for questions about past conversations: 'what did we decide about X', 'where did we leave Y'.",
300
+ args: {
301
+ type: "object",
302
+ properties: {
303
+ query: { type: "string", description: "words to find in past sessions" },
304
+ limit: { type: "integer", description: `max hits (default ${Math.min(DEFAULT_RESULTS, ceiling)}, max ${ceiling})` },
305
+ },
306
+ required: ["query"],
307
+ },
308
+ },
309
+ kind: "read",
310
+ sequential: false, // pure read: safe to run concurrently with sibling reads
311
+ async execute(args: unknown, ctx: ToolContext): Promise<ToolOutput> {
312
+ // Defense in depth: keep ONLY schema args (query, limit) — smuggled keys,
313
+ // notably `path`, must never influence behavior. core/tools.ts
314
+ // describeResource() prefers an args `path` over the tool-name fallback, so
315
+ // {query, path:"/x"} re-aims a `file.read recall` deny rule at "/x"; policy
316
+ // runs BEFORE execute, so this strip cannot repair that gate — the
317
+ // authoritative fix belongs in describeResource (validate against the tool
318
+ // schema). Residual gap documented in recall.test.ts. A fresh object (not
319
+ // deletes on `args`) because the registry reuses the caller's object for
320
+ // loop-guard identity and onToolResult after execute.
321
+ const raw = (args && typeof args === "object" ? args : {}) as Record<string, unknown>;
322
+ const a: { query?: unknown; limit?: unknown } = { query: raw.query, limit: raw.limit };
323
+ if (typeof a.query !== "string" || a.query.trim().length === 0) {
324
+ return { ok: false, output: "recall failed: query must be a non-empty string" };
325
+ }
326
+ // the echoed query is bounded like the searched one: a 200k-char query must
327
+ // not reflect 200k chars into tool output (truncation marked with an ellipsis)
328
+ const trimmed = a.query.trim();
329
+ const echo = trimmed.length > MAX_QUERY_CHARS
330
+ ? trimOrphanSurrogates(trimmed.slice(0, MAX_QUERY_CHARS)) + "…" : trimmed;
331
+ // hermes limit clamp: max(1, min(limit, ceiling)) (session_search_tool.py:1040-1046)
332
+ let limit = Math.min(DEFAULT_RESULTS, ceiling);
333
+ if (typeof a.limit === "number" && Number.isFinite(a.limit)) limit = Math.trunc(a.limit);
334
+ limit = Math.max(1, Math.min(limit, ceiling));
335
+
336
+ const hits = index.search(a.query, limit, ctx.sessionId);
337
+ if (hits.length === 0) {
338
+ // actionable empty message, hermes session_search_tool.py:806-810
339
+ return { ok: true, output: `recall: no matches for "${echo}" — terms are ANDed; try fewer or broader terms`, data: { hits } };
340
+ }
341
+ const lines = hits.map((h) =>
342
+ `- [${h.sessionId}] entry ${h.entryId} @ ${h.timestamp > 0 ? new Date(h.timestamp).toISOString() : "unknown time"}\n ${h.preview}`);
343
+ let output = `recall: ${hits.length} hit(s) for "${echo}"\n` + lines.join("\n");
344
+ if (opts.summarize) {
345
+ try {
346
+ const summary = await opts.summarize(a.query, hits);
347
+ if (summary) output += `\n\nsummary: ${summary}`;
348
+ } catch (e) {
349
+ output += `\n\n(summarize step failed: ${e instanceof Error ? e.message : String(e)}; hits above are unaffected)`;
350
+ }
351
+ }
352
+ return { ok: true, output, data: { hits } };
353
+ },
354
+ };
355
+ }
@@ -0,0 +1,105 @@
1
+ /** Bounded memory (ADR-008): typed records with budgets, decay, dedup, provenance.
2
+ * Memory edits flow through the tool pipeline so policy applies. */
3
+
4
+ import { createHash } from "node:crypto";
5
+ import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ export type MemoryKind = "task" | "episodic" | "semantic";
9
+
10
+ export interface MemoryRecord {
11
+ id: string;
12
+ kind: MemoryKind;
13
+ text: string;
14
+ createdAt: number;
15
+ lastAccessedAt: number;
16
+ accessCount: number;
17
+ /** where this came from: session id, tool call, user */
18
+ provenance: string;
19
+ tags: string[];
20
+ }
21
+
22
+ export interface MemoryLimits {
23
+ maxRecords: number;
24
+ maxCharsPerRecord: number;
25
+ /** records unused for this many ms get decayed (deleted at the tail) */
26
+ decayAfterMs: number;
27
+ }
28
+
29
+ export const defaultLimits: MemoryLimits = { maxRecords: 500, maxCharsPerRecord: 2_000, decayAfterMs: 1000 * 60 * 60 * 24 * 14 };
30
+
31
+ function dedupKey(r: MemoryRecord): string {
32
+ return createHash("sha256").update(r.kind + "|" + r.text.replace(/\s+/g, " ").trim().toLowerCase()).digest("hex").slice(0, 16);
33
+ }
34
+
35
+ export class MemoryStore {
36
+ private records: MemoryRecord[] = [];
37
+ private seen = new Set<string>();
38
+
39
+ constructor(private readonly dir: string, private readonly limits: MemoryLimits = defaultLimits) {
40
+ mkdirSync(dir, { recursive: true });
41
+ const p = join(dir, "memory.json");
42
+ if (existsSync(p)) {
43
+ this.records = JSON.parse(readFileSync(p, "utf8")) as MemoryRecord[];
44
+ this.seen = new Set(this.records.map(dedupKey));
45
+ }
46
+ }
47
+
48
+ private persist(): void {
49
+ writeFileSync(join(this.dir, "memory.json"), JSON.stringify(this.records, null, 2));
50
+ }
51
+
52
+ add(kind: MemoryKind, text: string, provenance: string, tags: string[] = []): { ok: boolean; reason?: string } {
53
+ const trimmed = text.trim();
54
+ if (trimmed.length === 0) return { ok: false, reason: "empty record" };
55
+ if (trimmed.length > this.limits.maxCharsPerRecord) return { ok: false, reason: `record exceeds ${this.limits.maxCharsPerRecord} chars` };
56
+ const rec: MemoryRecord = {
57
+ id: createHash("sha256").update(kind + trimmed + Date.now()).digest("hex").slice(0, 12),
58
+ kind, text: trimmed, createdAt: Date.now(), lastAccessedAt: Date.now(), accessCount: 0, provenance, tags,
59
+ };
60
+ const key = dedupKey(rec);
61
+ if (this.seen.has(key)) return { ok: false, reason: "duplicate" };
62
+ this.seen.add(key);
63
+ this.records.push(rec);
64
+ this.enforceLimits();
65
+ this.persist();
66
+ return { ok: true };
67
+ }
68
+
69
+ private enforceLimits(): void {
70
+ const now = Date.now();
71
+ // decay: drop stale records beyond the cap, oldest-accessed first
72
+ this.records = this.records.filter((r) => now - r.lastAccessedAt < this.limits.decayAfterMs);
73
+ if (this.records.length > this.limits.maxRecords) {
74
+ this.records.sort((a, b) => a.lastAccessedAt - b.lastAccessedAt);
75
+ this.records = this.records.slice(this.records.length - this.limits.maxRecords);
76
+ }
77
+ }
78
+
79
+ /** Recency × access × simple lexical relevance. */
80
+ retrieve(query: string, limit = 5, kind?: MemoryKind): MemoryRecord[] {
81
+ const terms = query.toLowerCase().split(/\W+/).filter((t) => t.length > 2);
82
+ const now = Date.now();
83
+ const scored = this.records
84
+ .filter((r) => !kind || r.kind === kind)
85
+ .map((r) => {
86
+ const text = r.text.toLowerCase();
87
+ const hits = terms.reduce((n, t) => n + (text.includes(t) ? 1 : 0), 0);
88
+ const recency = 1 / (1 + (now - r.lastAccessedAt) / 3_600_000);
89
+ const score = hits * 2 + Math.log1p(r.accessCount) + recency;
90
+ return { r, score };
91
+ })
92
+ .filter((s) => s.score > 0.3)
93
+ .sort((a, b) => b.score - a.score)
94
+ .slice(0, limit);
95
+ for (const s of scored) { s.r.accessCount++; s.r.lastAccessedAt = now; }
96
+ this.persist();
97
+ return scored.map((s) => s.r);
98
+ }
99
+
100
+ stats(): { count: number; byKind: Record<string, number> } {
101
+ const byKind: Record<string, number> = {};
102
+ for (const r of this.records) byKind[r.kind] = (byKind[r.kind] ?? 0) + 1;
103
+ return { count: this.records.length, byKind };
104
+ }
105
+ }
@@ -0,0 +1,99 @@
1
+ /** memory_edit tool (hermes memory_tool pattern): add/replace/remove on the bounded
2
+ * markdown blocks. Failures (bad match, cap overflow, bad args) count toward a
3
+ * per-turn cap of 3; at the cap the tool returns a terminal skip so the agent
4
+ * stops burning turns on memory writes (hermes #42405). */
5
+
6
+ import type { Tool, ToolContext, ToolOutput } from "../core/types.ts";
7
+ import { BlockStore, type BlockName } from "./blocks.ts";
8
+
9
+ const MAX_FAILURES_PER_TURN = 3;
10
+ const CAP_MESSAGE = "save skipped: memory at capacity or repeatedly failing";
11
+
12
+ let turnFailureCount = 0;
13
+
14
+ export function resetTurnFailureCount(): void { turnFailureCount = 0; }
15
+
16
+ export function turnFailures(): number { return turnFailureCount; }
17
+
18
+ export interface MemoryEditArgs {
19
+ op: "add" | "replace" | "remove";
20
+ block: BlockName;
21
+ text?: string;
22
+ oldText?: string;
23
+ newText?: string;
24
+ }
25
+
26
+ /** Build the tool bound to a store instance (the store is per-session). */
27
+ export function memoryEditTool(store: BlockStore): Tool {
28
+ return {
29
+ schema: {
30
+ name: "memory_edit",
31
+ description:
32
+ `Edit long-term memory blocks. Ops: add(text) appends a line; replace(oldText,newText) requires oldText to match exactly once; remove(oldText) same. ` +
33
+ `block: "memory" (session/task facts, cap ${store.cap("memory")} chars) or "user" (stable user preferences, cap ${store.cap("user")} chars). ` +
34
+ `Failures count toward a per-turn budget of ${MAX_FAILURES_PER_TURN}; after that saves are skipped until next turn.`,
35
+ args: {
36
+ type: "object",
37
+ properties: {
38
+ op: { type: "string", enum: ["add", "replace", "remove"] },
39
+ block: { type: "string", enum: ["memory", "user"] },
40
+ text: { type: "string", description: "text to add (op=add)" },
41
+ oldText: { type: "string", description: "exact text to replace/remove; must match exactly once" },
42
+ newText: { type: "string", description: "replacement text (op=replace; empty removes)" },
43
+ },
44
+ required: ["op", "block"],
45
+ },
46
+ },
47
+ kind: "memory",
48
+ sequential: true,
49
+ async execute(args: unknown, _ctx: ToolContext): Promise<ToolOutput> {
50
+ const a = args as MemoryEditArgs;
51
+ if (turnFailureCount >= MAX_FAILURES_PER_TURN) {
52
+ return { ok: false, output: CAP_MESSAGE };
53
+ }
54
+ const block = a.block === "user" ? "user" : a.block === "memory" ? "memory" : undefined;
55
+ if (!block) return fail(`invalid block: ${String(a.block)}`);
56
+
57
+ let res;
58
+ switch (a.op) {
59
+ case "add":
60
+ if (typeof a.text !== "string" || a.text.trim().length === 0) {
61
+ return fail("op=add requires non-empty text");
62
+ }
63
+ res = store.add(block, a.text);
64
+ break;
65
+ case "replace":
66
+ if (typeof a.oldText !== "string" || a.oldText.length === 0) {
67
+ return fail("op=replace requires oldText");
68
+ }
69
+ if (typeof a.newText !== "string") return fail("op=replace requires newText (use \"\" to delete)");
70
+ res = store.replace(block, a.oldText, a.newText);
71
+ break;
72
+ case "remove":
73
+ if (typeof a.oldText !== "string" || a.oldText.length === 0) {
74
+ return fail("op=remove requires oldText");
75
+ }
76
+ res = store.remove(block, a.oldText);
77
+ break;
78
+ default:
79
+ return fail(`invalid op: ${String(a.op)}`);
80
+ }
81
+
82
+ if (!res.ok) {
83
+ return fail(res.reason ?? "edit failed", res.current, res.limit);
84
+ }
85
+ return {
86
+ ok: true,
87
+ output: `${a.op} ok: ${block} block now ${res.current}/${res.limit} chars`,
88
+ data: { block, chars: res.current, limit: res.limit },
89
+ };
90
+ },
91
+ };
92
+ }
93
+
94
+ /** Every failure path routes through here so the per-turn counter stays honest. */
95
+ function fail(reason: string, current?: number, limit?: number): ToolOutput {
96
+ turnFailureCount++;
97
+ const detail = current !== undefined && limit !== undefined ? ` (current ${current}/${limit} chars)` : "";
98
+ return { ok: false, output: `memory_edit failed: ${reason}${detail}; failures this turn: ${turnFailureCount}/${MAX_FAILURES_PER_TURN}` };
99
+ }