@sma1lboy/rove 0.9.281-next.31 → 0.9.282

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 (110) hide show
  1. package/dist/cli/chunk-03a986ba.js +8 -0
  2. package/dist/cli/{chunk-j54c5a0a.js → chunk-084a4eq5.js} +1 -1
  3. package/dist/cli/{chunk-y7mjhjby.js → chunk-0gbg7bg8.js} +1 -1
  4. package/dist/cli/{chunk-6cfs0nqe.js → chunk-0mr6cckm.js} +1 -1
  5. package/dist/cli/{chunk-j8d9sk5j.js → chunk-0ramhr3v.js} +1 -1
  6. package/dist/cli/{chunk-rvjnafyt.js → chunk-0ws6j7zf.js} +1 -1
  7. package/dist/cli/{chunk-1g6vhgfe.js → chunk-16e6jqq6.js} +1 -1
  8. package/dist/cli/{chunk-vbvbwjad.js → chunk-1k6expt9.js} +1 -1
  9. package/dist/cli/{chunk-f262ywvg.js → chunk-231mqmk5.js} +1 -1
  10. package/dist/cli/{chunk-5g6v6tq5.js → chunk-3m6m1994.js} +1 -1
  11. package/dist/cli/{chunk-f0sahrfw.js → chunk-3xz0dmnc.js} +17 -14
  12. package/dist/cli/{chunk-nqwza8b0.js → chunk-4d5xp2p0.js} +1 -1
  13. package/dist/cli/{chunk-mjzjah9d.js → chunk-4g463eqt.js} +1 -1
  14. package/dist/cli/{chunk-hfvzvj91.js → chunk-4yr1534j.js} +1 -1
  15. package/dist/cli/{chunk-7aaw17vr.js → chunk-55e3pt18.js} +1 -1
  16. package/dist/cli/{chunk-hd1szhba.js → chunk-5bnhecx3.js} +1 -1
  17. package/dist/cli/{chunk-t2p22ww7.js → chunk-5z02j1em.js} +1 -1
  18. package/dist/cli/{chunk-r98h6b42.js → chunk-6jj8perx.js} +2 -2
  19. package/dist/cli/{chunk-5dq0n3h7.js → chunk-7r1wt7qp.js} +1 -1
  20. package/dist/cli/chunk-7zwehmay.js +2 -0
  21. package/dist/cli/{chunk-4drm07mp.js → chunk-8ccc9v77.js} +1 -1
  22. package/dist/cli/chunk-8ew9sqwa.js +2 -0
  23. package/dist/cli/{chunk-w003rpr2.js → chunk-8kspedny.js} +1 -1
  24. package/dist/cli/chunk-8ng1756g.js +3 -0
  25. package/dist/cli/{chunk-f7egph8g.js → chunk-92hpkjkk.js} +1 -1
  26. package/dist/cli/{chunk-ebdp0z1d.js → chunk-95kk75wh.js} +1 -1
  27. package/dist/cli/{chunk-np398f2a.js → chunk-97er67dc.js} +1 -1
  28. package/dist/cli/{chunk-qc9sj5wh.js → chunk-9q8wwfs1.js} +1 -1
  29. package/dist/cli/{chunk-zkby63ej.js → chunk-ae8dj2fd.js} +1 -1
  30. package/dist/cli/chunk-b4egks20.js +7 -0
  31. package/dist/cli/{chunk-s1xn6wav.js → chunk-b64w1jh6.js} +1 -1
  32. package/dist/cli/{chunk-x85rcep5.js → chunk-bbq6wc47.js} +1 -1
  33. package/dist/cli/{chunk-w2tcb3rn.js → chunk-h0wp2ghf.js} +1 -1
  34. package/dist/cli/{chunk-fkgemeww.js → chunk-h81h0arp.js} +2 -2
  35. package/dist/cli/{chunk-12wn9jtf.js → chunk-h8v2jzth.js} +1 -1
  36. package/dist/cli/{chunk-02x0ahxk.js → chunk-h9cjzh9j.js} +1 -1
  37. package/dist/cli/{chunk-9f34p80j.js → chunk-jp354m6j.js} +1 -1
  38. package/dist/cli/chunk-jxqhq9va.js +3 -0
  39. package/dist/cli/{chunk-bv050ssb.js → chunk-k3h4cpdy.js} +1 -1
  40. package/dist/cli/{chunk-jk4f0xsh.js → chunk-nb0be8dz.js} +1 -1
  41. package/dist/cli/{chunk-gk8efsx0.js → chunk-nbf3attz.js} +1 -1
  42. package/dist/cli/{chunk-wz307hf6.js → chunk-nh4dd7q4.js} +1 -1
  43. package/dist/cli/chunk-pg48v2kc.js +2 -0
  44. package/dist/cli/chunk-ph9m6y5j.js +8 -0
  45. package/dist/cli/{chunk-zp0q931y.js → chunk-pm6j59da.js} +1 -1
  46. package/dist/cli/{chunk-amq030gb.js → chunk-pv3c4vgt.js} +2 -2
  47. package/dist/cli/{chunk-zv0rrhck.js → chunk-q012n1t1.js} +1 -1
  48. package/dist/cli/{chunk-p11emdb2.js → chunk-qkdc5tc7.js} +1 -1
  49. package/dist/cli/{chunk-am5cpb9n.js → chunk-qx6d4bbb.js} +1 -1
  50. package/dist/cli/{chunk-hmsvt5nj.js → chunk-rqm2kn0c.js} +1 -1
  51. package/dist/cli/{chunk-7nnpp89t.js → chunk-s5eat30g.js} +1 -1
  52. package/dist/cli/{chunk-azt0c2b5.js → chunk-s7tbwn04.js} +1 -1
  53. package/dist/cli/{chunk-txvjg1g9.js → chunk-swcfg2vb.js} +3 -3
  54. package/dist/cli/chunk-swzdd5aj.js +2 -0
  55. package/dist/cli/chunk-t2418v0z.js +3 -0
  56. package/dist/cli/{chunk-5kgq379z.js → chunk-tcdz7sx9.js} +1 -1
  57. package/dist/cli/{chunk-qwwt4ch2.js → chunk-tg4swhr1.js} +1 -1
  58. package/dist/cli/{chunk-qzhfs668.js → chunk-v5bn5fnd.js} +1 -1
  59. package/dist/cli/{chunk-byh32qnx.js → chunk-v8c4n8by.js} +1 -1
  60. package/dist/cli/{chunk-bw2y7670.js → chunk-vcj2x6ne.js} +1 -1
  61. package/dist/cli/{chunk-6mk6ceb6.js → chunk-w3f993d8.js} +2 -2
  62. package/dist/cli/{chunk-nrx8pf9p.js → chunk-x35dfb1x.js} +1 -1
  63. package/dist/cli/{chunk-ggjmcqpt.js → chunk-x94e0rw4.js} +3 -3
  64. package/dist/cli/chunk-xaam0d8r.js +4 -0
  65. package/dist/cli/{chunk-mqvkd2g1.js → chunk-xqvyzv62.js} +1 -1
  66. package/dist/cli/chunk-y8n6trr4.js +2 -0
  67. package/dist/cli/{chunk-4z1a65bz.js → chunk-ydaj339x.js} +1 -1
  68. package/dist/cli/{chunk-k2j0tg1h.js → chunk-yeq2pc68.js} +7 -3
  69. package/dist/cli/{chunk-3a7x7jg7.js → chunk-z204mctb.js} +16 -17
  70. package/dist/cli/{chunk-cakyme4s.js → chunk-z8rvxvx5.js} +1 -1
  71. package/dist/cli/{chunk-3hxm56h6.js → chunk-za8pnw78.js} +1 -1
  72. package/dist/cli/{chunk-t7dyf50m.js → chunk-zd61a32g.js} +1 -1
  73. package/dist/cli/{chunk-8hernv9y.js → chunk-ze4v7eda.js} +1 -1
  74. package/dist/cli/{chunk-q6tsx6zc.js → chunk-zjsfr4y7.js} +1 -1
  75. package/dist/cli/chunk-zr058kbe.js +3 -0
  76. package/dist/cli/{chunk-jkgp816v.js → chunk-zypkjg5t.js} +1 -1
  77. package/dist/cli/index.js +3 -3
  78. package/dist/cli/rove.js +3 -3
  79. package/dist/skills/rove/SKILL.md +4 -2
  80. package/dist/skills/rove/references/api-flags.md +7 -4
  81. package/dist/voice/asr_server.py +318 -0
  82. package/dist/voice/engine.py +174 -0
  83. package/dist/voice/fetch_model.py +30 -0
  84. package/dist/voice/learn.py +184 -0
  85. package/dist/voice/live.py +93 -0
  86. package/dist/voice/phonetic.py +87 -0
  87. package/dist/voice/polish.py +84 -0
  88. package/dist/voice/polish_prompt.md +50 -0
  89. package/dist/voice/pyproject.toml +28 -0
  90. package/dist/voice/uv.lock +815 -0
  91. package/dist/voice/vocab.py +193 -0
  92. package/dist/web/assets/index-Bo92_NP5.css +1 -0
  93. package/dist/web/assets/index-Bpal5sTS.js +154 -0
  94. package/dist/web/index.html +3 -3
  95. package/dist/web/relay/index.html +5 -5
  96. package/package.json +4 -3
  97. package/dist/cli/chunk-0g1h35mr.js +0 -2
  98. package/dist/cli/chunk-1wwqf6kx.js +0 -3
  99. package/dist/cli/chunk-26ydgwym.js +0 -2
  100. package/dist/cli/chunk-2pfh3zm0.js +0 -33
  101. package/dist/cli/chunk-2wrt80ft.js +0 -3
  102. package/dist/cli/chunk-7fya3y0n.js +0 -2
  103. package/dist/cli/chunk-d2wndk4q.js +0 -2
  104. package/dist/cli/chunk-mey3vksj.js +0 -2
  105. package/dist/cli/chunk-nv8eyjep.js +0 -3
  106. package/dist/cli/chunk-ve76p2mx.js +0 -3
  107. package/dist/cli/chunk-wfgrn8p2.js +0 -4
  108. package/dist/cli/chunk-xmjknfy2.js +0 -7
  109. package/dist/web/assets/index-Dc0Khk0q.js +0 -149
  110. package/dist/web/assets/index-T8cCSdnk.css +0 -1
package/dist/cli/rove.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import{join as l}from"node:path";import{createInterface as a}from"node:readline/promises";import{pathToFileURL as ss}from"node:url";import{spawnSync as Q}from"node:child_process";import{constants as e,accessSync as V}from"node:fs";import{homedir as R}from"node:os";import{basename as o,delimiter as I,dirname as U,join as y}from"node:path";import{fileURLToPath as T}from"node:url";var H={$schema:"https://json.schemastore.org/package.json",name:"@sma1lboy/rove",version:"0.9.280",description:"Rove — the agent multiplexer for your terminal. Run coding agents on parallel tasks with isolated worktrees and persistent sessions.",keywords:["terminal","tui","cli","multiplexer","ai-agents","coding-agent","ai-coding-assistant","agentic-ai","parallel-agents","git-worktree","claude-code","codex","llm","developer-tools"],type:"module",packageManager:"bun@1.3.13",bin:{rove:"dist/cli/rove.js"},files:["dist/cli","dist/completions","dist/web","dist/web-ui","dist/skills","dist/*.wav","README.md","LICENSE"],publishConfig:{access:"public"},repository:{type:"git",url:"git+https://github.com/Sma1lboy/rove.git"},homepage:"https://rove.run",bugs:{url:"https://github.com/Sma1lboy/rove/issues"},engines:{bun:">=1.3.11"},scripts:{dev:"ROVE_DEV=1 bun --conditions=browser ./src/cli/rove.ts","dev:mock":"ROVE_DEV=1 bun ./src/tui-react/mock/host.tsx","dev:sandbox":"bun run scripts/dev-sandbox.ts run","dev:sandbox:reset":"bun run scripts/dev-sandbox.ts reset",build:"bun run scripts/build.ts","build:rubric":"bun run scripts/build-tier-rubric.ts",compile:"bun run scripts/compile.ts",typecheck:"tsc --noEmit","check-i18n":"bun run scripts/check-i18n.ts",test:"bun run test:fast && bun run test:socket","perf:golden":"bun scripts/perf-golden.ts","pty:soak":"bun scripts/pty-soak.ts","test:fast":"vitest run --passWithNoTests --minWorkers=1 --maxWorkers=8","test:socket":"ROVE_INCLUDE_SOCKET=1 vitest run test/daemon --pool forks --minWorkers=1 --maxWorkers=4 --passWithNoTests","test:socket:coverage":"ROVE_INCLUDE_SOCKET=1 ROVE_COVERAGE_DAEMON=1 vitest run test/daemon --coverage --pool forks --minWorkers=1 --maxWorkers=4 --passWithNoTests","test:behavior":"ROVE_INCLUDE_BEHAVIOR=1 vitest run test/behavior --pool forks --minWorkers=1 --maxWorkers=1 --retry=2 --passWithNoTests","test:render":"bun scripts/render-track.mjs",coverage:"vitest run --coverage --passWithNoTests",bench:"vitest bench --run",lint:"biome check .",knip:"knip-bun",postinstall:"bun run scripts/check-preview-deps.ts || true",prepublishOnly:"bun run typecheck && bun run build","plugin-sandbox":"bun run scripts/plugin-sandbox.ts"},"//":"biome.json + bunfig.toml live at the monorepo root since they apply repo-wide; bun.lock also lives at root (workspace-shared).",dependencies:{"@ansi-tools/parser":"^1.0.15","@opentui/core":"0.4.3","@opentui/react":"0.4.3","@xterm/addon-serialize":"^0.14.0","@xterm/addon-unicode11":"^0.9.0","@xterm/headless":"^6.0.0",react:"^19.2.8","smol-toml":"^1.7.1",ws:"^8.21.3"},optionalDependencies:{"node-pty":"^1.1.0"},devDependencies:{"@biomejs/biome":"1.9.4","@sma1lboy/rove-daemon":"0.7.23","@tsconfig/bun":"1.0.11","@types/bun":"1.3.14","@types/node":"25.6.2","@types/react":"^19.2.0","@vitest/coverage-v8":"2.1.9",knip:"^6.14.2","react-devtools-core":"^7.0.1","rove-bridge":"0.0.41","rove-weixin":"0.0.41",typescript:"5.8.2",vitest:"2.1.9"},trustedDependencies:[]};var q="rove";var k=q;var z="ROVE_BUN",_="ROVE_NO_BUN_BOOTSTRAP",F="ROVE_SKIP_BUN_CHECK",$=H.engines.bun.match(/\d+\.\d+\.\d+/)?.[0]??"0.0.0",L=(s)=>{try{return V(s,e.X_OK),!0}catch{return!1}},B=(s)=>s==="win32"?"bun.exe":"bun",f=(s)=>{let t=Q(s,["--version"],{encoding:"utf8",timeout:5000});if(t.error||t.status!==0)return null;return t.stdout?.split(`
3
- `)[0]?.trim()??""};function J(s){return s.trim().match(/^v?(\d+\.\d+\.\d+)/)?.[1]??null}function X(s,t=$){let c=J(s),W=J(t);if(!c||!W)return!0;let b=c.split(".").map(Number),g=W.split(".").map(Number);for(let p=0;p<3;p++){let r=b[p]??0,i=g[p]??0;if(r!==i)return r>i}return!0}function Y(s=process.env){return s[F]==="1"}function A(s={}){let t=s.env??process.env,c=s.platform??process.platform,W=s.home??t.HOME??t.USERPROFILE??R(),b=B(c),g=[],p=t[z]?.trim();if(p)g.push(p);for(let G of(t.PATH??t.Path??"").split(I))if(G)g.push(y(G,b));let r=t.BUN_INSTALL?.trim();if(r)g.push(y(r,"bin",b));if(W)g.push(y(W,".bun","bin",b));let i=s.launcherDir;if(i)g.push(y(i,"..","..","node_modules","bun","bin",b)),g.push(y(i,"..","..","..","bun","bin",b));return g}function N(s={}){let t=s.isExecutable??L;return A(s).find((c)=>t(c))??null}function Z(s={}){let t=s.isExecutable??L,c=s.bunVersionOf??f;if(Y(s.env??process.env))return{bun:N(s),stale:null,unusable:null};let W=null,b=null;for(let g of A(s)){if(!t(g))continue;let p=c(g);if(p===null){b??=g;continue}if(X(p))return{bun:g,stale:W,unusable:b};W??={path:g,version:J(p)??p}}return{bun:null,stale:W,unusable:b}}function h(s){return U(T(s))}function C(s){return o(T(s)).replace(/\.[cm]?[jt]s$/,"")}function n(s=process.platform){return s==="win32"?["powershell","-NoProfile","-Command","irm bun.sh/install.ps1 | iex"]:["bash","-c","curl -fsSL https://bun.sh/install | bash"]}function D(s=k,t=process.platform){let c=t==="win32"?'powershell -c "irm bun.sh/install.ps1 | iex"':"curl -fsSL https://bun.sh/install | bash";return[`${s}: Rove runs on the Bun runtime, and no Bun was found on this machine.`,"","Install Bun, then run this command again:",` ${c}`," npm install -g bun # any platform, if you already have npm","","Or install Bun and Rove together in one step:"," curl -fsSL https://rove.run/install.sh | sh","",`Already have Bun somewhere unusual? Point Rove at it: ${z}=/path/to/bun`,""].join(`
2
+ import{join as l}from"node:path";import{createInterface as a}from"node:readline/promises";import{pathToFileURL as ss}from"node:url";import{spawnSync as Q}from"node:child_process";import{constants as P,accessSync as V}from"node:fs";import{homedir as o}from"node:os";import{basename as R,delimiter as I,dirname as U,join as y}from"node:path";import{fileURLToPath as T}from"node:url";var H={$schema:"https://json.schemastore.org/package.json",name:"@sma1lboy/rove",version:"0.9.282",description:"Rove — the agent multiplexer for your terminal. Run coding agents on parallel tasks with isolated worktrees and persistent sessions.",keywords:["terminal","tui","cli","multiplexer","ai-agents","coding-agent","ai-coding-assistant","agentic-ai","parallel-agents","git-worktree","claude-code","codex","llm","developer-tools"],type:"module",packageManager:"bun@1.3.13",bin:{rove:"dist/cli/rove.js"},files:["dist/cli","dist/completions","dist/web","dist/web-ui","dist/skills","dist/voice","dist/*.wav","README.md","LICENSE"],publishConfig:{access:"public"},repository:{type:"git",url:"git+https://github.com/Sma1lboy/rove.git"},homepage:"https://rove.run",bugs:{url:"https://github.com/Sma1lboy/rove/issues"},engines:{bun:">=1.3.11"},scripts:{dev:"ROVE_DEV=1 bun --conditions=browser ./src/cli/rove.ts","dev:mock":"ROVE_DEV=1 bun ./src/tui-react/mock/host.tsx","dev:sandbox":"bun run scripts/dev-sandbox.ts run","dev:sandbox:reset":"bun run scripts/dev-sandbox.ts reset",build:"bun run scripts/build.ts","build:rubric":"bun run scripts/build-tier-rubric.ts",compile:"bun run scripts/compile.ts",typecheck:"tsc --noEmit","check-i18n":"bun run scripts/check-i18n.ts",test:"bun run test:fast && bun run test:socket","perf:golden":"bun scripts/perf-golden.ts","pty:soak":"bun scripts/pty-soak.ts","test:fast":"vitest run --passWithNoTests --minWorkers=1 --maxWorkers=8","test:socket":"ROVE_INCLUDE_SOCKET=1 vitest run test/daemon --pool forks --minWorkers=1 --maxWorkers=4 --passWithNoTests","test:socket:coverage":"ROVE_INCLUDE_SOCKET=1 ROVE_COVERAGE_DAEMON=1 vitest run test/daemon --coverage --pool forks --minWorkers=1 --maxWorkers=4 --passWithNoTests","test:behavior":"ROVE_INCLUDE_BEHAVIOR=1 vitest run test/behavior --pool forks --minWorkers=1 --maxWorkers=1 --retry=2 --passWithNoTests","test:render":"bun scripts/render-track.mjs",coverage:"vitest run --coverage --passWithNoTests",bench:"vitest bench --run",lint:"biome check .",knip:"knip-bun",postinstall:"bun run scripts/check-preview-deps.ts || true",prepublishOnly:"bun run typecheck && bun run build","plugin-sandbox":"bun run scripts/plugin-sandbox.ts"},"//":"biome.json + bunfig.toml live at the monorepo root since they apply repo-wide; bun.lock also lives at root (workspace-shared).",dependencies:{"@ansi-tools/parser":"^1.0.15","@opentui/core":"0.4.3","@opentui/react":"0.4.3","@xterm/addon-serialize":"^0.14.0","@xterm/addon-unicode11":"^0.9.0","@xterm/headless":"^6.0.0",react:"^19.2.8","smol-toml":"^1.7.1",ws:"^8.21.3"},optionalDependencies:{"node-pty":"^1.1.0"},devDependencies:{"@biomejs/biome":"1.9.4","@sma1lboy/rove-daemon":"0.7.23","@tsconfig/bun":"1.0.11","@types/bun":"1.3.14","@types/node":"25.6.2","@types/react":"^19.2.0","@vitest/coverage-v8":"2.1.9",knip:"^6.14.2","react-devtools-core":"^7.0.1","rove-bridge":"0.0.43","rove-weixin":"0.0.43",typescript:"5.8.2",vitest:"2.1.9"},trustedDependencies:[]};var q="rove";var k=q;var z="ROVE_BUN",_="ROVE_NO_BUN_BOOTSTRAP",F="ROVE_SKIP_BUN_CHECK",$=H.engines.bun.match(/\d+\.\d+\.\d+/)?.[0]??"0.0.0",L=(s)=>{try{return V(s,P.X_OK),!0}catch{return!1}},B=(s)=>s==="win32"?"bun.exe":"bun",f=(s)=>{let t=Q(s,["--version"],{encoding:"utf8",timeout:5000});if(t.error||t.status!==0)return null;return t.stdout?.split(`
3
+ `)[0]?.trim()??""};function J(s){return s.trim().match(/^v?(\d+\.\d+\.\d+)/)?.[1]??null}function X(s,t=$){let c=J(s),W=J(t);if(!c||!W)return!0;let b=c.split(".").map(Number),g=W.split(".").map(Number);for(let i=0;i<3;i++){let r=b[i]??0,x=g[i]??0;if(r!==x)return r>x}return!0}function Y(s=process.env){return s[F]==="1"}function A(s={}){let t=s.env??process.env,c=s.platform??process.platform,W=s.home??t.HOME??t.USERPROFILE??o(),b=B(c),g=[],i=t[z]?.trim();if(i)g.push(i);for(let G of(t.PATH??t.Path??"").split(I))if(G)g.push(y(G,b));let r=t.BUN_INSTALL?.trim();if(r)g.push(y(r,"bin",b));if(W)g.push(y(W,".bun","bin",b));let x=s.launcherDir;if(x)g.push(y(x,"..","..","node_modules","bun","bin",b)),g.push(y(x,"..","..","..","bun","bin",b));return g}function N(s={}){let t=s.isExecutable??L;return A(s).find((c)=>t(c))??null}function Z(s={}){let t=s.isExecutable??L,c=s.bunVersionOf??f;if(Y(s.env??process.env))return{bun:N(s),stale:null,unusable:null};let W=null,b=null;for(let g of A(s)){if(!t(g))continue;let i=c(g);if(i===null){b??=g;continue}if(X(i))return{bun:g,stale:W,unusable:b};W??={path:g,version:J(i)??i}}return{bun:null,stale:W,unusable:b}}function h(s){return U(T(s))}function C(s){return R(T(s)).replace(/\.[cm]?[jt]s$/,"")}function n(s=process.platform){return s==="win32"?["powershell","-NoProfile","-Command","irm bun.sh/install.ps1 | iex"]:["bash","-c","curl -fsSL https://bun.sh/install | bash"]}function D(s=k,t=process.platform){let c=t==="win32"?'powershell -c "irm bun.sh/install.ps1 | iex"':"curl -fsSL https://bun.sh/install | bash";return[`${s}: Rove runs on the Bun runtime, and no Bun was found on this machine.`,"","Install Bun, then run this command again:",` ${c}`," npm install -g bun # any platform, if you already have npm","","Or install Bun and Rove together in one step:"," curl -fsSL https://rove.run/install.sh | sh","",`Already have Bun somewhere unusual? Point Rove at it: ${z}=/path/to/bun`,""].join(`
4
4
  `)}function j(s,t,c=k){return[`${c}: this machine's Bun is too old for Rove — found ${t} at ${s}, need ${$} or newer.`,"","Rove's terminals are built on Bun's PTY API. An older Bun ignores it silently,","so every terminal and engine tab would open empty and stay empty.","","Upgrade Bun, then run this command again:"," bun upgrade # Bun installed itself (~/.bun)"," brew upgrade bun # Homebrew"," npm install -g bun@latest # npm-managed Bun","",`Have a newer Bun elsewhere? Point Rove at it: ${z}=/path/to/bun`,`Run on this one anyway (unsupported, terminals will be blank): ${F}=1`,""].join(`
5
5
  `)}function v(s,t=k){return[`${t}: the Bun at ${s} could not be run — \`${s} --version\` failed or never returned.`,"","Rove skipped it rather than starting through it, which would hang or fail","with a message from that binary instead of this one.","","Reinstall Bun, then run this command again:"," curl -fsSL https://bun.sh/install | bash","",`Have a working Bun elsewhere? Point Rove at it: ${z}=/path/to/bun`,""].join(`
6
6
  `)}function w(s=process.env,t=process.stdin,c=process.stdout){if(s[_]==="1")return!1;if(s.CI==="true"||s.CI==="1")return!1;return Boolean(t.isTTY&&c.isTTY)}function M(s={},t=Q){let c=s.platform??process.platform,[W,...b]=n(c);if(!W)return null;let g=t(W,b,{stdio:"inherit"});if(g.error||g.status!==0)return null;return Z(s).bun}function u(s){if(typeof s.status==="number")return s.status;if(s.signal)return 128+(Object.hasOwn(K,s.signal)?K[s.signal]:0);return 1}var K={SIGINT:2,SIGQUIT:3,SIGKILL:9,SIGTERM:15};function S(s,t,c,W=Q){let b=W(s,[t,...c],{stdio:"inherit"});if(b.error)return process.stderr.write(`rove: could not start Bun at ${s}: ${b.error.message}. Check that this path is executable, or reinstall Bun from https://bun.sh.
7
- `),1;return u(b)}var d=h(import.meta.url),x=C(import.meta.url),m=l(d,`${x}-run.js`);async function ts(s){let t=a({input:process.stdin,output:process.stdout});try{let c=(await t.question(s)).trim().toLowerCase();return c===""||c==="y"||c==="yes"}finally{t.close()}}async function cs(){let s={launcherDir:d},t=Z(s);if(t.bun||!w())return t;let c=t.stale?`${x}: Bun ${t.stale.version} is too old for Rove (needs ${$}+). Install the latest Bun now? [Y/n] `:`${x}: Rove runs on the Bun runtime, and none is installed. Install Bun now? [Y/n] `;if(!await ts(c))return t;return{bun:M(s),stale:t.stale,unusable:t.unusable}}var O=globalThis.Bun;if(O){let s=O.version;if(s&&!Y()&&!X(s))process.stderr.write(j(process.execPath,s,x)),process.exit(1);await import(ss(m).href)}else{let{bun:s,stale:t,unusable:c}=await cs();if(!s){let W=t?j(t.path,t.version,x):c?v(c,x):D(x);process.stderr.write(W),process.exit(1)}process.exit(S(s,m,process.argv.slice(2)))}
7
+ `),1;return u(b)}var O=h(import.meta.url),p=C(import.meta.url),d=l(O,`${p}-run.js`);async function ts(s){let t=a({input:process.stdin,output:process.stdout});try{let c=(await t.question(s)).trim().toLowerCase();return c===""||c==="y"||c==="yes"}finally{t.close()}}async function cs(){let s={launcherDir:O},t=Z(s);if(t.bun||!w())return t;let c=t.stale?`${p}: Bun ${t.stale.version} is too old for Rove (needs ${$}+). Install the latest Bun now? [Y/n] `:`${p}: Rove runs on the Bun runtime, and none is installed. Install Bun now? [Y/n] `;if(!await ts(c))return t;return{bun:M(s),stale:t.stale,unusable:t.unusable}}var m=globalThis.Bun;if(m){let s=m.version;if(s&&!Y()&&!X(s))process.stderr.write(j(process.execPath,s,p)),process.exit(1);await import(ss(d).href)}else{let{bun:s,stale:t,unusable:c}=await cs();if(!s){let W=t?j(t.path,t.version,p):c?v(c,p):D(p);process.stderr.write(W),process.exit(1)}process.exit(S(s,d,process.argv.slice(2)))}
@@ -3,7 +3,7 @@ name: rove
3
3
  description: Use when controlling Rove tasks, parallel coding attempts, hosted agent sessions, task lifecycle, or the daemon-owned issue tracker from a shell. Also the ONLY channel for messaging another agent session on this machine — `rove api send`, never a peer/MCP side channel.
4
4
  ---
5
5
 
6
- <!-- rove-skill-version: 108 — bump in lockstep with ROVE_SKILL_VERSION (src/lib/skill-install.ts). -->
6
+ <!-- rove-skill-version: 109 — bump in lockstep with ROVE_SKILL_VERSION (src/lib/skill-install.ts). -->
7
7
 
8
8
  # Rove shell control
9
9
 
@@ -309,9 +309,11 @@ Find your section by what you were ASKED, not by Rove's noun for it:
309
309
  | a prompt ends `[ROVE] … report with: rove api report`; tick a task unattended and want proof it reported, or skip a tick the task can't use | drive (`report`, `send --expect-report`, `send --if-runnable`) / read (`deliveries`, `should-run`) |
310
310
  | make / rename / retarget / land / close a task | create / edit / lifecycle |
311
311
  | materialize or adopt a checkout | worktree |
312
- | which engines exist, send Rove product feedback | discover / feedback |
312
+ | which engines exist, report a Rove problem or idea | discover / feedback |
313
313
  | an error code came back, `nextCommandArgs` | Error codes |
314
314
 
315
+ When the user says anything in Rove itself is awkward, broken or missing, offer to file it right away: draft a short title and body, show it, and on their OK run `rove feedback --kind bug|idea|question --title … --body-file -` (body on stdin), then share the URL. If it prints a prefilled link instead, hand that over and say to run `gh auth login`. Only for Rove; another project's bug is a `send` to its main task.
316
+
315
317
  Reach for `schema` when the binary and that file disagree.
316
318
 
317
319
  ```bash
@@ -433,7 +433,7 @@ schema --verb --group --all
433
433
  engine-list (none)
434
434
  repo-match --origin --root --allow-fork
435
435
  repo-list (none)
436
- feedback --title(REQ) --body(REQ) --category(feedback)
436
+ feedback --title(REQ) --body(REQ) --kind(bug)
437
437
  ```
438
438
  <!-- generated:end -->
439
439
 
@@ -452,9 +452,12 @@ resolves to (`{ repo, fork?, checkouts, searched }`); `repo-list` lists a
452
452
  machine's repos by id (`{ repos: [{ id, primary, checkouts }] }`) — pass
453
453
  `--machine <alias>` to ask another machine.
454
454
 
455
- `feedback` opens a GitHub Discussion in the Rove repo through `gh` — Rove's own
456
- product feedback channel, not a way to file work for another project (that is
457
- a `send` to their main task; see SKILL.md).
455
+ `feedback` files a GitHub issue on the Rove repo through `gh` (`--kind` picks the
456
+ label: bug, idea → enhancement, question) and appends an environment block.
457
+ It returns `{ filed: true, url, number }`; with no usable `gh` it returns
458
+ `{ filed: false, url, hint }` where `url` is a prefilled new-issue link — hand
459
+ it to the user. Rove's own product feedback channel, not a way to file work
460
+ for another project (that is a `send` to their main task; see SKILL.md).
458
461
 
459
462
  ## Error codes
460
463
 
@@ -0,0 +1,318 @@
1
+ """Dictation endpoint for rove's web client.
2
+
3
+ The phone records 16 kHz mono PCM, rove-bridge POSTs the WAV here with the text of the
4
+ conversation being dictated into, and this process returns the transcript. The recognizer is
5
+ local Whisper unless `~/.rove/voice/config.json` names a cloud endpoint (`engine.py`); the
6
+ vocabulary is `vocab.py`, sound-alike fixes `phonetic.py`. Everything this process keeps lives
7
+ under `~/.rove/voice`.
8
+
9
+ Run: `uv run --project scripts/voice --extra local scripts/voice/asr_server.py` (or Settings → Voice).
10
+ Binds 127.0.0.1 only; the bridge is the one public surface.
11
+
12
+ POST /transcribe {wav: hex, conversation?, context?, language?} -> {text, raw, language, ...}
13
+ POST /stream {session, seq, pcm: hex int16, final?, conversation?, context?, language?}
14
+ -> {partial} per chunk, the /transcribe reply at final (live.py)
15
+ GET /health
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import io
21
+ import json
22
+ import os
23
+ import secrets
24
+ import sys
25
+ import threading
26
+ import time
27
+ import wave
28
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ import numpy as np
33
+
34
+ import engine
35
+ import learn
36
+ import live
37
+ import phonetic
38
+ import polish
39
+ import vocab
40
+
41
+ PORT = int(os.environ.get("ROVE_VOICE_PORT", "8791"))
42
+ MIN_AUDIO_SEC = 0.25
43
+ SILENCE_RMS = 0.004 # 16-bit speech sits far above this; fan noise and dead mics do not.
44
+ MAX_AUDIO_SEC = 240
45
+
46
+ _ready = threading.Event()
47
+ _warm_ms: float | None = None
48
+ _error: str | None = None
49
+ _backend: engine.Backend | None = None
50
+ _config: dict[str, Any] = {}
51
+
52
+
53
+ def read_token() -> str:
54
+ """One shared secret so a stray local process cannot drive the microphone path."""
55
+ path = engine.HOME / "token"
56
+ engine.HOME.mkdir(parents=True, exist_ok=True)
57
+ if not path.exists():
58
+ path.write_text(secrets.token_hex(24))
59
+ path.chmod(0o600)
60
+ return path.read_text().strip()
61
+
62
+
63
+ def decode_wav(data: bytes) -> np.ndarray:
64
+ with wave.open(io.BytesIO(data)) as wav:
65
+ if wav.getnchannels() != 1:
66
+ raise ValueError("expected mono audio")
67
+ width = wav.getsampwidth()
68
+ rate = wav.getframerate()
69
+ frames = wav.readframes(wav.getnframes())
70
+ if width != 2:
71
+ raise ValueError(f"expected 16-bit samples, got {width * 8}-bit")
72
+ if rate != engine.SAMPLE_RATE:
73
+ raise ValueError(f"expected {engine.SAMPLE_RATE} Hz, got {rate}")
74
+ return np.frombuffer(frames, dtype="<i2").astype(np.float32) / 32768.0
75
+
76
+
77
+ def silent(pcm: np.ndarray) -> bool:
78
+ return not len(pcm) or float(np.sqrt(np.mean(np.square(pcm)))) < SILENCE_RMS
79
+
80
+
81
+ def dictate(
82
+ backend: engine.Backend,
83
+ pcm: np.ndarray,
84
+ *,
85
+ conversation: str | None,
86
+ draft: str | None,
87
+ language: str | None,
88
+ config: dict[str, Any],
89
+ final: bool = True,
90
+ ) -> dict[str, Any]:
91
+ """One dictation: rank the vocabulary, recognize, normalize the script, then fix the English:
92
+ stored mishearings first, then whatever lands close to a term the recognizer was given. A final
93
+ take is then cleaned up by a chat model when config.json asks for it (polish.py); a live take's
94
+ partials never are."""
95
+ stored = vocab.load()["terms"]
96
+ terms = vocab.rank(conversation, draft, stored, backend.max_terms)
97
+ # Measured on large-v3: terms in the prompt cost a term when they were not said (68 -> 67 with the
98
+ # conversation's, 68 -> 64 with 15 learned ones) and gain less (70) than using them only to correct
99
+ # (72, and 68 when not said). Cloud recognizers take the prompt without that cost, so they get it by default.
100
+ in_prompt = config.get("promptContext", not isinstance(backend, engine.LocalWhisper))
101
+ hinted = terms if in_prompt else vocab.rank(None, draft, stored, backend.max_terms, rest=False)
102
+ raw, detected = backend.transcribe(pcm, hinted, language)
103
+ text = engine.simplified(raw) if config.get("simplified", True) else raw
104
+ text = vocab.correct(vocab.apply_spoken(text, stored), terms, fuzzy=config.get("fuzzy", True))
105
+ # Bench: +1 to +3 terms when the conversation holds them, no change otherwise (phonetic.py).
106
+ if config.get("phonetic", True):
107
+ text = phonetic.correct(text, terms)
108
+ out = {"text": text, "raw": raw, "language": detected, "terms": len(terms)}
109
+ if final and polish.enabled(config):
110
+ started = time.monotonic()
111
+ polished, error = polish.polish(text, terms, config)
112
+ out |= {"text": polished, "unpolished": text, "polishMs": round((time.monotonic() - started) * 1000)}
113
+ if error:
114
+ out["polishError"] = error
115
+ sys.stderr.write(f"voice: polish failed, kept the recognizer's text: {error}\n")
116
+ return out
117
+
118
+
119
+ def warm() -> None:
120
+ """A cold graph costs seconds; pay it at boot, not on the first press."""
121
+ global _warm_ms, _error, _backend, _config
122
+ started = time.monotonic()
123
+ try:
124
+ _config = engine.load_config()
125
+ _backend = engine.backend(_config)
126
+ if isinstance(_backend, engine.LocalWhisper):
127
+ _backend.transcribe(np.zeros(engine.SAMPLE_RATE, dtype=np.float32), [], "en")
128
+ engine.simplified("說")
129
+ except Exception as err: # noqa: BLE001 - surfaced on /health, the server stays up
130
+ _error = str(err)
131
+ sys.stderr.write(f"voice: backend failed: {err}\n")
132
+ return
133
+ _warm_ms = round((time.monotonic() - started) * 1000)
134
+ _ready.set()
135
+
136
+
137
+ class Handler(BaseHTTPRequestHandler):
138
+ token = ""
139
+
140
+ def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003 - stdlib signature
141
+ sys.stderr.write("voice %s\n" % (fmt % args))
142
+
143
+ def reply(self, code: int, body: dict[str, Any]) -> None:
144
+ data = json.dumps(body, ensure_ascii=False).encode()
145
+ self.send_response(code)
146
+ self.send_header("content-type", "application/json")
147
+ self.send_header("content-length", str(len(data)))
148
+ self.end_headers()
149
+ self.wfile.write(data)
150
+
151
+ def do_GET(self) -> None: # noqa: N802 - stdlib signature
152
+ # /health stays open: the bridge probes it before the user says anything.
153
+ if self.path != "/health":
154
+ self.reply(404, {"error": "not_found"})
155
+ return
156
+ name = _backend.name if _backend else None
157
+ body = {"ready": _ready.is_set(), "model": name, "warm_ms": _warm_ms, "error": _error}
158
+ # Which copy answers: two bridges (release and a checkout) may each have started one.
159
+ self.reply(200, {**body, "pid": os.getpid(), "code": str(Path(__file__).resolve().parent)})
160
+
161
+ def body(self) -> dict[str, Any] | None:
162
+ if self.headers.get("x-voice-token", "") != self.token:
163
+ self.reply(401, {"error": "bad_token"})
164
+ return None
165
+ length = int(self.headers.get("content-length") or 0)
166
+ if length <= 0 or length > 32 * 1024 * 1024:
167
+ self.reply(400, {"error": "bad_size"})
168
+ return None
169
+ try:
170
+ return json.loads(self.rfile.read(length))
171
+ except ValueError:
172
+ self.reply(400, {"error": "bad_json"})
173
+ return None
174
+
175
+ def do_POST(self) -> None: # noqa: N802 - stdlib signature
176
+ routes = {
177
+ "/transcribe": self.transcribe,
178
+ "/stream": self.stream,
179
+ "/learn": self.learn_edit,
180
+ "/unlearn": self.unlearn_edit,
181
+ }
182
+ route = routes.get(self.path)
183
+ if route is None:
184
+ self.reply(404, {"error": "not_found"})
185
+ return
186
+ request = self.body()
187
+ if request is not None:
188
+ route(request)
189
+
190
+ def transcribe(self, request: dict[str, Any]) -> None:
191
+ try:
192
+ pcm = decode_wav(bytes.fromhex(request["wav"]))
193
+ except Exception as err: # noqa: BLE001 - every failure is the caller's audio
194
+ self.reply(400, {"error": f"bad_audio: {err}"})
195
+ return
196
+ self.finish_take(pcm, request.get("conversation"), request.get("context"), request.get("language"))
197
+
198
+ def finish_take(self, pcm: np.ndarray, conversation: str | None, draft: str | None, language: str | None) -> None:
199
+ seconds = round(len(pcm) / engine.SAMPLE_RATE, 2)
200
+ if seconds < MIN_AUDIO_SEC:
201
+ self.reply(200, {"text": "", "reason": "too_short", "seconds": seconds})
202
+ return
203
+ if seconds > MAX_AUDIO_SEC:
204
+ self.reply(200, {"text": "", "reason": "too_long", "seconds": seconds})
205
+ return
206
+ if silent(pcm):
207
+ self.reply(200, {"text": "", "reason": "silent", "seconds": seconds})
208
+ return
209
+ if _backend is None or not _ready.is_set():
210
+ self.reply(503, {"error": _error or "warming up"})
211
+ return
212
+ started = time.monotonic()
213
+ try:
214
+ out = dictate(_backend, pcm, conversation=conversation, draft=draft, language=language, config=_config)
215
+ except Exception as err: # noqa: BLE001 - report, never take the server down
216
+ self.reply(500, {"error": f"transcribe: {err}"})
217
+ return
218
+ ms = round((time.monotonic() - started) * 1000)
219
+ # Counts only, never the words: enough to see the conversation arrived and what a take cost.
220
+ heard = len(conversation or "")
221
+ terms = out["terms"]
222
+ sys.stderr.write(f"voice: {seconds}s audio, {heard} chars of conversation, {terms} terms, {ms} ms\n")
223
+ self.reply(200, {**out, "seconds": seconds, "ms": ms})
224
+
225
+ def stream(self, request: dict[str, Any]) -> None:
226
+ """One chunk of a live take (live.py): the text so far while it grows, the transcript at `final`."""
227
+ session, seq = request.get("session"), request.get("seq")
228
+ if not isinstance(session, str) or not isinstance(seq, int):
229
+ self.reply(400, {"error": "bad_args"})
230
+ return
231
+ try:
232
+ raw = bytes.fromhex(request.get("pcm") or "")
233
+ pcm = np.frombuffer(raw[: len(raw) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
234
+ take = live.append(session, seq, pcm, request)
235
+ except live.Gap as err:
236
+ self.reply(409, {"error": f"gap: {err}"})
237
+ return
238
+ except ValueError as err:
239
+ self.reply(400, {"error": f"bad_audio: {err}"})
240
+ return
241
+ if request.get("final"):
242
+ live.finish(session)
243
+ self.finish_take(take.pcm(), take.conversation, take.draft, take.language)
244
+ return
245
+ backend = _backend
246
+ if backend is None or not _ready.is_set() or not live.due(take, backend):
247
+ self.reply(200, {"partial": None})
248
+ return
249
+ # Never queue behind another decode: the next chunk asks again.
250
+ if not engine.GPU.acquire(blocking=False):
251
+ self.reply(200, {"partial": None})
252
+ return
253
+ try:
254
+ take.decoded = take.samples
255
+ pcm = take.pcm()
256
+ text = (
257
+ ""
258
+ if silent(pcm)
259
+ else dictate(
260
+ backend,
261
+ pcm,
262
+ conversation=take.conversation,
263
+ draft=take.draft,
264
+ language=take.language,
265
+ config=_config,
266
+ final=False,
267
+ )["text"]
268
+ )
269
+ except Exception as err: # noqa: BLE001 - a failed partial only means no partial
270
+ sys.stderr.write(f"voice: partial failed: {err}\n")
271
+ text = None
272
+ finally:
273
+ engine.GPU.release()
274
+ self.reply(200, {"partial": text})
275
+
276
+ def learn_edit(self, request: dict[str, Any]) -> None:
277
+ """A sent message against the draft its takes left: the fixes inside them join the vocabulary."""
278
+ base, sent, spans = request.get("base"), request.get("sent"), request.get("spans")
279
+ if not isinstance(base, str) or not isinstance(sent, str) or not isinstance(spans, list):
280
+ self.reply(400, {"error": "bad_args"})
281
+ return
282
+ if not _config.get("learn", True):
283
+ self.reply(200, {"learned": []})
284
+ return
285
+ pairs = [(int(s[0]), int(s[1])) for s in spans if isinstance(s, list) and len(s) == 2]
286
+ added = learn.remember(learn.fixes(base, pairs, sent))
287
+ sys.stderr.write(f"voice: learned {len(added)} from an edit\n")
288
+ self.reply(200, {"learned": added})
289
+
290
+ def unlearn_edit(self, request: dict[str, Any]) -> None:
291
+ learned = request.get("learned")
292
+ if not isinstance(learned, list):
293
+ self.reply(400, {"error": "bad_args"})
294
+ return
295
+ self.reply(200, {"undone": learn.forget([x for x in learned if isinstance(x, dict)])})
296
+
297
+
298
+ def main() -> None:
299
+ Handler.token = read_token()
300
+ try:
301
+ server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
302
+ except OSError as err:
303
+ # Another sidecar already answers here; it serves every bridge on this machine.
304
+ sys.stderr.write(f"voice: port {PORT} is taken ({err}); leaving it to that process\n")
305
+ raise SystemExit(0) from err
306
+ pidfile = engine.HOME / "sidecar.pid"
307
+ pidfile.write_text(str(os.getpid()))
308
+ threading.Thread(target=warm, daemon=True).start()
309
+ sys.stderr.write(f"voice listening on http://127.0.0.1:{PORT}\n")
310
+ try:
311
+ server.serve_forever()
312
+ finally:
313
+ if pidfile.exists() and pidfile.read_text().strip() == str(os.getpid()):
314
+ pidfile.unlink()
315
+
316
+
317
+ if __name__ == "__main__":
318
+ main()
@@ -0,0 +1,174 @@
1
+ """The recognizer behind the sidecar: local Whisper by default, or any OpenAI-compatible
2
+ `/audio/transcriptions` endpoint when `~/.rove/voice/config.json` names one.
3
+
4
+ config.json, all keys optional:
5
+ {"backend": "local", "model": "mlx-community/whisper-large-v3-turbo"} # faster, see LOCAL_MODEL
6
+ {"backend": "cloud", "baseURL": "https://openrouter.ai/api/v1", "model": "microsoft/mai-transcribe-2",
7
+ "apiKeyEnv": "OPENROUTER_API_KEY"} # or "apiKey": "..."; the key never leaves this Mac
8
+ {"simplified": false} # keep Traditional characters as recognized
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import io
15
+ import json
16
+ import os
17
+ import threading
18
+ import urllib.error
19
+ import urllib.request
20
+ import uuid
21
+ import wave
22
+ from functools import cache
23
+ from pathlib import Path
24
+ from typing import Any, Protocol
25
+
26
+ import numpy as np
27
+
28
+ HOME = Path(os.environ.get("ROVE_VOICE_HOME") or Path.home() / ".rove" / "voice")
29
+ SAMPLE_RATE = 16_000
30
+ # Code-switch bench (bench.py, 82 terms): large-v3 68, 74 with the conversation's terms, p50 1.8 s;
31
+ # turbo 63, 69 at best, p50 1.0 s. Turbo is the faster pick in config.json; it loses most when context is thin.
32
+ LOCAL_MODEL = "mlx-community/whisper-large-v3-mlx"
33
+ # Without this hint large-v3 often answers Mandarin in Traditional script; English is unaffected.
34
+ ZH_GUARD = "以下是普通话的句子。"
35
+ CLOUD_TIMEOUT_S = 30
36
+ # One decode at a time on the GPU: the server answers on threads, and a live take's partials run
37
+ # beside other requests. Reentrant so a caller can hold it across a whole dictation (live.py).
38
+ GPU = threading.RLock()
39
+
40
+
41
+ class Backend(Protocol):
42
+ name: str
43
+ max_terms: int
44
+
45
+ def transcribe(self, pcm: np.ndarray, terms: list[str], language: str | None) -> tuple[str, str | None]: ...
46
+
47
+
48
+ def load_config() -> dict[str, Any]:
49
+ path = HOME / "config.json"
50
+ if not path.exists():
51
+ return {}
52
+ return json.loads(path.read_text())
53
+
54
+
55
+ class LocalWhisper:
56
+ """mlx-whisper on the Mac's GPU. The terms ride in the prompt, which Whisper reads as preceding
57
+ text: spelled-right terms there are the cheapest bias a decoder without a boost list accepts."""
58
+
59
+ max_terms = 60
60
+
61
+ def __init__(self, model: str) -> None:
62
+ self.model = model
63
+ self.name = f"local:{model}"
64
+
65
+ def transcribe(self, pcm: np.ndarray, terms: list[str], language: str | None) -> tuple[str, str | None]:
66
+ import mlx_whisper
67
+
68
+ prompt = "\n".join([ZH_GUARD, ", ".join(terms)]) if terms else ZH_GUARD
69
+ with GPU:
70
+ result = mlx_whisper.transcribe(
71
+ pcm,
72
+ path_or_hf_repo=self.model,
73
+ initial_prompt=prompt,
74
+ language=language or None,
75
+ word_timestamps=False,
76
+ condition_on_previous_text=False,
77
+ verbose=None,
78
+ )
79
+ return str(result.get("text") or "").strip(), result.get("language")
80
+
81
+
82
+ def provider_options(model: str, terms: list[str]) -> dict[str, Any] | None:
83
+ """OpenRouter routes one body to many providers, and each reads the vocabulary from its own field:
84
+ mai-transcribe-2 ignores `prompt` and goes 59 → 68 of 82 terms with `azure.phraseList`."""
85
+ if model.startswith("microsoft/mai-transcribe"):
86
+ return {"azure": {"phraseList": {"phrases": terms}}}
87
+ if model.startswith("openai/gpt-4o"):
88
+ return {"openai": {"prompt": ", ".join(terms)}}
89
+ if model == "openai/whisper-large-v3":
90
+ return {slug: {"prompt": ", ".join(terms)} for slug in ("groq", "deepinfra/us", "together")}
91
+ return None # gemini rejects a prompt outright
92
+
93
+
94
+ def wav_bytes(pcm: np.ndarray) -> bytes:
95
+ out = io.BytesIO()
96
+ with wave.open(out, "wb") as wav:
97
+ wav.setnchannels(1)
98
+ wav.setsampwidth(2)
99
+ wav.setframerate(SAMPLE_RATE)
100
+ wav.writeframes((np.clip(pcm, -1, 1) * 32767).astype("<i2").tobytes())
101
+ return out.getvalue()
102
+
103
+
104
+ class CloudASR:
105
+ """One OpenAI-compatible transcription endpoint. OpenRouter gets its JSON body so the
106
+ vocabulary reaches the provider's own field; everything else gets the standard multipart
107
+ form with the vocabulary as `prompt`."""
108
+
109
+ max_terms = 100
110
+
111
+ def __init__(self, config: dict[str, Any]) -> None:
112
+ self.base = str(config.get("baseURL") or "https://openrouter.ai/api/v1").rstrip("/")
113
+ self.model = str(config.get("model") or "microsoft/mai-transcribe-2")
114
+ key = config.get("apiKey") or os.environ.get(str(config.get("apiKeyEnv") or "OPENROUTER_API_KEY"))
115
+ if not key:
116
+ raise RuntimeError("cloud backend has no API key: set apiKey or apiKeyEnv in config.json")
117
+ self.key = str(key)
118
+ self.name = f"cloud:{self.model}"
119
+
120
+ def request(self, audio: bytes, terms: list[str], language: str | None) -> urllib.request.Request:
121
+ url = f"{self.base}/audio/transcriptions"
122
+ auth = {"Authorization": f"Bearer {self.key}"}
123
+ if "openrouter.ai" in self.base:
124
+ body: dict[str, Any] = {
125
+ "model": self.model,
126
+ "input_audio": {"data": base64.b64encode(audio).decode(), "format": "wav"},
127
+ }
128
+ if language:
129
+ body["language"] = language
130
+ options = provider_options(self.model, terms) if terms else None
131
+ if options:
132
+ body["provider"] = {"options": options}
133
+ return urllib.request.Request(url, json.dumps(body).encode(), {**auth, "Content-Type": "application/json"})
134
+ fields = {"model": self.model, **({"language": language} if language else {})}
135
+ if terms:
136
+ fields["prompt"] = ", ".join(terms)
137
+ boundary = uuid.uuid4().hex
138
+ parts = [
139
+ f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.wav"\r\n'
140
+ "Content-Type: audio/wav\r\n\r\n".encode()
141
+ + audio
142
+ + b"\r\n"
143
+ ]
144
+ for name, value in fields.items():
145
+ parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode())
146
+ parts.append(f"--{boundary}--\r\n".encode())
147
+ headers = {**auth, "Content-Type": f"multipart/form-data; boundary={boundary}"}
148
+ return urllib.request.Request(url, b"".join(parts), headers)
149
+
150
+ def transcribe(self, pcm: np.ndarray, terms: list[str], language: str | None) -> tuple[str, str | None]:
151
+ try:
152
+ with urllib.request.urlopen(self.request(wav_bytes(pcm), terms, language), timeout=CLOUD_TIMEOUT_S) as res:
153
+ body = json.loads(res.read())
154
+ except urllib.error.HTTPError as err:
155
+ raise RuntimeError(f"{self.name} answered {err.code}: {err.read()[:200]!r}") from err
156
+ return str(body.get("text") or "").strip(), body.get("language")
157
+
158
+
159
+ def backend(config: dict[str, Any]) -> Backend:
160
+ if config.get("backend") == "cloud":
161
+ return CloudASR(config)
162
+ return LocalWhisper(os.environ.get("ROVE_VOICE_MODEL") or str(config.get("model") or LOCAL_MODEL))
163
+
164
+
165
+ @cache
166
+ def _t2s() -> Any:
167
+ from opencc import OpenCC
168
+
169
+ return OpenCC("t2s")
170
+
171
+
172
+ def simplified(text: str) -> str:
173
+ """Whisper drifts into Traditional characters on some Mandarin clips; English passes through."""
174
+ return _t2s().convert(text) if any("一" <= c <= "鿿" for c in text) else text
@@ -0,0 +1,30 @@
1
+ """Download the local recognizer once, so the sidecar's first start does not wait on the network.
2
+
3
+ Run by the bridge's install job (`rove-bridge/src/ops/voice-setup.ts`) after `uv sync --extra local`.
4
+ Prints one progress line per file; writes `~/.rove/voice/installed.json` when the model is complete.
5
+ A rerun resumes: the Hugging Face cache keeps what already arrived.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ import time
13
+
14
+ import engine
15
+
16
+
17
+ def main() -> None:
18
+ from huggingface_hub import snapshot_download
19
+
20
+ config = engine.load_config()
21
+ model = str(config.get("model") or engine.LOCAL_MODEL) if config.get("backend") != "cloud" else engine.LOCAL_MODEL
22
+ print(f"downloading {model}", flush=True)
23
+ path = snapshot_download(model)
24
+ engine.HOME.mkdir(parents=True, exist_ok=True)
25
+ (engine.HOME / "installed.json").write_text(json.dumps({"model": model, "path": path, "at": int(time.time())}))
26
+ print(f"installed {model}", flush=True)
27
+
28
+
29
+ if __name__ == "__main__":
30
+ sys.exit(main())