rovecode 0.4.0-beta.2 → 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.
- package/README.md +67 -69
- package/THIRD_PARTY_NOTICES.md +0 -44
- package/bin/rovecode.ts +21 -0
- package/package.json +16 -37
- package/src/account/keys.ts +97 -0
- package/src/account/login.ts +158 -0
- package/src/account/provision.ts +47 -0
- package/src/account/store.ts +63 -0
- package/src/acp/server.ts +373 -0
- package/src/cli/account-cmd.ts +116 -0
- package/src/cli/connect.ts +244 -0
- package/src/cli/context-cmd.ts +199 -0
- package/src/cli/dispatch.ts +109 -0
- package/src/cli/doctor.ts +324 -0
- package/src/cli/export.ts +278 -0
- package/src/cli/help.ts +240 -0
- package/src/cli/is-tui-invocation.ts +8 -0
- package/src/cli/main.ts +599 -0
- package/src/cli/market-cmd.ts +658 -0
- package/src/cli/mcp-market-cmd.ts +299 -0
- package/src/cli/output.ts +382 -0
- package/src/cli/repl.ts +172 -0
- package/src/cli/resume.ts +32 -0
- package/src/cli/run-limits.ts +78 -0
- package/src/cli/runtime.ts +792 -0
- package/src/cli/setup.ts +187 -0
- package/src/cli/update-cmd.ts +78 -0
- package/src/cli/workflow-cmd.ts +100 -0
- package/src/coding/checkpoints.ts +270 -0
- package/src/coding/diff.ts +136 -0
- package/src/coding/files.ts +339 -0
- package/src/coding/hashline.ts +319 -0
- package/src/coding/lsp.ts +406 -0
- package/src/coding/repomap-cache.ts +99 -0
- package/src/coding/repomap-files.ts +110 -0
- package/src/coding/repomap.ts +392 -0
- package/src/core/compaction.ts +399 -0
- package/src/core/config.ts +289 -0
- package/src/core/context-report.ts +228 -0
- package/src/core/context.ts +60 -0
- package/src/core/count-remote.ts +107 -0
- package/src/core/execpolicy-rules.ts +196 -0
- package/src/core/execpolicy.ts +385 -0
- package/src/core/executor.ts +397 -0
- package/src/core/guardrails.ts +400 -0
- package/src/core/hooks.ts +398 -0
- package/src/core/images.ts +230 -0
- package/src/core/intro.ts +236 -0
- package/src/core/loop.ts +621 -0
- package/src/core/modes.ts +372 -0
- package/src/core/orchestrator.ts +207 -0
- package/src/core/reflection.ts +165 -0
- package/src/core/sandbox-config.ts +167 -0
- package/src/core/session-images.ts +73 -0
- package/src/core/session.ts +398 -0
- package/src/core/settings.ts +98 -0
- package/src/core/stuck-detector.ts +273 -0
- package/src/core/tasks.ts +374 -0
- package/src/core/token-scale.ts +108 -0
- package/src/core/tool-output-budget.ts +166 -0
- package/src/core/tools.ts +288 -0
- package/src/core/types.ts +330 -0
- package/src/core/update-check.ts +171 -0
- package/src/core/update.ts +158 -0
- package/src/core/usage.ts +204 -0
- package/src/core/validate.ts +121 -0
- package/src/core/verify-gate.ts +159 -0
- package/src/core/verify.ts +237 -0
- package/src/core/voice.ts +158 -0
- package/src/core/win-job.ts +183 -0
- package/src/design/audit.ts +797 -0
- package/src/design/direction.ts +190 -0
- package/src/design/rules.ts +157 -0
- package/src/eval/bench.ts +150 -0
- package/src/eval/gauntlet-runner.ts +218 -0
- package/src/eval/gauntlet.ts +226 -0
- package/src/eval/grader.ts +186 -0
- package/src/eval/record.ts +202 -0
- package/src/eval/redact.ts +141 -0
- package/src/eval/replay.ts +147 -0
- package/src/eval/trajectory.ts +373 -0
- package/src/index.ts +17 -0
- package/src/market/catalogs/mcp-docs.json +111 -0
- package/src/market/catalogs/plugins.json +111 -0
- package/src/market/catalogs/skills.json +478 -0
- package/src/market/clone.ts +72 -0
- package/src/market/context-cost.ts +121 -0
- package/src/market/digest.ts +106 -0
- package/src/market/index.ts +22 -0
- package/src/market/install.ts +578 -0
- package/src/market/manifest.ts +187 -0
- package/src/market/prereq.ts +145 -0
- package/src/market/registry.ts +363 -0
- package/src/market/resolve.ts +111 -0
- package/src/market/types.ts +236 -0
- package/src/market/validate.ts +227 -0
- package/src/mcp/client.ts +431 -0
- package/src/mcp/config.ts +239 -0
- package/src/mcp/local-package.ts +211 -0
- package/src/mcp/market-catalog.ts +84 -0
- package/src/mcp/market-install.ts +289 -0
- package/src/mcp/market.ts +0 -0
- package/src/mcp/tools.ts +131 -0
- package/src/mcp/trust.ts +49 -0
- package/src/memory/blocks.ts +175 -0
- package/src/memory/recall.ts +355 -0
- package/src/memory/store.ts +105 -0
- package/src/memory/tools.ts +99 -0
- package/src/plugins/cli.ts +123 -0
- package/src/plugins/discover.ts +108 -0
- package/src/plugins/index.ts +50 -0
- package/src/plugins/init.ts +140 -0
- package/src/plugins/install.ts +184 -0
- package/src/plugins/load.ts +149 -0
- package/src/plugins/manifest.ts +106 -0
- package/src/plugins/state.ts +83 -0
- package/src/providers/auth.ts +293 -0
- package/src/providers/cache.ts +223 -0
- package/src/providers/catalog-local.ts +160 -0
- package/src/providers/catalog.ts +408 -0
- package/src/providers/middleware-context.ts +86 -0
- package/src/providers/middleware.ts +373 -0
- package/src/providers/profile-glm53.ts +111 -0
- package/src/providers/profile-sonnet5-persona.ts +65 -0
- package/src/providers/profile-sonnet5-voice.ts +23 -0
- package/src/providers/profiles.ts +156 -0
- package/src/providers/provider-config.ts +311 -0
- package/src/providers/registry.ts +302 -0
- package/src/providers/response-validation.ts +80 -0
- package/src/providers/retry.ts +234 -0
- package/src/providers/router.ts +294 -0
- package/src/providers/sse.ts +26 -0
- package/src/providers/stream-errors.ts +117 -0
- package/src/providers/stream.ts +569 -0
- package/src/providers/thinking.ts +189 -0
- package/src/providers/wire-messages.ts +129 -0
- package/src/sdk/client.ts +225 -0
- package/src/sdk/index.ts +3 -0
- package/src/server/dashboard.ts +144 -0
- package/src/server/http.ts +343 -0
- package/src/server/openapi.ts +246 -0
- package/src/sextant/card-hits.ts +102 -0
- package/src/sextant/card-keys.ts +55 -0
- package/src/sextant/context-source.ts +157 -0
- package/src/sextant/draw-agents.ts +273 -0
- package/src/sextant/draw-code.ts +388 -0
- package/src/sextant/draw-context.ts +222 -0
- package/src/sextant/draw-frame.ts +164 -0
- package/src/sextant/draw-market.ts +573 -0
- package/src/sextant/draw-messages.ts +386 -0
- package/src/sextant/draw-pet.ts +230 -0
- package/src/sextant/draw-plan.ts +159 -0
- package/src/sextant/draw-tabs.ts +85 -0
- package/src/sextant/draw-util.ts +65 -0
- package/src/sextant/engine.ts +230 -0
- package/src/sextant/frame-hits.ts +25 -0
- package/src/sextant/frame.ts +101 -0
- package/src/sextant/git-status.ts +197 -0
- package/src/sextant/grid.ts +59 -0
- package/src/sextant/input.ts +119 -0
- package/src/sextant/keys.ts +488 -0
- package/src/sextant/layout.ts +86 -0
- package/src/sextant/local-commands.ts +156 -0
- package/src/sextant/market-source.ts +287 -0
- package/src/sextant/mentions.ts +141 -0
- package/src/sextant/message-hits.ts +26 -0
- package/src/sextant/model.ts +387 -0
- package/src/sextant/overlays.ts +451 -0
- package/src/sextant/panel-hits.ts +38 -0
- package/src/sextant/pet.ts +399 -0
- package/src/sextant/screen.ts +324 -0
- package/src/sextant/scroll-hits.ts +66 -0
- package/src/sextant/scrollbar.ts +82 -0
- package/src/sextant/selection.ts +123 -0
- package/src/sextant/sextant-bridge.ts +174 -0
- package/src/sextant/sextant-cards.ts +142 -0
- package/src/sextant/sextant-diff-base.ts +63 -0
- package/src/sextant/sextant-files.ts +154 -0
- package/src/sextant/sextant-frame-loop.ts +314 -0
- package/src/sextant/sextant-renderer.ts +478 -0
- package/src/sextant/sextant-repo.ts +131 -0
- package/src/sextant/theme.ts +66 -0
- package/src/sextant/tool-rows.ts +189 -0
- package/src/sextant/types.ts +473 -0
- package/src/skills/index.ts +306 -0
- package/src/skills/tools.ts +69 -0
- package/src/skills/versioned.ts +227 -0
- package/src/telemetry/otel.ts +353 -0
- package/src/telemetry/otlp.ts +68 -0
- package/src/tools/ask-user.ts +156 -0
- package/src/tools/design.ts +151 -0
- package/src/tools/evalcell.ts +338 -0
- package/src/tools/html-text.ts +139 -0
- package/src/tools/provider.ts +149 -0
- package/src/tools/task.ts +216 -0
- package/src/tools/todo.ts +320 -0
- package/src/tools/webfetch.ts +331 -0
- package/src/tui/app.ts +608 -0
- package/src/tui/attach.ts +127 -0
- package/src/tui/checkpoints-cmd.ts +70 -0
- package/src/tui/clipboard-image.ts +81 -0
- package/src/tui/commands.ts +277 -0
- package/src/tui/cost.ts +108 -0
- package/src/tui/info-cmd.ts +144 -0
- package/src/tui/mcp-cmd.ts +128 -0
- package/src/tui/modes-cmd.ts +45 -0
- package/src/tui/overlays.ts +97 -0
- package/src/tui/pi-renderer.ts +424 -0
- package/src/tui/providers-cmd.ts +366 -0
- package/src/tui/renderer.ts +101 -0
- package/src/tui/replay-marker.ts +29 -0
- package/src/tui/session-cmd.ts +146 -0
- package/src/tui/sextant-attach.ts +68 -0
- package/src/tui/sextant-io.ts +184 -0
- package/src/tui/sextant-smoke.ts +110 -0
- package/src/tui/smoke.ts +72 -0
- package/src/tui/theme.ts +59 -0
- package/src/tui/todo-label.ts +7 -0
- package/src/workflow/engine.ts +266 -0
- package/tsconfig.json +30 -0
- package/vendor/pi-tui/LICENSE +21 -0
- package/vendor/pi-tui/PATCHES.md +12 -0
- package/vendor/pi-tui/PROVENANCE.md +12 -0
- package/vendor/pi-tui/README.upstream.md +854 -0
- package/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
- package/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
- package/vendor/pi-tui/src/alt-screen-search.ts +158 -0
- package/vendor/pi-tui/src/autocomplete.ts +827 -0
- package/vendor/pi-tui/src/components/alt-screen-flash.ts +52 -0
- package/vendor/pi-tui/src/components/box.ts +138 -0
- package/vendor/pi-tui/src/components/cancellable-loader.ts +41 -0
- package/vendor/pi-tui/src/components/editor.ts +2364 -0
- package/vendor/pi-tui/src/components/h-stack.ts +45 -0
- package/vendor/pi-tui/src/components/image.ts +128 -0
- package/vendor/pi-tui/src/components/input.ts +448 -0
- package/vendor/pi-tui/src/components/loader.ts +93 -0
- package/vendor/pi-tui/src/components/markdown.ts +1016 -0
- package/vendor/pi-tui/src/components/scroll-view.ts +217 -0
- package/vendor/pi-tui/src/components/select-list.ts +230 -0
- package/vendor/pi-tui/src/components/settings-list.ts +277 -0
- package/vendor/pi-tui/src/components/spacer.ts +29 -0
- package/vendor/pi-tui/src/components/stack.ts +155 -0
- package/vendor/pi-tui/src/components/text.ts +108 -0
- package/vendor/pi-tui/src/components/truncated-text.ts +66 -0
- package/vendor/pi-tui/src/components/v-stack.ts +34 -0
- package/vendor/pi-tui/src/editor-component.ts +75 -0
- package/vendor/pi-tui/src/fuzzy.ts +138 -0
- package/vendor/pi-tui/src/index.ts +149 -0
- package/vendor/pi-tui/src/keybindings.ts +321 -0
- package/vendor/pi-tui/src/keys.ts +1402 -0
- package/vendor/pi-tui/src/kill-ring.ts +47 -0
- package/vendor/pi-tui/src/latex.ts +1381 -0
- package/vendor/pi-tui/src/layout-node.ts +52 -0
- package/vendor/pi-tui/src/layout.ts +411 -0
- package/vendor/pi-tui/src/native-modifiers.ts +60 -0
- package/vendor/pi-tui/src/native-module-path.ts +32 -0
- package/vendor/pi-tui/src/stdin-buffer.ts +445 -0
- package/vendor/pi-tui/src/terminal-colors.ts +74 -0
- package/vendor/pi-tui/src/terminal-image.ts +701 -0
- package/vendor/pi-tui/src/terminal.ts +554 -0
- package/vendor/pi-tui/src/tui-alt-screen.ts +1379 -0
- package/vendor/pi-tui/src/tui-main-screen.ts +655 -0
- package/vendor/pi-tui/src/tui.ts +1264 -0
- package/vendor/pi-tui/src/undo-stack.ts +29 -0
- package/vendor/pi-tui/src/utils.ts +1327 -0
- package/vendor/pi-tui/src/word-navigation.ts +118 -0
- package/vendor/pi-tui/test/test-themes.ts +39 -0
- package/vendor/pi-tui/test/virtual-terminal.ts +219 -0
- package/CHANGELOG.md +0 -512
- package/bin/rovecode.js +0 -24
- package/dist/cli/app-dybnr56b.js +0 -2
- package/dist/cli/ask-user-p8hq4xgj.js +0 -2
- package/dist/cli/auth-login-ewpgw5sm.js +0 -2
- package/dist/cli/auth-m8p9grty.js +0 -2
- package/dist/cli/bench-xv3ypwev.js +0 -9
- package/dist/cli/catalog-737wb2s0.js +0 -2
- package/dist/cli/cli-arhg40m0.js +0 -2
- package/dist/cli/client-cf2pxx8q.js +0 -2
- package/dist/cli/commands-3p7e4xxs.js +0 -2
- package/dist/cli/connect-3q93d7cb.js +0 -2
- package/dist/cli/context-cmd-eqxmxhzq.js +0 -2
- package/dist/cli/context-report-hbw9zfes.js +0 -2
- package/dist/cli/count-remote-mby98cd0.js +0 -2
- package/dist/cli/design-122y0axd.js +0 -2
- package/dist/cli/dispatch-b4egzvvh.js +0 -2
- package/dist/cli/doctor-x4jkv72e.js +0 -3
- package/dist/cli/executor-ftvg6tsy.js +0 -2
- package/dist/cli/export-pdgdhkch.js +0 -2
- package/dist/cli/files-cez9a96p.js +0 -2
- package/dist/cli/gauntlet-r3xxaszc.js +0 -2
- package/dist/cli/gauntlet-runner-r515m7kk.js +0 -10
- package/dist/cli/gauntlet-wave3-bnkjk2v2.js +0 -5
- package/dist/cli/gauntlet-wave4-acs9s60q.js +0 -14
- package/dist/cli/hashline-ewg5hbe3.js +0 -2
- package/dist/cli/http-n0kehsk8.js +0 -5
- package/dist/cli/index-z5qt1s76.js +0 -2
- package/dist/cli/install-80mp63kx.js +0 -2
- package/dist/cli/loop-12twjcat.js +0 -2
- package/dist/cli/main-01pv9206.js +0 -4
- package/dist/cli/main-0jys2ccn.js +0 -3
- package/dist/cli/main-1ztz6fkj.js +0 -10
- package/dist/cli/main-23q7cmww.js +0 -9
- package/dist/cli/main-2rzbexn2.js +0 -3
- package/dist/cli/main-2wyax8k9.js +0 -9
- package/dist/cli/main-2yeveeve.js +0 -6
- package/dist/cli/main-2z3dek0b.js +0 -3
- package/dist/cli/main-2zgsknth.js +0 -3
- package/dist/cli/main-45ejth3a.js +0 -4
- package/dist/cli/main-45rn3trk.js +0 -22
- package/dist/cli/main-4p4e2w7x.js +0 -4
- package/dist/cli/main-4y0tnfpa.js +0 -16
- package/dist/cli/main-5py0rkmc.js +0 -4
- package/dist/cli/main-6dtqmbt6.js +0 -7
- package/dist/cli/main-6h9x282m.js +0 -4
- package/dist/cli/main-6vjeds42.js +0 -3
- package/dist/cli/main-78gq4bt9.js +0 -6
- package/dist/cli/main-7jd5vh3x.js +0 -4
- package/dist/cli/main-7kt6r53y.js +0 -4
- package/dist/cli/main-8c1tbazx.js +0 -58
- package/dist/cli/main-9a9rnh47.js +0 -19
- package/dist/cli/main-9ht36z12.js +0 -3
- package/dist/cli/main-a2yfvcy9.js +0 -7
- package/dist/cli/main-a3f51n0x.js +0 -5
- package/dist/cli/main-b8zq261k.js +0 -3
- package/dist/cli/main-bxtvnf6d.js +0 -13
- package/dist/cli/main-edxc3yzt.js +0 -4
- package/dist/cli/main-evgz4mp5.js +0 -21
- package/dist/cli/main-f33fc5je.js +0 -9
- package/dist/cli/main-fvnpq46y.js +0 -12
- package/dist/cli/main-gbbty4d4.js +0 -3
- package/dist/cli/main-gth53dnt.js +0 -25
- package/dist/cli/main-hqbz10aw.js +0 -9
- package/dist/cli/main-hrrvcfan.js +0 -38
- package/dist/cli/main-hzwtsb2m.js +0 -5
- package/dist/cli/main-j7ttv0sd.js +0 -34
- package/dist/cli/main-jak598k9.js +0 -5
- package/dist/cli/main-kba6zeyd.js +0 -6
- package/dist/cli/main-kwwsz6rq.js +0 -3
- package/dist/cli/main-m8vm17zq.js +0 -3
- package/dist/cli/main-mg4f96e1.js +0 -3
- package/dist/cli/main-mg9b20ac.js +0 -18
- package/dist/cli/main-mgb9ccnx.js +0 -3
- package/dist/cli/main-mjt2p7aj.js +0 -3
- package/dist/cli/main-n6qrdbmy.js +0 -3
- package/dist/cli/main-na7wse0x.js +0 -5
- package/dist/cli/main-nqveez48.js +0 -4
- package/dist/cli/main-ntqef02r.js +0 -10
- package/dist/cli/main-nvc3yjay.js +0 -136
- package/dist/cli/main-p0cfn6nr.js +0 -16
- package/dist/cli/main-qj2djy17.js +0 -19
- package/dist/cli/main-qsevpgsv.js +0 -3
- package/dist/cli/main-qvarybsp.js +0 -3
- package/dist/cli/main-rebtt91r.js +0 -5
- package/dist/cli/main-rpg7h8mb.js +0 -3
- package/dist/cli/main-rsy72qmw.js +0 -15
- package/dist/cli/main-rvetps99.js +0 -18
- package/dist/cli/main-s4bb0jav.js +0 -3
- package/dist/cli/main-s9v8k74e.js +0 -3
- package/dist/cli/main-tjvwmscs.js +0 -3
- package/dist/cli/main-tkgarpjj.js +0 -4
- package/dist/cli/main-v8y60bb2.js +0 -3
- package/dist/cli/main-vhrrq337.js +0 -3
- package/dist/cli/main-vp2dfb7s.js +0 -4
- package/dist/cli/main-vqbr22sz.js +0 -8
- package/dist/cli/main-vxnwe5xx.js +0 -18
- package/dist/cli/main-wgph00xf.js +0 -5
- package/dist/cli/main-wk2csfnj.js +0 -5
- package/dist/cli/main-wm997zjx.js +0 -3
- package/dist/cli/main-wpkyraxh.js +0 -3
- package/dist/cli/main-wqt32p5x.js +0 -4
- package/dist/cli/main-x9ct6y1a.js +0 -3
- package/dist/cli/main-xfekqh9m.js +0 -7
- package/dist/cli/main-xt9zc3n6.js +0 -7
- package/dist/cli/main-xx2z3zh5.js +0 -4
- package/dist/cli/main-y5c82rxr.js +0 -3
- package/dist/cli/main-yrjt2sqt.js +0 -14
- package/dist/cli/main-ys6zj3yr.js +0 -3
- package/dist/cli/main-ywbxshqc.js +0 -8
- package/dist/cli/main-z13755t8.js +0 -25
- package/dist/cli/main-zc7pyrbj.js +0 -4
- package/dist/cli/main.js +0 -279
- package/dist/cli/market-cmd-bm5xvn9f.js +0 -5
- package/dist/cli/mcp-login-bthtfpt7.js +0 -2
- package/dist/cli/mcp-market-cmd-mbeshfyd.js +0 -2
- package/dist/cli/notify-54v5z9dz.js +0 -2
- package/dist/cli/oauth-g5gme95c.js +0 -2
- package/dist/cli/output-satndjap.js +0 -16
- package/dist/cli/profiles-sfhpbq3m.js +0 -2
- package/dist/cli/provider-config-hv3xtdt4.js +0 -2
- package/dist/cli/provider-kwzq6g84.js +0 -2
- package/dist/cli/registry-fh0hdnyn.js +0 -2
- package/dist/cli/registry-y1y8e94r.js +0 -2
- package/dist/cli/repl-t4z03mqq.js +0 -11
- package/dist/cli/resume-fqt4chg8.js +0 -2
- package/dist/cli/run-flags-rysbag9t.js +0 -2
- package/dist/cli/runtime-j19fjbsa.js +0 -2
- package/dist/cli/sandbox-config-g4qxd7y5.js +0 -2
- package/dist/cli/server-r0b6bksk.js +0 -5
- package/dist/cli/session-arg-txmn5g4x.js +0 -2
- package/dist/cli/session-ed250d9j.js +0 -2
- package/dist/cli/sessions-cmd-adw7svfn.js +0 -7
- package/dist/cli/settings-y9rzcqx8.js +0 -2
- package/dist/cli/setup-jmbr11j0.js +0 -2
- package/dist/cli/sextant-smoke-tcth0vea.js +0 -5
- package/dist/cli/skills-cmd-zbdy99v6.js +0 -2
- package/dist/cli/smoke-1bg937kx.js +0 -8
- package/dist/cli/start-chat-p01cdks3.js +0 -12
- package/dist/cli/stream-4wmyaypz.js +0 -2
- package/dist/cli/task-eg4s093s.js +0 -2
- package/dist/cli/tasks-12v9rr9k.js +0 -2
- package/dist/cli/thinking-a5ngvqyh.js +0 -2
- package/dist/cli/todo-1wxpcecx.js +0 -2
- package/dist/cli/tools-2ftsya7w.js +0 -2
- package/dist/cli/tools-x1tj4fxm.js +0 -2
- package/dist/cli/trust-cmd-hccxehzb.js +0 -2
- package/dist/cli/update-check-ygt3vd7m.js +0 -2
- package/dist/cli/update-cmd-v23qhr8c.js +0 -2
- package/dist/cli/voice-g1gtck92.js +0 -2
- package/dist/cli/webfetch-0nnrjgb5.js +0 -2
- package/dist/cli/websearch-f0vr2p7d.js +0 -2
- package/dist/cli/workspace-9rq1w4ta.js +0 -2
- package/dist/lib/index.js +0 -62
- package/dist/lib/models-index.json +0 -1
- package/dist/lib/plugins.js +0 -6
- package/dist/lib/providers.js +0 -17
- package/dist/lib/public-api.js +0 -20
- package/dist/rovecode.exe +0 -4
- /package/{dist/cli → src/providers}/models-index.json +0 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/** Skills module (hermes pattern): SKILL.md files with frontmatter index,
|
|
2
|
+
* mtime+size manifest invalidation, usage sidecars, deterministic staleness.
|
|
3
|
+
* No LLM consolidation — lifecycle is a pure function of usage age. */
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
existsSync, readFileSync, readdirSync, renameSync, statSync, utimesSync, writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { rovecodeHome } from "../providers/auth.ts";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
export const SKILL_FILE = "SKILL.md";
|
|
12
|
+
export const MAX_DESCRIPTION_CHARS = 60;
|
|
13
|
+
export const STALE_AFTER_MS = 1000 * 60 * 60 * 24 * 90; // 90 days
|
|
14
|
+
|
|
15
|
+
export type SkillScope = "project" | "global";
|
|
16
|
+
|
|
17
|
+
export interface Skill {
|
|
18
|
+
name: string;
|
|
19
|
+
/** one line for the system-prompt index: clipped to MAX_DESCRIPTION_CHARS on a word boundary */
|
|
20
|
+
description: string;
|
|
21
|
+
/** the description as written, however long — what `skill_view` and the market show */
|
|
22
|
+
fullDescription: string;
|
|
23
|
+
version: string;
|
|
24
|
+
/** absolute path of the SKILL.md */
|
|
25
|
+
path: string;
|
|
26
|
+
/** content after the frontmatter block */
|
|
27
|
+
body: string;
|
|
28
|
+
scope: SkillScope;
|
|
29
|
+
mtimeMs: number;
|
|
30
|
+
size: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface InvalidSkill { path: string; reason: string }
|
|
34
|
+
|
|
35
|
+
export interface SkillUsage {
|
|
36
|
+
viewCount: number;
|
|
37
|
+
lastViewedAt: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ScanChanges {
|
|
41
|
+
added: string[];
|
|
42
|
+
removed: string[];
|
|
43
|
+
changed: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const IGNORED_DIRS: Record<string, true> = { node_modules: true, ".git": true };
|
|
47
|
+
export interface ScanResult {
|
|
48
|
+
skills: Skill[];
|
|
49
|
+
invalid: InvalidSkill[];
|
|
50
|
+
changes: ScanChanges;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SkillStoreOptions {
|
|
54
|
+
/** project skills dir (default: <cwd>/.rovecode/skills) */
|
|
55
|
+
projectDir?: string;
|
|
56
|
+
/** global skills dir; null disables (default: ~/.rovecode/skills) */
|
|
57
|
+
globalDir?: string | null;
|
|
58
|
+
/** max directory depth when walking for SKILL.md (default 3) */
|
|
59
|
+
maxDepth?: number;
|
|
60
|
+
/** more roots — a plugin's skills folder (src/plugins) — walked like the two above, tagged with the
|
|
61
|
+
* scope the plugin itself has (a project plugin's skills win a name clash as the project's own would) */
|
|
62
|
+
extraDirs?: readonly { dir: string; scope: SkillScope }[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------- Frontmatter ----------
|
|
66
|
+
|
|
67
|
+
/** Split `---\nkey: value` frontmatter from body. Returns null when no block.
|
|
68
|
+
*
|
|
69
|
+
* Values may be a plain scalar, or a YAML block scalar — `key: >` / `|` / `>-` / `|-` followed by
|
|
70
|
+
* more-indented lines, which real SKILL.md files use for anything longer than a phrase. Measured on
|
|
71
|
+
* anthropics/skills (2026-09-05): 3 of 19 skills write their description that way, and reading only
|
|
72
|
+
* the header line gave `description: ">"` — not an error, just a skill whose one-line summary is a
|
|
73
|
+
* punctuation mark, with its continuation lines mistaken for keys of their own. That is worse than
|
|
74
|
+
* rejecting it, because nothing says anything went wrong. Folded (`>`) joins its lines with a space,
|
|
75
|
+
* literal (`|`) keeps the newlines, a trailing `-` strips the final newline. The rest of YAML —
|
|
76
|
+
* anchors, nested maps, lists — is still out of scope: this is a frontmatter reader, not a parser. */
|
|
77
|
+
export function parseFrontmatter(text: string): { fm: Record<string, string>; body: string } | null {
|
|
78
|
+
if (!text.startsWith("---")) return null;
|
|
79
|
+
const end = text.indexOf("\n---", 3);
|
|
80
|
+
if (end === -1) return null;
|
|
81
|
+
const closeEnd = end + 1 + text.slice(end + 1).indexOf("\n") + 1; // past the closing `---` line
|
|
82
|
+
const block = text.slice(4, end); // skip leading `---\n`
|
|
83
|
+
const body = text.slice(closeEnd).replace(/^\r?\n/, "");
|
|
84
|
+
const fm: Record<string, string> = {};
|
|
85
|
+
const lines = block.split(/\r?\n/);
|
|
86
|
+
for (let i = 0; i < lines.length; i++) {
|
|
87
|
+
const line = lines[i] as string;
|
|
88
|
+
if (/^\s/.test(line)) continue; // an orphan continuation is not a key of its own
|
|
89
|
+
const c = line.indexOf(":");
|
|
90
|
+
if (c === -1) continue;
|
|
91
|
+
const k = line.slice(0, c).trim();
|
|
92
|
+
if (!k) continue;
|
|
93
|
+
const rest = line.slice(c + 1).trim();
|
|
94
|
+
const m = /^([|>])([+-]?)$/.exec(rest);
|
|
95
|
+
if (m === null) { fm[k] = rest.replace(/^["']|["']$/g, ""); continue; }
|
|
96
|
+
const folded = m[1] === ">";
|
|
97
|
+
const owned: string[] = [];
|
|
98
|
+
while (i + 1 < lines.length) {
|
|
99
|
+
const next = lines[i + 1] as string;
|
|
100
|
+
if (next.trim() !== "" && !/^\s/.test(next)) break; // back to column 0: the block ended
|
|
101
|
+
owned.push(next.replace(/^\s+/, ""));
|
|
102
|
+
i++;
|
|
103
|
+
}
|
|
104
|
+
while (owned.length > 0 && owned[owned.length - 1] === "") owned.pop();
|
|
105
|
+
fm[k] = folded ? owned.join(" ").replace(/\s+/g, " ").trim() : owned.join("\n");
|
|
106
|
+
}
|
|
107
|
+
return { fm, body };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Clip to the index budget on a word boundary, with an ellipsis so a reader can tell it was cut. */
|
|
111
|
+
export function clipDescription(text: string): string {
|
|
112
|
+
const one = text.replace(/\s+/g, " ").trim();
|
|
113
|
+
if (one.length <= MAX_DESCRIPTION_CHARS) return one;
|
|
114
|
+
const cut = one.slice(0, MAX_DESCRIPTION_CHARS - 1);
|
|
115
|
+
const sp = cut.lastIndexOf(" ");
|
|
116
|
+
return `${(sp > MAX_DESCRIPTION_CHARS / 2 ? cut.slice(0, sp) : cut).replace(/[,;:.]$/, "")}…`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseSkillFile(path: string, scope: SkillScope): { skill: Skill } | { invalid: InvalidSkill } {
|
|
120
|
+
let text: string;
|
|
121
|
+
try {
|
|
122
|
+
text = readFileSync(path, "utf8");
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return { invalid: { path, reason: `unreadable: ${(e as Error).message}` } };
|
|
125
|
+
}
|
|
126
|
+
const parsed = parseFrontmatter(text);
|
|
127
|
+
if (!parsed) return { invalid: { path, reason: "missing frontmatter block" } };
|
|
128
|
+
const name = parsed.fm["name"] ?? "";
|
|
129
|
+
const description = parsed.fm["description"] ?? "";
|
|
130
|
+
if (!name) return { invalid: { path, reason: "frontmatter missing name" } };
|
|
131
|
+
if (!description) return { invalid: { path, reason: "frontmatter missing description" } };
|
|
132
|
+
// A long description is CLIPPED for the index, never a reason to reject the skill. The cap exists to
|
|
133
|
+
// protect the system prompt (50 skills x a 950-char description is 47 KB of prefix), and clipping
|
|
134
|
+
// protects it just as well while a hard reject throws away a working skill for being wordy. Measured
|
|
135
|
+
// on anthropics/skills (2026-09-05): 16 of 19 real skills are over the cap, median 319 chars, longest
|
|
136
|
+
// 950 — the whole public corpus would have been unusable. The two other places rovecode reads a
|
|
137
|
+
// description already clip (plugins/manifest.ts, tui/commands.ts); this was the outlier.
|
|
138
|
+
const st = statSync(path);
|
|
139
|
+
return {
|
|
140
|
+
skill: {
|
|
141
|
+
name,
|
|
142
|
+
description: clipDescription(description),
|
|
143
|
+
fullDescription: description,
|
|
144
|
+
version: parsed.fm["version"] ?? "0.0.0",
|
|
145
|
+
path, body: parsed.body, scope,
|
|
146
|
+
mtimeMs: st.mtimeMs, size: st.size,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------- Usage sidecar ----------
|
|
152
|
+
|
|
153
|
+
export function usagePath(skillFile: string): string {
|
|
154
|
+
return skillFile + ".usage.json";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function readUsage(skillFile: string): SkillUsage | undefined {
|
|
158
|
+
const p = usagePath(skillFile);
|
|
159
|
+
if (!existsSync(p)) return undefined;
|
|
160
|
+
try {
|
|
161
|
+
const raw = JSON.parse(readFileSync(p, "utf8")) as Partial<SkillUsage>;
|
|
162
|
+
return {
|
|
163
|
+
viewCount: typeof raw.viewCount === "number" ? raw.viewCount : 0,
|
|
164
|
+
lastViewedAt: typeof raw.lastViewedAt === "number" ? raw.lastViewedAt : 0,
|
|
165
|
+
};
|
|
166
|
+
} catch {
|
|
167
|
+
return undefined; // corrupt sidecar → treat as never used
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Atomic write: temp file + rename, so a concurrent reader never sees a partial sidecar. */
|
|
172
|
+
export function writeUsageAtomic(skillFile: string, usage: SkillUsage): void {
|
|
173
|
+
const p = usagePath(skillFile);
|
|
174
|
+
const tmp = p + ".tmp";
|
|
175
|
+
writeFileSync(tmp, JSON.stringify(usage, null, 2));
|
|
176
|
+
renameSync(tmp, p);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------- Lifecycle ----------
|
|
180
|
+
|
|
181
|
+
/** Deterministic: stale when last view (or, never viewed, file mtime) is older than 90d. */
|
|
182
|
+
export function skillLifecycle(
|
|
183
|
+
skill: Pick<Skill, "mtimeMs">,
|
|
184
|
+
usage: SkillUsage | undefined,
|
|
185
|
+
now: number,
|
|
186
|
+
): "active" | "stale" {
|
|
187
|
+
const lastActivity = usage && usage.viewCount > 0 ? usage.lastViewedAt : skill.mtimeMs;
|
|
188
|
+
return now - lastActivity > STALE_AFTER_MS ? "stale" : "active";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------- Store ----------
|
|
192
|
+
|
|
193
|
+
export class SkillStore {
|
|
194
|
+
private readonly projectDir: string;
|
|
195
|
+
private readonly globalDir: string | null;
|
|
196
|
+
private readonly maxDepth: number;
|
|
197
|
+
/** path → parsed skill (cache valid while manifest matches) */
|
|
198
|
+
private cache = new Map<string, Skill>();
|
|
199
|
+
/** path → `mtimeMs:size` (hermes manifest invalidation) */
|
|
200
|
+
private manifest = new Map<string, string>();
|
|
201
|
+
|
|
202
|
+
constructor(readonly cwd: string, opts: SkillStoreOptions = {}) {
|
|
203
|
+
this.projectDir = resolve(cwd, opts.projectDir ?? join(".rovecode", "skills"));
|
|
204
|
+
this.globalDir = opts.globalDir === null
|
|
205
|
+
? null
|
|
206
|
+
// rovecodeHome(), not homedir(): ROVECODE_HOME is how every other part of rovecode finds its
|
|
207
|
+
// config — hooks, settings, plugins, credentials — and skills were the one that ignored it. So a
|
|
208
|
+
// user who set it read skills from a directory nothing else used, and every test that isolates
|
|
209
|
+
// itself with a scratch home silently indexed the DEVELOPER's real skills instead. That is how
|
|
210
|
+
// this was found: installing one skill into a real home broke a plugin test that never touches
|
|
211
|
+
// skills, and the failure named a skill the test had never heard of.
|
|
212
|
+
: resolve(opts.globalDir ?? join(rovecodeHome(), "skills"));
|
|
213
|
+
this.maxDepth = opts.maxDepth ?? 3;
|
|
214
|
+
this.extraDirs = (opts.extraDirs ?? []).map(({ dir, scope }) => [resolve(cwd, dir), scope]);
|
|
215
|
+
}
|
|
216
|
+
private readonly extraDirs: Array<[string, SkillScope]>;
|
|
217
|
+
|
|
218
|
+
private dirs(): Array<[string, SkillScope]> {
|
|
219
|
+
const d: Array<[string, SkillScope]> = [[this.projectDir, "project"]];
|
|
220
|
+
if (this.globalDir && existsSync(this.globalDir)) d.push([this.globalDir, "global"]);
|
|
221
|
+
for (const [dir, scope] of this.extraDirs) if (existsSync(dir)) d.push([dir, scope]);
|
|
222
|
+
return d;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private find(root: string, dir: string = root, depth: number = 0): string[] {
|
|
226
|
+
let entries;
|
|
227
|
+
try {
|
|
228
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
229
|
+
} catch {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
const found: string[] = [];
|
|
233
|
+
for (const e of entries) {
|
|
234
|
+
const p = join(dir, e.name);
|
|
235
|
+
if (e.isFile() && e.name === SKILL_FILE) found.push(p);
|
|
236
|
+
else if (e.isDirectory() && depth < this.maxDepth && !IGNORED_DIRS[e.name]) {
|
|
237
|
+
found.push(...this.find(root, p, depth + 1));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return found;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Rescan: stat every SKILL.md, diff the manifest, reparse only added/changed files. */
|
|
244
|
+
scan(): ScanResult {
|
|
245
|
+
const next = new Map<string, string>();
|
|
246
|
+
const scopeOf = new Map<string, SkillScope>(); // by root, not by path prefix: a plugin's project skills live outside <cwd>/.rovecode/skills
|
|
247
|
+
for (const [dir, scope] of this.dirs()) {
|
|
248
|
+
for (const p of this.find(dir)) {
|
|
249
|
+
try {
|
|
250
|
+
const st = statSync(p);
|
|
251
|
+
next.set(p, `${st.mtimeMs}:${st.size}`);
|
|
252
|
+
if (!scopeOf.has(p)) scopeOf.set(p, scope);
|
|
253
|
+
} catch { /* vanished between readdir and stat */ }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const changes: ScanChanges = { added: [], removed: [], changed: [] };
|
|
257
|
+
for (const [p, sig] of next) {
|
|
258
|
+
if (!this.manifest.has(p)) changes.added.push(p);
|
|
259
|
+
else if (this.manifest.get(p) !== sig) changes.changed.push(p);
|
|
260
|
+
}
|
|
261
|
+
for (const p of this.manifest.keys()) if (!next.has(p)) changes.removed.push(p);
|
|
262
|
+
|
|
263
|
+
const invalid: InvalidSkill[] = [];
|
|
264
|
+
for (const p of [...changes.added, ...changes.changed]) {
|
|
265
|
+
const scope = scopeOf.get(p) ?? (p.startsWith(this.projectDir) ? "project" : "global");
|
|
266
|
+
const r = parseSkillFile(p, scope);
|
|
267
|
+
if ("skill" in r) this.cache.set(p, r.skill);
|
|
268
|
+
else { this.cache.delete(p); invalid.push(r.invalid); }
|
|
269
|
+
}
|
|
270
|
+
for (const p of changes.removed) this.cache.delete(p);
|
|
271
|
+
this.manifest = next;
|
|
272
|
+
return { skills: this.list(), invalid, changes };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Project scope wins on name collisions with the global dir. */
|
|
276
|
+
list(): Skill[] {
|
|
277
|
+
const byName = new Map<string, Skill>();
|
|
278
|
+
for (const s of this.cache.values()) {
|
|
279
|
+
const prev = byName.get(s.name);
|
|
280
|
+
if (!prev || (prev.scope === "global" && s.scope === "project")) byName.set(s.name, s);
|
|
281
|
+
}
|
|
282
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
get(name: string): Skill | undefined {
|
|
286
|
+
return this.list().find((s) => s.name === name);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
usage(name: string): SkillUsage | undefined {
|
|
290
|
+
const s = this.get(name);
|
|
291
|
+
return s ? readUsage(s.path) : undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Record one view. Returns the updated sidecar contents. */
|
|
295
|
+
bumpUsage(skill: Skill, now: number = Date.now()): SkillUsage {
|
|
296
|
+
const prev = readUsage(skill.path);
|
|
297
|
+
const usage: SkillUsage = { viewCount: (prev?.viewCount ?? 0) + 1, lastViewedAt: now };
|
|
298
|
+
writeUsageAtomic(skill.path, usage);
|
|
299
|
+
return usage;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Touch a skill file's mtime (same size) so manifest invalidation is observable in tests. */
|
|
304
|
+
export function touchSkillFile(path: string, at: Date = new Date()): void {
|
|
305
|
+
utimesSync(path, at, at);
|
|
306
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Skill tools (hermes pattern): `skill_view` returns a body and bumps the
|
|
2
|
+
* usage sidecar; `skills_list` returns the index. Above 50 skills the index
|
|
3
|
+
* is too big for the system prompt, so `skills_list` is the only access path
|
|
4
|
+
* and `buildSkillsIndex` returns "" (prompt_builder.py:2083 reject rule). */
|
|
5
|
+
|
|
6
|
+
import type { Tool, ToolContext, ToolOutput } from "../core/types.ts";
|
|
7
|
+
import { SkillStore, skillLifecycle, type Skill, type SkillUsage } from "./index.ts";
|
|
8
|
+
|
|
9
|
+
export const INDEX_PROMPT_LIMIT = 50; // hermes: >50 skills → skills_list tool only
|
|
10
|
+
|
|
11
|
+
function renderIndexLine(s: Skill, usage: SkillUsage | undefined, now: number): string {
|
|
12
|
+
const state = skillLifecycle(s, usage, now);
|
|
13
|
+
return `- ${s.name}${s.version ? ` (v${s.version})` : ""}: ${s.description}${state === "stale" ? " [stale]" : ""}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** System-prompt index. Empty string when the catalogue outgrows the prompt. */
|
|
17
|
+
export function buildSkillsIndex(store: SkillStore, now: number = Date.now()): string {
|
|
18
|
+
const skills = store.list();
|
|
19
|
+
if (skills.length === 0 || skills.length > INDEX_PROMPT_LIMIT) return "";
|
|
20
|
+
return skills.map((s) => renderIndexLine(s, store.usage(s.name), now)).join("\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createSkillTools(store: SkillStore): Tool[] {
|
|
24
|
+
const skillView: Tool = {
|
|
25
|
+
schema: {
|
|
26
|
+
name: "skill_view",
|
|
27
|
+
description:
|
|
28
|
+
"Read one skill's full instructions. Use when a skill in the index matches the task.",
|
|
29
|
+
args: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: { name: { type: "string", description: "skill name from the index" } },
|
|
32
|
+
required: ["name"],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
kind: "read",
|
|
36
|
+
sequential: true, // sidecar write
|
|
37
|
+
execute(args): Promise<ToolOutput> {
|
|
38
|
+
const raw = args as { name?: unknown };
|
|
39
|
+
const name = typeof raw.name === "string" ? raw.name : "";
|
|
40
|
+
const skill = store.get(name);
|
|
41
|
+
if (!skill) return Promise.resolve({ ok: false, output: `no skill named '${name}'` });
|
|
42
|
+
const usage = store.bumpUsage(skill);
|
|
43
|
+
// the FULL description, not the index's clipped line: the model opened this skill to read it, so
|
|
44
|
+
// the sentence saying when to use it belongs here in one piece
|
|
45
|
+
const header = `# ${skill.name} (v${skill.version})\n\n${skill.fullDescription}\n\n`;
|
|
46
|
+
return Promise.resolve({ ok: true, output: header + skill.body, data: usage });
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const skillsList: Tool = {
|
|
51
|
+
schema: {
|
|
52
|
+
name: "skills_list",
|
|
53
|
+
description:
|
|
54
|
+
"List available skills (name, version, description, staleness). Use to discover skills when the system prompt has no skill index.",
|
|
55
|
+
args: { type: "object", properties: {} },
|
|
56
|
+
},
|
|
57
|
+
kind: "read",
|
|
58
|
+
execute(_args: unknown, _ctx: ToolContext): Promise<ToolOutput> {
|
|
59
|
+
store.scan(); // fresh catalogue even when the caller forgot to rescan
|
|
60
|
+
const skills = store.list();
|
|
61
|
+
if (skills.length === 0) return Promise.resolve({ ok: true, output: "no skills installed" });
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const lines = skills.map((s) => renderIndexLine(s, store.usage(s.name), now));
|
|
64
|
+
return Promise.resolve({ ok: true, output: lines.join("\n"), data: { count: skills.length } });
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return [skillView, skillsList];
|
|
69
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/** Versioned edit ledger for skill/memory files (PORT #16, prime-agent MIT).
|
|
2
|
+
*
|
|
3
|
+
* Port of the refinement/versioned-state machinery in prime-agent
|
|
4
|
+
* packages/coding-agent/src/core/refinement/refinement.ts:
|
|
5
|
+
* - per-edit before/after snapshots + version bump (AppliedRefinementEdit:85,
|
|
6
|
+
* `version = before ? before.version + 1 : 1`:770, record push:788)
|
|
7
|
+
* - optimistic-concurrency baseline check → typed reject, no write (735-749)
|
|
8
|
+
* - rollback built from recorded before/after, itself a new history entry
|
|
9
|
+
* (rollbackProposal:813-845, rollbackOf:100)
|
|
10
|
+
* - append-only JSONL history beside the store; malformed lines skipped so a
|
|
11
|
+
* bad append can never break rollback (refinements.jsonl:25, 374-400)
|
|
12
|
+
*
|
|
13
|
+
* Deliberately NOT ported (BLUEPRINT §5 + prime's reward-hacking findings):
|
|
14
|
+
* no auto-refinement loop (prime's reviewAutoRefine LLM gate stays out), no
|
|
15
|
+
* LLM proposal pass, and no grader/eval surface anywhere in this API
|
|
16
|
+
* (ouroboros rule: graders stay hidden from agent-facing code). Edits happen
|
|
17
|
+
* only when a human or an explicit tool call invokes commit()/rollback().
|
|
18
|
+
*
|
|
19
|
+
* Storage: sidecar JSONL beside the target file — `<target>.versions.jsonl`
|
|
20
|
+
* (mirrors the house `SKILL.md.usage.json` sidecar). One line per edit:
|
|
21
|
+
* {version, baseVersion, before, after, reason, timestamp, rollbackTo?}.
|
|
22
|
+
* Every record carries FULL before/after content, so record v restores both
|
|
23
|
+
* content@v (`after`) and content@v-1 (`before`). With the newest K records
|
|
24
|
+
* retained, rollback covers versions [oldest.baseVersion .. newest.version].
|
|
25
|
+
* History is bounded: past `maxEdits` records the oldest are evicted, and
|
|
26
|
+
* versions older than the window become unreachable (typed reject).
|
|
27
|
+
* Versions are monotonic and never reused after eviction. */
|
|
28
|
+
|
|
29
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { dirname } from "node:path";
|
|
31
|
+
|
|
32
|
+
export const LEDGER_SUFFIX = ".versions.jsonl";
|
|
33
|
+
export const DEFAULT_MAX_EDITS = 20;
|
|
34
|
+
|
|
35
|
+
export interface VersionedEdit {
|
|
36
|
+
/** version this edit produced (monotonic; = baseVersion + 1) */
|
|
37
|
+
version: number;
|
|
38
|
+
/** version the editor read before making the change */
|
|
39
|
+
baseVersion: number;
|
|
40
|
+
/** full target content before the edit (content at baseVersion) */
|
|
41
|
+
before: string;
|
|
42
|
+
/** full target content after the edit (content at version) */
|
|
43
|
+
after: string;
|
|
44
|
+
reason: string;
|
|
45
|
+
/** ms epoch */
|
|
46
|
+
timestamp: number;
|
|
47
|
+
/** set when this edit was produced by rollback(): the restored version */
|
|
48
|
+
rollbackTo?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Inclusive range of versions rollback can currently restore. */
|
|
52
|
+
export interface VersionRange { from: number; to: number }
|
|
53
|
+
|
|
54
|
+
export type CommitResult =
|
|
55
|
+
| { ok: true; edit: VersionedEdit }
|
|
56
|
+
| { ok: false; code: "version-conflict"; baseVersion: number; currentVersion: number; message: string };
|
|
57
|
+
|
|
58
|
+
export type RollbackResult =
|
|
59
|
+
| { ok: true; edit: VersionedEdit }
|
|
60
|
+
| { ok: false; code: "unknown-version"; requested: number; available: VersionRange | null; message: string };
|
|
61
|
+
|
|
62
|
+
// ---------- internals ----------
|
|
63
|
+
|
|
64
|
+
/** Atomic write (house pattern, skills/index.ts writeUsageAtomic): tmp + rename. */
|
|
65
|
+
function atomicWrite(path: string, text: string): void {
|
|
66
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
67
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
68
|
+
writeFileSync(tmp, text);
|
|
69
|
+
renameSync(tmp, path);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Parse one JSONL line into an edit, or null when malformed (prime: skip, never throw). */
|
|
73
|
+
function parseEdit(line: string): VersionedEdit | null {
|
|
74
|
+
let raw: unknown;
|
|
75
|
+
try {
|
|
76
|
+
raw = JSON.parse(line);
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
81
|
+
const r = raw as Record<string, unknown>;
|
|
82
|
+
if (
|
|
83
|
+
typeof r["version"] !== "number" || typeof r["baseVersion"] !== "number" ||
|
|
84
|
+
typeof r["before"] !== "string" || typeof r["after"] !== "string" ||
|
|
85
|
+
typeof r["reason"] !== "string" || typeof r["timestamp"] !== "number"
|
|
86
|
+
) return null;
|
|
87
|
+
const edit: VersionedEdit = {
|
|
88
|
+
version: r["version"], baseVersion: r["baseVersion"],
|
|
89
|
+
before: r["before"], after: r["after"],
|
|
90
|
+
reason: r["reason"], timestamp: r["timestamp"],
|
|
91
|
+
};
|
|
92
|
+
if (typeof r["rollbackTo"] === "number") edit.rollbackTo = r["rollbackTo"];
|
|
93
|
+
return edit;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------- ledger ----------
|
|
97
|
+
|
|
98
|
+
export interface VersionLedgerOptions {
|
|
99
|
+
/** sidecar path (default: `<target>${LEDGER_SUFFIX}`) */
|
|
100
|
+
ledgerPath?: string;
|
|
101
|
+
/** bounded snapshot count; oldest records evicted past this (default 20, min 1) */
|
|
102
|
+
maxEdits?: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class VersionLedger {
|
|
106
|
+
readonly ledgerPath: string;
|
|
107
|
+
private readonly maxEdits: number;
|
|
108
|
+
|
|
109
|
+
constructor(readonly targetPath: string, opts: VersionLedgerOptions = {}) {
|
|
110
|
+
this.ledgerPath = opts.ledgerPath ?? targetPath + LEDGER_SUFFIX;
|
|
111
|
+
this.maxEdits = Math.max(1, opts.maxEdits ?? DEFAULT_MAX_EDITS);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Retained edits, oldest → newest. Malformed lines are skipped; duplicate
|
|
115
|
+
* versions keep the last occurrence (append order wins). */
|
|
116
|
+
history(): VersionedEdit[] {
|
|
117
|
+
if (!existsSync(this.ledgerPath)) return [];
|
|
118
|
+
const byVersion = new Map<number, VersionedEdit>();
|
|
119
|
+
for (const line of readFileSync(this.ledgerPath, "utf8").split("\n")) {
|
|
120
|
+
const trimmed = line.trim();
|
|
121
|
+
if (!trimmed) continue;
|
|
122
|
+
const edit = parseEdit(trimmed);
|
|
123
|
+
if (edit) byVersion.set(edit.version, edit);
|
|
124
|
+
}
|
|
125
|
+
return [...byVersion.values()].sort((a, b) => a.version - b.version);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Current version: 0 until the first recorded edit; unaffected by eviction. */
|
|
129
|
+
version(): number {
|
|
130
|
+
return this.history().at(-1)?.version ?? 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Current on-disk content of the target ("" when the file does not exist). */
|
|
134
|
+
read(): string {
|
|
135
|
+
return existsSync(this.targetPath) ? readFileSync(this.targetPath, "utf8") : "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Versions rollback can restore right now, or null before the first edit.
|
|
139
|
+
* Derived from the CONTIGUOUS suffix of retained records: a corruption hole
|
|
140
|
+
* (missing/garbled record) breaks before/after coverage, so a naive
|
|
141
|
+
* first..last span would advertise versions rollback cannot restore. */
|
|
142
|
+
range(): VersionRange | null {
|
|
143
|
+
const edits = this.history();
|
|
144
|
+
const last = edits.at(-1);
|
|
145
|
+
if (!last) return null;
|
|
146
|
+
let start = edits.length - 1;
|
|
147
|
+
while (start > 0 && edits[start - 1]!.version === edits[start]!.baseVersion) start--;
|
|
148
|
+
return { from: edits[start]!.baseVersion, to: last.version };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Content at a recorded version, or undefined when evicted/unknown. Besides a
|
|
152
|
+
* record's own `after`, any record with baseVersion === version restores it via
|
|
153
|
+
* `before` — recoverable even when the record that PRODUCED it was corrupted. */
|
|
154
|
+
contentAt(version: number): string | undefined {
|
|
155
|
+
const edits = this.history();
|
|
156
|
+
const direct = edits.find((e) => e.version === version);
|
|
157
|
+
if (direct) return direct.after;
|
|
158
|
+
return edits.find((e) => e.baseVersion === version)?.before;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** True when the target was modified outside the ledger since the last edit. */
|
|
162
|
+
drifted(): boolean {
|
|
163
|
+
const last = this.history().at(-1);
|
|
164
|
+
return last !== undefined && this.read() !== last.after;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Record an edit and write the target. Optimistic concurrency: `baseVersion`
|
|
168
|
+
* must equal the current version or the commit is rejected BEFORE any disk
|
|
169
|
+
* mutation — no partial write. `before` is read from disk here, so the
|
|
170
|
+
* record stays truthful even if the target drifted externally. */
|
|
171
|
+
commit(after: string, reason: string, baseVersion: number, now: number = Date.now()): CommitResult {
|
|
172
|
+
const currentVersion = this.version();
|
|
173
|
+
if (baseVersion !== currentVersion) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false, code: "version-conflict", baseVersion, currentVersion,
|
|
176
|
+
message: `baseVersion ${baseVersion} does not match current version ${currentVersion}; re-read and retry`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return { ok: true, edit: this.write(after, reason, currentVersion, now) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** One-call rollback to any retained version. Recorded as a NEW edit with
|
|
183
|
+
* `rollbackTo` (prime `rollbackOf` pattern) — history is append-only, and
|
|
184
|
+
* the restored content is byte-exact from the stored snapshot. */
|
|
185
|
+
rollback(toVersion: number, reason?: string, now: number = Date.now()): RollbackResult {
|
|
186
|
+
const content = this.contentAt(toVersion);
|
|
187
|
+
if (content === undefined) {
|
|
188
|
+
const available = this.range();
|
|
189
|
+
return {
|
|
190
|
+
ok: false, code: "unknown-version", requested: toVersion, available,
|
|
191
|
+
message: available
|
|
192
|
+
? `version ${toVersion} is not restorable; available: ${available.from}..${available.to}`
|
|
193
|
+
: `version ${toVersion} is not restorable; no recorded edits`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const edit = this.write(content, reason ?? `rollback to v${toVersion}`, this.version(), now, toVersion);
|
|
197
|
+
return { ok: true, edit };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Append the record, evict past the bound, then atomically write the target.
|
|
201
|
+
* Ledger-first ordering: a crash between the two leaves an honest record
|
|
202
|
+
* (visible via drifted()) rather than an unrecorded content change. */
|
|
203
|
+
private write(after: string, reason: string, currentVersion: number, now: number, rollbackTo?: number): VersionedEdit {
|
|
204
|
+
const edit: VersionedEdit = {
|
|
205
|
+
version: currentVersion + 1, baseVersion: currentVersion,
|
|
206
|
+
before: this.read(), after, reason, timestamp: now,
|
|
207
|
+
...(rollbackTo !== undefined ? { rollbackTo } : {}),
|
|
208
|
+
};
|
|
209
|
+
mkdirSync(dirname(this.ledgerPath), { recursive: true });
|
|
210
|
+
// Heal a torn trailing append: a crash mid-append can leave the last line
|
|
211
|
+
// without "\n" — appending straight on would concatenate this record into the
|
|
212
|
+
// torn line, making the NEW commit invisible to history()/version().
|
|
213
|
+
let record = JSON.stringify(edit) + "\n";
|
|
214
|
+
if (existsSync(this.ledgerPath)) {
|
|
215
|
+
const raw = readFileSync(this.ledgerPath);
|
|
216
|
+
if (raw.length > 0 && raw[raw.length - 1] !== 0x0a) record = "\n" + record;
|
|
217
|
+
}
|
|
218
|
+
appendFileSync(this.ledgerPath, record, "utf8");
|
|
219
|
+
const edits = this.history();
|
|
220
|
+
if (edits.length > this.maxEdits) {
|
|
221
|
+
const kept = edits.slice(edits.length - this.maxEdits);
|
|
222
|
+
atomicWrite(this.ledgerPath, kept.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
223
|
+
}
|
|
224
|
+
atomicWrite(this.targetPath, after);
|
|
225
|
+
return edit;
|
|
226
|
+
}
|
|
227
|
+
}
|