jfl 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (381) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +313 -0
  3. package/clawdbot-skill/README.md +328 -0
  4. package/clawdbot-skill/SKILL.md +362 -0
  5. package/clawdbot-skill/index.ts +486 -0
  6. package/clawdbot-skill/package.json +28 -0
  7. package/clawdbot-skill/skill.json +28 -0
  8. package/dist/commands/agents.d.ts +5 -0
  9. package/dist/commands/agents.d.ts.map +1 -0
  10. package/dist/commands/agents.js +399 -0
  11. package/dist/commands/agents.js.map +1 -0
  12. package/dist/commands/context-hub.d.ts +12 -0
  13. package/dist/commands/context-hub.d.ts.map +1 -0
  14. package/dist/commands/context-hub.js +642 -0
  15. package/dist/commands/context-hub.js.map +1 -0
  16. package/dist/commands/deploy.d.ts +5 -0
  17. package/dist/commands/deploy.d.ts.map +1 -0
  18. package/dist/commands/deploy.js +370 -0
  19. package/dist/commands/deploy.js.map +1 -0
  20. package/dist/commands/feedback.d.ts +2 -0
  21. package/dist/commands/feedback.d.ts.map +1 -0
  22. package/dist/commands/feedback.js +178 -0
  23. package/dist/commands/feedback.js.map +1 -0
  24. package/dist/commands/hud.d.ts +4 -0
  25. package/dist/commands/hud.d.ts.map +1 -0
  26. package/dist/commands/hud.js +262 -0
  27. package/dist/commands/hud.js.map +1 -0
  28. package/dist/commands/init.d.ts +4 -0
  29. package/dist/commands/init.d.ts.map +1 -0
  30. package/dist/commands/init.js +553 -0
  31. package/dist/commands/init.js.map +1 -0
  32. package/dist/commands/login.d.ts +23 -0
  33. package/dist/commands/login.d.ts.map +1 -0
  34. package/dist/commands/login.js +818 -0
  35. package/dist/commands/login.js.map +1 -0
  36. package/dist/commands/ralph.d.ts +9 -0
  37. package/dist/commands/ralph.d.ts.map +1 -0
  38. package/dist/commands/ralph.js +67 -0
  39. package/dist/commands/ralph.js.map +1 -0
  40. package/dist/commands/repair.d.ts +7 -0
  41. package/dist/commands/repair.d.ts.map +1 -0
  42. package/dist/commands/repair.js +283 -0
  43. package/dist/commands/repair.js.map +1 -0
  44. package/dist/commands/session-mgmt.d.ts +33 -0
  45. package/dist/commands/session-mgmt.d.ts.map +1 -0
  46. package/dist/commands/session-mgmt.js +404 -0
  47. package/dist/commands/session-mgmt.js.map +1 -0
  48. package/dist/commands/session.d.ts +2 -0
  49. package/dist/commands/session.d.ts.map +1 -0
  50. package/dist/commands/session.js +639 -0
  51. package/dist/commands/session.js.map +1 -0
  52. package/dist/commands/skills.d.ts +31 -0
  53. package/dist/commands/skills.d.ts.map +1 -0
  54. package/dist/commands/skills.js +314 -0
  55. package/dist/commands/skills.js.map +1 -0
  56. package/dist/commands/status.d.ts +2 -0
  57. package/dist/commands/status.d.ts.map +1 -0
  58. package/dist/commands/status.js +127 -0
  59. package/dist/commands/status.js.map +1 -0
  60. package/dist/commands/synopsis.d.ts +10 -0
  61. package/dist/commands/synopsis.d.ts.map +1 -0
  62. package/dist/commands/synopsis.js +277 -0
  63. package/dist/commands/synopsis.js.map +1 -0
  64. package/dist/commands/update.d.ts +10 -0
  65. package/dist/commands/update.d.ts.map +1 -0
  66. package/dist/commands/update.js +165 -0
  67. package/dist/commands/update.js.map +1 -0
  68. package/dist/commands/voice.d.ts +410 -0
  69. package/dist/commands/voice.d.ts.map +1 -0
  70. package/dist/commands/voice.js +4763 -0
  71. package/dist/commands/voice.js.map +1 -0
  72. package/dist/index.d.ts +9 -0
  73. package/dist/index.d.ts.map +1 -0
  74. package/dist/index.js +512 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/mcp/context-hub-mcp.d.ts +11 -0
  77. package/dist/mcp/context-hub-mcp.d.ts.map +1 -0
  78. package/dist/mcp/context-hub-mcp.js +548 -0
  79. package/dist/mcp/context-hub-mcp.js.map +1 -0
  80. package/dist/telegram/voice.d.ts +146 -0
  81. package/dist/telegram/voice.d.ts.map +1 -0
  82. package/dist/telegram/voice.js +351 -0
  83. package/dist/telegram/voice.js.map +1 -0
  84. package/dist/types/skills.d.ts +44 -0
  85. package/dist/types/skills.d.ts.map +1 -0
  86. package/dist/types/skills.js +5 -0
  87. package/dist/types/skills.js.map +1 -0
  88. package/dist/ui/banner.d.ts +18 -0
  89. package/dist/ui/banner.d.ts.map +1 -0
  90. package/dist/ui/banner.js +323 -0
  91. package/dist/ui/banner.js.map +1 -0
  92. package/dist/ui/index.d.ts +8 -0
  93. package/dist/ui/index.d.ts.map +1 -0
  94. package/dist/ui/index.js +8 -0
  95. package/dist/ui/index.js.map +1 -0
  96. package/dist/ui/prompts.d.ts +52 -0
  97. package/dist/ui/prompts.d.ts.map +1 -0
  98. package/dist/ui/prompts.js +72 -0
  99. package/dist/ui/prompts.js.map +1 -0
  100. package/dist/ui/theme.d.ts +82 -0
  101. package/dist/ui/theme.d.ts.map +1 -0
  102. package/dist/ui/theme.js +142 -0
  103. package/dist/ui/theme.js.map +1 -0
  104. package/dist/utils/auth-guard.d.ts +66 -0
  105. package/dist/utils/auth-guard.d.ts.map +1 -0
  106. package/dist/utils/auth-guard.js +348 -0
  107. package/dist/utils/auth-guard.js.map +1 -0
  108. package/dist/utils/ensure-project.d.ts +11 -0
  109. package/dist/utils/ensure-project.d.ts.map +1 -0
  110. package/dist/utils/ensure-project.js +70 -0
  111. package/dist/utils/ensure-project.js.map +1 -0
  112. package/dist/utils/git.d.ts +73 -0
  113. package/dist/utils/git.d.ts.map +1 -0
  114. package/dist/utils/git.js +219 -0
  115. package/dist/utils/git.js.map +1 -0
  116. package/dist/utils/github-auth.d.ts +54 -0
  117. package/dist/utils/github-auth.d.ts.map +1 -0
  118. package/dist/utils/github-auth.js +375 -0
  119. package/dist/utils/github-auth.js.map +1 -0
  120. package/dist/utils/github-repo.d.ts +30 -0
  121. package/dist/utils/github-repo.d.ts.map +1 -0
  122. package/dist/utils/github-repo.js +219 -0
  123. package/dist/utils/github-repo.js.map +1 -0
  124. package/dist/utils/platform-auth.d.ts +81 -0
  125. package/dist/utils/platform-auth.d.ts.map +1 -0
  126. package/dist/utils/platform-auth.js +191 -0
  127. package/dist/utils/platform-auth.js.map +1 -0
  128. package/dist/utils/project-config.d.ts +43 -0
  129. package/dist/utils/project-config.d.ts.map +1 -0
  130. package/dist/utils/project-config.js +97 -0
  131. package/dist/utils/project-config.js.map +1 -0
  132. package/dist/utils/skill-registry.d.ts +49 -0
  133. package/dist/utils/skill-registry.d.ts.map +1 -0
  134. package/dist/utils/skill-registry.js +192 -0
  135. package/dist/utils/skill-registry.js.map +1 -0
  136. package/dist/utils/wallet.d.ts +62 -0
  137. package/dist/utils/wallet.d.ts.map +1 -0
  138. package/dist/utils/wallet.js +252 -0
  139. package/dist/utils/wallet.js.map +1 -0
  140. package/dist/utils/x402-client.d.ts +86 -0
  141. package/dist/utils/x402-client.d.ts.map +1 -0
  142. package/dist/utils/x402-client.js +265 -0
  143. package/dist/utils/x402-client.js.map +1 -0
  144. package/package.json +76 -0
  145. package/scripts/postinstall.js +116 -0
  146. package/scripts/test-onboarding.sh +121 -0
  147. package/scripts/voice-start.sh +128 -0
  148. package/scripts/voice-stop.sh +33 -0
  149. package/template/.claude/settings.json +92 -0
  150. package/template/.claude/skills/agent-browser/SKILL.md +116 -0
  151. package/template/.claude/skills/brand-architect/SKILL.md +240 -0
  152. package/template/.claude/skills/brand-architect/config.yaml +137 -0
  153. package/template/.claude/skills/campaign-hud/config.yaml +112 -0
  154. package/template/.claude/skills/content-creator/SKILL.md +294 -0
  155. package/template/.claude/skills/debug/MULTI_AGENT.md +360 -0
  156. package/template/.claude/skills/debug/SKILL.md +549 -0
  157. package/template/.claude/skills/fly-deploy/SKILL.md +676 -0
  158. package/template/.claude/skills/founder-video/SKILL.md +467 -0
  159. package/template/.claude/skills/hud/SKILL.md +157 -0
  160. package/template/.claude/skills/ralph-tui/SKILL.md +210 -0
  161. package/template/.claude/skills/react-best-practices/AGENTS.md +2249 -0
  162. package/template/.claude/skills/react-best-practices/README.md +123 -0
  163. package/template/.claude/skills/react-best-practices/SKILL.md +125 -0
  164. package/template/.claude/skills/react-best-practices/metadata.json +15 -0
  165. package/template/.claude/skills/react-best-practices/rules/_sections.md +46 -0
  166. package/template/.claude/skills/react-best-practices/rules/_template.md +28 -0
  167. package/template/.claude/skills/react-best-practices/rules/advanced-event-handler-refs.md +55 -0
  168. package/template/.claude/skills/react-best-practices/rules/advanced-use-latest.md +49 -0
  169. package/template/.claude/skills/react-best-practices/rules/async-api-routes.md +38 -0
  170. package/template/.claude/skills/react-best-practices/rules/async-defer-await.md +80 -0
  171. package/template/.claude/skills/react-best-practices/rules/async-dependencies.md +36 -0
  172. package/template/.claude/skills/react-best-practices/rules/async-parallel.md +28 -0
  173. package/template/.claude/skills/react-best-practices/rules/async-suspense-boundaries.md +99 -0
  174. package/template/.claude/skills/react-best-practices/rules/bundle-barrel-imports.md +59 -0
  175. package/template/.claude/skills/react-best-practices/rules/bundle-conditional.md +31 -0
  176. package/template/.claude/skills/react-best-practices/rules/bundle-defer-third-party.md +49 -0
  177. package/template/.claude/skills/react-best-practices/rules/bundle-dynamic-imports.md +35 -0
  178. package/template/.claude/skills/react-best-practices/rules/bundle-preload.md +50 -0
  179. package/template/.claude/skills/react-best-practices/rules/client-event-listeners.md +74 -0
  180. package/template/.claude/skills/react-best-practices/rules/client-swr-dedup.md +56 -0
  181. package/template/.claude/skills/react-best-practices/rules/js-batch-dom-css.md +82 -0
  182. package/template/.claude/skills/react-best-practices/rules/js-cache-function-results.md +80 -0
  183. package/template/.claude/skills/react-best-practices/rules/js-cache-property-access.md +28 -0
  184. package/template/.claude/skills/react-best-practices/rules/js-cache-storage.md +70 -0
  185. package/template/.claude/skills/react-best-practices/rules/js-combine-iterations.md +32 -0
  186. package/template/.claude/skills/react-best-practices/rules/js-early-exit.md +50 -0
  187. package/template/.claude/skills/react-best-practices/rules/js-hoist-regexp.md +45 -0
  188. package/template/.claude/skills/react-best-practices/rules/js-index-maps.md +37 -0
  189. package/template/.claude/skills/react-best-practices/rules/js-length-check-first.md +49 -0
  190. package/template/.claude/skills/react-best-practices/rules/js-min-max-loop.md +82 -0
  191. package/template/.claude/skills/react-best-practices/rules/js-set-map-lookups.md +24 -0
  192. package/template/.claude/skills/react-best-practices/rules/js-tosorted-immutable.md +57 -0
  193. package/template/.claude/skills/react-best-practices/rules/rendering-activity.md +26 -0
  194. package/template/.claude/skills/react-best-practices/rules/rendering-animate-svg-wrapper.md +47 -0
  195. package/template/.claude/skills/react-best-practices/rules/rendering-conditional-render.md +40 -0
  196. package/template/.claude/skills/react-best-practices/rules/rendering-content-visibility.md +38 -0
  197. package/template/.claude/skills/react-best-practices/rules/rendering-hoist-jsx.md +46 -0
  198. package/template/.claude/skills/react-best-practices/rules/rendering-hydration-no-flicker.md +82 -0
  199. package/template/.claude/skills/react-best-practices/rules/rendering-svg-precision.md +28 -0
  200. package/template/.claude/skills/react-best-practices/rules/rerender-defer-reads.md +39 -0
  201. package/template/.claude/skills/react-best-practices/rules/rerender-dependencies.md +45 -0
  202. package/template/.claude/skills/react-best-practices/rules/rerender-derived-state.md +29 -0
  203. package/template/.claude/skills/react-best-practices/rules/rerender-functional-setstate.md +74 -0
  204. package/template/.claude/skills/react-best-practices/rules/rerender-lazy-state-init.md +58 -0
  205. package/template/.claude/skills/react-best-practices/rules/rerender-memo.md +44 -0
  206. package/template/.claude/skills/react-best-practices/rules/rerender-transitions.md +40 -0
  207. package/template/.claude/skills/react-best-practices/rules/server-after-nonblocking.md +73 -0
  208. package/template/.claude/skills/react-best-practices/rules/server-cache-lru.md +41 -0
  209. package/template/.claude/skills/react-best-practices/rules/server-cache-react.md +26 -0
  210. package/template/.claude/skills/react-best-practices/rules/server-parallel-fetching.md +79 -0
  211. package/template/.claude/skills/react-best-practices/rules/server-serialization.md +38 -0
  212. package/template/.claude/skills/remotion-best-practices/SKILL.md +43 -0
  213. package/template/.claude/skills/remotion-best-practices/rules/3d.md +86 -0
  214. package/template/.claude/skills/remotion-best-practices/rules/animations.md +29 -0
  215. package/template/.claude/skills/remotion-best-practices/rules/assets/charts-bar-chart.tsx +173 -0
  216. package/template/.claude/skills/remotion-best-practices/rules/assets/text-animations-typewriter.tsx +100 -0
  217. package/template/.claude/skills/remotion-best-practices/rules/assets/text-animations-word-highlight.tsx +108 -0
  218. package/template/.claude/skills/remotion-best-practices/rules/assets.md +78 -0
  219. package/template/.claude/skills/remotion-best-practices/rules/audio.md +172 -0
  220. package/template/.claude/skills/remotion-best-practices/rules/calculate-metadata.md +104 -0
  221. package/template/.claude/skills/remotion-best-practices/rules/can-decode.md +75 -0
  222. package/template/.claude/skills/remotion-best-practices/rules/charts.md +58 -0
  223. package/template/.claude/skills/remotion-best-practices/rules/compositions.md +146 -0
  224. package/template/.claude/skills/remotion-best-practices/rules/display-captions.md +126 -0
  225. package/template/.claude/skills/remotion-best-practices/rules/extract-frames.md +229 -0
  226. package/template/.claude/skills/remotion-best-practices/rules/fonts.md +152 -0
  227. package/template/.claude/skills/remotion-best-practices/rules/get-audio-duration.md +58 -0
  228. package/template/.claude/skills/remotion-best-practices/rules/get-video-dimensions.md +68 -0
  229. package/template/.claude/skills/remotion-best-practices/rules/get-video-duration.md +58 -0
  230. package/template/.claude/skills/remotion-best-practices/rules/gifs.md +138 -0
  231. package/template/.claude/skills/remotion-best-practices/rules/images.md +130 -0
  232. package/template/.claude/skills/remotion-best-practices/rules/import-srt-captions.md +67 -0
  233. package/template/.claude/skills/remotion-best-practices/rules/lottie.md +68 -0
  234. package/template/.claude/skills/remotion-best-practices/rules/measuring-dom-nodes.md +35 -0
  235. package/template/.claude/skills/remotion-best-practices/rules/measuring-text.md +143 -0
  236. package/template/.claude/skills/remotion-best-practices/rules/sequencing.md +106 -0
  237. package/template/.claude/skills/remotion-best-practices/rules/tailwind.md +11 -0
  238. package/template/.claude/skills/remotion-best-practices/rules/text-animations.md +20 -0
  239. package/template/.claude/skills/remotion-best-practices/rules/timing.md +179 -0
  240. package/template/.claude/skills/remotion-best-practices/rules/transcribe-captions.md +19 -0
  241. package/template/.claude/skills/remotion-best-practices/rules/transitions.md +122 -0
  242. package/template/.claude/skills/remotion-best-practices/rules/trimming.md +53 -0
  243. package/template/.claude/skills/remotion-best-practices/rules/videos.md +171 -0
  244. package/template/.claude/skills/search/SKILL.md +220 -0
  245. package/template/.claude/skills/spec/SKILL.md +377 -0
  246. package/template/.claude/skills/startup/SKILL.md +310 -0
  247. package/template/.claude/skills/web-architect/SKILL.md +309 -0
  248. package/template/.claude/skills/x-algorithm/SKILL.md +305 -0
  249. package/template/.jfl/config.json +8 -0
  250. package/template/.mcp.json +11 -0
  251. package/template/CLAUDE.md +960 -0
  252. package/template/content/.gitkeep +0 -0
  253. package/template/context-hub +3 -0
  254. package/template/knowledge/BRAND_BRIEF.md +124 -0
  255. package/template/knowledge/BRAND_DECISIONS.md +168 -0
  256. package/template/knowledge/NARRATIVE.md +114 -0
  257. package/template/knowledge/ROADMAP.md +128 -0
  258. package/template/knowledge/THESIS.md +108 -0
  259. package/template/knowledge/VISION.md +74 -0
  260. package/template/knowledge/VOICE_AND_TONE.md +146 -0
  261. package/template/previews/.gitkeep +0 -0
  262. package/template/scripts/session/auto-commit.sh +245 -0
  263. package/template/scripts/session/auto-merge.sh +325 -0
  264. package/template/scripts/session/jfl-doctor.sh +587 -0
  265. package/template/scripts/session/session-end.sh +194 -0
  266. package/template/scripts/session/session-init.sh +163 -0
  267. package/template/scripts/session/session-sync.sh +167 -0
  268. package/template/scripts/session/test-context-preservation.sh +160 -0
  269. package/template/skills/agent-browser/SKILL.md +116 -0
  270. package/template/skills/brand-architect/SKILL.md +240 -0
  271. package/template/skills/brand-architect/config.yaml +137 -0
  272. package/template/skills/campaign-hud/config.yaml +112 -0
  273. package/template/skills/content-creator/SKILL.md +294 -0
  274. package/template/skills/debug/MULTI_AGENT.md +360 -0
  275. package/template/skills/debug/SKILL.md +549 -0
  276. package/template/skills/fly-deploy/SKILL.md +676 -0
  277. package/template/skills/founder-video/SKILL.md +467 -0
  278. package/template/skills/hud/SKILL.md +204 -0
  279. package/template/skills/ralph-tui/SKILL.md +210 -0
  280. package/template/skills/react-best-practices/AGENTS.md +2249 -0
  281. package/template/skills/react-best-practices/README.md +123 -0
  282. package/template/skills/react-best-practices/SKILL.md +125 -0
  283. package/template/skills/react-best-practices/metadata.json +15 -0
  284. package/template/skills/react-best-practices/rules/_sections.md +46 -0
  285. package/template/skills/react-best-practices/rules/_template.md +28 -0
  286. package/template/skills/react-best-practices/rules/advanced-event-handler-refs.md +55 -0
  287. package/template/skills/react-best-practices/rules/advanced-use-latest.md +49 -0
  288. package/template/skills/react-best-practices/rules/async-api-routes.md +38 -0
  289. package/template/skills/react-best-practices/rules/async-defer-await.md +80 -0
  290. package/template/skills/react-best-practices/rules/async-dependencies.md +36 -0
  291. package/template/skills/react-best-practices/rules/async-parallel.md +28 -0
  292. package/template/skills/react-best-practices/rules/async-suspense-boundaries.md +99 -0
  293. package/template/skills/react-best-practices/rules/bundle-barrel-imports.md +59 -0
  294. package/template/skills/react-best-practices/rules/bundle-conditional.md +31 -0
  295. package/template/skills/react-best-practices/rules/bundle-defer-third-party.md +49 -0
  296. package/template/skills/react-best-practices/rules/bundle-dynamic-imports.md +35 -0
  297. package/template/skills/react-best-practices/rules/bundle-preload.md +50 -0
  298. package/template/skills/react-best-practices/rules/client-event-listeners.md +74 -0
  299. package/template/skills/react-best-practices/rules/client-swr-dedup.md +56 -0
  300. package/template/skills/react-best-practices/rules/js-batch-dom-css.md +82 -0
  301. package/template/skills/react-best-practices/rules/js-cache-function-results.md +80 -0
  302. package/template/skills/react-best-practices/rules/js-cache-property-access.md +28 -0
  303. package/template/skills/react-best-practices/rules/js-cache-storage.md +70 -0
  304. package/template/skills/react-best-practices/rules/js-combine-iterations.md +32 -0
  305. package/template/skills/react-best-practices/rules/js-early-exit.md +50 -0
  306. package/template/skills/react-best-practices/rules/js-hoist-regexp.md +45 -0
  307. package/template/skills/react-best-practices/rules/js-index-maps.md +37 -0
  308. package/template/skills/react-best-practices/rules/js-length-check-first.md +49 -0
  309. package/template/skills/react-best-practices/rules/js-min-max-loop.md +82 -0
  310. package/template/skills/react-best-practices/rules/js-set-map-lookups.md +24 -0
  311. package/template/skills/react-best-practices/rules/js-tosorted-immutable.md +57 -0
  312. package/template/skills/react-best-practices/rules/rendering-activity.md +26 -0
  313. package/template/skills/react-best-practices/rules/rendering-animate-svg-wrapper.md +47 -0
  314. package/template/skills/react-best-practices/rules/rendering-conditional-render.md +40 -0
  315. package/template/skills/react-best-practices/rules/rendering-content-visibility.md +38 -0
  316. package/template/skills/react-best-practices/rules/rendering-hoist-jsx.md +46 -0
  317. package/template/skills/react-best-practices/rules/rendering-hydration-no-flicker.md +82 -0
  318. package/template/skills/react-best-practices/rules/rendering-svg-precision.md +28 -0
  319. package/template/skills/react-best-practices/rules/rerender-defer-reads.md +39 -0
  320. package/template/skills/react-best-practices/rules/rerender-dependencies.md +45 -0
  321. package/template/skills/react-best-practices/rules/rerender-derived-state.md +29 -0
  322. package/template/skills/react-best-practices/rules/rerender-functional-setstate.md +74 -0
  323. package/template/skills/react-best-practices/rules/rerender-lazy-state-init.md +58 -0
  324. package/template/skills/react-best-practices/rules/rerender-memo.md +44 -0
  325. package/template/skills/react-best-practices/rules/rerender-transitions.md +40 -0
  326. package/template/skills/react-best-practices/rules/server-after-nonblocking.md +73 -0
  327. package/template/skills/react-best-practices/rules/server-cache-lru.md +41 -0
  328. package/template/skills/react-best-practices/rules/server-cache-react.md +26 -0
  329. package/template/skills/react-best-practices/rules/server-parallel-fetching.md +79 -0
  330. package/template/skills/react-best-practices/rules/server-serialization.md +38 -0
  331. package/template/skills/remotion-best-practices/SKILL.md +43 -0
  332. package/template/skills/remotion-best-practices/rules/3d.md +86 -0
  333. package/template/skills/remotion-best-practices/rules/animations.md +29 -0
  334. package/template/skills/remotion-best-practices/rules/assets/charts-bar-chart.tsx +173 -0
  335. package/template/skills/remotion-best-practices/rules/assets/text-animations-typewriter.tsx +100 -0
  336. package/template/skills/remotion-best-practices/rules/assets/text-animations-word-highlight.tsx +108 -0
  337. package/template/skills/remotion-best-practices/rules/assets.md +78 -0
  338. package/template/skills/remotion-best-practices/rules/audio.md +172 -0
  339. package/template/skills/remotion-best-practices/rules/calculate-metadata.md +104 -0
  340. package/template/skills/remotion-best-practices/rules/can-decode.md +75 -0
  341. package/template/skills/remotion-best-practices/rules/charts.md +58 -0
  342. package/template/skills/remotion-best-practices/rules/compositions.md +146 -0
  343. package/template/skills/remotion-best-practices/rules/display-captions.md +126 -0
  344. package/template/skills/remotion-best-practices/rules/extract-frames.md +229 -0
  345. package/template/skills/remotion-best-practices/rules/fonts.md +152 -0
  346. package/template/skills/remotion-best-practices/rules/get-audio-duration.md +58 -0
  347. package/template/skills/remotion-best-practices/rules/get-video-dimensions.md +68 -0
  348. package/template/skills/remotion-best-practices/rules/get-video-duration.md +58 -0
  349. package/template/skills/remotion-best-practices/rules/gifs.md +138 -0
  350. package/template/skills/remotion-best-practices/rules/images.md +130 -0
  351. package/template/skills/remotion-best-practices/rules/import-srt-captions.md +67 -0
  352. package/template/skills/remotion-best-practices/rules/lottie.md +68 -0
  353. package/template/skills/remotion-best-practices/rules/measuring-dom-nodes.md +35 -0
  354. package/template/skills/remotion-best-practices/rules/measuring-text.md +143 -0
  355. package/template/skills/remotion-best-practices/rules/sequencing.md +106 -0
  356. package/template/skills/remotion-best-practices/rules/tailwind.md +11 -0
  357. package/template/skills/remotion-best-practices/rules/text-animations.md +20 -0
  358. package/template/skills/remotion-best-practices/rules/timing.md +179 -0
  359. package/template/skills/remotion-best-practices/rules/transcribe-captions.md +19 -0
  360. package/template/skills/remotion-best-practices/rules/transitions.md +122 -0
  361. package/template/skills/remotion-best-practices/rules/trimming.md +53 -0
  362. package/template/skills/remotion-best-practices/rules/videos.md +171 -0
  363. package/template/skills/search/SKILL.md +220 -0
  364. package/template/skills/spec/SKILL.md +377 -0
  365. package/template/skills/startup/SKILL.md +310 -0
  366. package/template/skills/web-architect/SKILL.md +309 -0
  367. package/template/skills/x-algorithm/SKILL.md +305 -0
  368. package/template/suggestions/.gitkeep +0 -0
  369. package/template/templates/QUICKSTART_SKILL_TO_PRODUCT.md +242 -0
  370. package/template/templates/brand/BRAND_BRIEF.md +124 -0
  371. package/template/templates/brand/BRAND_DECISIONS.md +168 -0
  372. package/template/templates/brand/BRAND_GUIDELINES.md +251 -0
  373. package/template/templates/brand/VOICE_AND_TONE.md +146 -0
  374. package/template/templates/brand/global.css +240 -0
  375. package/template/templates/collaboration/CONTRIBUTOR.md +74 -0
  376. package/template/templates/collaboration/CRM.md +97 -0
  377. package/template/templates/collaboration/TASKS.md +83 -0
  378. package/template/templates/strategic/NARRATIVE.md +114 -0
  379. package/template/templates/strategic/ROADMAP.md +128 -0
  380. package/template/templates/strategic/THESIS.md +108 -0
  381. package/template/templates/strategic/VISION.md +74 -0
@@ -0,0 +1,4763 @@
1
+ import chalk from "chalk";
2
+ import ora from "ora";
3
+ import inquirer from "inquirer";
4
+ import { existsSync, mkdirSync, statSync, createWriteStream, unlinkSync, renameSync, readFileSync, writeFileSync, openSync, closeSync } from "fs";
5
+ import { join } from "path";
6
+ import { homedir, platform } from "os";
7
+ import { createHash } from "crypto";
8
+ import https from "https";
9
+ import http from "http";
10
+ import { EventEmitter } from "events";
11
+ import WebSocket from "ws";
12
+ import { spawn, execSync } from "child_process";
13
+ // @ts-ignore - node-global-key-listener doesn't have type definitions
14
+ import { GlobalKeyboardListener } from "node-global-key-listener";
15
+ // VAD Configuration
16
+ const VAD_SILENCE_THRESHOLD_DB = -40; // dB threshold for silence detection
17
+ const VAD_SILENCE_DURATION_MS = 1500; // Stop after 1.5 seconds of silence
18
+ // =============================================================================
19
+ // Voice Error Handling System
20
+ // =============================================================================
21
+ /**
22
+ * Error types for voice-related failures
23
+ */
24
+ export var VoiceErrorType;
25
+ (function (VoiceErrorType) {
26
+ VoiceErrorType["SERVER_NOT_RUNNING"] = "SERVER_NOT_RUNNING";
27
+ VoiceErrorType["MIC_UNAVAILABLE"] = "MIC_UNAVAILABLE";
28
+ VoiceErrorType["CONNECTION_DROPPED"] = "CONNECTION_DROPPED";
29
+ VoiceErrorType["TRANSCRIPTION_EMPTY"] = "TRANSCRIPTION_EMPTY";
30
+ VoiceErrorType["PERMISSION_DENIED"] = "PERMISSION_DENIED";
31
+ VoiceErrorType["MODEL_NOT_FOUND"] = "MODEL_NOT_FOUND";
32
+ VoiceErrorType["AUTH_FAILED"] = "AUTH_FAILED";
33
+ VoiceErrorType["SOX_NOT_INSTALLED"] = "SOX_NOT_INSTALLED";
34
+ VoiceErrorType["RECORDING_FAILED"] = "RECORDING_FAILED";
35
+ VoiceErrorType["TIMEOUT"] = "TIMEOUT";
36
+ VoiceErrorType["ACCESSIBILITY_NOT_GRANTED"] = "ACCESSIBILITY_NOT_GRANTED";
37
+ VoiceErrorType["PLATFORM_NOT_SUPPORTED"] = "PLATFORM_NOT_SUPPORTED";
38
+ VoiceErrorType["WAYLAND_NOT_SUPPORTED"] = "WAYLAND_NOT_SUPPORTED";
39
+ })(VoiceErrorType || (VoiceErrorType = {}));
40
+ /**
41
+ * Error messages and recovery suggestions for each error type
42
+ */
43
+ const VOICE_ERROR_MESSAGES = {
44
+ [VoiceErrorType.SERVER_NOT_RUNNING]: {
45
+ message: "Voice server is not running.",
46
+ suggestions: [
47
+ "Start the server with: jfl voice server start",
48
+ "Check if another process is using the port",
49
+ ],
50
+ },
51
+ [VoiceErrorType.MIC_UNAVAILABLE]: {
52
+ message: "Microphone not available.",
53
+ suggestions: [
54
+ platform() === "darwin"
55
+ ? "Check Settings > Privacy & Security > Microphone"
56
+ : platform() === "win32"
57
+ ? "Check Settings > Privacy > Microphone"
58
+ : "Check your audio settings and permissions",
59
+ "Ensure your microphone is connected and enabled",
60
+ "Try selecting a different device with: jfl voice devices",
61
+ ],
62
+ },
63
+ [VoiceErrorType.CONNECTION_DROPPED]: {
64
+ message: "Connection to voice server lost.",
65
+ suggestions: [
66
+ "Check if the server is still running: jfl voice server status",
67
+ "Restart the server: jfl voice server start",
68
+ "Check your network connection",
69
+ ],
70
+ },
71
+ [VoiceErrorType.TRANSCRIPTION_EMPTY]: {
72
+ message: "No speech detected.",
73
+ suggestions: [
74
+ "Try speaking louder or more clearly",
75
+ "Move closer to your microphone",
76
+ "Check your microphone is working: jfl voice test",
77
+ "Ensure background noise is minimized",
78
+ ],
79
+ },
80
+ [VoiceErrorType.PERMISSION_DENIED]: {
81
+ message: "Microphone permission denied.",
82
+ suggestions: platform() === "darwin"
83
+ ? [
84
+ "Open System Settings > Privacy & Security > Microphone",
85
+ "Grant permission to Terminal (or your terminal app)",
86
+ "You may need to restart your terminal after granting permission",
87
+ ]
88
+ : platform() === "win32"
89
+ ? [
90
+ "Open Settings > Privacy > Microphone",
91
+ "Enable 'Allow apps to access your microphone'",
92
+ "Ensure your terminal app is allowed",
93
+ ]
94
+ : [
95
+ "Check your system's audio permissions",
96
+ "On some systems, run: sudo usermod -aG audio $USER",
97
+ "Then log out and log back in",
98
+ ],
99
+ },
100
+ [VoiceErrorType.MODEL_NOT_FOUND]: {
101
+ message: "Whisper model not found.",
102
+ suggestions: [
103
+ "Download a model with: jfl voice model download base",
104
+ "List available models: jfl voice model list",
105
+ "Run setup wizard: jfl voice setup",
106
+ ],
107
+ },
108
+ [VoiceErrorType.AUTH_FAILED]: {
109
+ message: "Authentication failed. Server token may have changed.",
110
+ suggestions: [
111
+ "Restart the voice server: jfl voice server start",
112
+ "If the issue persists, delete ~/.jfl/voice-server.token and restart",
113
+ ],
114
+ },
115
+ [VoiceErrorType.SOX_NOT_INSTALLED]: {
116
+ message: "Audio recording tool (sox) not found.",
117
+ suggestions: platform() === "darwin"
118
+ ? ["Install with: brew install sox"]
119
+ : platform() === "win32"
120
+ ? ["Install with: choco install sox.portable"]
121
+ : [
122
+ "Install with: sudo apt-get install sox libsox-fmt-all",
123
+ "Or for Fedora/RHEL: sudo dnf install sox",
124
+ ],
125
+ },
126
+ [VoiceErrorType.RECORDING_FAILED]: {
127
+ message: "Failed to start recording.",
128
+ suggestions: [
129
+ "Check microphone connection",
130
+ "Try a different audio device: jfl voice devices",
131
+ "Check if another application is using the microphone",
132
+ ],
133
+ },
134
+ [VoiceErrorType.TIMEOUT]: {
135
+ message: "Operation timed out.",
136
+ suggestions: [
137
+ "The server may be overloaded - try again",
138
+ "Check server status: jfl voice server status",
139
+ "For large audio files, the model may need more time",
140
+ ],
141
+ },
142
+ [VoiceErrorType.ACCESSIBILITY_NOT_GRANTED]: {
143
+ message: "Accessibility permission not granted.",
144
+ suggestions: [
145
+ "Open System Settings > Privacy & Security > Accessibility",
146
+ "Add your terminal app (Terminal, iTerm2, etc.) to the allowed list",
147
+ "Toggle the permission off and on if already added",
148
+ "You may need to restart your terminal after granting permission",
149
+ ],
150
+ },
151
+ [VoiceErrorType.PLATFORM_NOT_SUPPORTED]: {
152
+ message: "Global hotkey is not supported on this platform.",
153
+ suggestions: [
154
+ "Use 'jfl voice' for manual recording",
155
+ "Supported platforms: macOS, Linux (X11), Windows 10/11",
156
+ ],
157
+ },
158
+ [VoiceErrorType.WAYLAND_NOT_SUPPORTED]: {
159
+ message: "Global hotkey is not supported on Wayland.",
160
+ suggestions: [
161
+ "Use 'jfl voice' for manual recording instead",
162
+ "Switch to an X11 session for hotkey support",
163
+ "Or use an X11-based desktop environment (GNOME on X11, KDE on X11)",
164
+ ],
165
+ },
166
+ };
167
+ /**
168
+ * Custom error class for voice-related errors
169
+ */
170
+ export class VoiceError extends Error {
171
+ type;
172
+ originalError;
173
+ context;
174
+ recoverable;
175
+ audioBuffer; // Preserved audio for retry
176
+ constructor(type, options) {
177
+ const errorInfo = VOICE_ERROR_MESSAGES[type];
178
+ super(errorInfo.message);
179
+ this.name = "VoiceError";
180
+ this.type = type;
181
+ this.originalError = options?.originalError;
182
+ this.context = options?.context;
183
+ this.recoverable = options?.recoverable ?? false;
184
+ this.audioBuffer = options?.audioBuffer;
185
+ // Maintain proper stack trace
186
+ if (Error.captureStackTrace) {
187
+ Error.captureStackTrace(this, VoiceError);
188
+ }
189
+ }
190
+ /**
191
+ * Get user-friendly suggestions for recovering from this error
192
+ */
193
+ getSuggestions() {
194
+ return VOICE_ERROR_MESSAGES[this.type].suggestions;
195
+ }
196
+ /**
197
+ * Get full error message including original error if present
198
+ */
199
+ getFullMessage() {
200
+ let msg = this.message;
201
+ if (this.originalError) {
202
+ msg += ` (${this.originalError.message})`;
203
+ }
204
+ return msg;
205
+ }
206
+ }
207
+ /**
208
+ * Display a voice error with formatted output and suggestions
209
+ */
210
+ export function handleVoiceError(error, spinner) {
211
+ // Stop spinner if active
212
+ if (spinner) {
213
+ spinner.stop();
214
+ }
215
+ // Handle VoiceError with full formatting
216
+ if (error instanceof VoiceError) {
217
+ console.log();
218
+ console.log(chalk.red(` ✗ ${error.getFullMessage()}`));
219
+ console.log();
220
+ const suggestions = error.getSuggestions();
221
+ if (suggestions.length > 0) {
222
+ console.log(chalk.yellow(" Suggestions:"));
223
+ for (const suggestion of suggestions) {
224
+ console.log(chalk.gray(` - ${suggestion}`));
225
+ }
226
+ console.log();
227
+ }
228
+ // Show additional context if in debug mode
229
+ if (process.env.DEBUG && error.context) {
230
+ console.log(chalk.gray(" Debug context:"));
231
+ console.log(chalk.gray(` ${JSON.stringify(error.context, null, 2)}`));
232
+ console.log();
233
+ }
234
+ // Indicate if the error is recoverable
235
+ if (error.recoverable && error.audioBuffer) {
236
+ console.log(chalk.cyan(" Audio has been preserved. You can retry the transcription."));
237
+ console.log();
238
+ }
239
+ }
240
+ else {
241
+ // Handle generic errors
242
+ console.log();
243
+ console.log(chalk.red(` ✗ Error: ${error.message}`));
244
+ console.log();
245
+ // Try to infer error type from message and provide suggestions
246
+ const inferredSuggestions = inferErrorSuggestions(error);
247
+ if (inferredSuggestions.length > 0) {
248
+ console.log(chalk.yellow(" Suggestions:"));
249
+ for (const suggestion of inferredSuggestions) {
250
+ console.log(chalk.gray(` - ${suggestion}`));
251
+ }
252
+ console.log();
253
+ }
254
+ }
255
+ }
256
+ /**
257
+ * Try to infer helpful suggestions from a generic error
258
+ */
259
+ function inferErrorSuggestions(error) {
260
+ const message = error.message.toLowerCase();
261
+ if (message.includes("enoent") || message.includes("not found")) {
262
+ if (message.includes("sock") || message.includes("socket")) {
263
+ return VOICE_ERROR_MESSAGES[VoiceErrorType.SERVER_NOT_RUNNING].suggestions;
264
+ }
265
+ if (message.includes("model") || message.includes("ggml")) {
266
+ return VOICE_ERROR_MESSAGES[VoiceErrorType.MODEL_NOT_FOUND].suggestions;
267
+ }
268
+ if (message.includes("sox") || message.includes("rec")) {
269
+ return VOICE_ERROR_MESSAGES[VoiceErrorType.SOX_NOT_INSTALLED].suggestions;
270
+ }
271
+ }
272
+ if (message.includes("permission") || message.includes("denied") || message.includes("access")) {
273
+ return VOICE_ERROR_MESSAGES[VoiceErrorType.PERMISSION_DENIED].suggestions;
274
+ }
275
+ if (message.includes("auth") || message.includes("token") || message.includes("401")) {
276
+ return VOICE_ERROR_MESSAGES[VoiceErrorType.AUTH_FAILED].suggestions;
277
+ }
278
+ if (message.includes("timeout") || message.includes("timed out")) {
279
+ return VOICE_ERROR_MESSAGES[VoiceErrorType.TIMEOUT].suggestions;
280
+ }
281
+ if (message.includes("connection") || message.includes("connect") || message.includes("econnrefused")) {
282
+ return VOICE_ERROR_MESSAGES[VoiceErrorType.CONNECTION_DROPPED].suggestions;
283
+ }
284
+ if (message.includes("microphone") || message.includes("mic") || message.includes("audio")) {
285
+ return VOICE_ERROR_MESSAGES[VoiceErrorType.MIC_UNAVAILABLE].suggestions;
286
+ }
287
+ return [];
288
+ }
289
+ /**
290
+ * Create appropriate VoiceError based on error analysis
291
+ */
292
+ export function createVoiceError(error, context, audioBuffer) {
293
+ const message = error.message.toLowerCase();
294
+ // Determine error type from message patterns
295
+ let type;
296
+ if (message.includes("socket not found") || message.includes("voice server socket")) {
297
+ type = VoiceErrorType.SERVER_NOT_RUNNING;
298
+ }
299
+ else if (message.includes("auth") || message.includes("token") || message.includes("unauthorized")) {
300
+ type = VoiceErrorType.AUTH_FAILED;
301
+ }
302
+ else if (message.includes("permission") || message.includes("denied")) {
303
+ type = VoiceErrorType.PERMISSION_DENIED;
304
+ }
305
+ else if (message.includes("model") && (message.includes("not found") || message.includes("missing"))) {
306
+ type = VoiceErrorType.MODEL_NOT_FOUND;
307
+ }
308
+ else if (message.includes("no speech") || message.includes("empty transcript")) {
309
+ type = VoiceErrorType.TRANSCRIPTION_EMPTY;
310
+ }
311
+ else if (message.includes("connection") || message.includes("disconnect")) {
312
+ type = VoiceErrorType.CONNECTION_DROPPED;
313
+ }
314
+ else if (message.includes("sox") || message.includes("no audio recording tool")) {
315
+ type = VoiceErrorType.SOX_NOT_INSTALLED;
316
+ }
317
+ else if (message.includes("microphone") || message.includes("audio device")) {
318
+ type = VoiceErrorType.MIC_UNAVAILABLE;
319
+ }
320
+ else if (message.includes("timeout")) {
321
+ type = VoiceErrorType.TIMEOUT;
322
+ }
323
+ else if (message.includes("record")) {
324
+ type = VoiceErrorType.RECORDING_FAILED;
325
+ }
326
+ else {
327
+ // Default to recording failed for unknown errors
328
+ type = VoiceErrorType.RECORDING_FAILED;
329
+ }
330
+ // Determine if recoverable (has audio buffer for retry)
331
+ const recoverable = audioBuffer !== undefined && audioBuffer.length > 0;
332
+ return new VoiceError(type, {
333
+ originalError: error,
334
+ context,
335
+ recoverable,
336
+ audioBuffer,
337
+ });
338
+ }
339
+ /**
340
+ * Check if server is running and return appropriate error if not
341
+ * Checks both TCP port 9090 and Unix socket for backwards compatibility
342
+ */
343
+ export function checkServerRunning() {
344
+ // Check if server PID file exists and process is running
345
+ const pidPath = join(getJflDir(), "voice-server.pid");
346
+ if (existsSync(pidPath)) {
347
+ try {
348
+ const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
349
+ // Check if process is running by sending signal 0
350
+ process.kill(pid, 0);
351
+ return null; // Server is running
352
+ }
353
+ catch {
354
+ // PID file exists but process not running
355
+ }
356
+ }
357
+ // Fallback: check Unix socket for backwards compatibility
358
+ const socketPath = getVoiceSocketPath();
359
+ if (existsSync(socketPath)) {
360
+ return null;
361
+ }
362
+ return new VoiceError(VoiceErrorType.SERVER_NOT_RUNNING);
363
+ }
364
+ /**
365
+ * Check if auth token exists and return appropriate error if not
366
+ */
367
+ export function checkAuthToken() {
368
+ const token = readAuthToken();
369
+ if (!token) {
370
+ return new VoiceError(VoiceErrorType.AUTH_FAILED, {
371
+ context: { reason: "Token file not found or empty" },
372
+ });
373
+ }
374
+ return null;
375
+ }
376
+ /**
377
+ * Check if a model is available and return appropriate error if not
378
+ */
379
+ export function checkModelAvailable(modelName) {
380
+ const model = modelName || getCurrentDefaultModel();
381
+ if (!isModelDownloaded(model)) {
382
+ return new VoiceError(VoiceErrorType.MODEL_NOT_FOUND, {
383
+ context: { modelName: model },
384
+ });
385
+ }
386
+ return null;
387
+ }
388
+ // Whisper model definitions
389
+ // Source: https://huggingface.co/ggerganov/whisper.cpp
390
+ const WHISPER_MODELS = {
391
+ tiny: {
392
+ name: "tiny",
393
+ displayName: "Tiny",
394
+ size: "75 MB",
395
+ sizeBytes: 75_000_000,
396
+ description: "Fastest, lowest accuracy. Good for quick testing.",
397
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin",
398
+ sha256: "be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21",
399
+ },
400
+ "tiny.en": {
401
+ name: "tiny.en",
402
+ displayName: "Tiny (English)",
403
+ size: "75 MB",
404
+ sizeBytes: 75_000_000,
405
+ description: "English-only tiny model. Slightly better for English.",
406
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin",
407
+ sha256: "921e4cf8b0c2c68d26b626b8b0adfe5f188ccd0e42f74ea3a3c4a02313978c93",
408
+ },
409
+ base: {
410
+ name: "base",
411
+ displayName: "Base",
412
+ size: "142 MB",
413
+ sizeBytes: 142_000_000,
414
+ description: "Balanced speed/accuracy. Recommended default.",
415
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin",
416
+ sha256: "60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe",
417
+ },
418
+ "base.en": {
419
+ name: "base.en",
420
+ displayName: "Base (English)",
421
+ size: "142 MB",
422
+ sizeBytes: 142_000_000,
423
+ description: "English-only base model.",
424
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin",
425
+ sha256: "a03779c86df3323075f5e796b3f6af1e6faa6a45b5eb1ef6c3fba57b4ccd0f66",
426
+ },
427
+ small: {
428
+ name: "small",
429
+ displayName: "Small",
430
+ size: "466 MB",
431
+ sizeBytes: 466_000_000,
432
+ description: "Higher accuracy, slower. Good for important transcriptions.",
433
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin",
434
+ sha256: "1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1c8c6f0da8",
435
+ },
436
+ "small.en": {
437
+ name: "small.en",
438
+ displayName: "Small (English)",
439
+ size: "466 MB",
440
+ sizeBytes: 466_000_000,
441
+ description: "English-only small model.",
442
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin",
443
+ sha256: "db8a495a91d927739e50b3fc1830cbe8b5d3ce7c499c1ab2c1a4d508d4f5bede",
444
+ },
445
+ };
446
+ const DEFAULT_MODEL = "base";
447
+ // Get JFL directory
448
+ export function getJflDir() {
449
+ return join(homedir(), ".jfl");
450
+ }
451
+ // Get models directory
452
+ function getModelsDir() {
453
+ return join(getJflDir(), "models");
454
+ }
455
+ // Get voice config path
456
+ function getVoiceConfigPath() {
457
+ return join(getJflDir(), "voice.yaml");
458
+ }
459
+ // Get voice socket path
460
+ function getVoiceSocketPath() {
461
+ return join(getJflDir(), "voice.sock");
462
+ }
463
+ // Get voice server token path
464
+ function getVoiceTokenPath() {
465
+ return join(getJflDir(), "voice-server.token");
466
+ }
467
+ // Get voice daemon PID file path
468
+ function getVoiceDaemonPidPath() {
469
+ return join(getJflDir(), "voice-daemon.pid");
470
+ }
471
+ // Get voice daemon log file path
472
+ function getVoiceDaemonLogPath() {
473
+ return join(getJflDir(), "voice-daemon.log");
474
+ }
475
+ // Read auth token from file
476
+ export function readAuthToken() {
477
+ const tokenPath = getVoiceTokenPath();
478
+ if (!existsSync(tokenPath)) {
479
+ return null;
480
+ }
481
+ try {
482
+ return readFileSync(tokenPath, "utf-8").trim();
483
+ }
484
+ catch {
485
+ return null;
486
+ }
487
+ }
488
+ // Ensure directories exist
489
+ function ensureDirectories() {
490
+ const jflDir = getJflDir();
491
+ const modelsDir = getModelsDir();
492
+ if (!existsSync(jflDir)) {
493
+ mkdirSync(jflDir, { mode: 0o700 });
494
+ }
495
+ if (!existsSync(modelsDir)) {
496
+ mkdirSync(modelsDir, { mode: 0o755 });
497
+ }
498
+ }
499
+ // Get path for a model file
500
+ function getModelPath(modelName) {
501
+ return join(getModelsDir(), `ggml-${modelName}.bin`);
502
+ }
503
+ // Get path for partial download file
504
+ function getPartialPath(modelName) {
505
+ return join(getModelsDir(), `ggml-${modelName}.bin.partial`);
506
+ }
507
+ // Check if model is downloaded
508
+ function isModelDownloaded(modelName) {
509
+ const modelPath = getModelPath(modelName);
510
+ if (!existsSync(modelPath))
511
+ return false;
512
+ const model = WHISPER_MODELS[modelName];
513
+ if (!model)
514
+ return false;
515
+ // Check file size is approximately correct (within 1%)
516
+ const stats = statSync(modelPath);
517
+ const expectedSize = model.sizeBytes;
518
+ const tolerance = expectedSize * 0.01;
519
+ return Math.abs(stats.size - expectedSize) < tolerance;
520
+ }
521
+ // Get current default model from config
522
+ function getCurrentDefaultModel() {
523
+ const configPath = getVoiceConfigPath();
524
+ if (!existsSync(configPath))
525
+ return DEFAULT_MODEL;
526
+ try {
527
+ const content = readFileSync(configPath, "utf-8");
528
+ const match = content.match(/^model:\s*(\S+)/m);
529
+ return match ? match[1] : DEFAULT_MODEL;
530
+ }
531
+ catch {
532
+ return DEFAULT_MODEL;
533
+ }
534
+ }
535
+ // Set default model in config
536
+ function setDefaultModel(modelName) {
537
+ const configPath = getVoiceConfigPath();
538
+ ensureDirectories();
539
+ let content = "";
540
+ if (existsSync(configPath)) {
541
+ content = readFileSync(configPath, "utf-8");
542
+ if (content.match(/^model:/m)) {
543
+ content = content.replace(/^model:\s*\S+/m, `model: ${modelName}`);
544
+ }
545
+ else {
546
+ content = `model: ${modelName}\n${content}`;
547
+ }
548
+ }
549
+ else {
550
+ content = `# JFL Voice Configuration\nmodel: ${modelName}\n`;
551
+ }
552
+ writeFileSync(configPath, content, { mode: 0o644 });
553
+ }
554
+ // Format bytes to human readable
555
+ function formatBytes(bytes) {
556
+ if (bytes < 1024)
557
+ return `${bytes} B`;
558
+ if (bytes < 1024 * 1024)
559
+ return `${(bytes / 1024).toFixed(1)} KB`;
560
+ if (bytes < 1024 * 1024 * 1024)
561
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
562
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
563
+ }
564
+ // Download file with progress and resume support
565
+ async function downloadFile(url, destPath, partialPath, expectedSize, onProgress) {
566
+ return new Promise((resolve, reject) => {
567
+ // Check for existing partial download
568
+ let startByte = 0;
569
+ if (existsSync(partialPath)) {
570
+ const stats = statSync(partialPath);
571
+ startByte = stats.size;
572
+ }
573
+ const headers = {};
574
+ if (startByte > 0) {
575
+ headers["Range"] = `bytes=${startByte}-`;
576
+ }
577
+ const protocol = url.startsWith("https") ? https : http;
578
+ const request = protocol.get(url, { headers }, (response) => {
579
+ // Handle redirects
580
+ if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307) {
581
+ const redirectUrl = response.headers.location;
582
+ if (redirectUrl) {
583
+ downloadFile(redirectUrl, destPath, partialPath, expectedSize, onProgress)
584
+ .then(resolve)
585
+ .catch(reject);
586
+ return;
587
+ }
588
+ }
589
+ // Handle 416 Range Not Satisfiable (file already complete)
590
+ if (response.statusCode === 416) {
591
+ if (existsSync(partialPath)) {
592
+ renameSync(partialPath, destPath);
593
+ }
594
+ resolve();
595
+ return;
596
+ }
597
+ // Check for successful response
598
+ if (response.statusCode !== 200 && response.statusCode !== 206) {
599
+ reject(new Error(`Download failed with status ${response.statusCode}`));
600
+ return;
601
+ }
602
+ const totalSize = response.statusCode === 206
603
+ ? expectedSize
604
+ : parseInt(response.headers["content-length"] || "0", 10);
605
+ const fileStream = createWriteStream(partialPath, {
606
+ flags: startByte > 0 ? "a" : "w",
607
+ mode: 0o644,
608
+ });
609
+ let downloaded = startByte;
610
+ response.on("data", (chunk) => {
611
+ downloaded += chunk.length;
612
+ onProgress(downloaded, totalSize || expectedSize);
613
+ });
614
+ response.pipe(fileStream);
615
+ fileStream.on("finish", () => {
616
+ fileStream.close();
617
+ // Move partial to final destination
618
+ renameSync(partialPath, destPath);
619
+ resolve();
620
+ });
621
+ fileStream.on("error", (err) => {
622
+ fileStream.close();
623
+ reject(err);
624
+ });
625
+ response.on("error", (err) => {
626
+ fileStream.close();
627
+ reject(err);
628
+ });
629
+ });
630
+ request.on("error", (err) => {
631
+ reject(err);
632
+ });
633
+ });
634
+ }
635
+ // Verify downloaded file SHA256
636
+ function verifyChecksum(filePath, expectedHash) {
637
+ const fileBuffer = readFileSync(filePath);
638
+ const hash = createHash("sha256").update(fileBuffer).digest("hex");
639
+ return hash === expectedHash;
640
+ }
641
+ /**
642
+ * VoiceClient - Manages WebSocket connection to the whisper server
643
+ *
644
+ * Features:
645
+ * - Connects to Unix socket at ~/.jfl/voice.sock
646
+ * - Authenticates with token from ~/.jfl/voice-server.token
647
+ * - Streams 16-bit PCM audio at 16kHz mono
648
+ * - Receives partial and final transcripts
649
+ * - Auto-reconnects with exponential backoff
650
+ *
651
+ * Usage:
652
+ * ```typescript
653
+ * const client = new VoiceClient()
654
+ *
655
+ * client.onTranscript((text, isFinal) => {
656
+ * if (isFinal) console.log("Final:", text)
657
+ * else console.log("Partial:", text)
658
+ * })
659
+ *
660
+ * client.onError((error) => {
661
+ * console.error("Error:", error.message)
662
+ * })
663
+ *
664
+ * await client.connect()
665
+ *
666
+ * // Send audio chunks (16-bit PCM, 16kHz mono)
667
+ * client.sendAudio(audioBuffer)
668
+ *
669
+ * // When done
670
+ * client.disconnect()
671
+ * ```
672
+ */
673
+ export class VoiceClient extends EventEmitter {
674
+ ws = null;
675
+ serverUrl;
676
+ authToken;
677
+ state = "disconnected";
678
+ reconnectAttempts = 0;
679
+ reconnectTimer = null;
680
+ shouldReconnect = false;
681
+ // Configuration
682
+ maxReconnectAttempts;
683
+ initialReconnectDelay;
684
+ maxReconnectDelay;
685
+ // Callback holders
686
+ transcriptCallbacks = [];
687
+ errorCallbacks = [];
688
+ connectionCallbacks = [];
689
+ constructor(options = {}) {
690
+ super();
691
+ // Prefer direct TCP connection over Unix socket for better binary data handling
692
+ this.serverUrl = options.serverUrl ?? "ws://127.0.0.1:9090";
693
+ this.authToken = options.authToken ?? null;
694
+ this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
695
+ this.initialReconnectDelay = options.initialReconnectDelay ?? 1000;
696
+ this.maxReconnectDelay = options.maxReconnectDelay ?? 30000;
697
+ }
698
+ /**
699
+ * Get the current connection state
700
+ */
701
+ getState() {
702
+ return this.state;
703
+ }
704
+ /**
705
+ * Check if connected
706
+ */
707
+ isConnected() {
708
+ return this.state === "connected" && this.ws?.readyState === WebSocket.OPEN;
709
+ }
710
+ /**
711
+ * Connect to the whisper server
712
+ *
713
+ * @throws VoiceError if auth token is missing (AUTH_FAILED)
714
+ * @throws VoiceError if connection fails
715
+ */
716
+ async connect() {
717
+ if (this.state === "connected" || this.state === "connecting") {
718
+ return;
719
+ }
720
+ // Get auth token if not provided
721
+ if (!this.authToken) {
722
+ this.authToken = readAuthToken();
723
+ }
724
+ if (!this.authToken) {
725
+ throw new VoiceError(VoiceErrorType.AUTH_FAILED, {
726
+ context: { tokenPath: getVoiceTokenPath(), reason: "Token not found" },
727
+ });
728
+ }
729
+ this.setState("connecting");
730
+ this.shouldReconnect = true;
731
+ this.reconnectAttempts = 0;
732
+ return this.doConnect();
733
+ }
734
+ /**
735
+ * Internal connection method
736
+ */
737
+ doConnect() {
738
+ return new Promise((resolve, reject) => {
739
+ try {
740
+ // Create WebSocket connection via TCP
741
+ // Server expects token as query parameter for WebSocket connections
742
+ const url = `${this.serverUrl}/?token=${this.authToken}`;
743
+ this.ws = new WebSocket(url);
744
+ const connectionTimeout = setTimeout(() => {
745
+ if (this.state === "connecting") {
746
+ this.ws?.terminate();
747
+ const error = new Error("Connection timeout");
748
+ this.handleError(error);
749
+ reject(error);
750
+ }
751
+ }, 10000);
752
+ this.ws.on("open", () => {
753
+ clearTimeout(connectionTimeout);
754
+ this.setState("connected");
755
+ this.reconnectAttempts = 0;
756
+ this.emit("connected");
757
+ resolve();
758
+ });
759
+ this.ws.on("message", (data) => {
760
+ this.handleMessage(data);
761
+ });
762
+ this.ws.on("error", (error) => {
763
+ clearTimeout(connectionTimeout);
764
+ this.handleError(error);
765
+ if (this.state === "connecting") {
766
+ reject(error);
767
+ }
768
+ });
769
+ this.ws.on("close", (code, reason) => {
770
+ clearTimeout(connectionTimeout);
771
+ this.handleClose(code, reason.toString());
772
+ });
773
+ }
774
+ catch (error) {
775
+ this.setState("disconnected");
776
+ const err = error instanceof Error ? error : new Error(String(error));
777
+ this.handleError(err);
778
+ reject(err);
779
+ }
780
+ });
781
+ }
782
+ /**
783
+ * Disconnect from the whisper server
784
+ */
785
+ disconnect() {
786
+ this.shouldReconnect = false;
787
+ this.clearReconnectTimer();
788
+ if (this.ws) {
789
+ // Remove listeners to prevent reconnection attempts
790
+ this.ws.removeAllListeners();
791
+ if (this.ws.readyState === WebSocket.OPEN ||
792
+ this.ws.readyState === WebSocket.CONNECTING) {
793
+ this.ws.close(1000, "Client disconnect");
794
+ }
795
+ this.ws = null;
796
+ }
797
+ this.setState("disconnected");
798
+ this.emit("disconnected");
799
+ }
800
+ /**
801
+ * Send audio data to the whisper server
802
+ *
803
+ * @param buffer - 16-bit PCM audio data at 16kHz mono
804
+ * @throws Error if not connected
805
+ */
806
+ sendAudio(buffer) {
807
+ if (!this.isConnected()) {
808
+ throw new Error("Not connected to voice server");
809
+ }
810
+ if (!this.ws) {
811
+ throw new Error("WebSocket is null");
812
+ }
813
+ // WORKAROUND: Node.js 25 + ws library bug
814
+ // Buffer.concat() creates buffers that don't send properly via WebSocket
815
+ // Writing to a temp file and reading back creates a buffer that works
816
+ // This is a known issue with certain Buffer pooling/sharing behaviors
817
+ const tempPath = `/tmp/.voice-send-${Date.now()}.tmp`;
818
+ try {
819
+ if (process.env.JFL_VOICE_DEBUG) {
820
+ console.log(`[VoiceClient] Writing ${buffer.length} bytes to temp file: ${tempPath}`);
821
+ }
822
+ writeFileSync(tempPath, buffer);
823
+ const sendBuffer = readFileSync(tempPath);
824
+ unlinkSync(tempPath);
825
+ if (process.env.JFL_VOICE_DEBUG) {
826
+ console.log(`[VoiceClient] sendAudio: ${sendBuffer.length} bytes (via temp file)`);
827
+ console.log(`[VoiceClient] Buffer.isBuffer: ${Buffer.isBuffer(sendBuffer)}`);
828
+ console.log(`[VoiceClient] ws.readyState: ${this.ws.readyState} (OPEN=1)`);
829
+ console.log(`[VoiceClient] ws.bufferedAmount before: ${this.ws.bufferedAmount}`);
830
+ }
831
+ // Send binary audio data - Buffer is automatically sent as binary
832
+ // Use callback to catch send errors
833
+ this.ws.send(sendBuffer, (err) => {
834
+ if (err) {
835
+ console.error(`[VoiceClient] send error:`, err);
836
+ }
837
+ else if (process.env.JFL_VOICE_DEBUG) {
838
+ console.log(`[VoiceClient] send callback: success`);
839
+ console.log(`[VoiceClient] ws.bufferedAmount after: ${this.ws?.bufferedAmount}`);
840
+ }
841
+ });
842
+ }
843
+ catch (e) {
844
+ console.error(`[VoiceClient] Error in temp file workaround:`, e);
845
+ // Clean up temp file on error
846
+ try {
847
+ unlinkSync(tempPath);
848
+ }
849
+ catch { }
850
+ throw e;
851
+ }
852
+ if (process.env.JFL_VOICE_DEBUG) {
853
+ console.log(`[VoiceClient] ws.bufferedAmount after: ${this.ws.bufferedAmount}`);
854
+ }
855
+ }
856
+ /**
857
+ * Send end-of-audio signal to get final transcript
858
+ */
859
+ endAudio() {
860
+ if (!this.isConnected() || !this.ws) {
861
+ return;
862
+ }
863
+ // Send a JSON message indicating end of audio stream
864
+ this.ws.send(JSON.stringify({ type: "end_audio" }));
865
+ }
866
+ /**
867
+ * Register callback for transcript events
868
+ *
869
+ * @param callback - Called with transcript text and whether it's final
870
+ */
871
+ onTranscript(callback) {
872
+ this.transcriptCallbacks.push(callback);
873
+ }
874
+ /**
875
+ * Register callback for error events
876
+ *
877
+ * @param callback - Called with error
878
+ */
879
+ onError(callback) {
880
+ this.errorCallbacks.push(callback);
881
+ }
882
+ /**
883
+ * Register callback for connection state changes
884
+ *
885
+ * @param callback - Called with new connection state
886
+ */
887
+ onConnectionStateChange(callback) {
888
+ this.connectionCallbacks.push(callback);
889
+ }
890
+ /**
891
+ * Remove all callbacks
892
+ */
893
+ removeAllCallbacks() {
894
+ this.transcriptCallbacks = [];
895
+ this.errorCallbacks = [];
896
+ this.connectionCallbacks = [];
897
+ }
898
+ // ---------------------------------------------------------------------------
899
+ // Private methods
900
+ // ---------------------------------------------------------------------------
901
+ setState(state) {
902
+ if (this.state !== state) {
903
+ this.state = state;
904
+ for (const callback of this.connectionCallbacks) {
905
+ try {
906
+ callback(state);
907
+ }
908
+ catch (e) {
909
+ // Ignore callback errors
910
+ }
911
+ }
912
+ }
913
+ }
914
+ handleMessage(data) {
915
+ try {
916
+ // Parse JSON message
917
+ const message = JSON.parse(data.toString());
918
+ // Debug logging
919
+ if (process.env.JFL_VOICE_DEBUG) {
920
+ console.log(`[VoiceClient] Received: ${message.type}`, JSON.stringify(message));
921
+ }
922
+ if (message.type === "error") {
923
+ const errorMsg = message;
924
+ const error = new Error(errorMsg.error);
925
+ this.handleError(error);
926
+ return;
927
+ }
928
+ if (message.type === "partial" || message.type === "final") {
929
+ const transcript = message;
930
+ const isFinal = transcript.type === "final";
931
+ for (const callback of this.transcriptCallbacks) {
932
+ try {
933
+ callback(transcript.text, isFinal);
934
+ }
935
+ catch (e) {
936
+ // Ignore callback errors
937
+ }
938
+ }
939
+ this.emit("transcript", transcript.text, isFinal);
940
+ }
941
+ }
942
+ catch (e) {
943
+ // Failed to parse message - could be binary data or malformed JSON
944
+ if (process.env.JFL_VOICE_DEBUG) {
945
+ console.error("Failed to parse server message:", e);
946
+ }
947
+ }
948
+ }
949
+ handleError(error) {
950
+ // Notify error callbacks
951
+ for (const callback of this.errorCallbacks) {
952
+ try {
953
+ callback(error);
954
+ }
955
+ catch (e) {
956
+ // Ignore callback errors
957
+ }
958
+ }
959
+ this.emit("error", error);
960
+ }
961
+ handleClose(code, reason) {
962
+ const wasConnected = this.state === "connected";
963
+ this.ws = null;
964
+ if (process.env.DEBUG) {
965
+ console.log(`WebSocket closed: code=${code}, reason=${reason}`);
966
+ }
967
+ // Check if we should attempt to reconnect
968
+ if (this.shouldReconnect && wasConnected) {
969
+ this.scheduleReconnect();
970
+ }
971
+ else {
972
+ this.setState("disconnected");
973
+ }
974
+ this.emit("close", code, reason);
975
+ }
976
+ scheduleReconnect() {
977
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
978
+ const error = new Error(`Failed to reconnect after ${this.maxReconnectAttempts} attempts`);
979
+ this.handleError(error);
980
+ this.setState("disconnected");
981
+ this.emit("reconnect_failed");
982
+ return;
983
+ }
984
+ this.setState("reconnecting");
985
+ this.reconnectAttempts++;
986
+ // Calculate delay with exponential backoff
987
+ const delay = Math.min(this.initialReconnectDelay * Math.pow(2, this.reconnectAttempts - 1), this.maxReconnectDelay);
988
+ if (process.env.DEBUG) {
989
+ console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
990
+ }
991
+ this.emit("reconnecting", this.reconnectAttempts, delay);
992
+ this.reconnectTimer = setTimeout(async () => {
993
+ if (!this.shouldReconnect) {
994
+ return;
995
+ }
996
+ try {
997
+ await this.doConnect();
998
+ this.emit("reconnected");
999
+ }
1000
+ catch (error) {
1001
+ // doConnect will schedule another reconnect attempt on failure
1002
+ // through handleClose callback
1003
+ }
1004
+ }, delay);
1005
+ }
1006
+ clearReconnectTimer() {
1007
+ if (this.reconnectTimer) {
1008
+ clearTimeout(this.reconnectTimer);
1009
+ this.reconnectTimer = null;
1010
+ }
1011
+ }
1012
+ }
1013
+ /**
1014
+ * AudioRecorder - Cross-platform audio recording with sample rate conversion
1015
+ *
1016
+ * Records audio from the system microphone and outputs 16-bit PCM at 16kHz mono.
1017
+ * Works on macOS, Linux, and Windows using sox/rec/arecord backends.
1018
+ *
1019
+ * Prerequisites:
1020
+ * - macOS: `brew install sox`
1021
+ * - Linux: `sudo apt-get install sox libsox-fmt-all` or `alsa-utils`
1022
+ * - Windows: `choco install sox.portable`
1023
+ *
1024
+ * Usage:
1025
+ * ```typescript
1026
+ * const recorder = new AudioRecorder({ sampleRate: 16000 })
1027
+ *
1028
+ * recorder.on('data', (chunk: Buffer) => {
1029
+ * // Process 16-bit PCM audio data
1030
+ * voiceClient.sendAudio(chunk)
1031
+ * })
1032
+ *
1033
+ * recorder.on('error', (error: Error) => {
1034
+ * console.error('Recording error:', error)
1035
+ * })
1036
+ *
1037
+ * await recorder.start()
1038
+ *
1039
+ * // Later...
1040
+ * recorder.stop()
1041
+ * ```
1042
+ */
1043
+ export class AudioRecorder extends EventEmitter {
1044
+ state = "idle";
1045
+ process = null;
1046
+ device;
1047
+ targetSampleRate;
1048
+ recorderBackend;
1049
+ currentPlatform;
1050
+ disconnectCheckInterval = null;
1051
+ constructor(options = {}) {
1052
+ super();
1053
+ this.device = options.device;
1054
+ this.targetSampleRate = options.sampleRate ?? 16000;
1055
+ this.currentPlatform = platform();
1056
+ this.recorderBackend = this.selectRecorder(options.recorder ?? "auto");
1057
+ }
1058
+ /**
1059
+ * List available audio input devices
1060
+ *
1061
+ * @returns Promise resolving to array of available audio devices
1062
+ */
1063
+ static async listDevices() {
1064
+ const currentPlatform = platform();
1065
+ try {
1066
+ if (currentPlatform === "darwin") {
1067
+ return await AudioRecorder.listDevicesMacOS();
1068
+ }
1069
+ else if (currentPlatform === "linux") {
1070
+ return await AudioRecorder.listDevicesLinux();
1071
+ }
1072
+ else if (currentPlatform === "win32") {
1073
+ return await AudioRecorder.listDevicesWindows();
1074
+ }
1075
+ else {
1076
+ throw new Error(`Unsupported platform: ${currentPlatform}`);
1077
+ }
1078
+ }
1079
+ catch (error) {
1080
+ // If listing fails, return empty array with a warning
1081
+ console.warn("Failed to list audio devices:", error);
1082
+ return [];
1083
+ }
1084
+ }
1085
+ /**
1086
+ * List audio devices on macOS using sox
1087
+ */
1088
+ static async listDevicesMacOS() {
1089
+ const devices = [];
1090
+ try {
1091
+ // On macOS, sox -d uses coreaudio which lists devices differently
1092
+ // Try using system_profiler for detailed device list
1093
+ const output = execSync("system_profiler SPAudioDataType -json 2>/dev/null", { encoding: "utf-8", timeout: 5000 });
1094
+ const data = JSON.parse(output);
1095
+ const audioData = data.SPAudioDataType;
1096
+ if (audioData && Array.isArray(audioData)) {
1097
+ for (const device of audioData) {
1098
+ // Each audio device group may have input devices
1099
+ const name = device._name || "Unknown Device";
1100
+ const items = device._items || [];
1101
+ for (const item of items) {
1102
+ if (item.coreaudio_input_source) {
1103
+ devices.push({
1104
+ id: String(item.coreaudio_device_input || name),
1105
+ name: String(item._name || name),
1106
+ isDefault: item.coreaudio_default_audio_input_device === "yes",
1107
+ });
1108
+ }
1109
+ }
1110
+ // Also check the main device entry
1111
+ if (device.coreaudio_input_source) {
1112
+ devices.push({
1113
+ id: String(device.coreaudio_device_input || name),
1114
+ name: String(name),
1115
+ isDefault: device.coreaudio_default_audio_input_device === "yes",
1116
+ });
1117
+ }
1118
+ }
1119
+ }
1120
+ }
1121
+ catch {
1122
+ // Fallback: try using sox to list devices if available
1123
+ try {
1124
+ // Sox on macOS with coreaudio can list devices
1125
+ const soxOutput = execSync("rec -q --list-devices 2>&1 || true", {
1126
+ encoding: "utf-8",
1127
+ timeout: 5000,
1128
+ });
1129
+ // Parse sox output for device names
1130
+ const lines = soxOutput.split("\n");
1131
+ for (const line of lines) {
1132
+ const match = line.match(/^\s*(\d+)\s+(.+)$/);
1133
+ if (match) {
1134
+ devices.push({
1135
+ id: match[1],
1136
+ name: match[2].trim(),
1137
+ isDefault: match[1] === "0",
1138
+ });
1139
+ }
1140
+ }
1141
+ }
1142
+ catch {
1143
+ // Last resort: add a default device entry
1144
+ devices.push({
1145
+ id: "default",
1146
+ name: "Default Input Device",
1147
+ isDefault: true,
1148
+ });
1149
+ }
1150
+ }
1151
+ // If no devices found, add default
1152
+ if (devices.length === 0) {
1153
+ devices.push({
1154
+ id: "default",
1155
+ name: "Default Input Device",
1156
+ isDefault: true,
1157
+ });
1158
+ }
1159
+ return devices;
1160
+ }
1161
+ /**
1162
+ * List audio devices on Linux using arecord or pactl
1163
+ */
1164
+ static async listDevicesLinux() {
1165
+ const devices = [];
1166
+ // Try PulseAudio/PipeWire first
1167
+ try {
1168
+ const pactlOutput = execSync("pactl list short sources 2>/dev/null", {
1169
+ encoding: "utf-8",
1170
+ timeout: 5000,
1171
+ });
1172
+ const lines = pactlOutput.trim().split("\n");
1173
+ for (const line of lines) {
1174
+ const parts = line.split("\t");
1175
+ if (parts.length >= 2) {
1176
+ const id = parts[1];
1177
+ // Skip monitor devices (output monitors)
1178
+ if (id.includes(".monitor"))
1179
+ continue;
1180
+ devices.push({
1181
+ id,
1182
+ name: id,
1183
+ isDefault: id.includes("@DEFAULT_SOURCE@") || devices.length === 0,
1184
+ });
1185
+ }
1186
+ }
1187
+ if (devices.length > 0) {
1188
+ return devices;
1189
+ }
1190
+ }
1191
+ catch {
1192
+ // PulseAudio not available, try ALSA
1193
+ }
1194
+ // Try ALSA
1195
+ try {
1196
+ const arecordOutput = execSync("arecord -l 2>/dev/null", {
1197
+ encoding: "utf-8",
1198
+ timeout: 5000,
1199
+ });
1200
+ const lines = arecordOutput.split("\n");
1201
+ for (const line of lines) {
1202
+ // Parse lines like: "card 0: PCH [HDA Intel PCH], device 0: ALC892 Analog [ALC892 Analog]"
1203
+ const match = line.match(/^card\s+(\d+):\s+([^,]+),\s+device\s+(\d+):\s+(.+)$/);
1204
+ if (match) {
1205
+ const cardNum = match[1];
1206
+ const deviceNum = match[3];
1207
+ const deviceName = match[4].trim();
1208
+ devices.push({
1209
+ id: `hw:${cardNum},${deviceNum}`,
1210
+ name: `${match[2].trim()} - ${deviceName}`,
1211
+ isDefault: devices.length === 0,
1212
+ });
1213
+ }
1214
+ }
1215
+ }
1216
+ catch {
1217
+ // ALSA not available
1218
+ }
1219
+ // Fallback to default
1220
+ if (devices.length === 0) {
1221
+ devices.push({
1222
+ id: "default",
1223
+ name: "Default Input Device",
1224
+ isDefault: true,
1225
+ });
1226
+ }
1227
+ return devices;
1228
+ }
1229
+ /**
1230
+ * List audio devices on Windows using sox or powershell
1231
+ */
1232
+ static async listDevicesWindows() {
1233
+ const devices = [];
1234
+ try {
1235
+ // Use PowerShell to list audio input devices
1236
+ const psCommand = `
1237
+ Get-WmiObject Win32_SoundDevice |
1238
+ Where-Object { $_.Status -eq 'OK' } |
1239
+ Select-Object DeviceID, Name |
1240
+ ConvertTo-Json
1241
+ `;
1242
+ const output = execSync(`powershell -Command "${psCommand}"`, {
1243
+ encoding: "utf-8",
1244
+ timeout: 10000,
1245
+ });
1246
+ const data = JSON.parse(output);
1247
+ const deviceList = Array.isArray(data) ? data : [data];
1248
+ for (let i = 0; i < deviceList.length; i++) {
1249
+ const device = deviceList[i];
1250
+ if (device && device.Name) {
1251
+ devices.push({
1252
+ id: device.DeviceID || String(i),
1253
+ name: device.Name,
1254
+ isDefault: i === 0,
1255
+ });
1256
+ }
1257
+ }
1258
+ }
1259
+ catch {
1260
+ // Fallback to default
1261
+ }
1262
+ if (devices.length === 0) {
1263
+ devices.push({
1264
+ id: "-1",
1265
+ name: "Default Input Device",
1266
+ isDefault: true,
1267
+ });
1268
+ }
1269
+ return devices;
1270
+ }
1271
+ /**
1272
+ * Select the appropriate recorder backend based on platform and availability
1273
+ */
1274
+ selectRecorder(preference) {
1275
+ if (preference !== "auto") {
1276
+ // Verify the requested backend is available
1277
+ if (this.isRecorderAvailable(preference)) {
1278
+ return preference;
1279
+ }
1280
+ console.warn(`Recorder '${preference}' not available, falling back to auto-detection`);
1281
+ }
1282
+ // Auto-detect based on platform
1283
+ if (this.currentPlatform === "darwin") {
1284
+ // macOS: prefer rec (comes with sox), then sox
1285
+ if (this.isRecorderAvailable("rec"))
1286
+ return "rec";
1287
+ if (this.isRecorderAvailable("sox"))
1288
+ return "sox";
1289
+ }
1290
+ else if (this.currentPlatform === "linux") {
1291
+ // Linux: prefer arecord (ALSA), then sox
1292
+ if (this.isRecorderAvailable("arecord"))
1293
+ return "arecord";
1294
+ if (this.isRecorderAvailable("sox"))
1295
+ return "sox";
1296
+ if (this.isRecorderAvailable("rec"))
1297
+ return "rec";
1298
+ }
1299
+ else if (this.currentPlatform === "win32") {
1300
+ // Windows: prefer sox
1301
+ if (this.isRecorderAvailable("sox"))
1302
+ return "sox";
1303
+ if (this.isRecorderAvailable("rec"))
1304
+ return "rec";
1305
+ }
1306
+ // Default fallback - throw VoiceError
1307
+ throw new VoiceError(VoiceErrorType.SOX_NOT_INSTALLED);
1308
+ }
1309
+ /**
1310
+ * Check if a recorder binary is available
1311
+ */
1312
+ isRecorderAvailable(recorder) {
1313
+ try {
1314
+ const command = this.currentPlatform === "win32" ? `where ${recorder}` : `which ${recorder}`;
1315
+ execSync(command, { stdio: "ignore", timeout: 3000 });
1316
+ return true;
1317
+ }
1318
+ catch {
1319
+ return false;
1320
+ }
1321
+ }
1322
+ /**
1323
+ * Check if currently recording
1324
+ */
1325
+ isRecording() {
1326
+ return this.state === "recording";
1327
+ }
1328
+ /**
1329
+ * Get current recording state
1330
+ */
1331
+ getState() {
1332
+ return this.state;
1333
+ }
1334
+ /**
1335
+ * Start recording audio
1336
+ *
1337
+ * @throws Error if already recording
1338
+ * @throws Error if recorder is not available
1339
+ */
1340
+ async start() {
1341
+ if (this.state === "recording") {
1342
+ throw new Error("Already recording");
1343
+ }
1344
+ this.state = "recording";
1345
+ try {
1346
+ const args = this.buildRecorderArgs();
1347
+ const command = this.recorderBackend === "arecord" ? "arecord" : this.recorderBackend;
1348
+ if (process.env.DEBUG) {
1349
+ console.log(`Starting recorder: ${command} ${args.join(" ")}`);
1350
+ }
1351
+ this.process = spawn(command, args, {
1352
+ stdio: ["ignore", "pipe", "pipe"],
1353
+ });
1354
+ if (!this.process.stdout) {
1355
+ throw new Error("Failed to open audio stream");
1356
+ }
1357
+ // Set up stdout as the audio data stream
1358
+ const stdout = this.process.stdout;
1359
+ // Buffer for collecting data
1360
+ let audioBuffer = Buffer.alloc(0);
1361
+ const chunkSize = 3200; // 100ms of 16kHz 16-bit mono audio
1362
+ stdout.on("data", (chunk) => {
1363
+ // Accumulate data - ensure chunk is a proper Buffer (Node may emit Uint8Array)
1364
+ audioBuffer = Buffer.concat([audioBuffer, Buffer.from(chunk)]);
1365
+ // Emit complete chunks
1366
+ while (audioBuffer.length >= chunkSize) {
1367
+ // IMPORTANT: Use Buffer.from() to create an independent copy, not subarray()
1368
+ // subarray() creates a view that shares the underlying ArrayBuffer
1369
+ // which can cause issues when the buffer is sent over WebSocket
1370
+ const emitChunk = Buffer.from(audioBuffer.subarray(0, chunkSize));
1371
+ audioBuffer = audioBuffer.subarray(chunkSize);
1372
+ this.emit("data", emitChunk);
1373
+ }
1374
+ });
1375
+ stdout.on("end", () => {
1376
+ // Emit any remaining data (as a copy)
1377
+ if (audioBuffer.length > 0) {
1378
+ this.emit("data", Buffer.from(audioBuffer));
1379
+ }
1380
+ this.handleProcessEnd();
1381
+ });
1382
+ stdout.on("error", (error) => {
1383
+ this.handleError(error);
1384
+ });
1385
+ // Handle stderr for warnings/errors
1386
+ if (this.process.stderr) {
1387
+ this.process.stderr.on("data", (data) => {
1388
+ const message = data.toString();
1389
+ // Only emit as error if it contains actual error indicators
1390
+ if (message.toLowerCase().includes("error") ||
1391
+ message.toLowerCase().includes("fail") ||
1392
+ message.toLowerCase().includes("cannot")) {
1393
+ this.handleError(new Error(`Recorder error: ${message}`));
1394
+ }
1395
+ else if (process.env.DEBUG) {
1396
+ console.warn("Recorder stderr:", message);
1397
+ }
1398
+ });
1399
+ }
1400
+ // Handle process errors
1401
+ this.process.on("error", (error) => {
1402
+ this.handleError(error);
1403
+ });
1404
+ this.process.on("exit", (code, signal) => {
1405
+ if (this.state === "recording") {
1406
+ // Unexpected exit
1407
+ if (code !== 0 && code !== null) {
1408
+ this.handleError(new Error(`Recorder exited with code ${code}`));
1409
+ }
1410
+ else if (signal) {
1411
+ this.handleError(new Error(`Recorder killed by signal ${signal}`));
1412
+ }
1413
+ else {
1414
+ this.handleProcessEnd();
1415
+ }
1416
+ }
1417
+ });
1418
+ // Start monitoring for device disconnect
1419
+ this.startDisconnectMonitor();
1420
+ }
1421
+ catch (error) {
1422
+ this.state = "error";
1423
+ throw error;
1424
+ }
1425
+ }
1426
+ /**
1427
+ * Stop recording
1428
+ */
1429
+ stop() {
1430
+ if (this.state !== "recording") {
1431
+ return;
1432
+ }
1433
+ this.state = "stopping";
1434
+ this.stopDisconnectMonitor();
1435
+ if (this.process) {
1436
+ // Send SIGTERM for graceful shutdown
1437
+ try {
1438
+ this.process.kill("SIGTERM");
1439
+ }
1440
+ catch {
1441
+ // Process may have already exited
1442
+ }
1443
+ // Force kill after timeout
1444
+ const killTimeout = setTimeout(() => {
1445
+ if (this.process) {
1446
+ try {
1447
+ this.process.kill("SIGKILL");
1448
+ }
1449
+ catch {
1450
+ // Ignore
1451
+ }
1452
+ }
1453
+ }, 1000);
1454
+ this.process.once("exit", () => {
1455
+ clearTimeout(killTimeout);
1456
+ this.process = null;
1457
+ this.state = "idle";
1458
+ this.emit("stopped");
1459
+ });
1460
+ }
1461
+ else {
1462
+ this.state = "idle";
1463
+ this.emit("stopped");
1464
+ }
1465
+ }
1466
+ /**
1467
+ * Build command-line arguments for the recorder
1468
+ */
1469
+ buildRecorderArgs() {
1470
+ const args = [];
1471
+ if (this.recorderBackend === "arecord") {
1472
+ // ALSA arecord arguments
1473
+ args.push("-f", "S16_LE", // 16-bit signed little-endian
1474
+ "-r", String(this.targetSampleRate), "-c", "1", // Mono
1475
+ "-t", "raw", // Raw PCM output
1476
+ "-q" // Quiet mode
1477
+ );
1478
+ if (this.device) {
1479
+ args.push("-D", this.device);
1480
+ }
1481
+ }
1482
+ else if (this.recorderBackend === "sox" || this.recorderBackend === "rec") {
1483
+ // sox/rec arguments for recording
1484
+ // Format: rec [input-options] output-file [effects]
1485
+ // On macOS, hardware often can't record at 16kHz directly, so we record
1486
+ // at native rate and use sox's 'rate' effect to resample to target
1487
+ args.push("-q", // Quiet
1488
+ "-c", "1", // Mono
1489
+ "-b", "16", // 16-bit
1490
+ "-e", "signed-integer", // Signed integer encoding
1491
+ "-t", "raw", // Raw PCM output
1492
+ "-", // Output to stdout
1493
+ "rate", String(this.targetSampleRate) // Resample to target rate
1494
+ );
1495
+ if (this.device) {
1496
+ // Device specification differs by platform
1497
+ if (this.currentPlatform === "darwin") {
1498
+ // macOS: use -d for default device or specify device
1499
+ args.unshift("-d", this.device);
1500
+ }
1501
+ else if (this.currentPlatform === "linux") {
1502
+ // Linux: AUDIODEV environment or -d flag
1503
+ args.unshift("-d", this.device);
1504
+ }
1505
+ else if (this.currentPlatform === "win32") {
1506
+ // Windows: use -t waveaudio with device number
1507
+ args.unshift("-t", "waveaudio", this.device);
1508
+ }
1509
+ }
1510
+ else {
1511
+ // Default device - rec uses system default automatically on macOS
1512
+ if (this.currentPlatform === "linux") {
1513
+ // Linux: pulseaudio or alsa default
1514
+ args.unshift("-d");
1515
+ }
1516
+ else if (this.currentPlatform === "win32") {
1517
+ args.unshift("-t", "waveaudio", "-1");
1518
+ }
1519
+ // macOS: no flag needed, rec uses coreaudio default automatically
1520
+ }
1521
+ }
1522
+ return args;
1523
+ }
1524
+ /**
1525
+ * Start monitoring for device disconnect
1526
+ */
1527
+ startDisconnectMonitor() {
1528
+ // Check every 2 seconds if the process is still healthy
1529
+ this.disconnectCheckInterval = setInterval(() => {
1530
+ if (this.state === "recording" && this.process) {
1531
+ // Check if process is still running
1532
+ try {
1533
+ // Sending signal 0 checks if process exists without affecting it
1534
+ process.kill(this.process.pid, 0);
1535
+ }
1536
+ catch {
1537
+ // Process doesn't exist
1538
+ this.handleError(new Error("Audio device disconnected or recorder stopped unexpectedly"));
1539
+ this.stop();
1540
+ }
1541
+ }
1542
+ }, 2000);
1543
+ }
1544
+ /**
1545
+ * Stop disconnect monitoring
1546
+ */
1547
+ stopDisconnectMonitor() {
1548
+ if (this.disconnectCheckInterval) {
1549
+ clearInterval(this.disconnectCheckInterval);
1550
+ this.disconnectCheckInterval = null;
1551
+ }
1552
+ }
1553
+ /**
1554
+ * Handle process end
1555
+ */
1556
+ handleProcessEnd() {
1557
+ this.stopDisconnectMonitor();
1558
+ if (this.state === "recording") {
1559
+ this.state = "idle";
1560
+ this.emit("end");
1561
+ }
1562
+ }
1563
+ /**
1564
+ * Handle recording error
1565
+ */
1566
+ handleError(error) {
1567
+ this.stopDisconnectMonitor();
1568
+ this.state = "error";
1569
+ this.emit("error", error);
1570
+ }
1571
+ }
1572
+ // List models command
1573
+ export async function listModelsCommand() {
1574
+ console.log(chalk.bold("\n🎤 JFL Voice - Available Models\n"));
1575
+ ensureDirectories();
1576
+ const currentDefault = getCurrentDefaultModel();
1577
+ console.log(chalk.gray("Models are downloaded from Hugging Face (ggerganov/whisper.cpp)\n"));
1578
+ const modelNames = Object.keys(WHISPER_MODELS);
1579
+ // Table header
1580
+ console.log(chalk.gray(" ") +
1581
+ chalk.bold("Model".padEnd(14)) +
1582
+ chalk.bold("Size".padEnd(10)) +
1583
+ chalk.bold("Status".padEnd(14)) +
1584
+ chalk.bold("Description"));
1585
+ console.log(chalk.gray(" " + "─".repeat(70)));
1586
+ for (const name of modelNames) {
1587
+ const model = WHISPER_MODELS[name];
1588
+ const isDownloaded = isModelDownloaded(name);
1589
+ const isDefault = name === currentDefault;
1590
+ const statusIcon = isDownloaded ? chalk.green("✓") : chalk.gray("○");
1591
+ const defaultMarker = isDefault ? chalk.cyan(" (default)") : "";
1592
+ const status = isDownloaded ? chalk.green("downloaded") + defaultMarker : chalk.gray("not downloaded");
1593
+ console.log(` ${statusIcon} ` +
1594
+ chalk.white(name.padEnd(12)) +
1595
+ chalk.gray(model.size.padEnd(10)) +
1596
+ status.padEnd(24) +
1597
+ chalk.gray(model.description));
1598
+ }
1599
+ console.log();
1600
+ console.log(chalk.cyan("Commands:"));
1601
+ console.log(" jfl voice model download <name> Download a model");
1602
+ console.log(" jfl voice model default <name> Set default model");
1603
+ console.log();
1604
+ }
1605
+ // Download model command
1606
+ export async function downloadModelCommand(modelName, options) {
1607
+ console.log(chalk.bold("\n🎤 JFL Voice - Download Model\n"));
1608
+ // Validate model name
1609
+ if (!(modelName in WHISPER_MODELS)) {
1610
+ console.log(chalk.red(`Unknown model: ${modelName}`));
1611
+ console.log(chalk.gray("\nAvailable models:"));
1612
+ for (const name of Object.keys(WHISPER_MODELS)) {
1613
+ console.log(` - ${name}`);
1614
+ }
1615
+ return;
1616
+ }
1617
+ const model = WHISPER_MODELS[modelName];
1618
+ ensureDirectories();
1619
+ const modelPath = getModelPath(modelName);
1620
+ const partialPath = getPartialPath(modelName);
1621
+ // Check if already downloaded
1622
+ if (isModelDownloaded(modelName) && !options?.force) {
1623
+ console.log(chalk.green(`✓ Model '${modelName}' is already downloaded`));
1624
+ console.log(chalk.gray(` Location: ${modelPath}`));
1625
+ return;
1626
+ }
1627
+ // Check for partial download
1628
+ let resuming = false;
1629
+ if (existsSync(partialPath)) {
1630
+ const stats = statSync(partialPath);
1631
+ const percent = ((stats.size / model.sizeBytes) * 100).toFixed(1);
1632
+ console.log(chalk.yellow(`Found incomplete download (${percent}% complete)`));
1633
+ const { resume } = await inquirer.prompt([
1634
+ {
1635
+ type: "confirm",
1636
+ name: "resume",
1637
+ message: "Resume download?",
1638
+ default: true,
1639
+ },
1640
+ ]);
1641
+ if (!resume) {
1642
+ unlinkSync(partialPath);
1643
+ }
1644
+ else {
1645
+ resuming = true;
1646
+ }
1647
+ }
1648
+ console.log(chalk.gray(`Model: ${model.displayName}`));
1649
+ console.log(chalk.gray(`Size: ${model.size}`));
1650
+ console.log(chalk.gray(`URL: ${model.url}`));
1651
+ if (resuming) {
1652
+ console.log(chalk.cyan("Resuming download..."));
1653
+ }
1654
+ console.log();
1655
+ const spinner = ora({
1656
+ text: "Starting download...",
1657
+ prefixText: " ",
1658
+ }).start();
1659
+ const startTime = Date.now();
1660
+ let lastUpdate = 0;
1661
+ try {
1662
+ await downloadFile(model.url, modelPath, partialPath, model.sizeBytes, (downloaded, total) => {
1663
+ const now = Date.now();
1664
+ // Update at most every 100ms to avoid flickering
1665
+ if (now - lastUpdate < 100)
1666
+ return;
1667
+ lastUpdate = now;
1668
+ const percent = ((downloaded / total) * 100).toFixed(1);
1669
+ const elapsed = (now - startTime) / 1000;
1670
+ const speed = downloaded / elapsed;
1671
+ const remaining = (total - downloaded) / speed;
1672
+ spinner.text = `Downloading: ${percent}% (${formatBytes(downloaded)}/${formatBytes(total)}) - ${formatBytes(speed)}/s - ${remaining.toFixed(0)}s remaining`;
1673
+ });
1674
+ spinner.text = "Verifying checksum...";
1675
+ // Verify checksum
1676
+ if (!verifyChecksum(modelPath, model.sha256)) {
1677
+ spinner.fail("Checksum verification failed");
1678
+ console.log(chalk.red("\nThe downloaded file is corrupted."));
1679
+ console.log(chalk.gray("Try downloading again with: jfl voice model download " + modelName + " --force"));
1680
+ unlinkSync(modelPath);
1681
+ return;
1682
+ }
1683
+ spinner.succeed("Download complete!");
1684
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
1685
+ console.log();
1686
+ console.log(chalk.green(`✓ Model '${modelName}' downloaded successfully`));
1687
+ console.log(chalk.gray(` Location: ${modelPath}`));
1688
+ console.log(chalk.gray(` Time: ${elapsed}s`));
1689
+ // Offer to set as default if base model
1690
+ if (modelName === "base" && getCurrentDefaultModel() !== "base") {
1691
+ const { setDefault } = await inquirer.prompt([
1692
+ {
1693
+ type: "confirm",
1694
+ name: "setDefault",
1695
+ message: "Set 'base' as your default model?",
1696
+ default: true,
1697
+ },
1698
+ ]);
1699
+ if (setDefault) {
1700
+ setDefaultModel("base");
1701
+ console.log(chalk.green("✓ Default model set to 'base'"));
1702
+ }
1703
+ }
1704
+ }
1705
+ catch (error) {
1706
+ spinner.fail("Download failed");
1707
+ console.error(chalk.red(error));
1708
+ console.log(chalk.gray("\nYou can resume the download by running the same command again."));
1709
+ }
1710
+ console.log();
1711
+ }
1712
+ // Set default model command
1713
+ export async function setDefaultModelCommand(modelName) {
1714
+ // Validate model name
1715
+ if (!(modelName in WHISPER_MODELS)) {
1716
+ console.log(chalk.red(`Unknown model: ${modelName}`));
1717
+ console.log(chalk.gray("\nAvailable models:"));
1718
+ for (const name of Object.keys(WHISPER_MODELS)) {
1719
+ console.log(` - ${name}`);
1720
+ }
1721
+ return;
1722
+ }
1723
+ // Check if model is downloaded
1724
+ if (!isModelDownloaded(modelName)) {
1725
+ console.log(chalk.yellow(`Model '${modelName}' is not downloaded yet.`));
1726
+ const { download } = await inquirer.prompt([
1727
+ {
1728
+ type: "confirm",
1729
+ name: "download",
1730
+ message: "Download it now?",
1731
+ default: true,
1732
+ },
1733
+ ]);
1734
+ if (download) {
1735
+ await downloadModelCommand(modelName);
1736
+ }
1737
+ return;
1738
+ }
1739
+ setDefaultModel(modelName);
1740
+ console.log(chalk.green(`✓ Default model set to '${modelName}'`));
1741
+ }
1742
+ // List audio devices command
1743
+ export async function listDevicesCommand() {
1744
+ console.log(chalk.bold("\n🎤 JFL Voice - Audio Devices\n"));
1745
+ const spinner = ora({
1746
+ text: "Detecting audio devices...",
1747
+ prefixText: " ",
1748
+ }).start();
1749
+ try {
1750
+ const devices = await AudioRecorder.listDevices();
1751
+ spinner.stop();
1752
+ if (devices.length === 0) {
1753
+ console.log(chalk.yellow(" No audio input devices found."));
1754
+ console.log();
1755
+ console.log(chalk.gray(" Make sure you have a microphone connected and permissions granted."));
1756
+ console.log();
1757
+ return;
1758
+ }
1759
+ console.log(chalk.gray(` Found ${devices.length} audio input device${devices.length > 1 ? "s" : ""}:\n`));
1760
+ // Table header
1761
+ console.log(chalk.gray(" ") +
1762
+ chalk.bold("Device".padEnd(40)) +
1763
+ chalk.bold("ID".padEnd(30)) +
1764
+ chalk.bold("Default"));
1765
+ console.log(chalk.gray(" " + "─".repeat(75)));
1766
+ for (const device of devices) {
1767
+ const defaultMarker = device.isDefault ? chalk.green("✓") : chalk.gray("-");
1768
+ const deviceName = String(device.name || "Unknown").substring(0, 38).padEnd(40);
1769
+ const deviceId = String(device.id || "default").substring(0, 28).padEnd(30);
1770
+ console.log(` ${deviceName}` +
1771
+ chalk.gray(deviceId) +
1772
+ defaultMarker);
1773
+ }
1774
+ console.log();
1775
+ console.log(chalk.cyan("Usage:"));
1776
+ console.log(" jfl voice test Test with default device");
1777
+ console.log(" jfl voice test --device <id> Test with specific device");
1778
+ console.log();
1779
+ }
1780
+ catch (error) {
1781
+ spinner.fail("Failed to list devices");
1782
+ console.error(chalk.red(` ${error}`));
1783
+ console.log();
1784
+ }
1785
+ }
1786
+ // Voice test command - records audio and sends to whisper server for transcription
1787
+ export async function voiceTestCommand(options) {
1788
+ console.log(chalk.bold("\n🎤 JFL Voice - Test\n"));
1789
+ const duration = 3; // Fixed 3 second recording
1790
+ // Step 1: Check if whisper server is running
1791
+ const serverError = checkServerRunning();
1792
+ if (serverError) {
1793
+ handleVoiceError(serverError);
1794
+ return;
1795
+ }
1796
+ // Step 2: Check for auth token
1797
+ const authError = checkAuthToken();
1798
+ if (authError) {
1799
+ handleVoiceError(authError);
1800
+ return;
1801
+ }
1802
+ const authToken = readAuthToken();
1803
+ const socketPath = getVoiceSocketPath();
1804
+ // Step 3: Check for sox/rec availability
1805
+ let recorder;
1806
+ try {
1807
+ recorder = new AudioRecorder({
1808
+ device: options?.device,
1809
+ sampleRate: 16000,
1810
+ });
1811
+ }
1812
+ catch (error) {
1813
+ if (error instanceof VoiceError) {
1814
+ handleVoiceError(error);
1815
+ }
1816
+ else {
1817
+ handleVoiceError(createVoiceError(error instanceof Error ? error : new Error(String(error))));
1818
+ }
1819
+ return;
1820
+ }
1821
+ console.log(chalk.gray(" Recording for 3 seconds..."));
1822
+ console.log(chalk.gray(" Speak clearly into your microphone!\n"));
1823
+ // Collect audio data
1824
+ const audioChunks = [];
1825
+ let peakLevel = 0;
1826
+ recorder.on("data", (chunk) => {
1827
+ audioChunks.push(chunk);
1828
+ // Calculate peak level from 16-bit samples
1829
+ for (let i = 0; i < chunk.length - 1; i += 2) {
1830
+ const sample = chunk.readInt16LE(i);
1831
+ const level = Math.abs(sample) / 32768;
1832
+ if (level > peakLevel) {
1833
+ peakLevel = level;
1834
+ }
1835
+ }
1836
+ });
1837
+ let recordingError = null;
1838
+ recorder.on("error", (error) => {
1839
+ recordingError = error;
1840
+ });
1841
+ // Step 4: Start recording with countdown
1842
+ try {
1843
+ await recorder.start();
1844
+ }
1845
+ catch (error) {
1846
+ const voiceError = new VoiceError(VoiceErrorType.RECORDING_FAILED, {
1847
+ originalError: error instanceof Error ? error : new Error(String(error)),
1848
+ context: { device: options?.device },
1849
+ });
1850
+ handleVoiceError(voiceError);
1851
+ return;
1852
+ }
1853
+ // Countdown display
1854
+ for (let i = duration; i > 0; i--) {
1855
+ process.stdout.write(` Recording... ${chalk.cyan(String(i))}...\r`);
1856
+ await new Promise((resolve) => setTimeout(resolve, 1000));
1857
+ }
1858
+ process.stdout.write(" \r"); // Clear countdown line
1859
+ recorder.stop();
1860
+ // Wait for recorder to fully stop
1861
+ await new Promise((resolve) => {
1862
+ const checkStopped = () => {
1863
+ if (recorder.getState() === "idle") {
1864
+ resolve();
1865
+ }
1866
+ else {
1867
+ setTimeout(checkStopped, 50);
1868
+ }
1869
+ };
1870
+ setTimeout(checkStopped, 100);
1871
+ });
1872
+ if (recordingError !== null) {
1873
+ const voiceError = new VoiceError(VoiceErrorType.RECORDING_FAILED, {
1874
+ originalError: recordingError,
1875
+ context: { device: options?.device },
1876
+ });
1877
+ handleVoiceError(voiceError);
1878
+ return;
1879
+ }
1880
+ // Check if we got any audio
1881
+ const totalBytes = audioChunks.reduce((sum, chunk) => sum + chunk.length, 0);
1882
+ if (totalBytes === 0) {
1883
+ const voiceError = new VoiceError(VoiceErrorType.MIC_UNAVAILABLE, {
1884
+ context: { totalBytes: 0, device: options?.device },
1885
+ });
1886
+ handleVoiceError(voiceError);
1887
+ return;
1888
+ }
1889
+ // Check audio levels
1890
+ if (peakLevel < 0.01) {
1891
+ // Low audio but not an error, just a warning
1892
+ console.log(chalk.yellow(" ⚠ Very low audio level detected"));
1893
+ console.log(chalk.gray(" Suggestion: Try speaking louder or check microphone"));
1894
+ console.log();
1895
+ }
1896
+ // Combine all audio chunks and normalize
1897
+ const audioBuffer = normalizeAudio(Buffer.concat(audioChunks));
1898
+ // Step 5: Connect to whisper server and send audio
1899
+ console.log(chalk.gray(" Transcribing..."));
1900
+ const client = new VoiceClient({
1901
+ socketPath,
1902
+ authToken,
1903
+ maxReconnectAttempts: 1, // Don't retry for test
1904
+ });
1905
+ let transcription = "";
1906
+ let transcriptionReceived = false;
1907
+ let transcriptionError = null;
1908
+ client.onTranscript((text, isFinal) => {
1909
+ // Store the latest transcription (partial or final)
1910
+ // Filter out blank audio markers
1911
+ if (text && text.trim() && !text.includes("[BLANK_AUDIO]")) {
1912
+ transcription = text;
1913
+ }
1914
+ if (isFinal) {
1915
+ transcriptionReceived = true;
1916
+ }
1917
+ });
1918
+ client.onError((error) => {
1919
+ transcriptionError = error;
1920
+ });
1921
+ try {
1922
+ await client.connect();
1923
+ // Send audio data and wait for it to be sent
1924
+ client.sendAudio(audioBuffer);
1925
+ // Wait for data to fully transmit before sending end signal
1926
+ // WebSocket bufferedAmount=0 doesn't guarantee the server received it
1927
+ await new Promise((resolve) => setTimeout(resolve, 500));
1928
+ // Signal end of audio
1929
+ client.endAudio();
1930
+ // Wait for transcription with timeout
1931
+ const timeout = 10000; // 10 seconds
1932
+ const startTime = Date.now();
1933
+ while (!transcriptionReceived && !transcriptionError) {
1934
+ if (Date.now() - startTime > timeout) {
1935
+ // If we have a partial transcription, use it instead of erroring
1936
+ if (transcription) {
1937
+ break;
1938
+ }
1939
+ transcriptionError = new VoiceError(VoiceErrorType.TIMEOUT, {
1940
+ context: { timeout, operation: "transcription" },
1941
+ recoverable: true,
1942
+ audioBuffer,
1943
+ });
1944
+ break;
1945
+ }
1946
+ await new Promise((resolve) => setTimeout(resolve, 100));
1947
+ }
1948
+ client.disconnect();
1949
+ }
1950
+ catch (error) {
1951
+ client.disconnect();
1952
+ const voiceError = error instanceof VoiceError
1953
+ ? error
1954
+ : createVoiceError(error instanceof Error ? error : new Error(String(error)), { operation: "connect" }, audioBuffer);
1955
+ handleVoiceError(voiceError);
1956
+ return;
1957
+ }
1958
+ // Step 6: Display results
1959
+ console.log();
1960
+ if (transcriptionError) {
1961
+ const voiceError = transcriptionError instanceof VoiceError
1962
+ ? transcriptionError
1963
+ : createVoiceError(transcriptionError, { operation: "transcription" }, audioBuffer);
1964
+ handleVoiceError(voiceError);
1965
+ return;
1966
+ }
1967
+ if (!transcription || transcription.trim() === "") {
1968
+ const voiceError = new VoiceError(VoiceErrorType.TRANSCRIPTION_EMPTY, {
1969
+ context: { audioLength: totalBytes, peakLevel },
1970
+ recoverable: true,
1971
+ audioBuffer,
1972
+ });
1973
+ handleVoiceError(voiceError);
1974
+ return;
1975
+ }
1976
+ // Success! Show the transcription
1977
+ console.log(chalk.green(" ✓ Transcription successful!\n"));
1978
+ console.log(chalk.white(" You said:"));
1979
+ console.log(chalk.cyan(` "${transcription.trim()}"`));
1980
+ console.log();
1981
+ // Show audio stats
1982
+ const durationActual = totalBytes / (16000 * 2); // 16kHz, 16-bit
1983
+ console.log(chalk.gray(` Audio: ${durationActual.toFixed(1)}s, peak level ${(peakLevel * 100).toFixed(0)}%`));
1984
+ console.log();
1985
+ }
1986
+ // Test audio recording command (without transcription, for debugging)
1987
+ export async function testRecordingCommand(options) {
1988
+ console.log(chalk.bold("\n🎤 JFL Voice - Recording Test\n"));
1989
+ const duration = options?.duration ?? 5;
1990
+ // Check for sox/rec availability
1991
+ try {
1992
+ const recorder = new AudioRecorder({
1993
+ device: options?.device,
1994
+ sampleRate: 16000,
1995
+ });
1996
+ console.log(chalk.gray(` Recording for ${duration} seconds...`));
1997
+ console.log(chalk.gray(` Format: 16-bit PCM, 16kHz, mono`));
1998
+ if (options?.device) {
1999
+ console.log(chalk.gray(` Device: ${options.device}`));
2000
+ }
2001
+ else {
2002
+ console.log(chalk.gray(` Device: default`));
2003
+ }
2004
+ console.log();
2005
+ let totalBytes = 0;
2006
+ let chunkCount = 0;
2007
+ let peakLevel = 0;
2008
+ recorder.on("data", (chunk) => {
2009
+ totalBytes += chunk.length;
2010
+ chunkCount++;
2011
+ // Calculate peak level from 16-bit samples
2012
+ for (let i = 0; i < chunk.length - 1; i += 2) {
2013
+ const sample = chunk.readInt16LE(i);
2014
+ const level = Math.abs(sample) / 32768;
2015
+ if (level > peakLevel) {
2016
+ peakLevel = level;
2017
+ }
2018
+ }
2019
+ });
2020
+ recorder.on("error", (error) => {
2021
+ console.error(chalk.red(`\n Recording error: ${error.message}`));
2022
+ });
2023
+ const spinner = ora({
2024
+ text: "Recording...",
2025
+ prefixText: " ",
2026
+ }).start();
2027
+ await recorder.start();
2028
+ // Record for specified duration
2029
+ await new Promise((resolve) => {
2030
+ const startTime = Date.now();
2031
+ const updateInterval = setInterval(() => {
2032
+ const elapsed = (Date.now() - startTime) / 1000;
2033
+ const remaining = Math.max(0, duration - elapsed);
2034
+ // Create a visual level meter
2035
+ const levelBars = Math.round(peakLevel * 20);
2036
+ const meter = "█".repeat(levelBars) + "░".repeat(20 - levelBars);
2037
+ spinner.text = `Recording... ${remaining.toFixed(1)}s remaining [${meter}]`;
2038
+ if (elapsed >= duration) {
2039
+ clearInterval(updateInterval);
2040
+ recorder.stop();
2041
+ resolve();
2042
+ }
2043
+ }, 100);
2044
+ });
2045
+ spinner.succeed("Recording complete!");
2046
+ console.log();
2047
+ // Show stats
2048
+ const durationActual = totalBytes / (16000 * 2); // 16kHz, 16-bit
2049
+ console.log(chalk.gray(" Statistics:"));
2050
+ console.log(` - Duration: ${durationActual.toFixed(2)}s`);
2051
+ console.log(` - Data received: ${formatBytes(totalBytes)}`);
2052
+ console.log(` - Chunks: ${chunkCount}`);
2053
+ console.log(` - Peak level: ${(peakLevel * 100).toFixed(1)}%`);
2054
+ if (peakLevel < 0.01) {
2055
+ console.log();
2056
+ console.log(chalk.yellow(" ⚠️ No audio detected. Check your microphone:"));
2057
+ console.log(chalk.gray(" - Is the microphone connected and enabled?"));
2058
+ console.log(chalk.gray(" - Does the application have microphone permission?"));
2059
+ console.log(chalk.gray(" - Try speaking louder or moving closer to the mic."));
2060
+ }
2061
+ else if (peakLevel < 0.1) {
2062
+ console.log();
2063
+ console.log(chalk.yellow(" ⚠️ Audio level is low. Consider:"));
2064
+ console.log(chalk.gray(" - Speaking louder"));
2065
+ console.log(chalk.gray(" - Increasing microphone gain"));
2066
+ console.log(chalk.gray(" - Moving closer to the microphone"));
2067
+ }
2068
+ else {
2069
+ console.log();
2070
+ console.log(chalk.green(" ✓ Audio input is working correctly!"));
2071
+ }
2072
+ console.log();
2073
+ }
2074
+ catch (error) {
2075
+ if (error instanceof VoiceError) {
2076
+ handleVoiceError(error);
2077
+ }
2078
+ else {
2079
+ handleVoiceError(createVoiceError(error instanceof Error ? error : new Error(String(error))));
2080
+ }
2081
+ }
2082
+ }
2083
+ // Default preview configuration
2084
+ const DEFAULT_PREVIEW_CONFIG = {
2085
+ timeout: 2.5, // seconds
2086
+ };
2087
+ // Default security configuration
2088
+ const DEFAULT_SECURITY_CONFIG = {
2089
+ maxRecordingDuration: 60, // 60 seconds max recording
2090
+ clipboardClearDelay: 5, // Clear clipboard 5 seconds after paste
2091
+ };
2092
+ // Default hotkey configuration
2093
+ const DEFAULT_HOTKEY_CONFIG = {
2094
+ mode: "auto",
2095
+ holdThreshold: 300, // ms
2096
+ };
2097
+ // Read voice config from YAML file
2098
+ function readVoiceConfig() {
2099
+ const configPath = getVoiceConfigPath();
2100
+ if (!existsSync(configPath)) {
2101
+ return null;
2102
+ }
2103
+ try {
2104
+ const content = readFileSync(configPath, "utf-8");
2105
+ const config = {
2106
+ model: "base",
2107
+ device: "default",
2108
+ sampleRate: 16000,
2109
+ autoStart: false,
2110
+ hotkey: { ...DEFAULT_HOTKEY_CONFIG },
2111
+ preview: { ...DEFAULT_PREVIEW_CONFIG },
2112
+ security: { ...DEFAULT_SECURITY_CONFIG },
2113
+ };
2114
+ // Parse YAML manually (simple key: value format)
2115
+ // Supports nested hotkey, preview, and security sections
2116
+ const lines = content.split("\n");
2117
+ let currentSection = "none";
2118
+ for (const line of lines) {
2119
+ const trimmed = line.trim();
2120
+ if (trimmed.startsWith("#") || trimmed === "")
2121
+ continue;
2122
+ // Check if entering a section
2123
+ if (trimmed === "hotkey:") {
2124
+ currentSection = "hotkey";
2125
+ continue;
2126
+ }
2127
+ if (trimmed === "preview:") {
2128
+ currentSection = "preview";
2129
+ continue;
2130
+ }
2131
+ if (trimmed === "security:") {
2132
+ currentSection = "security";
2133
+ continue;
2134
+ }
2135
+ // Check if leaving section (new top-level key)
2136
+ if (!line.startsWith(" ") && !line.startsWith("\t") && trimmed.includes(":")) {
2137
+ currentSection = "none";
2138
+ }
2139
+ if (!trimmed.includes(":"))
2140
+ continue;
2141
+ const [key, ...valueParts] = trimmed.split(":");
2142
+ const value = valueParts.join(":").trim();
2143
+ if (currentSection === "hotkey") {
2144
+ // Parse hotkey sub-keys
2145
+ if (key === "mode" && (value === "auto" || value === "tap" || value === "hold")) {
2146
+ config.hotkey.mode = value;
2147
+ }
2148
+ else if (key === "holdThreshold") {
2149
+ config.hotkey.holdThreshold = parseInt(value, 10) || 300;
2150
+ }
2151
+ }
2152
+ else if (currentSection === "preview") {
2153
+ // Parse preview sub-keys
2154
+ if (key === "timeout") {
2155
+ const parsed = parseFloat(value);
2156
+ // Validate: 0 (disabled) or 1-10 seconds
2157
+ if (!isNaN(parsed) && (parsed === 0 || (parsed >= 1 && parsed <= 10))) {
2158
+ config.preview.timeout = parsed;
2159
+ }
2160
+ }
2161
+ }
2162
+ else if (currentSection === "security") {
2163
+ // Parse security sub-keys (VS-SEC-3)
2164
+ if (key === "maxRecordingDuration") {
2165
+ const parsed = parseInt(value, 10);
2166
+ // Validate: 10-300 seconds (reasonable bounds)
2167
+ if (!isNaN(parsed) && parsed >= 10 && parsed <= 300) {
2168
+ config.security.maxRecordingDuration = parsed;
2169
+ }
2170
+ }
2171
+ else if (key === "clipboardClearDelay") {
2172
+ const parsed = parseInt(value, 10);
2173
+ // Validate: 1-60 seconds
2174
+ if (!isNaN(parsed) && parsed >= 1 && parsed <= 60) {
2175
+ config.security.clipboardClearDelay = parsed;
2176
+ }
2177
+ }
2178
+ }
2179
+ else {
2180
+ // Parse top-level keys
2181
+ if (key === "model")
2182
+ config.model = value;
2183
+ else if (key === "device")
2184
+ config.device = value;
2185
+ else if (key === "sampleRate")
2186
+ config.sampleRate = parseInt(value, 10) || 16000;
2187
+ else if (key === "autoStart")
2188
+ config.autoStart = value === "true";
2189
+ }
2190
+ }
2191
+ return config;
2192
+ }
2193
+ catch {
2194
+ return null;
2195
+ }
2196
+ }
2197
+ // Write voice config to YAML file
2198
+ function writeVoiceConfig(config) {
2199
+ ensureDirectories();
2200
+ const configPath = getVoiceConfigPath();
2201
+ // Ensure config sections have defaults if not provided
2202
+ const hotkeyConfig = config.hotkey || DEFAULT_HOTKEY_CONFIG;
2203
+ const previewConfig = config.preview || DEFAULT_PREVIEW_CONFIG;
2204
+ const securityConfig = config.security || DEFAULT_SECURITY_CONFIG;
2205
+ const content = `# JFL Voice Configuration
2206
+ # Generated by: jfl voice setup
2207
+ # Re-run setup to change settings: jfl voice setup
2208
+
2209
+ model: ${config.model}
2210
+ device: ${config.device}
2211
+ sampleRate: ${config.sampleRate}
2212
+ autoStart: ${config.autoStart}
2213
+
2214
+ # Hotkey settings for voice hotkey mode
2215
+ # mode: auto (smart detection), tap (tap-to-toggle), or hold (hold-to-talk)
2216
+ # holdThreshold: ms to hold before entering hold-to-talk mode (default: 300)
2217
+ hotkey:
2218
+ mode: ${hotkeyConfig.mode}
2219
+ holdThreshold: ${hotkeyConfig.holdThreshold}
2220
+
2221
+ # Preview settings for transcript review before sending
2222
+ # timeout: seconds to wait before auto-sending (1-10, or 0 to disable auto-send)
2223
+ # When preview is shown: Enter=send immediately, Esc=cancel, any other key=edit mode
2224
+ preview:
2225
+ timeout: ${previewConfig.timeout}
2226
+
2227
+ # Security settings for clipboard hygiene and recording limits (VS-SEC-3)
2228
+ # maxRecordingDuration: seconds before auto-stop (10-300, default: 60)
2229
+ # clipboardClearDelay: seconds after paste before clearing clipboard (1-60, default: 5)
2230
+ security:
2231
+ maxRecordingDuration: ${securityConfig.maxRecordingDuration}
2232
+ clipboardClearDelay: ${securityConfig.clipboardClearDelay}
2233
+ `;
2234
+ writeFileSync(configPath, content, { mode: 0o644 });
2235
+ }
2236
+ // Check microphone permissions on macOS
2237
+ async function checkMicPermissions() {
2238
+ if (platform() !== "darwin") {
2239
+ return { granted: true, message: "Permissions check not required on this platform" };
2240
+ }
2241
+ try {
2242
+ // On macOS, try a quick recording test to trigger permission prompt
2243
+ // If sox can record, permissions are granted
2244
+ execSync("rec -q -r 16000 -c 1 -b 16 -e signed-integer -t raw - trim 0 0.1 2>/dev/null | head -c 1", {
2245
+ timeout: 5000,
2246
+ stdio: ["ignore", "pipe", "ignore"],
2247
+ });
2248
+ return { granted: true, message: "Microphone access granted" };
2249
+ }
2250
+ catch (error) {
2251
+ // Check if it's a permission error
2252
+ try {
2253
+ // Try to check system preferences
2254
+ const result = execSync("defaults read com.apple.controlcenter 'NSStatusItem Visible Microphone' 2>/dev/null || echo 'unknown'", { encoding: "utf-8", timeout: 3000 }).trim();
2255
+ if (result === "unknown") {
2256
+ return {
2257
+ granted: false,
2258
+ message: "Unable to determine microphone permission status. Try recording to trigger permission prompt.",
2259
+ };
2260
+ }
2261
+ }
2262
+ catch {
2263
+ // Ignore
2264
+ }
2265
+ return {
2266
+ granted: false,
2267
+ message: "Microphone access may not be granted. Go to System Preferences > Privacy & Security > Microphone",
2268
+ };
2269
+ }
2270
+ }
2271
+ // Test audio device with a short recording
2272
+ async function testAudioDevice(device, durationSecs = 3) {
2273
+ return new Promise((resolve) => {
2274
+ try {
2275
+ const recorder = new AudioRecorder({
2276
+ device: device === "default" ? undefined : device,
2277
+ sampleRate: 16000,
2278
+ });
2279
+ let totalBytes = 0;
2280
+ let peakLevel = 0;
2281
+ let hasError = false;
2282
+ recorder.on("data", (chunk) => {
2283
+ totalBytes += chunk.length;
2284
+ // Calculate peak level from 16-bit samples
2285
+ for (let i = 0; i < chunk.length - 1; i += 2) {
2286
+ const sample = chunk.readInt16LE(i);
2287
+ const level = Math.abs(sample) / 32768;
2288
+ if (level > peakLevel) {
2289
+ peakLevel = level;
2290
+ }
2291
+ }
2292
+ });
2293
+ recorder.on("error", (error) => {
2294
+ hasError = true;
2295
+ recorder.stop();
2296
+ resolve({ success: false, peakLevel: 0, error: error.message });
2297
+ });
2298
+ recorder.start().then(() => {
2299
+ // Record for specified duration
2300
+ setTimeout(() => {
2301
+ recorder.stop();
2302
+ if (!hasError) {
2303
+ resolve({
2304
+ success: totalBytes > 0,
2305
+ peakLevel,
2306
+ error: totalBytes === 0 ? "No audio data received" : undefined,
2307
+ });
2308
+ }
2309
+ }, durationSecs * 1000);
2310
+ }).catch((error) => {
2311
+ resolve({ success: false, peakLevel: 0, error: String(error) });
2312
+ });
2313
+ }
2314
+ catch (error) {
2315
+ resolve({ success: false, peakLevel: 0, error: String(error) });
2316
+ }
2317
+ });
2318
+ }
2319
+ // First-time setup wizard
2320
+ export async function voiceSetupCommand() {
2321
+ console.log(chalk.bold("\n🎤 JFL Voice - Setup Wizard\n"));
2322
+ // Check for existing config
2323
+ const existingConfig = readVoiceConfig();
2324
+ if (existingConfig) {
2325
+ console.log(chalk.gray(" Existing configuration found:"));
2326
+ console.log(chalk.gray(` Model: ${existingConfig.model}`));
2327
+ console.log(chalk.gray(` Device: ${existingConfig.device}`));
2328
+ console.log(chalk.gray(` Sample Rate: ${existingConfig.sampleRate}Hz`));
2329
+ console.log(chalk.gray(` Auto Start: ${existingConfig.autoStart}`));
2330
+ console.log();
2331
+ const { reconfigure } = await inquirer.prompt([
2332
+ {
2333
+ type: "confirm",
2334
+ name: "reconfigure",
2335
+ message: "Reconfigure voice settings?",
2336
+ default: false,
2337
+ },
2338
+ ]);
2339
+ if (!reconfigure) {
2340
+ console.log(chalk.gray("\n Setup cancelled. Using existing configuration.\n"));
2341
+ return;
2342
+ }
2343
+ console.log();
2344
+ }
2345
+ console.log(chalk.gray(" This wizard will help you set up voice input for JFL."));
2346
+ console.log(chalk.gray(" You'll configure:"));
2347
+ console.log(chalk.gray(" 1. Whisper model (for speech-to-text)"));
2348
+ console.log(chalk.gray(" 2. Microphone device"));
2349
+ console.log(chalk.gray(" 3. Test the audio pipeline"));
2350
+ console.log();
2351
+ ensureDirectories();
2352
+ // ============================================================================
2353
+ // Step 1: Select and download whisper model
2354
+ // ============================================================================
2355
+ console.log(chalk.cyan.bold(" Step 1: Select Whisper Model\n"));
2356
+ // Build model choices with download status
2357
+ const modelChoices = [
2358
+ {
2359
+ name: `${chalk.white("tiny")} (75 MB) - Fastest, lowest accuracy ${isModelDownloaded("tiny") ? chalk.green("[downloaded]") : ""}`,
2360
+ value: "tiny",
2361
+ },
2362
+ {
2363
+ name: `${chalk.white("tiny.en")} (75 MB) - English-only tiny ${isModelDownloaded("tiny.en") ? chalk.green("[downloaded]") : ""}`,
2364
+ value: "tiny.en",
2365
+ },
2366
+ {
2367
+ name: `${chalk.white("base")} (142 MB) - Balanced, recommended ${isModelDownloaded("base") ? chalk.green("[downloaded]") : chalk.yellow("[recommended]")}`,
2368
+ value: "base",
2369
+ },
2370
+ {
2371
+ name: `${chalk.white("base.en")} (142 MB) - English-only base ${isModelDownloaded("base.en") ? chalk.green("[downloaded]") : ""}`,
2372
+ value: "base.en",
2373
+ },
2374
+ {
2375
+ name: `${chalk.white("small")} (466 MB) - Higher accuracy, slower ${isModelDownloaded("small") ? chalk.green("[downloaded]") : ""}`,
2376
+ value: "small",
2377
+ },
2378
+ {
2379
+ name: `${chalk.white("small.en")} (466 MB) - English-only small ${isModelDownloaded("small.en") ? chalk.green("[downloaded]") : ""}`,
2380
+ value: "small.en",
2381
+ },
2382
+ ];
2383
+ const { selectedModel } = await inquirer.prompt([
2384
+ {
2385
+ type: "list",
2386
+ name: "selectedModel",
2387
+ message: "Select a whisper model:",
2388
+ choices: modelChoices,
2389
+ default: "base",
2390
+ },
2391
+ ]);
2392
+ // Download model if needed
2393
+ if (!isModelDownloaded(selectedModel)) {
2394
+ console.log();
2395
+ const { confirmDownload } = await inquirer.prompt([
2396
+ {
2397
+ type: "confirm",
2398
+ name: "confirmDownload",
2399
+ message: `Download ${selectedModel} model (${WHISPER_MODELS[selectedModel].size})?`,
2400
+ default: true,
2401
+ },
2402
+ ]);
2403
+ if (!confirmDownload) {
2404
+ console.log(chalk.yellow("\n Model not downloaded. Setup incomplete.\n"));
2405
+ console.log(chalk.gray(" Run 'jfl voice setup' again when ready.\n"));
2406
+ return;
2407
+ }
2408
+ // Download the model
2409
+ await downloadModelCommand(selectedModel);
2410
+ // Verify download succeeded
2411
+ if (!isModelDownloaded(selectedModel)) {
2412
+ console.log(chalk.red("\n Model download failed. Setup incomplete.\n"));
2413
+ return;
2414
+ }
2415
+ }
2416
+ else {
2417
+ console.log(chalk.green(`\n ✓ Model '${selectedModel}' is already downloaded.\n`));
2418
+ }
2419
+ // ============================================================================
2420
+ // Step 2: Select microphone device
2421
+ // ============================================================================
2422
+ console.log(chalk.cyan.bold(" Step 2: Select Microphone\n"));
2423
+ const spinner = ora({
2424
+ text: "Detecting audio devices...",
2425
+ prefixText: " ",
2426
+ }).start();
2427
+ const devices = await AudioRecorder.listDevices();
2428
+ spinner.stop();
2429
+ if (devices.length === 0) {
2430
+ console.log(chalk.yellow(" No audio input devices found."));
2431
+ console.log(chalk.gray(" Make sure you have a microphone connected.\n"));
2432
+ const { continueWithDefault } = await inquirer.prompt([
2433
+ {
2434
+ type: "confirm",
2435
+ name: "continueWithDefault",
2436
+ message: "Continue with default device anyway?",
2437
+ default: true,
2438
+ },
2439
+ ]);
2440
+ if (!continueWithDefault) {
2441
+ console.log(chalk.yellow("\n Setup cancelled.\n"));
2442
+ return;
2443
+ }
2444
+ }
2445
+ let selectedDevice = "default";
2446
+ if (devices.length > 0) {
2447
+ // Build device choices
2448
+ const deviceChoices = devices.map((device) => ({
2449
+ name: `${device.name}${device.isDefault ? chalk.cyan(" (system default)") : ""}`,
2450
+ value: device.id,
2451
+ }));
2452
+ // Add "default" option at the top
2453
+ deviceChoices.unshift({
2454
+ name: `${chalk.white("default")} - Use system default device`,
2455
+ value: "default",
2456
+ });
2457
+ const { device } = await inquirer.prompt([
2458
+ {
2459
+ type: "list",
2460
+ name: "device",
2461
+ message: "Select microphone:",
2462
+ choices: deviceChoices,
2463
+ default: "default",
2464
+ },
2465
+ ]);
2466
+ selectedDevice = device;
2467
+ }
2468
+ // ============================================================================
2469
+ // Step 3: Check permissions (macOS)
2470
+ // ============================================================================
2471
+ if (platform() === "darwin") {
2472
+ console.log(chalk.cyan.bold("\n Step 3: Check Permissions\n"));
2473
+ const permSpinner = ora({
2474
+ text: "Checking microphone permissions...",
2475
+ prefixText: " ",
2476
+ }).start();
2477
+ const permStatus = await checkMicPermissions();
2478
+ if (permStatus.granted) {
2479
+ permSpinner.succeed("Microphone permission granted");
2480
+ }
2481
+ else {
2482
+ permSpinner.warn("Microphone permission may be needed");
2483
+ console.log(chalk.yellow(`\n ${permStatus.message}`));
2484
+ console.log();
2485
+ const { openPrefs } = await inquirer.prompt([
2486
+ {
2487
+ type: "confirm",
2488
+ name: "openPrefs",
2489
+ message: "Open System Preferences > Privacy & Security?",
2490
+ default: true,
2491
+ },
2492
+ ]);
2493
+ if (openPrefs) {
2494
+ try {
2495
+ execSync("open x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", {
2496
+ stdio: "ignore",
2497
+ });
2498
+ console.log(chalk.gray("\n Grant microphone access to Terminal (or your terminal app).\n"));
2499
+ const { permGranted } = await inquirer.prompt([
2500
+ {
2501
+ type: "confirm",
2502
+ name: "permGranted",
2503
+ message: "Did you grant microphone permission?",
2504
+ default: true,
2505
+ },
2506
+ ]);
2507
+ if (!permGranted) {
2508
+ console.log(chalk.yellow("\n You may need to grant permission for voice input to work.\n"));
2509
+ }
2510
+ }
2511
+ catch {
2512
+ console.log(chalk.gray(" Could not open System Preferences automatically."));
2513
+ console.log(chalk.gray(" Please manually go to System Preferences > Privacy & Security > Microphone\n"));
2514
+ }
2515
+ }
2516
+ }
2517
+ }
2518
+ // ============================================================================
2519
+ // Step 4: Test the microphone
2520
+ // ============================================================================
2521
+ console.log(chalk.cyan.bold("\n Step 4: Test Microphone\n"));
2522
+ const { runTest } = await inquirer.prompt([
2523
+ {
2524
+ type: "confirm",
2525
+ name: "runTest",
2526
+ message: "Test the microphone for 3 seconds?",
2527
+ default: true,
2528
+ },
2529
+ ]);
2530
+ let testPassed = false;
2531
+ if (runTest) {
2532
+ console.log(chalk.gray("\n Recording for 3 seconds... Speak into your microphone!\n"));
2533
+ const testSpinner = ora({
2534
+ text: "Recording...",
2535
+ prefixText: " ",
2536
+ }).start();
2537
+ const result = await testAudioDevice(selectedDevice, 3);
2538
+ if (result.success) {
2539
+ testSpinner.succeed("Recording complete!");
2540
+ console.log();
2541
+ // Show level meter
2542
+ const levelBars = Math.round(result.peakLevel * 20);
2543
+ const meter = "█".repeat(levelBars) + "░".repeat(20 - levelBars);
2544
+ console.log(chalk.gray(` Peak level: [${meter}] ${(result.peakLevel * 100).toFixed(1)}%`));
2545
+ if (result.peakLevel < 0.01) {
2546
+ console.log(chalk.yellow("\n ⚠️ No audio detected. Check your microphone connection."));
2547
+ }
2548
+ else if (result.peakLevel < 0.1) {
2549
+ console.log(chalk.yellow("\n ⚠️ Audio level is low. Consider increasing microphone gain."));
2550
+ testPassed = true;
2551
+ }
2552
+ else {
2553
+ console.log(chalk.green("\n ✓ Audio input is working correctly!"));
2554
+ testPassed = true;
2555
+ }
2556
+ }
2557
+ else {
2558
+ testSpinner.fail("Test failed");
2559
+ console.log(chalk.red(`\n Error: ${result.error}`));
2560
+ console.log(chalk.gray("\n Make sure you have sox installed:"));
2561
+ console.log(chalk.gray(" macOS: brew install sox"));
2562
+ console.log(chalk.gray(" Linux: sudo apt-get install sox libsox-fmt-all"));
2563
+ console.log(chalk.gray(" Windows: choco install sox.portable"));
2564
+ }
2565
+ // Option to retry with different device
2566
+ if (!testPassed && devices.length > 1) {
2567
+ console.log();
2568
+ const { tryAnother } = await inquirer.prompt([
2569
+ {
2570
+ type: "confirm",
2571
+ name: "tryAnother",
2572
+ message: "Try a different device?",
2573
+ default: true,
2574
+ },
2575
+ ]);
2576
+ if (tryAnother) {
2577
+ // Let them pick again
2578
+ const remainingDevices = devices.filter((d) => d.id !== selectedDevice);
2579
+ const retryChoices = remainingDevices.map((device) => ({
2580
+ name: `${device.name}${device.isDefault ? chalk.cyan(" (system default)") : ""}`,
2581
+ value: device.id,
2582
+ }));
2583
+ if (retryChoices.length > 0) {
2584
+ const { retryDevice } = await inquirer.prompt([
2585
+ {
2586
+ type: "list",
2587
+ name: "retryDevice",
2588
+ message: "Select another device:",
2589
+ choices: retryChoices,
2590
+ },
2591
+ ]);
2592
+ selectedDevice = retryDevice;
2593
+ console.log(chalk.gray("\n Recording for 3 seconds with new device...\n"));
2594
+ const retrySpinner = ora({
2595
+ text: "Recording...",
2596
+ prefixText: " ",
2597
+ }).start();
2598
+ const retryResult = await testAudioDevice(selectedDevice, 3);
2599
+ if (retryResult.success && retryResult.peakLevel >= 0.01) {
2600
+ retrySpinner.succeed("Recording complete!");
2601
+ const levelBars2 = Math.round(retryResult.peakLevel * 20);
2602
+ const meter2 = "█".repeat(levelBars2) + "░".repeat(20 - levelBars2);
2603
+ console.log(chalk.gray(`\n Peak level: [${meter2}] ${(retryResult.peakLevel * 100).toFixed(1)}%`));
2604
+ console.log(chalk.green("\n ✓ Audio input is working with this device!"));
2605
+ testPassed = true;
2606
+ }
2607
+ else {
2608
+ retrySpinner.fail("Test failed with this device too");
2609
+ }
2610
+ }
2611
+ }
2612
+ }
2613
+ }
2614
+ else {
2615
+ console.log(chalk.gray("\n Skipping microphone test."));
2616
+ testPassed = true; // Assume it works if user skips
2617
+ }
2618
+ // ============================================================================
2619
+ // Step 5: Save configuration
2620
+ // ============================================================================
2621
+ console.log(chalk.cyan.bold("\n Step 5: Save Configuration\n"));
2622
+ // Ask about auto-start preference
2623
+ const { autoStart } = await inquirer.prompt([
2624
+ {
2625
+ type: "confirm",
2626
+ name: "autoStart",
2627
+ message: "Auto-start whisper server when using voice? (recommended)",
2628
+ default: false,
2629
+ },
2630
+ ]);
2631
+ // Build final config (preserve existing hotkey, preview, and security settings if any)
2632
+ const existingVoiceConfig = readVoiceConfig();
2633
+ const config = {
2634
+ model: selectedModel,
2635
+ device: selectedDevice,
2636
+ sampleRate: 16000,
2637
+ autoStart,
2638
+ hotkey: existingVoiceConfig?.hotkey || { ...DEFAULT_HOTKEY_CONFIG },
2639
+ preview: existingVoiceConfig?.preview || { ...DEFAULT_PREVIEW_CONFIG },
2640
+ security: existingVoiceConfig?.security || { ...DEFAULT_SECURITY_CONFIG },
2641
+ };
2642
+ // Save config
2643
+ writeVoiceConfig(config);
2644
+ setDefaultModel(selectedModel);
2645
+ console.log(chalk.green("\n ✓ Configuration saved to ~/.jfl/voice.yaml"));
2646
+ console.log();
2647
+ console.log(chalk.gray(" Configuration:"));
2648
+ console.log(chalk.gray(` Model: ${config.model}`));
2649
+ console.log(chalk.gray(` Device: ${config.device}`));
2650
+ console.log(chalk.gray(` Sample Rate: ${config.sampleRate}Hz`));
2651
+ console.log(chalk.gray(` Auto Start: ${config.autoStart}`));
2652
+ // ============================================================================
2653
+ // Summary
2654
+ // ============================================================================
2655
+ console.log(chalk.bold("\n ✅ Setup Complete!\n"));
2656
+ if (testPassed) {
2657
+ console.log(chalk.green(" Voice input is ready to use."));
2658
+ }
2659
+ else {
2660
+ console.log(chalk.yellow(" Voice input configured, but microphone test did not pass."));
2661
+ console.log(chalk.gray(" Run 'jfl voice test' to troubleshoot."));
2662
+ }
2663
+ console.log();
2664
+ console.log(chalk.cyan(" Next steps:"));
2665
+ console.log(chalk.gray(" jfl voice test Test microphone again"));
2666
+ console.log(chalk.gray(" jfl voice server start Start the whisper server"));
2667
+ console.log(chalk.gray(" jfl voice setup Re-run this wizard"));
2668
+ console.log();
2669
+ }
2670
+ // =============================================================================
2671
+ // Voice Slash Command - /voice with VAD
2672
+ // =============================================================================
2673
+ /** Show voice command help */
2674
+ function showVoiceHelp() {
2675
+ console.log(chalk.bold("\n🎤 JFL Voice\n"));
2676
+ console.log(chalk.gray("Voice input for JFL CLI with automatic silence detection.\n"));
2677
+ console.log(chalk.cyan("Quick Start:"));
2678
+ console.log(" jfl voice Start recording (stops on silence)");
2679
+ console.log(" jfl voice record Same as above");
2680
+ console.log(chalk.cyan("\nModel Management:"));
2681
+ console.log(" jfl voice model list List available models");
2682
+ console.log(" jfl voice model download <name> Download a model");
2683
+ console.log(" jfl voice model default <name> Set default model");
2684
+ console.log(chalk.cyan("\nHotkey Mode (macOS):"));
2685
+ console.log(" jfl voice hotkey Start global hotkey listener");
2686
+ console.log(" jfl voice hotkey --mode <mode> Set hotkey mode: auto, tap, or hold");
2687
+ console.log(chalk.gray(" Ctrl+Shift+Space triggers recording"));
2688
+ console.log(chalk.gray(" auto: Tap to toggle, or hold to talk"));
2689
+ console.log(chalk.gray(" tap: Tap to start/stop recording"));
2690
+ console.log(chalk.gray(" hold: Hold to record, release to stop"));
2691
+ console.log(chalk.gray(" Requires Accessibility permission"));
2692
+ console.log(chalk.cyan("\nDaemon Mode (macOS):"));
2693
+ console.log(" jfl voice daemon start Start hotkey listener in background");
2694
+ console.log(" jfl voice daemon stop Stop the background daemon");
2695
+ console.log(" jfl voice daemon status Show daemon status and uptime");
2696
+ console.log(" jfl voice daemon start --mode <m> Start daemon with mode: auto, tap, hold");
2697
+ console.log(chalk.gray(" Daemon survives terminal close"));
2698
+ console.log(chalk.gray(" PID stored in ~/.jfl/voice-daemon.pid"));
2699
+ console.log(chalk.cyan("\nAudio Input:"));
2700
+ console.log(" jfl voice devices List audio input devices");
2701
+ console.log(" jfl voice test Test voice input (record + transcribe)");
2702
+ console.log(" jfl voice test --device <id> Test with specific device");
2703
+ console.log(" jfl voice recording Test recording only (no transcription)");
2704
+ console.log(" jfl voice recording --duration N Record for N seconds (default: 5)");
2705
+ console.log(chalk.cyan("\nServer Commands: (coming soon)"));
2706
+ console.log(chalk.gray(" jfl voice server start Start whisper server"));
2707
+ console.log(chalk.gray(" jfl voice server stop Stop whisper server"));
2708
+ console.log(chalk.gray(" jfl voice server status Show server status"));
2709
+ console.log(chalk.cyan("\nSetup:"));
2710
+ console.log(" jfl voice setup First-time setup wizard");
2711
+ console.log(" jfl voice help Show this help");
2712
+ console.log();
2713
+ }
2714
+ /** Convert linear amplitude to dB */
2715
+ function amplitudeToDb(amplitude) {
2716
+ if (amplitude <= 0)
2717
+ return -100;
2718
+ return 20 * Math.log10(amplitude);
2719
+ }
2720
+ /** Calculate peak amplitude from 16-bit PCM audio chunk */
2721
+ function calculatePeakAmplitude(chunk) {
2722
+ let peak = 0;
2723
+ for (let i = 0; i < chunk.length - 1; i += 2) {
2724
+ const sample = chunk.readInt16LE(i);
2725
+ const amplitude = Math.abs(sample) / 32768;
2726
+ if (amplitude > peak) {
2727
+ peak = amplitude;
2728
+ }
2729
+ }
2730
+ return peak;
2731
+ }
2732
+ // =============================================================================
2733
+ // VS-012: Waveform Visualization
2734
+ // =============================================================================
2735
+ /** Unicode block characters for waveform visualization (sorted by height) */
2736
+ const WAVEFORM_BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
2737
+ /** Rolling buffer size for waveform display */
2738
+ const WAVEFORM_BUFFER_SIZE = 7;
2739
+ /** Rolling buffer to store recent audio levels for waveform display */
2740
+ let waveformBuffer = [];
2741
+ /**
2742
+ * Map a dB level to a waveform character
2743
+ * @param dbLevel - Audio level in dB (typically -60 to 0)
2744
+ * @returns A Unicode block character representing the level
2745
+ */
2746
+ function dbToWaveformChar(dbLevel) {
2747
+ // Map dB range (-60 to 0) to character index (0 to 7)
2748
+ // -60 dB or below = lowest bar, 0 dB = highest bar
2749
+ const minDb = -60;
2750
+ const maxDb = 0;
2751
+ const clampedDb = Math.max(minDb, Math.min(maxDb, dbLevel));
2752
+ // Normalize to 0-1 range
2753
+ const normalized = (clampedDb - minDb) / (maxDb - minDb);
2754
+ // Map to character index
2755
+ const index = Math.floor(normalized * (WAVEFORM_BLOCKS.length - 1));
2756
+ return WAVEFORM_BLOCKS[index];
2757
+ }
2758
+ /**
2759
+ * Add a level to the waveform buffer
2760
+ * @param dbLevel - Audio level in dB
2761
+ */
2762
+ function addToWaveformBuffer(dbLevel) {
2763
+ waveformBuffer.push(dbLevel);
2764
+ if (waveformBuffer.length > WAVEFORM_BUFFER_SIZE) {
2765
+ waveformBuffer.shift();
2766
+ }
2767
+ }
2768
+ /**
2769
+ * Reset the waveform buffer (call at start of new recording)
2770
+ */
2771
+ function resetWaveformBuffer() {
2772
+ waveformBuffer = [];
2773
+ }
2774
+ /**
2775
+ * Render the waveform visualization from the rolling buffer
2776
+ * @returns A string like "▁▃▅▇▅▃▁" representing recent audio levels
2777
+ */
2778
+ function renderWaveform() {
2779
+ if (waveformBuffer.length === 0) {
2780
+ // Return minimal bars when no data yet
2781
+ return WAVEFORM_BLOCKS[0].repeat(WAVEFORM_BUFFER_SIZE);
2782
+ }
2783
+ // Pad with low values if buffer isn't full yet
2784
+ const paddedBuffer = [...waveformBuffer];
2785
+ while (paddedBuffer.length < WAVEFORM_BUFFER_SIZE) {
2786
+ paddedBuffer.unshift(-60); // Pad with silence at the start
2787
+ }
2788
+ return paddedBuffer.map(db => dbToWaveformChar(db)).join("");
2789
+ }
2790
+ /**
2791
+ * Check if terminal supports Unicode waveform characters
2792
+ * @returns true if waveform should be displayed
2793
+ */
2794
+ function supportsWaveform() {
2795
+ // Check for dumb terminal
2796
+ if (process.env.TERM === "dumb") {
2797
+ return false;
2798
+ }
2799
+ // Check for Windows cmd.exe (not PowerShell or Windows Terminal)
2800
+ if (process.platform === "win32" && !process.env.WT_SESSION && !process.env.TERM_PROGRAM) {
2801
+ return false;
2802
+ }
2803
+ return true;
2804
+ }
2805
+ /** Copy text to clipboard (cross-platform) */
2806
+ function copyToClipboard(text) {
2807
+ const currentPlatform = platform();
2808
+ try {
2809
+ if (currentPlatform === "darwin") {
2810
+ execSync("pbcopy", { input: text, stdio: ["pipe", "ignore", "ignore"] });
2811
+ return true;
2812
+ }
2813
+ else if (currentPlatform === "linux") {
2814
+ // Try xclip first, then xsel
2815
+ try {
2816
+ execSync("xclip -selection clipboard", { input: text, stdio: ["pipe", "ignore", "ignore"] });
2817
+ return true;
2818
+ }
2819
+ catch {
2820
+ try {
2821
+ execSync("xsel --clipboard --input", { input: text, stdio: ["pipe", "ignore", "ignore"] });
2822
+ return true;
2823
+ }
2824
+ catch {
2825
+ return false;
2826
+ }
2827
+ }
2828
+ }
2829
+ else if (currentPlatform === "win32") {
2830
+ execSync("clip", { input: text, stdio: ["pipe", "ignore", "ignore"] });
2831
+ return true;
2832
+ }
2833
+ return false;
2834
+ }
2835
+ catch {
2836
+ return false;
2837
+ }
2838
+ }
2839
+ /**
2840
+ * Clear the clipboard contents (VS-SEC-3: clipboard hygiene)
2841
+ * Uses osascript on macOS to set clipboard to empty string
2842
+ * Returns true on success, false on failure
2843
+ */
2844
+ function clearClipboard() {
2845
+ const currentPlatform = platform();
2846
+ try {
2847
+ if (currentPlatform === "darwin") {
2848
+ // Use osascript to clear clipboard on macOS
2849
+ execSync(`osascript -e 'set the clipboard to ""'`, { stdio: ["pipe", "ignore", "ignore"] });
2850
+ return true;
2851
+ }
2852
+ else if (currentPlatform === "linux") {
2853
+ // Try xclip first, then xsel
2854
+ try {
2855
+ execSync("xclip -selection clipboard", { input: "", stdio: ["pipe", "ignore", "ignore"] });
2856
+ return true;
2857
+ }
2858
+ catch {
2859
+ try {
2860
+ execSync("xsel --clipboard --input", { input: "", stdio: ["pipe", "ignore", "ignore"] });
2861
+ return true;
2862
+ }
2863
+ catch {
2864
+ return false;
2865
+ }
2866
+ }
2867
+ }
2868
+ else if (currentPlatform === "win32") {
2869
+ // Use PowerShell to clear clipboard on Windows
2870
+ execSync("powershell.exe -command \"Set-Clipboard -Value ''\"", { stdio: ["pipe", "ignore", "ignore"] });
2871
+ return true;
2872
+ }
2873
+ return false;
2874
+ }
2875
+ catch {
2876
+ return false;
2877
+ }
2878
+ }
2879
+ /**
2880
+ * Schedule clipboard clearing after a delay (VS-SEC-3)
2881
+ * @param delaySeconds Seconds to wait before clearing (default: 5)
2882
+ * @returns Timer reference for potential cancellation
2883
+ */
2884
+ function scheduleClipboardClear(delaySeconds = 5) {
2885
+ return setTimeout(() => {
2886
+ const cleared = clearClipboard();
2887
+ if (process.env.DEBUG && cleared) {
2888
+ console.log(chalk.gray(" [debug] Clipboard cleared for security"));
2889
+ }
2890
+ }, delaySeconds * 1000);
2891
+ }
2892
+ /**
2893
+ * Securely zero out a Buffer's contents (VS-SEC-3: buffer hygiene)
2894
+ * Overwrites the buffer with zeros to prevent sensitive audio data from lingering in memory
2895
+ * @param buffer The Buffer to zero out
2896
+ */
2897
+ function zeroBuffer(buffer) {
2898
+ if (buffer && buffer.length > 0) {
2899
+ buffer.fill(0);
2900
+ }
2901
+ }
2902
+ /**
2903
+ * Securely zero out an array of Buffers (VS-SEC-3)
2904
+ * @param buffers Array of Buffers to zero out
2905
+ */
2906
+ function zeroBuffers(buffers) {
2907
+ for (const buffer of buffers) {
2908
+ zeroBuffer(buffer);
2909
+ }
2910
+ // Clear the array reference
2911
+ buffers.length = 0;
2912
+ }
2913
+ /**
2914
+ * Normalize audio buffer to improve transcription quality.
2915
+ * Many microphones record at very low levels which causes blank transcriptions.
2916
+ * This normalizes to a target RMS for better whisper recognition.
2917
+ *
2918
+ * @param audioBuffer - 16-bit PCM audio buffer
2919
+ * @returns Normalized buffer (may be the same buffer if no normalization needed)
2920
+ */
2921
+ function normalizeAudio(audioBuffer) {
2922
+ const samples = audioBuffer.length / 2;
2923
+ let sumSquares = 0;
2924
+ let maxSample = 0;
2925
+ for (let i = 0; i < samples; i++) {
2926
+ const sample = audioBuffer.readInt16LE(i * 2);
2927
+ sumSquares += sample * sample;
2928
+ if (Math.abs(sample) > maxSample)
2929
+ maxSample = Math.abs(sample);
2930
+ }
2931
+ const rms = Math.sqrt(sumSquares / samples);
2932
+ // Target RMS of 3000 for good whisper recognition
2933
+ const targetRms = 3000;
2934
+ if (rms > 0 && rms < targetRms) {
2935
+ const maxGain = 32767 / (maxSample || 1);
2936
+ const desiredGain = targetRms / rms;
2937
+ // Allow up to 3x the clipping-safe gain, accepting some peak clipping
2938
+ const gain = Math.min(desiredGain, maxGain * 3);
2939
+ if (gain > 1.2) {
2940
+ const amplified = Buffer.alloc(audioBuffer.length);
2941
+ for (let i = 0; i < samples; i++) {
2942
+ let sample = audioBuffer.readInt16LE(i * 2);
2943
+ sample = Math.round(Math.max(-32768, Math.min(32767, sample * gain)));
2944
+ amplified.writeInt16LE(sample, i * 2);
2945
+ }
2946
+ if (process.env.JFL_VOICE_DEBUG) {
2947
+ console.log(`[Voice] Audio normalized: gain=${gain.toFixed(2)}x, rms=${rms.toFixed(0)}->${(rms * gain).toFixed(0)}`);
2948
+ }
2949
+ return amplified;
2950
+ }
2951
+ }
2952
+ if (process.env.JFL_VOICE_DEBUG && rms > 0) {
2953
+ console.log(`[Voice] Audio level ok: rms=${rms.toFixed(0)}, max=${maxSample}`);
2954
+ }
2955
+ return audioBuffer;
2956
+ }
2957
+ /**
2958
+ * Get the name of the currently focused application
2959
+ * - macOS: via osascript
2960
+ * - Linux: via xdotool (X11 only)
2961
+ * - Windows: via PowerShell
2962
+ * VS-010/VS-011: Cross-platform focused app detection
2963
+ * Returns null if unable to determine
2964
+ */
2965
+ function getFocusedApp() {
2966
+ const currentPlatform = platform();
2967
+ try {
2968
+ if (currentPlatform === "darwin") {
2969
+ const result = execSync(`osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true'`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
2970
+ return result.trim();
2971
+ }
2972
+ else if (currentPlatform === "linux") {
2973
+ // VS-010: Get focused window on Linux X11 using xdotool
2974
+ const windowId = execSync(`xdotool getactivewindow`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
2975
+ if (windowId) {
2976
+ const windowName = execSync(`xdotool getwindowname ${windowId}`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
2977
+ return windowName || null;
2978
+ }
2979
+ return null;
2980
+ }
2981
+ else if (currentPlatform === "win32") {
2982
+ // VS-011: Get focused window on Windows using PowerShell
2983
+ const result = execSync(`powershell.exe -command "(Get-Process | Where-Object {$_.MainWindowHandle -eq (Add-Type -MemberDefinition '[DllImport(\\\"user32.dll\\\")]public static extern IntPtr GetForegroundWindow();' -Name 'Win32' -Namespace 'Native' -PassThru)::GetForegroundWindow()}).MainWindowTitle"`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
2984
+ return result.trim() || null;
2985
+ }
2986
+ return null;
2987
+ }
2988
+ catch {
2989
+ return null;
2990
+ }
2991
+ }
2992
+ /**
2993
+ * Get the platform-appropriate paste shortcut string for display
2994
+ * VS-010/VS-011: Cross-platform paste shortcut labels
2995
+ */
2996
+ function getPasteShortcut() {
2997
+ const currentPlatform = platform();
2998
+ if (currentPlatform === "darwin") {
2999
+ return "Cmd+V";
3000
+ }
3001
+ else if (currentPlatform === "linux") {
3002
+ return "Ctrl+Shift+V";
3003
+ }
3004
+ else {
3005
+ return "Ctrl+V";
3006
+ }
3007
+ }
3008
+ /**
3009
+ * Simulate paste keystroke
3010
+ * - macOS: Cmd+V via osascript
3011
+ * - Linux: Ctrl+Shift+V via xdotool (X11 only)
3012
+ * - Windows: Ctrl+V via PowerShell SendKeys
3013
+ * VS-010/VS-011: Cross-platform paste simulation
3014
+ * Returns true on success, false on failure
3015
+ */
3016
+ function simulatePaste() {
3017
+ const currentPlatform = platform();
3018
+ try {
3019
+ if (currentPlatform === "darwin") {
3020
+ execSync(`osascript -e 'tell application "System Events" to keystroke "v" using command down'`, { stdio: ["pipe", "ignore", "ignore"] });
3021
+ return true;
3022
+ }
3023
+ else if (currentPlatform === "linux") {
3024
+ // VS-010: Linux paste via xdotool (X11 only)
3025
+ // Use Ctrl+Shift+V for terminal compatibility
3026
+ execSync(`xdotool key --clearmodifiers ctrl+shift+v`, { stdio: ["pipe", "ignore", "ignore"] });
3027
+ return true;
3028
+ }
3029
+ else if (currentPlatform === "win32") {
3030
+ // VS-011: Windows paste via PowerShell SendKeys
3031
+ // ^v is Ctrl+V in SendKeys notation
3032
+ execSync(`powershell.exe -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('^v')"`, { stdio: ["pipe", "ignore", "ignore"] });
3033
+ return true;
3034
+ }
3035
+ return false;
3036
+ }
3037
+ catch {
3038
+ return false;
3039
+ }
3040
+ }
3041
+ /**
3042
+ * Show a desktop notification with title and message
3043
+ * - macOS: via osascript
3044
+ * - Linux: via notify-send (libnotify)
3045
+ * - Windows: via PowerShell toast notification
3046
+ * VS-010/VS-011: Cross-platform notification support
3047
+ * Returns true on success, false on failure
3048
+ */
3049
+ function showNotification(title, message) {
3050
+ const currentPlatform = platform();
3051
+ try {
3052
+ if (currentPlatform === "darwin") {
3053
+ // Escape backslashes and double quotes for AppleScript strings
3054
+ const escapedMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
3055
+ const escapedTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
3056
+ execSync(`osascript -e 'display notification "${escapedMessage}" with title "${escapedTitle}"'`, { stdio: ["pipe", "ignore", "ignore"] });
3057
+ return true;
3058
+ }
3059
+ else if (currentPlatform === "linux") {
3060
+ // VS-010: Linux notification via notify-send (part of libnotify)
3061
+ // Escape single quotes for shell
3062
+ const escapedMessage = message.replace(/'/g, "'\\''");
3063
+ const escapedTitle = title.replace(/'/g, "'\\''");
3064
+ execSync(`notify-send '${escapedTitle}' '${escapedMessage}'`, { stdio: ["pipe", "ignore", "ignore"] });
3065
+ return true;
3066
+ }
3067
+ else if (currentPlatform === "win32") {
3068
+ // VS-011: Windows toast notification via PowerShell
3069
+ // Escape for PowerShell string
3070
+ const escapedMessage = message.replace(/'/g, "''").replace(/`/g, "``");
3071
+ const escapedTitle = title.replace(/'/g, "''").replace(/`/g, "``");
3072
+ execSync(`powershell.exe -command "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); $texts = $xml.GetElementsByTagName('text'); $texts[0].AppendChild($xml.CreateTextNode('${escapedTitle}')); $texts[1].AppendChild($xml.CreateTextNode('${escapedMessage}')); [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('JFL Voice').Show([Windows.UI.Notifications.ToastNotification]::new($xml))"`, { stdio: ["pipe", "ignore", "ignore"] });
3073
+ return true;
3074
+ }
3075
+ return false;
3076
+ }
3077
+ catch {
3078
+ return false;
3079
+ }
3080
+ }
3081
+ // =============================================================================
3082
+ // VS-UX-2: System Recording Indicator
3083
+ // =============================================================================
3084
+ /**
3085
+ * Show system recording indicator via desktop notification
3086
+ * This provides a visual indicator that recording is active, even when terminal is not visible
3087
+ * VS-010/VS-011: Now works on macOS, Linux, and Windows
3088
+ * Returns true on success, false on failure
3089
+ */
3090
+ export function showRecordingIndicator() {
3091
+ return showNotification("Voice Recording", "Recording started... Press Ctrl+Shift+Space to stop");
3092
+ }
3093
+ /**
3094
+ * Hide system recording indicator (show stopped notification)
3095
+ * VS-010/VS-011: Now works on macOS, Linux, and Windows
3096
+ * Returns true on success, false on failure
3097
+ */
3098
+ export function hideRecordingIndicator(reason) {
3099
+ const messages = {
3100
+ stopped: "Recording stopped",
3101
+ cancelled: "Recording cancelled",
3102
+ completed: "Recording complete - transcribing...",
3103
+ };
3104
+ const message = messages[reason || "stopped"] || "Recording stopped";
3105
+ return showNotification("Voice Recording", message);
3106
+ }
3107
+ /**
3108
+ * Preview transcript with configurable auto-send countdown
3109
+ *
3110
+ * Behavior:
3111
+ * - Shows transcript with countdown (if timeout > 0)
3112
+ * - Enter: send immediately
3113
+ * - Esc: cancel entirely
3114
+ * - Any other key: pause countdown and enter edit mode
3115
+ * - In edit mode: user can modify text, Enter to send, Esc to cancel
3116
+ * - If countdown reaches 0: send automatically
3117
+ *
3118
+ * @param transcript - The transcribed text to preview
3119
+ * @param timeoutSeconds - Countdown duration (0 = disabled, require explicit Enter)
3120
+ * @returns PreviewResult indicating user action and final text
3121
+ */
3122
+ async function previewTranscript(transcript, timeoutSeconds) {
3123
+ return new Promise((resolve) => {
3124
+ // Set up raw mode for immediate key detection
3125
+ const stdin = process.stdin;
3126
+ const stdout = process.stdout;
3127
+ // If stdin is not a TTY (e.g., running as daemon), skip preview and auto-send
3128
+ if (!stdin.isTTY) {
3129
+ resolve({ action: "send", text: transcript });
3130
+ return;
3131
+ }
3132
+ // Store original mode to restore later
3133
+ const wasRaw = stdin.isRaw;
3134
+ stdin.setRawMode(true);
3135
+ stdin.resume();
3136
+ stdin.setEncoding("utf8");
3137
+ let currentText = transcript;
3138
+ let countdownValue = timeoutSeconds;
3139
+ let countdownInterval = null;
3140
+ let inEditMode = false;
3141
+ let editBuffer = "";
3142
+ let cursorPos = 0;
3143
+ // Helper to clear the current line and write new content
3144
+ const clearAndWrite = (text) => {
3145
+ stdout.write("\r\x1b[K" + text);
3146
+ };
3147
+ // Helper to show the preview line
3148
+ const showPreview = () => {
3149
+ if (inEditMode) {
3150
+ // Edit mode: show editable text with cursor
3151
+ const before = editBuffer.slice(0, cursorPos);
3152
+ const cursor = editBuffer[cursorPos] || " ";
3153
+ const after = editBuffer.slice(cursorPos + 1);
3154
+ clearAndWrite(chalk.gray(" Edit: ") +
3155
+ chalk.cyan(before) +
3156
+ chalk.bgCyan.black(cursor) +
3157
+ chalk.cyan(after) +
3158
+ chalk.gray(" [Enter=send, Esc=cancel]"));
3159
+ }
3160
+ else if (timeoutSeconds === 0) {
3161
+ // No countdown - require explicit action
3162
+ clearAndWrite(chalk.gray(" ") +
3163
+ chalk.cyan(`"${currentText}"`) +
3164
+ chalk.gray(" [Enter=send, Esc=cancel, any key=edit]"));
3165
+ }
3166
+ else {
3167
+ // Show countdown
3168
+ const countdownDisplay = countdownValue.toFixed(1);
3169
+ clearAndWrite(chalk.gray(" ") +
3170
+ chalk.cyan(`"${currentText}"`) +
3171
+ chalk.yellow(` Sending in ${countdownDisplay}s...`) +
3172
+ chalk.gray(" [Enter=send, Esc=cancel, any key=edit]"));
3173
+ }
3174
+ };
3175
+ // Cleanup function
3176
+ const cleanup = () => {
3177
+ if (countdownInterval) {
3178
+ clearInterval(countdownInterval);
3179
+ countdownInterval = null;
3180
+ }
3181
+ stdin.setRawMode(wasRaw || false);
3182
+ stdin.removeListener("data", onData);
3183
+ stdout.write("\n");
3184
+ };
3185
+ // Start countdown if enabled
3186
+ if (timeoutSeconds > 0) {
3187
+ countdownInterval = setInterval(() => {
3188
+ countdownValue -= 0.1;
3189
+ if (countdownValue <= 0) {
3190
+ cleanup();
3191
+ resolve({ action: "send", text: currentText });
3192
+ }
3193
+ else {
3194
+ showPreview();
3195
+ }
3196
+ }, 100);
3197
+ }
3198
+ // Handle key input
3199
+ const onData = (key) => {
3200
+ // Handle special keys
3201
+ const keyCode = key.charCodeAt(0);
3202
+ if (inEditMode) {
3203
+ // Edit mode key handling
3204
+ if (key === "\r" || key === "\n") {
3205
+ // Enter - send the edited text
3206
+ cleanup();
3207
+ resolve({ action: "send", text: editBuffer });
3208
+ }
3209
+ else if (key === "\x1b") {
3210
+ // Check for escape sequences (arrow keys, etc.)
3211
+ // Simple escape = cancel
3212
+ // Arrow keys come as \x1b[A, \x1b[B, \x1b[C, \x1b[D
3213
+ // We'll handle simple escape for now
3214
+ cleanup();
3215
+ resolve({ action: "cancel" });
3216
+ }
3217
+ else if (key === "\x7f" || key === "\b") {
3218
+ // Backspace - delete character before cursor
3219
+ if (cursorPos > 0) {
3220
+ editBuffer = editBuffer.slice(0, cursorPos - 1) + editBuffer.slice(cursorPos);
3221
+ cursorPos--;
3222
+ showPreview();
3223
+ }
3224
+ }
3225
+ else if (key === "\x1b[D") {
3226
+ // Left arrow
3227
+ if (cursorPos > 0) {
3228
+ cursorPos--;
3229
+ showPreview();
3230
+ }
3231
+ }
3232
+ else if (key === "\x1b[C") {
3233
+ // Right arrow
3234
+ if (cursorPos < editBuffer.length) {
3235
+ cursorPos++;
3236
+ showPreview();
3237
+ }
3238
+ }
3239
+ else if (key === "\x03") {
3240
+ // Ctrl+C - cancel
3241
+ cleanup();
3242
+ resolve({ action: "cancel" });
3243
+ }
3244
+ else if (keyCode >= 32 && keyCode < 127) {
3245
+ // Printable character - insert at cursor
3246
+ editBuffer = editBuffer.slice(0, cursorPos) + key + editBuffer.slice(cursorPos);
3247
+ cursorPos++;
3248
+ showPreview();
3249
+ }
3250
+ }
3251
+ else {
3252
+ // Preview mode key handling
3253
+ if (key === "\r" || key === "\n") {
3254
+ // Enter - send immediately
3255
+ cleanup();
3256
+ resolve({ action: "send", text: currentText });
3257
+ }
3258
+ else if (key === "\x1b") {
3259
+ // Escape - cancel
3260
+ cleanup();
3261
+ resolve({ action: "cancel" });
3262
+ }
3263
+ else if (key === "\x03") {
3264
+ // Ctrl+C - cancel
3265
+ cleanup();
3266
+ resolve({ action: "cancel" });
3267
+ }
3268
+ else {
3269
+ // Any other key - enter edit mode
3270
+ if (countdownInterval) {
3271
+ clearInterval(countdownInterval);
3272
+ countdownInterval = null;
3273
+ }
3274
+ inEditMode = true;
3275
+ editBuffer = currentText;
3276
+ // If printable character, start with it
3277
+ if (keyCode >= 32 && keyCode < 127) {
3278
+ editBuffer = currentText + key;
3279
+ cursorPos = editBuffer.length;
3280
+ }
3281
+ else {
3282
+ cursorPos = editBuffer.length;
3283
+ }
3284
+ showPreview();
3285
+ }
3286
+ }
3287
+ };
3288
+ stdin.on("data", onData);
3289
+ // Show initial preview
3290
+ console.log(); // New line before preview
3291
+ showPreview();
3292
+ });
3293
+ }
3294
+ /**
3295
+ * Voice Slash Command - /voice
3296
+ *
3297
+ * Records audio with VAD (voice activity detection), transcribes it,
3298
+ * and offers to send to Claude Code or copy to clipboard.
3299
+ */
3300
+ export async function voiceSlashCommand(options = {}) {
3301
+ const silenceThresholdDb = options.silenceThresholdDb ?? VAD_SILENCE_THRESHOLD_DB;
3302
+ const silenceDurationMs = options.silenceDurationMs ?? VAD_SILENCE_DURATION_MS;
3303
+ const maxDurationSecs = options.maxDurationSecs ?? 60;
3304
+ console.log(chalk.bold("\n🎤 Voice Recording\n"));
3305
+ console.log(chalk.gray(" Speak into your microphone. Recording will stop automatically"));
3306
+ console.log(chalk.gray(` after ${(silenceDurationMs / 1000).toFixed(1)}s of silence, or press Ctrl+C to stop.\n`));
3307
+ // Step 1: Check if whisper server is running
3308
+ const serverError = checkServerRunning();
3309
+ if (serverError) {
3310
+ handleVoiceError(serverError);
3311
+ return;
3312
+ }
3313
+ // Step 2: Check for auth token
3314
+ const authError = checkAuthToken();
3315
+ if (authError) {
3316
+ handleVoiceError(authError);
3317
+ return;
3318
+ }
3319
+ const authToken = readAuthToken();
3320
+ const socketPath = getVoiceSocketPath();
3321
+ // Step 3: Initialize audio recorder
3322
+ let recorder;
3323
+ try {
3324
+ recorder = new AudioRecorder({
3325
+ device: options.device,
3326
+ sampleRate: 16000,
3327
+ });
3328
+ }
3329
+ catch (error) {
3330
+ if (error instanceof VoiceError) {
3331
+ handleVoiceError(error);
3332
+ }
3333
+ else {
3334
+ handleVoiceError(createVoiceError(error instanceof Error ? error : new Error(String(error))));
3335
+ }
3336
+ return;
3337
+ }
3338
+ // Collect audio data
3339
+ const audioChunks = [];
3340
+ let peakLevel = 0;
3341
+ let recordingError = null;
3342
+ // VAD state
3343
+ let silenceStartTime = null;
3344
+ let hasVoiceActivity = false;
3345
+ const startTime = Date.now();
3346
+ // VS-012: Reset waveform buffer for new recording
3347
+ resetWaveformBuffer();
3348
+ const useWaveform = supportsWaveform();
3349
+ // Spinner for recording indicator
3350
+ const spinner = ora({
3351
+ text: chalk.cyan("Recording...") + chalk.gray(" (waiting for voice)"),
3352
+ prefixText: " ",
3353
+ spinner: "dots",
3354
+ });
3355
+ // Handle Ctrl+C gracefully
3356
+ let interrupted = false;
3357
+ const handleInterrupt = () => {
3358
+ interrupted = true;
3359
+ recorder.stop();
3360
+ };
3361
+ process.on("SIGINT", handleInterrupt);
3362
+ process.on("SIGTERM", handleInterrupt);
3363
+ // Set up recorder event handlers
3364
+ recorder.on("data", (chunk) => {
3365
+ audioChunks.push(chunk);
3366
+ // Calculate peak level
3367
+ const chunkPeak = calculatePeakAmplitude(chunk);
3368
+ if (chunkPeak > peakLevel) {
3369
+ peakLevel = chunkPeak;
3370
+ }
3371
+ // VAD: Check if audio level is above silence threshold
3372
+ const peakDb = amplitudeToDb(chunkPeak);
3373
+ const isSilent = peakDb < silenceThresholdDb;
3374
+ // VS-012: Add level to waveform buffer on every chunk
3375
+ addToWaveformBuffer(peakDb);
3376
+ // VS-012: Update spinner with waveform on every chunk for real-time feedback
3377
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
3378
+ if (useWaveform) {
3379
+ const waveform = renderWaveform();
3380
+ spinner.text = chalk.cyan("Recording") + chalk.gray(` ${waveform} (${elapsed}s)`);
3381
+ }
3382
+ if (!isSilent) {
3383
+ // Voice activity detected
3384
+ hasVoiceActivity = true;
3385
+ silenceStartTime = null;
3386
+ }
3387
+ else if (hasVoiceActivity) {
3388
+ // Silence detected after voice activity
3389
+ if (silenceStartTime === null) {
3390
+ silenceStartTime = Date.now();
3391
+ }
3392
+ else {
3393
+ const silenceDuration = Date.now() - silenceStartTime;
3394
+ // Update spinner to show silence detection
3395
+ const remaining = Math.max(0, silenceDurationMs - silenceDuration);
3396
+ if (remaining > 0) {
3397
+ if (useWaveform) {
3398
+ const waveform = renderWaveform();
3399
+ spinner.text = chalk.cyan("Recording") + chalk.gray(` ${waveform}`) + chalk.yellow(` (silence: ${(remaining / 1000).toFixed(1)}s)`);
3400
+ }
3401
+ else {
3402
+ spinner.text = chalk.cyan("Recording") + chalk.yellow(` (silence: ${(remaining / 1000).toFixed(1)}s until stop)`);
3403
+ }
3404
+ }
3405
+ // Stop recording after silence duration
3406
+ if (silenceDuration >= silenceDurationMs) {
3407
+ recorder.stop();
3408
+ }
3409
+ }
3410
+ }
3411
+ // Check max duration
3412
+ const elapsedSecs = (Date.now() - startTime) / 1000;
3413
+ if (elapsedSecs >= maxDurationSecs) {
3414
+ recorder.stop();
3415
+ }
3416
+ });
3417
+ recorder.on("error", (error) => {
3418
+ recordingError = error;
3419
+ recorder.stop();
3420
+ });
3421
+ // Start recording
3422
+ try {
3423
+ await recorder.start();
3424
+ spinner.start();
3425
+ // VS-UX-2: Show system recording indicator (notification on macOS)
3426
+ showRecordingIndicator();
3427
+ }
3428
+ catch (error) {
3429
+ const voiceError = new VoiceError(VoiceErrorType.RECORDING_FAILED, {
3430
+ originalError: error instanceof Error ? error : new Error(String(error)),
3431
+ context: { device: options.device },
3432
+ });
3433
+ handleVoiceError(voiceError);
3434
+ process.removeListener("SIGINT", handleInterrupt);
3435
+ process.removeListener("SIGTERM", handleInterrupt);
3436
+ return;
3437
+ }
3438
+ // Wait for recording to stop
3439
+ await new Promise((resolve) => {
3440
+ const checkStopped = () => {
3441
+ if (recorder.getState() === "idle" || recorder.getState() === "error") {
3442
+ resolve();
3443
+ }
3444
+ else {
3445
+ setTimeout(checkStopped, 50);
3446
+ }
3447
+ };
3448
+ // Small delay to let the stop signal propagate
3449
+ setTimeout(checkStopped, 100);
3450
+ });
3451
+ // Clean up interrupt handler
3452
+ process.removeListener("SIGINT", handleInterrupt);
3453
+ process.removeListener("SIGTERM", handleInterrupt);
3454
+ // Stop spinner
3455
+ spinner.stop();
3456
+ // Handle interruption
3457
+ if (interrupted) {
3458
+ // VS-UX-2: Show cancelled indicator
3459
+ hideRecordingIndicator("cancelled");
3460
+ console.log(chalk.yellow("\n Recording stopped by user.\n"));
3461
+ }
3462
+ // Handle recording error
3463
+ if (recordingError !== null) {
3464
+ // VS-UX-2: Show stopped indicator on error
3465
+ hideRecordingIndicator("stopped");
3466
+ const voiceError = new VoiceError(VoiceErrorType.RECORDING_FAILED, {
3467
+ originalError: recordingError,
3468
+ context: { device: options.device },
3469
+ });
3470
+ handleVoiceError(voiceError);
3471
+ return;
3472
+ }
3473
+ // Check if we got any audio
3474
+ const totalBytes = audioChunks.reduce((sum, chunk) => sum + chunk.length, 0);
3475
+ if (totalBytes === 0) {
3476
+ // VS-UX-2: Show stopped indicator when no audio
3477
+ hideRecordingIndicator("stopped");
3478
+ const voiceError = new VoiceError(VoiceErrorType.MIC_UNAVAILABLE, {
3479
+ context: { totalBytes: 0, device: options.device },
3480
+ });
3481
+ handleVoiceError(voiceError);
3482
+ return;
3483
+ }
3484
+ // Check if there was any voice activity
3485
+ if (!hasVoiceActivity) {
3486
+ // VS-UX-2: Show stopped indicator when no voice detected
3487
+ hideRecordingIndicator("stopped");
3488
+ const voiceError = new VoiceError(VoiceErrorType.TRANSCRIPTION_EMPTY, {
3489
+ context: { reason: "No voice activity detected", peakLevel },
3490
+ });
3491
+ handleVoiceError(voiceError);
3492
+ return;
3493
+ }
3494
+ // Combine all audio chunks and normalize for better transcription
3495
+ const audioBuffer = normalizeAudio(Buffer.concat(audioChunks));
3496
+ const durationSecs = totalBytes / (16000 * 2); // 16kHz, 16-bit
3497
+ // VS-UX-2: Show completed indicator when moving to transcription
3498
+ hideRecordingIndicator("completed");
3499
+ console.log(chalk.gray(`\n Recorded ${durationSecs.toFixed(1)}s of audio.`));
3500
+ console.log(chalk.gray(" Transcribing...\n"));
3501
+ // Step 4: Connect to whisper server and send audio
3502
+ const transcribeSpinner = ora({
3503
+ text: "Transcribing...",
3504
+ prefixText: " ",
3505
+ }).start();
3506
+ const client = new VoiceClient({
3507
+ socketPath,
3508
+ authToken,
3509
+ maxReconnectAttempts: 1,
3510
+ });
3511
+ let transcription = "";
3512
+ let transcriptionReceived = false;
3513
+ let transcriptionError = null;
3514
+ client.onTranscript((text, isFinal) => {
3515
+ // Accept partial transcriptions too (in case final never arrives)
3516
+ if (text && text.trim() && !text.includes("[BLANK_AUDIO]")) {
3517
+ transcription = text;
3518
+ }
3519
+ if (isFinal) {
3520
+ transcriptionReceived = true;
3521
+ }
3522
+ });
3523
+ client.onError((error) => {
3524
+ transcriptionError = error;
3525
+ });
3526
+ try {
3527
+ await client.connect();
3528
+ // Send audio data and wait for it to be sent
3529
+ client.sendAudio(audioBuffer);
3530
+ // Wait for audio to be sent before signaling end
3531
+ await new Promise(resolve => setTimeout(resolve, 500));
3532
+ // Signal end of audio
3533
+ client.endAudio();
3534
+ // Wait for transcription with timeout
3535
+ const timeout = 30000; // 30 seconds
3536
+ const transcribeStartTime = Date.now();
3537
+ while (!transcriptionReceived && !transcriptionError) {
3538
+ if (Date.now() - transcribeStartTime > timeout) {
3539
+ transcriptionError = new VoiceError(VoiceErrorType.TIMEOUT, {
3540
+ context: { timeout, operation: "transcription" },
3541
+ recoverable: true,
3542
+ audioBuffer,
3543
+ });
3544
+ break;
3545
+ }
3546
+ await new Promise((resolve) => setTimeout(resolve, 100));
3547
+ }
3548
+ client.disconnect();
3549
+ }
3550
+ catch (error) {
3551
+ client.disconnect();
3552
+ const voiceError = error instanceof VoiceError
3553
+ ? error
3554
+ : createVoiceError(error instanceof Error ? error : new Error(String(error)), { operation: "connect" }, audioBuffer);
3555
+ handleVoiceError(voiceError, transcribeSpinner);
3556
+ return;
3557
+ }
3558
+ // Handle transcription error
3559
+ if (transcriptionError) {
3560
+ const voiceError = transcriptionError instanceof VoiceError
3561
+ ? transcriptionError
3562
+ : createVoiceError(transcriptionError, { operation: "transcription" }, audioBuffer);
3563
+ handleVoiceError(voiceError, transcribeSpinner);
3564
+ return;
3565
+ }
3566
+ // Handle empty transcription
3567
+ if (!transcription || transcription.trim() === "") {
3568
+ const voiceError = new VoiceError(VoiceErrorType.TRANSCRIPTION_EMPTY, {
3569
+ context: { audioLength: totalBytes, durationSecs, peakLevel },
3570
+ recoverable: true,
3571
+ audioBuffer,
3572
+ });
3573
+ handleVoiceError(voiceError, transcribeSpinner);
3574
+ return;
3575
+ }
3576
+ transcribeSpinner.succeed("Transcription complete!");
3577
+ // Step 5: Display transcription
3578
+ console.log();
3579
+ console.log(chalk.bold(" Transcription:"));
3580
+ console.log();
3581
+ console.log(chalk.cyan(` "${transcription.trim()}"`));
3582
+ console.log();
3583
+ // Step 6: Ask what to do with the transcription
3584
+ const { action } = await inquirer.prompt([
3585
+ {
3586
+ type: "list",
3587
+ name: "action",
3588
+ message: "What would you like to do?",
3589
+ choices: [
3590
+ { name: "Send to Claude Code (paste into conversation)", value: "send" },
3591
+ { name: "Copy to clipboard", value: "copy" },
3592
+ { name: "Cancel (do nothing)", value: "cancel" },
3593
+ ],
3594
+ default: "send",
3595
+ },
3596
+ ]);
3597
+ if (action === "send") {
3598
+ // For "send to Claude Code", we simply output the text so the user can paste it
3599
+ // In a full implementation, this could integrate with Claude Code's API
3600
+ console.log(chalk.green("\n ✓ Text ready to send to Claude Code:\n"));
3601
+ console.log(chalk.white(` ${transcription.trim()}`));
3602
+ console.log();
3603
+ console.log(chalk.gray(" Copy and paste this into your Claude Code conversation."));
3604
+ console.log();
3605
+ }
3606
+ else if (action === "copy") {
3607
+ const copied = copyToClipboard(transcription.trim());
3608
+ if (copied) {
3609
+ console.log(chalk.green("\n ✓ Copied to clipboard!"));
3610
+ }
3611
+ else {
3612
+ console.log(chalk.yellow("\n ⚠ Could not copy to clipboard."));
3613
+ console.log(chalk.gray(" On Linux, install xclip or xsel for clipboard support."));
3614
+ console.log(chalk.gray("\n Text:"));
3615
+ console.log(chalk.white(` ${transcription.trim()}`));
3616
+ }
3617
+ console.log();
3618
+ }
3619
+ else {
3620
+ console.log(chalk.gray("\n Cancelled.\n"));
3621
+ }
3622
+ }
3623
+ // =============================================================================
3624
+ // Hotkey Command - Global Keyboard Shortcut for Voice Recording
3625
+ // =============================================================================
3626
+ /**
3627
+ * Check if running on Wayland (Linux only)
3628
+ * VS-010: Wayland detection for Linux hotkey support
3629
+ * @returns true if running on Wayland, false otherwise
3630
+ */
3631
+ function isWayland() {
3632
+ if (platform() !== "linux") {
3633
+ return false;
3634
+ }
3635
+ // Check for Wayland indicators
3636
+ // WAYLAND_DISPLAY is set when running under a Wayland compositor
3637
+ // XDG_SESSION_TYPE is set to "wayland" on Wayland sessions
3638
+ return !!(process.env.WAYLAND_DISPLAY || process.env.XDG_SESSION_TYPE === "wayland");
3639
+ }
3640
+ /**
3641
+ * Check if Accessibility/keyboard permission is available
3642
+ * - macOS: Requires Accessibility permission for global hotkey capture
3643
+ * - Linux X11: Uses XGrabKey, no special permissions needed
3644
+ * - Linux Wayland: Not supported (returns false)
3645
+ * - Windows: Uses RegisterHotKey, no admin privileges needed
3646
+ */
3647
+ function checkAccessibilityPermission() {
3648
+ const currentPlatform = platform();
3649
+ if (currentPlatform === "darwin") {
3650
+ try {
3651
+ // Use osascript to check if Accessibility permission is granted
3652
+ // This method attempts to list processes, which requires accessibility
3653
+ const result = execSync(`osascript -e 'tell application "System Events" to get name of first process'`, { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
3654
+ return true;
3655
+ }
3656
+ catch (error) {
3657
+ // If the script fails, accessibility permission is likely not granted
3658
+ return false;
3659
+ }
3660
+ }
3661
+ else if (currentPlatform === "linux") {
3662
+ // On Linux X11, we just need to verify X11 is available
3663
+ // The node-global-key-listener uses XGrabKey which doesn't need special permissions
3664
+ // Just check that we're not on Wayland (handled separately)
3665
+ return !isWayland();
3666
+ }
3667
+ else if (currentPlatform === "win32") {
3668
+ // On Windows, RegisterHotKey doesn't require special permissions
3669
+ // Works without admin privileges
3670
+ return true;
3671
+ }
3672
+ return false;
3673
+ }
3674
+ /**
3675
+ * Open system settings for keyboard/accessibility permissions
3676
+ * - macOS: Opens Accessibility pane in System Settings
3677
+ * - Linux: Prints instructions (no GUI settings for X11)
3678
+ * - Windows: Prints instructions (no special permissions needed)
3679
+ * VS-010/VS-011: Cross-platform settings guidance
3680
+ */
3681
+ function openAccessibilitySettings() {
3682
+ const currentPlatform = platform();
3683
+ if (currentPlatform === "darwin") {
3684
+ try {
3685
+ // macOS 13+ uses System Settings, older versions use System Preferences
3686
+ execSync(`open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"`, { stdio: "ignore" });
3687
+ }
3688
+ catch {
3689
+ // Fallback for older macOS versions
3690
+ try {
3691
+ execSync(`open "/System/Library/PreferencePanes/Security.prefPane"`, { stdio: "ignore" });
3692
+ }
3693
+ catch {
3694
+ console.log(chalk.gray(" Could not open System Settings automatically."));
3695
+ }
3696
+ }
3697
+ }
3698
+ else if (currentPlatform === "linux") {
3699
+ // VS-010: Linux doesn't have a system settings pane for X11 keyboard access
3700
+ // Just print helpful information
3701
+ console.log(chalk.gray(" Linux X11 hotkey requirements:"));
3702
+ console.log(chalk.gray(" - Install xdotool: sudo apt-get install xdotool"));
3703
+ console.log(chalk.gray(" - Ensure you're running X11, not Wayland"));
3704
+ console.log(chalk.gray(" - Check session type: echo $XDG_SESSION_TYPE"));
3705
+ }
3706
+ else if (currentPlatform === "win32") {
3707
+ // VS-011: Windows doesn't need special permissions for RegisterHotKey
3708
+ console.log(chalk.gray(" Windows hotkey should work without special permissions."));
3709
+ console.log(chalk.gray(" If you have issues, try running as Administrator."));
3710
+ }
3711
+ }
3712
+ // =============================================================================
3713
+ // VS-013: Voice Daemon Commands
3714
+ // =============================================================================
3715
+ /**
3716
+ * Read the daemon PID from the PID file
3717
+ * @returns The PID number or null if not found/invalid
3718
+ */
3719
+ function readDaemonPid() {
3720
+ const pidPath = getVoiceDaemonPidPath();
3721
+ if (!existsSync(pidPath)) {
3722
+ return null;
3723
+ }
3724
+ try {
3725
+ const pidStr = readFileSync(pidPath, "utf-8").trim();
3726
+ const pid = parseInt(pidStr, 10);
3727
+ if (isNaN(pid) || pid <= 0) {
3728
+ return null;
3729
+ }
3730
+ return pid;
3731
+ }
3732
+ catch {
3733
+ return null;
3734
+ }
3735
+ }
3736
+ /**
3737
+ * Check if a process with the given PID is running
3738
+ * @param pid - Process ID to check
3739
+ * @returns true if process is running, false otherwise
3740
+ */
3741
+ function isProcessRunning(pid) {
3742
+ try {
3743
+ // Sending signal 0 doesn't kill the process, just checks if it exists
3744
+ process.kill(pid, 0);
3745
+ return true;
3746
+ }
3747
+ catch (error) {
3748
+ return false;
3749
+ }
3750
+ }
3751
+ /**
3752
+ * Get daemon uptime if running
3753
+ * @returns Uptime string or null if not running
3754
+ */
3755
+ function getDaemonUptime() {
3756
+ const pidPath = getVoiceDaemonPidPath();
3757
+ if (!existsSync(pidPath)) {
3758
+ return null;
3759
+ }
3760
+ try {
3761
+ const stats = statSync(pidPath);
3762
+ const startTime = stats.mtime.getTime();
3763
+ const uptime = Date.now() - startTime;
3764
+ // Format uptime
3765
+ const seconds = Math.floor(uptime / 1000) % 60;
3766
+ const minutes = Math.floor(uptime / (1000 * 60)) % 60;
3767
+ const hours = Math.floor(uptime / (1000 * 60 * 60)) % 24;
3768
+ const days = Math.floor(uptime / (1000 * 60 * 60 * 24));
3769
+ if (days > 0) {
3770
+ return `${days}d ${hours}h ${minutes}m`;
3771
+ }
3772
+ else if (hours > 0) {
3773
+ return `${hours}h ${minutes}m ${seconds}s`;
3774
+ }
3775
+ else if (minutes > 0) {
3776
+ return `${minutes}m ${seconds}s`;
3777
+ }
3778
+ else {
3779
+ return `${seconds}s`;
3780
+ }
3781
+ }
3782
+ catch {
3783
+ return null;
3784
+ }
3785
+ }
3786
+ /**
3787
+ * Daemon Start Command - Launch voice hotkey listener in background
3788
+ *
3789
+ * Spawns a detached child process running the hotkey listener that survives
3790
+ * terminal close. PID is stored in ~/.jfl/voice-daemon.pid
3791
+ */
3792
+ export async function daemonStartCommand(options = {}) {
3793
+ // Check platform - daemon only supported on macOS for now
3794
+ if (platform() !== "darwin") {
3795
+ const error = new VoiceError(VoiceErrorType.PLATFORM_NOT_SUPPORTED);
3796
+ handleVoiceError(error);
3797
+ return;
3798
+ }
3799
+ console.log(chalk.bold("\n🎤 Voice Daemon\n"));
3800
+ // Check if daemon is already running
3801
+ const existingPid = readDaemonPid();
3802
+ if (existingPid && isProcessRunning(existingPid)) {
3803
+ console.log(chalk.yellow(" Daemon is already running."));
3804
+ console.log(chalk.gray(` PID: ${existingPid}`));
3805
+ console.log(chalk.gray(` Uptime: ${getDaemonUptime()}`));
3806
+ console.log();
3807
+ console.log(chalk.gray(" Use 'jfl voice daemon stop' to stop it."));
3808
+ console.log(chalk.gray(" Use 'jfl voice daemon status' to check status."));
3809
+ console.log();
3810
+ return;
3811
+ }
3812
+ // Check Accessibility permission first
3813
+ console.log(chalk.gray(" Checking Accessibility permission..."));
3814
+ if (!checkAccessibilityPermission()) {
3815
+ console.log();
3816
+ const error = new VoiceError(VoiceErrorType.ACCESSIBILITY_NOT_GRANTED);
3817
+ handleVoiceError(error);
3818
+ console.log(chalk.cyan(" Opening System Settings..."));
3819
+ openAccessibilitySettings();
3820
+ console.log();
3821
+ console.log(chalk.yellow(" After granting permission:"));
3822
+ console.log(chalk.gray(" 1. Add your terminal app to Accessibility"));
3823
+ console.log(chalk.gray(" 2. Restart your terminal"));
3824
+ console.log(chalk.gray(" 3. Run 'jfl voice daemon start' again"));
3825
+ console.log();
3826
+ return;
3827
+ }
3828
+ console.log(chalk.green(" ✓ Accessibility permission granted"));
3829
+ // Check other prerequisites (server, auth)
3830
+ const serverError = checkServerRunning();
3831
+ if (serverError) {
3832
+ handleVoiceError(serverError);
3833
+ return;
3834
+ }
3835
+ const authError = checkAuthToken();
3836
+ if (authError) {
3837
+ handleVoiceError(authError);
3838
+ return;
3839
+ }
3840
+ console.log(chalk.gray(" Starting daemon..."));
3841
+ // Get the path to the current executable (jfl CLI)
3842
+ const jflPath = process.argv[1];
3843
+ const nodePath = process.argv[0];
3844
+ // Build the command arguments
3845
+ const args = ["voice", "hotkey"];
3846
+ if (options.mode) {
3847
+ args.push("--mode", options.mode);
3848
+ }
3849
+ // Spawn detached process
3850
+ const logPath = getVoiceDaemonLogPath();
3851
+ const pidPath = getVoiceDaemonPidPath();
3852
+ ensureDirectories();
3853
+ // Create log file for daemon output
3854
+ const logFd = openSync(logPath, "a");
3855
+ const child = spawn(nodePath, [jflPath, ...args], {
3856
+ detached: true,
3857
+ stdio: ["ignore", logFd, logFd],
3858
+ env: {
3859
+ ...process.env,
3860
+ JFL_VOICE_DAEMON: "1", // Mark this as daemon mode
3861
+ },
3862
+ });
3863
+ // Write PID to file
3864
+ if (child.pid) {
3865
+ writeFileSync(pidPath, child.pid.toString(), { mode: 0o644 });
3866
+ // Unref so parent can exit independently
3867
+ child.unref();
3868
+ // Close the log file descriptor in the parent
3869
+ closeSync(logFd);
3870
+ // Give it a moment to start and check if it's running
3871
+ await new Promise(resolve => setTimeout(resolve, 500));
3872
+ if (isProcessRunning(child.pid)) {
3873
+ console.log(chalk.green("\n ✓ Daemon started successfully!"));
3874
+ console.log(chalk.gray(` PID: ${child.pid}`));
3875
+ console.log(chalk.gray(` Log: ${logPath}`));
3876
+ console.log();
3877
+ console.log(chalk.cyan(" Hotkey: Ctrl+Shift+Space"));
3878
+ console.log(chalk.gray(" The daemon will continue running after you close this terminal."));
3879
+ console.log();
3880
+ console.log(chalk.gray(" Commands:"));
3881
+ console.log(chalk.gray(" jfl voice daemon status Check daemon status"));
3882
+ console.log(chalk.gray(" jfl voice daemon stop Stop the daemon"));
3883
+ console.log();
3884
+ }
3885
+ else {
3886
+ // Daemon may have exited immediately - check log for errors
3887
+ console.log(chalk.red("\n ✗ Daemon failed to start"));
3888
+ console.log(chalk.gray(` Check log for details: ${logPath}`));
3889
+ // Clean up PID file
3890
+ try {
3891
+ unlinkSync(pidPath);
3892
+ }
3893
+ catch { }
3894
+ console.log();
3895
+ }
3896
+ }
3897
+ else {
3898
+ closeSync(logFd);
3899
+ console.log(chalk.red("\n ✗ Failed to spawn daemon process"));
3900
+ console.log();
3901
+ }
3902
+ }
3903
+ /**
3904
+ * Daemon Stop Command - Stop the voice daemon gracefully
3905
+ *
3906
+ * Reads PID from ~/.jfl/voice-daemon.pid and sends SIGTERM
3907
+ */
3908
+ export async function daemonStopCommand() {
3909
+ console.log(chalk.bold("\n🎤 Voice Daemon\n"));
3910
+ const pid = readDaemonPid();
3911
+ const pidPath = getVoiceDaemonPidPath();
3912
+ if (!pid) {
3913
+ console.log(chalk.yellow(" Daemon is not running (no PID file)."));
3914
+ console.log();
3915
+ return;
3916
+ }
3917
+ if (!isProcessRunning(pid)) {
3918
+ console.log(chalk.yellow(" Daemon is not running (stale PID file)."));
3919
+ console.log(chalk.gray(" Cleaning up PID file..."));
3920
+ try {
3921
+ unlinkSync(pidPath);
3922
+ }
3923
+ catch { }
3924
+ console.log(chalk.green(" ✓ Cleaned up"));
3925
+ console.log();
3926
+ return;
3927
+ }
3928
+ console.log(chalk.gray(` Stopping daemon (PID: ${pid})...`));
3929
+ try {
3930
+ // Send SIGTERM for graceful shutdown
3931
+ process.kill(pid, "SIGTERM");
3932
+ // Wait for process to stop (up to 5 seconds)
3933
+ let stopped = false;
3934
+ for (let i = 0; i < 50; i++) {
3935
+ await new Promise(resolve => setTimeout(resolve, 100));
3936
+ if (!isProcessRunning(pid)) {
3937
+ stopped = true;
3938
+ break;
3939
+ }
3940
+ }
3941
+ if (stopped) {
3942
+ console.log(chalk.green(" ✓ Daemon stopped successfully"));
3943
+ // Clean up PID file
3944
+ try {
3945
+ unlinkSync(pidPath);
3946
+ }
3947
+ catch { }
3948
+ }
3949
+ else {
3950
+ console.log(chalk.yellow(" Daemon did not stop gracefully, sending SIGKILL..."));
3951
+ try {
3952
+ process.kill(pid, "SIGKILL");
3953
+ await new Promise(resolve => setTimeout(resolve, 500));
3954
+ console.log(chalk.green(" ✓ Daemon killed"));
3955
+ try {
3956
+ unlinkSync(pidPath);
3957
+ }
3958
+ catch { }
3959
+ }
3960
+ catch (error) {
3961
+ console.log(chalk.red(" ✗ Failed to kill daemon"));
3962
+ console.log(chalk.gray(` You may need to manually kill PID ${pid}`));
3963
+ }
3964
+ }
3965
+ }
3966
+ catch (error) {
3967
+ console.log(chalk.red(" ✗ Failed to stop daemon"));
3968
+ if (error instanceof Error) {
3969
+ console.log(chalk.gray(` ${error.message}`));
3970
+ }
3971
+ console.log(chalk.gray(` You may need to manually kill PID ${pid}`));
3972
+ }
3973
+ console.log();
3974
+ }
3975
+ /**
3976
+ * Daemon Status Command - Show daemon running status
3977
+ *
3978
+ * Checks if daemon is running based on PID file and process existence
3979
+ */
3980
+ export async function daemonStatusCommand() {
3981
+ console.log(chalk.bold("\n🎤 Voice Daemon Status\n"));
3982
+ const pid = readDaemonPid();
3983
+ const pidPath = getVoiceDaemonPidPath();
3984
+ const logPath = getVoiceDaemonLogPath();
3985
+ if (!pid) {
3986
+ console.log(chalk.yellow(" Status: stopped"));
3987
+ console.log(chalk.gray(" (no PID file found)"));
3988
+ console.log();
3989
+ console.log(chalk.gray(" Start with: jfl voice daemon start"));
3990
+ console.log();
3991
+ return;
3992
+ }
3993
+ if (!isProcessRunning(pid)) {
3994
+ console.log(chalk.yellow(" Status: stopped (stale)"));
3995
+ console.log(chalk.gray(` PID file exists but process ${pid} is not running`));
3996
+ console.log();
3997
+ console.log(chalk.gray(" Cleaning up stale PID file..."));
3998
+ try {
3999
+ unlinkSync(pidPath);
4000
+ console.log(chalk.green(" ✓ Cleaned up"));
4001
+ }
4002
+ catch { }
4003
+ console.log();
4004
+ console.log(chalk.gray(" Start with: jfl voice daemon start"));
4005
+ console.log();
4006
+ return;
4007
+ }
4008
+ // Daemon is running
4009
+ const uptime = getDaemonUptime();
4010
+ console.log(chalk.green(" Status: running"));
4011
+ console.log(chalk.gray(` PID: ${pid}`));
4012
+ if (uptime) {
4013
+ console.log(chalk.gray(` Uptime: ${uptime}`));
4014
+ }
4015
+ console.log(chalk.gray(` Log: ${logPath}`));
4016
+ console.log();
4017
+ console.log(chalk.cyan(" Hotkey: Ctrl+Shift+Space"));
4018
+ console.log();
4019
+ console.log(chalk.gray(" Commands:"));
4020
+ console.log(chalk.gray(" jfl voice daemon stop Stop the daemon"));
4021
+ console.log();
4022
+ // Show last few lines of log if it exists
4023
+ if (existsSync(logPath)) {
4024
+ try {
4025
+ const logContent = readFileSync(logPath, "utf-8");
4026
+ const lines = logContent.trim().split("\n");
4027
+ const lastLines = lines.slice(-5);
4028
+ if (lastLines.length > 0 && lastLines[0]) {
4029
+ console.log(chalk.gray(" Recent log:"));
4030
+ for (const line of lastLines) {
4031
+ console.log(chalk.gray(` ${line.substring(0, 80)}`));
4032
+ }
4033
+ console.log();
4034
+ }
4035
+ }
4036
+ catch { }
4037
+ }
4038
+ }
4039
+ /**
4040
+ * Hotkey Command - Start global hotkey listener
4041
+ *
4042
+ * Listens for Ctrl+Shift+Space globally (even when other apps have focus).
4043
+ * Supports multiple modes:
4044
+ * - auto: Smart detection - tap to toggle, or hold for hold-to-talk
4045
+ * - tap: Tap to start/stop recording
4046
+ * - hold: Hold to record, release to stop
4047
+ * VS-010/VS-011: Supported on macOS, Linux (X11), and Windows.
4048
+ * Requires Accessibility permission on macOS. On Linux Wayland, hotkeys are not supported.
4049
+ */
4050
+ export async function hotkeyCommand(options = {}) {
4051
+ const currentPlatform = platform();
4052
+ // VS-010: Check for Linux Wayland (not supported)
4053
+ if (currentPlatform === "linux" && isWayland()) {
4054
+ const error = new VoiceError(VoiceErrorType.WAYLAND_NOT_SUPPORTED);
4055
+ handleVoiceError(error);
4056
+ return;
4057
+ }
4058
+ // Check platform - hotkey supported on macOS, Linux (X11), and Windows
4059
+ if (currentPlatform !== "darwin" && currentPlatform !== "linux" && currentPlatform !== "win32") {
4060
+ const error = new VoiceError(VoiceErrorType.PLATFORM_NOT_SUPPORTED);
4061
+ handleVoiceError(error);
4062
+ return;
4063
+ }
4064
+ // Load hotkey config from voice.yaml, with command-line override
4065
+ const voiceConfig = readVoiceConfig();
4066
+ const hotkeyConfig = voiceConfig?.hotkey || DEFAULT_HOTKEY_CONFIG;
4067
+ const securityConfig = voiceConfig?.security || DEFAULT_SECURITY_CONFIG;
4068
+ const activeMode = options.mode || hotkeyConfig.mode;
4069
+ const holdThreshold = hotkeyConfig.holdThreshold;
4070
+ console.log(chalk.bold("\n🎤 Voice Hotkey Mode\n"));
4071
+ console.log(chalk.gray(" Global hotkey: Ctrl+Shift+Space"));
4072
+ // Show mode-specific instructions
4073
+ if (activeMode === "tap") {
4074
+ console.log(chalk.gray(" Mode: tap-to-toggle"));
4075
+ console.log(chalk.gray(" First tap starts recording, second tap stops.\n"));
4076
+ }
4077
+ else if (activeMode === "hold") {
4078
+ console.log(chalk.gray(" Mode: hold-to-talk"));
4079
+ console.log(chalk.gray(" Hold to record, release to stop.\n"));
4080
+ }
4081
+ else {
4082
+ console.log(chalk.gray(" Mode: auto (smart detection)"));
4083
+ console.log(chalk.gray(` Quick tap (<${holdThreshold}ms): toggle recording`));
4084
+ console.log(chalk.gray(` Hold (>${holdThreshold}ms): hold-to-talk\n`));
4085
+ }
4086
+ // Check Accessibility/keyboard permission (platform-specific)
4087
+ if (currentPlatform === "darwin") {
4088
+ console.log(chalk.gray(" Checking Accessibility permission..."));
4089
+ }
4090
+ else if (currentPlatform === "linux") {
4091
+ console.log(chalk.gray(" Checking X11 environment..."));
4092
+ }
4093
+ else if (currentPlatform === "win32") {
4094
+ console.log(chalk.gray(" Checking keyboard access..."));
4095
+ }
4096
+ if (!checkAccessibilityPermission()) {
4097
+ console.log();
4098
+ if (currentPlatform === "darwin") {
4099
+ const error = new VoiceError(VoiceErrorType.ACCESSIBILITY_NOT_GRANTED);
4100
+ handleVoiceError(error);
4101
+ console.log(chalk.cyan(" Opening System Settings..."));
4102
+ openAccessibilitySettings();
4103
+ console.log();
4104
+ console.log(chalk.yellow(" After granting permission:"));
4105
+ console.log(chalk.gray(" 1. Add your terminal app to Accessibility"));
4106
+ console.log(chalk.gray(" 2. Restart your terminal"));
4107
+ console.log(chalk.gray(" 3. Run 'jfl voice hotkey' again"));
4108
+ }
4109
+ else if (currentPlatform === "linux") {
4110
+ // VS-010: Linux X11 requirements
4111
+ console.log(chalk.red(" X11 environment not detected or xdotool not available."));
4112
+ console.log();
4113
+ console.log(chalk.yellow(" Requirements for Linux hotkey support:"));
4114
+ console.log(chalk.gray(" 1. Must be running an X11 session (not Wayland)"));
4115
+ console.log(chalk.gray(" 2. Install xdotool: sudo apt-get install xdotool"));
4116
+ console.log(chalk.gray(" 3. Run 'jfl voice hotkey' again"));
4117
+ console.log();
4118
+ console.log(chalk.gray(" To check your session type: echo $XDG_SESSION_TYPE"));
4119
+ }
4120
+ else if (currentPlatform === "win32") {
4121
+ // VS-011: Windows should work without special permissions
4122
+ console.log(chalk.red(" Keyboard access check failed."));
4123
+ console.log(chalk.gray(" This is unexpected on Windows. Please try restarting your terminal."));
4124
+ }
4125
+ console.log();
4126
+ return;
4127
+ }
4128
+ if (currentPlatform === "darwin") {
4129
+ console.log(chalk.green(" ✓ Accessibility permission granted\n"));
4130
+ }
4131
+ else if (currentPlatform === "linux") {
4132
+ console.log(chalk.green(" ✓ X11 environment detected\n"));
4133
+ }
4134
+ else if (currentPlatform === "win32") {
4135
+ console.log(chalk.green(" ✓ Keyboard access available\n"));
4136
+ }
4137
+ // Check other prerequisites (server, auth, model)
4138
+ const serverError = checkServerRunning();
4139
+ if (serverError) {
4140
+ handleVoiceError(serverError);
4141
+ return;
4142
+ }
4143
+ const authError = checkAuthToken();
4144
+ if (authError) {
4145
+ handleVoiceError(authError);
4146
+ return;
4147
+ }
4148
+ // Initialize keyboard listener
4149
+ let keyboardListener;
4150
+ try {
4151
+ keyboardListener = new GlobalKeyboardListener();
4152
+ }
4153
+ catch (error) {
4154
+ console.log(chalk.red("\n Failed to initialize keyboard listener."));
4155
+ if (currentPlatform === "darwin") {
4156
+ console.log(chalk.gray(" This may be due to missing Accessibility permission."));
4157
+ console.log();
4158
+ openAccessibilitySettings();
4159
+ }
4160
+ else if (currentPlatform === "linux") {
4161
+ // VS-010: Linux-specific error guidance
4162
+ console.log(chalk.gray(" On Linux X11, this requires the X11 display server."));
4163
+ console.log(chalk.gray(" Ensure you are running an X11 session and not Wayland."));
4164
+ console.log();
4165
+ console.log(chalk.yellow(" To check your session type:"));
4166
+ console.log(chalk.gray(" echo $XDG_SESSION_TYPE"));
4167
+ }
4168
+ else if (currentPlatform === "win32") {
4169
+ // VS-011: Windows-specific error guidance
4170
+ console.log(chalk.gray(" On Windows, this should work without special permissions."));
4171
+ console.log(chalk.gray(" Try running your terminal as Administrator if the issue persists."));
4172
+ }
4173
+ console.log();
4174
+ return;
4175
+ }
4176
+ // State management for hotkey
4177
+ let isRecording = false;
4178
+ let recordingPromise = null;
4179
+ let currentRecorder = null;
4180
+ let audioChunks = [];
4181
+ let hasVoiceActivity = false;
4182
+ let silenceStartTime = null;
4183
+ let recordingStartTime = null;
4184
+ let recordingSpinner = null;
4185
+ let focusedAppAtStart = null; // Track which app was focused when recording started
4186
+ // VAD settings
4187
+ const silenceThresholdDb = VAD_SILENCE_THRESHOLD_DB;
4188
+ const silenceDurationMs = VAD_SILENCE_DURATION_MS;
4189
+ // VS-SEC-3: Configurable max recording duration from security config
4190
+ const maxDurationSecs = securityConfig.maxRecordingDuration;
4191
+ const warningThresholdSecs = Math.max(10, maxDurationSecs - 10); // Warning 10 seconds before limit
4192
+ let warningShown = false; // Track if warning has been displayed
4193
+ // Helper function to start recording
4194
+ const startRecording = async () => {
4195
+ if (isRecording)
4196
+ return;
4197
+ // Capture the focused app before we start recording (VS-SEC-2)
4198
+ focusedAppAtStart = getFocusedApp();
4199
+ if (process.env.DEBUG && focusedAppAtStart) {
4200
+ console.log(chalk.gray(` [debug] Recording started in: ${focusedAppAtStart}`));
4201
+ }
4202
+ console.log(chalk.cyan("\n Recording started... (press Ctrl+Shift+Space to stop)\n"));
4203
+ // Play sound and show notification for daemon mode feedback
4204
+ if (!process.stdin.isTTY) {
4205
+ // Play start sound (Pop = friendly "go" sound)
4206
+ try {
4207
+ execSync('afplay /System/Library/Sounds/Pop.aiff &', { stdio: 'ignore' });
4208
+ }
4209
+ catch { }
4210
+ showNotification("🎤 Recording", "Speak now... Press Ctrl+Shift+Space to stop");
4211
+ }
4212
+ isRecording = true;
4213
+ audioChunks = [];
4214
+ hasVoiceActivity = false;
4215
+ silenceStartTime = null;
4216
+ recordingStartTime = Date.now();
4217
+ warningShown = false; // VS-SEC-3: Reset warning flag for new recording
4218
+ // VS-012: Reset waveform buffer for new recording
4219
+ resetWaveformBuffer();
4220
+ const useWaveform = supportsWaveform();
4221
+ try {
4222
+ currentRecorder = new AudioRecorder({
4223
+ device: options.device,
4224
+ sampleRate: 16000,
4225
+ });
4226
+ }
4227
+ catch (error) {
4228
+ console.log(chalk.red(" Failed to initialize recorder"));
4229
+ isRecording = false;
4230
+ return;
4231
+ }
4232
+ recordingSpinner = ora({
4233
+ text: chalk.cyan("Recording...") + chalk.gray(" (waiting for voice)"),
4234
+ prefixText: " ",
4235
+ spinner: "dots",
4236
+ });
4237
+ // Set up recorder event handlers
4238
+ currentRecorder.on("data", (chunk) => {
4239
+ audioChunks.push(chunk);
4240
+ // Calculate peak level
4241
+ const chunkPeak = calculatePeakAmplitude(chunk);
4242
+ const peakDb = amplitudeToDb(chunkPeak);
4243
+ const isSilent = peakDb < silenceThresholdDb;
4244
+ // VS-012: Add level to waveform buffer on every chunk
4245
+ addToWaveformBuffer(peakDb);
4246
+ // VS-012: Update spinner with waveform on every chunk
4247
+ if (recordingSpinner && recordingStartTime) {
4248
+ const elapsed = ((Date.now() - recordingStartTime) / 1000).toFixed(1);
4249
+ if (useWaveform) {
4250
+ const waveform = renderWaveform();
4251
+ recordingSpinner.text = chalk.cyan("Recording") + chalk.gray(` ${waveform} (${elapsed}s)`);
4252
+ }
4253
+ }
4254
+ if (!isSilent) {
4255
+ hasVoiceActivity = true;
4256
+ silenceStartTime = null;
4257
+ }
4258
+ else if (hasVoiceActivity) {
4259
+ if (silenceStartTime === null) {
4260
+ silenceStartTime = Date.now();
4261
+ }
4262
+ else {
4263
+ const silenceDuration = Date.now() - silenceStartTime;
4264
+ if (silenceDuration >= silenceDurationMs) {
4265
+ // Auto-stop on silence
4266
+ stopRecording();
4267
+ }
4268
+ }
4269
+ }
4270
+ // Check max duration (VS-SEC-3)
4271
+ if (recordingStartTime) {
4272
+ const elapsed = (Date.now() - recordingStartTime) / 1000;
4273
+ // Show warning 10 seconds before limit
4274
+ if (elapsed >= warningThresholdSecs && !warningShown) {
4275
+ warningShown = true;
4276
+ const remaining = Math.ceil(maxDurationSecs - elapsed);
4277
+ console.log(chalk.yellow(`\n ⚠ Recording will stop in ${remaining} seconds`));
4278
+ }
4279
+ // Auto-stop at max duration
4280
+ if (elapsed >= maxDurationSecs) {
4281
+ console.log(chalk.yellow(`\n ⚠ Maximum recording duration (${maxDurationSecs}s) reached`));
4282
+ stopRecording();
4283
+ }
4284
+ }
4285
+ });
4286
+ currentRecorder.on("error", (error) => {
4287
+ console.log(chalk.red(`\n Recording error: ${error.message}`));
4288
+ isRecording = false;
4289
+ });
4290
+ try {
4291
+ await currentRecorder.start();
4292
+ recordingSpinner.start();
4293
+ // VS-UX-2: Show system recording indicator (notification on macOS)
4294
+ showRecordingIndicator();
4295
+ }
4296
+ catch (error) {
4297
+ console.log(chalk.red(" Failed to start recording"));
4298
+ isRecording = false;
4299
+ }
4300
+ };
4301
+ // Helper function to stop recording and transcribe
4302
+ const stopRecording = async () => {
4303
+ if (!isRecording || !currentRecorder)
4304
+ return;
4305
+ currentRecorder.stop();
4306
+ isRecording = false;
4307
+ // Wait for recorder to fully stop
4308
+ await new Promise((resolve) => {
4309
+ const checkStopped = () => {
4310
+ if (!currentRecorder || currentRecorder.getState() === "idle" || currentRecorder.getState() === "error") {
4311
+ resolve();
4312
+ }
4313
+ else {
4314
+ setTimeout(checkStopped, 50);
4315
+ }
4316
+ };
4317
+ setTimeout(checkStopped, 100);
4318
+ });
4319
+ if (recordingSpinner) {
4320
+ recordingSpinner.stop();
4321
+ recordingSpinner = null;
4322
+ }
4323
+ // Check if we have audio
4324
+ const totalBytes = audioChunks.reduce((sum, chunk) => sum + chunk.length, 0);
4325
+ if (totalBytes === 0 || !hasVoiceActivity) {
4326
+ // VS-SEC-3: Zero audio buffers even on early return
4327
+ zeroBuffers(audioChunks);
4328
+ // VS-UX-2: Show stopped indicator when no audio
4329
+ hideRecordingIndicator("stopped");
4330
+ console.log(chalk.yellow(" No audio captured or no voice detected.\n"));
4331
+ console.log(chalk.gray(" Press Ctrl+Shift+Space to try again, or Ctrl+C to quit.\n"));
4332
+ return;
4333
+ }
4334
+ // VS-UX-2: Show completed indicator when moving to transcription
4335
+ hideRecordingIndicator("completed");
4336
+ // Combine audio and normalize for better transcription
4337
+ const audioBuffer = normalizeAudio(Buffer.concat(audioChunks));
4338
+ // VS-SEC-3: Zero the individual chunks immediately after combining
4339
+ zeroBuffers(audioChunks);
4340
+ const durationSecs = totalBytes / (16000 * 2);
4341
+ console.log(chalk.gray(`\n Recorded ${durationSecs.toFixed(1)}s of audio.`));
4342
+ console.log(chalk.gray(" Transcribing...\n"));
4343
+ // Transcribe
4344
+ const transcribeSpinner = ora({
4345
+ text: "Transcribing...",
4346
+ prefixText: " ",
4347
+ }).start();
4348
+ const authToken = readAuthToken();
4349
+ const socketPath = getVoiceSocketPath();
4350
+ const client = new VoiceClient({
4351
+ socketPath,
4352
+ authToken,
4353
+ maxReconnectAttempts: 1,
4354
+ });
4355
+ let transcription = "";
4356
+ let transcriptionReceived = false;
4357
+ let transcriptionError = null;
4358
+ client.onTranscript((text, isFinal) => {
4359
+ // Accept partial transcriptions too (in case final never arrives)
4360
+ if (text && text.trim() && !text.includes("[BLANK_AUDIO]")) {
4361
+ transcription = text;
4362
+ }
4363
+ if (isFinal) {
4364
+ transcriptionReceived = true;
4365
+ }
4366
+ });
4367
+ client.onError((error) => {
4368
+ transcriptionError = error;
4369
+ });
4370
+ try {
4371
+ await client.connect();
4372
+ client.sendAudio(audioBuffer);
4373
+ // Wait for audio to be sent before signaling end
4374
+ await new Promise(resolve => setTimeout(resolve, 300));
4375
+ client.endAudio();
4376
+ const timeout = 10000; // Reduced from 30s
4377
+ const startTime = Date.now();
4378
+ while (!transcriptionReceived && !transcriptionError) {
4379
+ // If we have a transcription, accept it even without final flag (wait 1s for final)
4380
+ if (transcription && Date.now() - startTime > 1000) {
4381
+ break;
4382
+ }
4383
+ if (Date.now() - startTime > timeout) {
4384
+ transcriptionError = new Error("Transcription timeout");
4385
+ break;
4386
+ }
4387
+ await new Promise((resolve) => setTimeout(resolve, 100));
4388
+ }
4389
+ client.disconnect();
4390
+ // VS-SEC-3: Zero combined audio buffer immediately after transcription
4391
+ zeroBuffer(audioBuffer);
4392
+ }
4393
+ catch (error) {
4394
+ client.disconnect();
4395
+ // VS-SEC-3: Zero combined audio buffer on error
4396
+ zeroBuffer(audioBuffer);
4397
+ transcribeSpinner.fail("Transcription failed");
4398
+ console.log(chalk.red(` ${error instanceof Error ? error.message : String(error)}\n`));
4399
+ console.log(chalk.gray(" Press Ctrl+Shift+Space to try again, or Ctrl+C to quit.\n"));
4400
+ return;
4401
+ }
4402
+ if (transcriptionError || !transcription || transcription.trim() === "") {
4403
+ transcribeSpinner.fail("No transcription result");
4404
+ console.log(chalk.gray(" Press Ctrl+Shift+Space to try again, or Ctrl+C to quit.\n"));
4405
+ return;
4406
+ }
4407
+ transcribeSpinner.succeed("Transcription complete!");
4408
+ const trimmedTranscription = transcription.trim();
4409
+ // VS-008: Preview transcript with configurable auto-send
4410
+ const previewConfig = voiceConfig?.preview || DEFAULT_PREVIEW_CONFIG;
4411
+ const previewResult = await previewTranscript(trimmedTranscription, previewConfig.timeout);
4412
+ if (previewResult.action === "cancel") {
4413
+ console.log(chalk.yellow(" Cancelled."));
4414
+ console.log(chalk.gray(" Press Ctrl+Shift+Space to record again, or Ctrl+C to quit.\n"));
4415
+ return;
4416
+ }
4417
+ // Use the final text (may have been edited by user)
4418
+ const finalText = previewResult.text;
4419
+ // VS-SEC-2: Focus verification before paste
4420
+ // Check if the same app is still focused
4421
+ const currentFocusedApp = getFocusedApp();
4422
+ const focusUnchanged = focusedAppAtStart && currentFocusedApp && focusedAppAtStart === currentFocusedApp;
4423
+ if (process.env.DEBUG) {
4424
+ console.log(chalk.gray(` [debug] Focus at start: ${focusedAppAtStart}`));
4425
+ console.log(chalk.gray(` [debug] Focus now: ${currentFocusedApp}`));
4426
+ console.log(chalk.gray(` [debug] Focus unchanged: ${focusUnchanged}`));
4427
+ }
4428
+ if (focusUnchanged) {
4429
+ // VS-007: Same app focused - copy to clipboard and simulate paste
4430
+ const copied = copyToClipboard(finalText);
4431
+ if (copied) {
4432
+ // Small delay to ensure clipboard is ready
4433
+ await new Promise(resolve => setTimeout(resolve, 50));
4434
+ const pasted = simulatePaste();
4435
+ if (pasted) {
4436
+ console.log(chalk.green(`\n ✓ Pasted to ${currentFocusedApp}!`));
4437
+ // Play success sound and show notification for daemon mode
4438
+ if (!process.stdin.isTTY) {
4439
+ try {
4440
+ execSync('afplay /System/Library/Sounds/Glass.aiff &', { stdio: 'ignore' });
4441
+ }
4442
+ catch { }
4443
+ const preview = finalText.length > 50 ? finalText.slice(0, 47) + "..." : finalText;
4444
+ showNotification("✓ Pasted", preview);
4445
+ }
4446
+ // VS-SEC-3: Schedule clipboard clear after successful paste
4447
+ scheduleClipboardClear(securityConfig.clipboardClearDelay);
4448
+ if (process.env.DEBUG) {
4449
+ console.log(chalk.gray(` [debug] Clipboard will be cleared in ${securityConfig.clipboardClearDelay}s`));
4450
+ }
4451
+ }
4452
+ else {
4453
+ console.log(chalk.green("\n ✓ Copied to clipboard!"));
4454
+ console.log(chalk.yellow(` ⚠ Could not auto-paste (${getPasteShortcut()}). Text is on clipboard.`));
4455
+ // Show notification for daemon mode
4456
+ if (!process.stdin.isTTY) {
4457
+ const preview = finalText.length > 50 ? finalText.slice(0, 47) + "..." : finalText;
4458
+ showNotification("📋 Copied", `${preview}\n\nPress ${getPasteShortcut()} to paste`);
4459
+ }
4460
+ // VS-SEC-3: Still schedule clipboard clear even if paste failed
4461
+ scheduleClipboardClear(securityConfig.clipboardClearDelay);
4462
+ }
4463
+ }
4464
+ else {
4465
+ console.log(chalk.yellow("\n ⚠ Could not copy to clipboard"));
4466
+ // Display result for manual copy
4467
+ console.log();
4468
+ console.log(chalk.bold(" Transcription:"));
4469
+ console.log();
4470
+ console.log(chalk.cyan(` "${finalText}"`));
4471
+ }
4472
+ }
4473
+ else {
4474
+ // Focus changed - show notification and don't auto-paste (security measure)
4475
+ console.log();
4476
+ console.log(chalk.yellow(" ⚠ Focus changed during recording"));
4477
+ if (focusedAppAtStart && currentFocusedApp) {
4478
+ console.log(chalk.gray(` Started in: ${focusedAppAtStart}`));
4479
+ console.log(chalk.gray(` Now in: ${currentFocusedApp}`));
4480
+ }
4481
+ // Copy to clipboard anyway for user convenience
4482
+ const copied = copyToClipboard(finalText);
4483
+ // Show notification with transcription
4484
+ const notified = showNotification("Voice Transcription", finalText.length > 100
4485
+ ? finalText.substring(0, 97) + "..."
4486
+ : finalText);
4487
+ if (notified) {
4488
+ console.log(chalk.cyan("\n 📋 Notification shown with transcription"));
4489
+ }
4490
+ if (copied) {
4491
+ console.log(chalk.green(` ✓ Copied to clipboard (${getPasteShortcut()} to paste manually)`));
4492
+ // VS-SEC-3: Schedule clipboard clear after copy
4493
+ scheduleClipboardClear(securityConfig.clipboardClearDelay);
4494
+ }
4495
+ // Display result
4496
+ console.log();
4497
+ console.log(chalk.bold(" Transcription:"));
4498
+ console.log();
4499
+ console.log(chalk.cyan(` "${finalText}"`));
4500
+ }
4501
+ console.log();
4502
+ console.log(chalk.gray(" Press Ctrl+Shift+Space to record again, or Ctrl+C to quit.\n"));
4503
+ };
4504
+ // Track modifier keys state
4505
+ let ctrlPressed = false;
4506
+ let shiftPressed = false;
4507
+ // Hold-to-talk state tracking
4508
+ let keyDownTime = null;
4509
+ let holdTimer = null;
4510
+ let isInHoldMode = false; // True when user has held key past threshold
4511
+ // Clear hold timer
4512
+ const clearHoldTimer = () => {
4513
+ if (holdTimer) {
4514
+ clearTimeout(holdTimer);
4515
+ holdTimer = null;
4516
+ }
4517
+ };
4518
+ // Add keyboard listener
4519
+ keyboardListener.addListener((event, isDown) => {
4520
+ // Update modifier key states
4521
+ if (event.name === "LEFT CTRL" || event.name === "RIGHT CTRL") {
4522
+ ctrlPressed = event.state === "DOWN";
4523
+ }
4524
+ if (event.name === "LEFT SHIFT" || event.name === "RIGHT SHIFT") {
4525
+ shiftPressed = event.state === "DOWN";
4526
+ }
4527
+ // Check for Ctrl+Shift+Space
4528
+ const isHotkeyCombo = event.name === "SPACE" && ctrlPressed && shiftPressed;
4529
+ if (!isHotkeyCombo)
4530
+ return;
4531
+ if (event.state === "DOWN") {
4532
+ // Key pressed down
4533
+ if (keyDownTime !== null) {
4534
+ // Already tracking a press, ignore (debounce)
4535
+ return;
4536
+ }
4537
+ keyDownTime = Date.now();
4538
+ if (activeMode === "tap") {
4539
+ // Pure tap mode: toggle on keydown
4540
+ if (isRecording) {
4541
+ stopRecording();
4542
+ }
4543
+ else {
4544
+ startRecording();
4545
+ }
4546
+ }
4547
+ else if (activeMode === "hold") {
4548
+ // Pure hold mode: start recording immediately on keydown
4549
+ if (!isRecording) {
4550
+ isInHoldMode = true;
4551
+ startRecording();
4552
+ }
4553
+ }
4554
+ else {
4555
+ // Auto mode: wait for threshold to determine behavior
4556
+ // Start a timer to enter hold mode
4557
+ holdTimer = setTimeout(() => {
4558
+ // Timer fired - we're in hold mode now
4559
+ isInHoldMode = true;
4560
+ if (!isRecording) {
4561
+ startRecording();
4562
+ }
4563
+ }, holdThreshold);
4564
+ }
4565
+ }
4566
+ else if (event.state === "UP") {
4567
+ // Key released
4568
+ const pressDuration = keyDownTime !== null ? Date.now() - keyDownTime : 0;
4569
+ keyDownTime = null;
4570
+ clearHoldTimer();
4571
+ if (activeMode === "tap") {
4572
+ // Pure tap mode: already handled on keydown, nothing to do on keyup
4573
+ // Reset state
4574
+ isInHoldMode = false;
4575
+ }
4576
+ else if (activeMode === "hold") {
4577
+ // Pure hold mode: stop recording on keyup
4578
+ if (isRecording) {
4579
+ stopRecording();
4580
+ }
4581
+ isInHoldMode = false;
4582
+ }
4583
+ else {
4584
+ // Auto mode: check if this was a tap or hold
4585
+ if (isInHoldMode) {
4586
+ // Was holding - stop recording on release
4587
+ if (isRecording) {
4588
+ stopRecording();
4589
+ }
4590
+ isInHoldMode = false;
4591
+ }
4592
+ else {
4593
+ // Was a quick tap (released before threshold)
4594
+ // Toggle recording
4595
+ if (isRecording) {
4596
+ stopRecording();
4597
+ }
4598
+ else {
4599
+ startRecording();
4600
+ }
4601
+ }
4602
+ }
4603
+ }
4604
+ });
4605
+ console.log(chalk.green(" ✓ Hotkey listener started"));
4606
+ if (activeMode === "tap") {
4607
+ console.log(chalk.gray(" Press Ctrl+Shift+Space to start/stop recording"));
4608
+ }
4609
+ else if (activeMode === "hold") {
4610
+ console.log(chalk.gray(" Hold Ctrl+Shift+Space to record, release to stop"));
4611
+ }
4612
+ else {
4613
+ console.log(chalk.gray(" Tap Ctrl+Shift+Space to toggle, or hold to talk"));
4614
+ }
4615
+ console.log(chalk.gray(" Press Ctrl+C to quit\n"));
4616
+ // Handle Ctrl+C to exit
4617
+ const cleanup = () => {
4618
+ console.log(chalk.yellow("\n Stopping hotkey listener...\n"));
4619
+ clearHoldTimer();
4620
+ keyboardListener.kill();
4621
+ if (currentRecorder) {
4622
+ currentRecorder.stop();
4623
+ }
4624
+ process.exit(0);
4625
+ };
4626
+ process.on("SIGINT", cleanup);
4627
+ process.on("SIGTERM", cleanup);
4628
+ // Keep the process running with an interval (needed for detached daemon mode)
4629
+ const keepAlive = setInterval(() => {
4630
+ // Keepalive - prevents Node from exiting when detached
4631
+ }, 60000);
4632
+ await new Promise(() => {
4633
+ // This promise never resolves - we run until Ctrl+C
4634
+ });
4635
+ }
4636
+ // Helper functions used by hotkeyCommand (reference existing functions)
4637
+ // calculatePeakAmplitude is already defined elsewhere in this file
4638
+ // amplitudeToDb is already defined elsewhere in this file
4639
+ // copyToClipboard is already defined elsewhere in this file
4640
+ // Main voice command handler
4641
+ export async function voiceCommand(action, subaction, arg, options) {
4642
+ // If no action, run the voice slash command (default behavior)
4643
+ if (!action) {
4644
+ await voiceSlashCommand({ device: options?.device });
4645
+ return;
4646
+ }
4647
+ // Handle help explicitly
4648
+ if (action === "help" || options?.help) {
4649
+ showVoiceHelp();
4650
+ return;
4651
+ }
4652
+ // Handle "record" as alias for the default voice slash command
4653
+ if (action === "record") {
4654
+ await voiceSlashCommand({ device: options?.device });
4655
+ return;
4656
+ }
4657
+ if (action === "model") {
4658
+ if (!subaction || subaction === "list") {
4659
+ await listModelsCommand();
4660
+ }
4661
+ else if (subaction === "download") {
4662
+ if (!arg) {
4663
+ console.log(chalk.red("Missing model name."));
4664
+ console.log(chalk.gray("Usage: jfl voice model download <name>"));
4665
+ console.log(chalk.gray("\nExample: jfl voice model download base"));
4666
+ return;
4667
+ }
4668
+ await downloadModelCommand(arg, options);
4669
+ }
4670
+ else if (subaction === "default") {
4671
+ if (!arg) {
4672
+ const current = getCurrentDefaultModel();
4673
+ console.log(chalk.gray(`Current default model: ${chalk.white(current)}`));
4674
+ console.log(chalk.gray("Usage: jfl voice model default <name>"));
4675
+ return;
4676
+ }
4677
+ await setDefaultModelCommand(arg);
4678
+ }
4679
+ else {
4680
+ console.log(chalk.red(`Unknown model command: ${subaction}`));
4681
+ console.log(chalk.gray("\nAvailable commands: list, download, default"));
4682
+ }
4683
+ return;
4684
+ }
4685
+ if (action === "devices") {
4686
+ await listDevicesCommand();
4687
+ return;
4688
+ }
4689
+ if (action === "test") {
4690
+ await voiceTestCommand({
4691
+ device: options?.device,
4692
+ });
4693
+ return;
4694
+ }
4695
+ if (action === "recording") {
4696
+ await testRecordingCommand({
4697
+ device: options?.device,
4698
+ duration: options?.duration,
4699
+ });
4700
+ return;
4701
+ }
4702
+ if (action === "setup") {
4703
+ await voiceSetupCommand();
4704
+ return;
4705
+ }
4706
+ if (action === "hotkey") {
4707
+ // Validate mode option if provided
4708
+ const validModes = ["auto", "tap", "hold"];
4709
+ let mode;
4710
+ if (options?.mode) {
4711
+ if (validModes.includes(options.mode)) {
4712
+ mode = options.mode;
4713
+ }
4714
+ else {
4715
+ console.log(chalk.red(`Invalid mode: ${options.mode}`));
4716
+ console.log(chalk.gray("Valid modes: auto, tap, hold"));
4717
+ return;
4718
+ }
4719
+ }
4720
+ await hotkeyCommand({ device: options?.device, mode });
4721
+ return;
4722
+ }
4723
+ // VS-013: Daemon commands for background hotkey listening
4724
+ if (action === "daemon") {
4725
+ // Validate mode option if provided
4726
+ const validModes = ["auto", "tap", "hold"];
4727
+ let mode;
4728
+ if (options?.mode) {
4729
+ if (validModes.includes(options.mode)) {
4730
+ mode = options.mode;
4731
+ }
4732
+ else {
4733
+ console.log(chalk.red(`Invalid mode: ${options.mode}`));
4734
+ console.log(chalk.gray("Valid modes: auto, tap, hold"));
4735
+ return;
4736
+ }
4737
+ }
4738
+ if (!subaction || subaction === "status") {
4739
+ await daemonStatusCommand();
4740
+ }
4741
+ else if (subaction === "start") {
4742
+ await daemonStartCommand({ mode });
4743
+ }
4744
+ else if (subaction === "stop") {
4745
+ await daemonStopCommand();
4746
+ }
4747
+ else {
4748
+ console.log(chalk.red(`Unknown daemon command: ${subaction}`));
4749
+ console.log(chalk.gray("\nAvailable commands: start, stop, status"));
4750
+ }
4751
+ return;
4752
+ }
4753
+ // Placeholder for future commands
4754
+ if (action === "server") {
4755
+ console.log(chalk.yellow(`\n⚠️ 'jfl voice ${action}' is coming soon.\n`));
4756
+ console.log(chalk.gray("For now, use 'jfl voice model' commands to manage whisper models."));
4757
+ console.log();
4758
+ return;
4759
+ }
4760
+ console.log(chalk.red(`Unknown voice command: ${action}`));
4761
+ console.log(chalk.gray("Run 'jfl voice' for help."));
4762
+ }
4763
+ //# sourceMappingURL=voice.js.map