@thevinci/web 1.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 (337) hide show
  1. package/README.md +197 -0
  2. package/bin/cli-entry.js +55 -0
  3. package/bin/cli-output.js +145 -0
  4. package/bin/cli.js +4887 -0
  5. package/bin/cli.test.js +64 -0
  6. package/dist/apple-touch-icon-120x120.png +0 -0
  7. package/dist/apple-touch-icon-152x152.png +0 -0
  8. package/dist/apple-touch-icon-167x167.png +0 -0
  9. package/dist/apple-touch-icon-180x180.png +0 -0
  10. package/dist/apple-touch-icon.png +0 -0
  11. package/dist/apple-touch-icon.svg +528 -0
  12. package/dist/assets/JsonTreeView-CSm9OzXG.js +1 -0
  13. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  14. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  15. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  16. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  17. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  18. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  19. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  20. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  21. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  22. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  23. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  24. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  25. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  26. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  27. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  28. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  30. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  31. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  32. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  33. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  34. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  35. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  36. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  37. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  38. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  39. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  40. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  41. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  42. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  43. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  44. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  45. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  48. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  49. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  50. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  51. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  52. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  53. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  54. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  55. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  56. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  57. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  58. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  59. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  60. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  61. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  62. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  63. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  64. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  65. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  66. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  67. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  68. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  69. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  70. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  71. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  72. package/dist/assets/MarkdownRendererImpl-DensKOLc.js +6 -0
  73. package/dist/assets/MultiRunWindow-Bo7THayo.js +1 -0
  74. package/dist/assets/OnboardingScreen-BDqmzTVR.js +2 -0
  75. package/dist/assets/SettingsWindow-coz__Ykw.js +1 -0
  76. package/dist/assets/TerminalView-DrZ-i3Dr.js +1 -0
  77. package/dist/assets/ToolOutputDialog-Eglzslt3.js +16 -0
  78. package/dist/assets/es-4o9ciP61.js +15 -0
  79. package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  80. package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  81. package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  82. package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  83. package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  84. package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  85. package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  86. package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  87. package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  88. package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  89. package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  90. package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  91. package/dist/assets/index-DLTDToSP.css +1 -0
  92. package/dist/assets/index-DgiFEKGN.js +1 -0
  93. package/dist/assets/ko-B20imCHE.js +15 -0
  94. package/dist/assets/main-BV3KOtdA.css +1 -0
  95. package/dist/assets/main-CDKJj0sH.js +226 -0
  96. package/dist/assets/main-LC-PSNVM.js +2 -0
  97. package/dist/assets/miniChat-CQUiG_cr.js +2 -0
  98. package/dist/assets/modelPrefsAutoSave-Dm799vzR.js +6986 -0
  99. package/dist/assets/pl-DQJ7LSzj.js +15 -0
  100. package/dist/assets/pt-BR-OmjHUz9y.js +15 -0
  101. package/dist/assets/renderElectronMiniChatApp-CARbeW0G.js +2 -0
  102. package/dist/assets/uk-BNFxOlO4.js +15 -0
  103. package/dist/assets/vendor--DBfsbEis.css +1 -0
  104. package/dist/assets/vendor-.bun-B9l0ZNi2.js +4094 -0
  105. package/dist/assets/wasm-CG6Dc4jp.js +1 -0
  106. package/dist/assets/wasmSttWorker-Dtlxac_K.js +1 -0
  107. package/dist/assets/wasmSttWorker-oo7Dm_jy.js +1806 -0
  108. package/dist/assets/worker-CbT6TVo7.js +155 -0
  109. package/dist/assets/zh-CN-C6T-Ac7F.js +15 -0
  110. package/dist/favicon-16.png +0 -0
  111. package/dist/favicon-32.png +0 -0
  112. package/dist/favicon.png +0 -0
  113. package/dist/favicon.svg +528 -0
  114. package/dist/index.html +607 -0
  115. package/dist/logo-dark-192x192.png +0 -0
  116. package/dist/logo-dark-512x512.png +0 -0
  117. package/dist/logo-dark-512x512.svg +528 -0
  118. package/dist/logo-light-192x192.png +0 -0
  119. package/dist/logo-light-512x512.png +0 -0
  120. package/dist/logo-light-512x512.svg +528 -0
  121. package/dist/mini-chat.html +16 -0
  122. package/dist/pwa-192.png +0 -0
  123. package/dist/pwa-512.png +0 -0
  124. package/dist/pwa-maskable-192.png +0 -0
  125. package/dist/pwa-maskable-512.png +0 -0
  126. package/dist/site.webmanifest +21 -0
  127. package/dist/sw.js +1 -0
  128. package/package.json +118 -0
  129. package/public/apple-touch-icon-120x120.png +0 -0
  130. package/public/apple-touch-icon-152x152.png +0 -0
  131. package/public/apple-touch-icon-167x167.png +0 -0
  132. package/public/apple-touch-icon-180x180.png +0 -0
  133. package/public/apple-touch-icon.png +0 -0
  134. package/public/apple-touch-icon.svg +528 -0
  135. package/public/favicon-16.png +0 -0
  136. package/public/favicon-32.png +0 -0
  137. package/public/favicon.png +0 -0
  138. package/public/favicon.svg +528 -0
  139. package/public/logo-dark-192x192.png +0 -0
  140. package/public/logo-dark-512x512.png +0 -0
  141. package/public/logo-dark-512x512.svg +528 -0
  142. package/public/logo-light-192x192.png +0 -0
  143. package/public/logo-light-512x512.png +0 -0
  144. package/public/logo-light-512x512.svg +528 -0
  145. package/public/pwa-192.png +0 -0
  146. package/public/pwa-512.png +0 -0
  147. package/public/pwa-maskable-192.png +0 -0
  148. package/public/pwa-maskable-512.png +0 -0
  149. package/public/site.webmanifest +21 -0
  150. package/server/TERMINAL_WS_PROTOCOL.md +48 -0
  151. package/server/index.d.ts +39 -0
  152. package/server/index.js +1311 -0
  153. package/server/lib/cloudflare-tunnel.js +650 -0
  154. package/server/lib/event-stream/DOCUMENTATION.md +61 -0
  155. package/server/lib/event-stream/directory-ws-bridge.js +185 -0
  156. package/server/lib/event-stream/global-hub.js +158 -0
  157. package/server/lib/event-stream/global-hub.test.js +140 -0
  158. package/server/lib/event-stream/global-ws-bridge.js +206 -0
  159. package/server/lib/event-stream/index.js +25 -0
  160. package/server/lib/event-stream/protocol.js +131 -0
  161. package/server/lib/event-stream/protocol.test.js +182 -0
  162. package/server/lib/event-stream/runtime.js +180 -0
  163. package/server/lib/event-stream/runtime.test.js +512 -0
  164. package/server/lib/event-stream/upstream-reader.js +226 -0
  165. package/server/lib/event-stream/upstream-reader.test.js +276 -0
  166. package/server/lib/fs/DOCUMENTATION.md +36 -0
  167. package/server/lib/fs/routes.js +1040 -0
  168. package/server/lib/fs/search.js +238 -0
  169. package/server/lib/git/DOCUMENTATION.md +152 -0
  170. package/server/lib/git/credentials.js +74 -0
  171. package/server/lib/git/identity-storage.js +112 -0
  172. package/server/lib/git/index.js +6 -0
  173. package/server/lib/git/routes.js +972 -0
  174. package/server/lib/git/service.js +3432 -0
  175. package/server/lib/git/service.test.js +39 -0
  176. package/server/lib/github/DOCUMENTATION.md +171 -0
  177. package/server/lib/github/auth.js +307 -0
  178. package/server/lib/github/device-flow.js +50 -0
  179. package/server/lib/github/index.js +24 -0
  180. package/server/lib/github/octokit.js +10 -0
  181. package/server/lib/github/pr-status.js +519 -0
  182. package/server/lib/github/repo/fork-detection.js +102 -0
  183. package/server/lib/github/repo/index.js +55 -0
  184. package/server/lib/github/routes.js +1560 -0
  185. package/server/lib/magic-prompts/routes.js +63 -0
  186. package/server/lib/magic-prompts/runtime.js +119 -0
  187. package/server/lib/notifications/DOCUMENTATION.md +122 -0
  188. package/server/lib/notifications/emitter-runtime.js +102 -0
  189. package/server/lib/notifications/index.js +4 -0
  190. package/server/lib/notifications/message.js +52 -0
  191. package/server/lib/notifications/message.test.js +34 -0
  192. package/server/lib/notifications/push-runtime.js +304 -0
  193. package/server/lib/notifications/routes.js +315 -0
  194. package/server/lib/notifications/runtime.js +566 -0
  195. package/server/lib/notifications/template-runtime.js +349 -0
  196. package/server/lib/notifications/template-runtime.test.js +26 -0
  197. package/server/lib/opencode/DOCUMENTATION.md +362 -0
  198. package/server/lib/opencode/agents.js +634 -0
  199. package/server/lib/opencode/auth-state-runtime.js +88 -0
  200. package/server/lib/opencode/auth.js +83 -0
  201. package/server/lib/opencode/bootstrap-runtime.js +131 -0
  202. package/server/lib/opencode/cli-entry-runtime.js +43 -0
  203. package/server/lib/opencode/cli-options.js +128 -0
  204. package/server/lib/opencode/commands.js +339 -0
  205. package/server/lib/opencode/config-entity-routes.js +370 -0
  206. package/server/lib/opencode/core-routes.js +500 -0
  207. package/server/lib/opencode/core-routes.test.js +26 -0
  208. package/server/lib/opencode/env-config.js +74 -0
  209. package/server/lib/opencode/env-keys.js +68 -0
  210. package/server/lib/opencode/env-runtime.js +1162 -0
  211. package/server/lib/opencode/env-runtime.test.js +116 -0
  212. package/server/lib/opencode/feature-routes-runtime.js +244 -0
  213. package/server/lib/opencode/hmr-state-runtime.js +85 -0
  214. package/server/lib/opencode/index.js +66 -0
  215. package/server/lib/opencode/lifecycle.js +1019 -0
  216. package/server/lib/opencode/lifecycle.test.js +240 -0
  217. package/server/lib/opencode/mcp.js +278 -0
  218. package/server/lib/opencode/network-runtime.js +104 -0
  219. package/server/lib/opencode/network-runtime.test.js +37 -0
  220. package/server/lib/opencode/opencode-resolution-runtime.js +71 -0
  221. package/server/lib/opencode/path-utils.js +100 -0
  222. package/server/lib/opencode/path-utils.test.js +71 -0
  223. package/server/lib/opencode/project-directory-runtime.js +124 -0
  224. package/server/lib/opencode/project-icon-routes.js +399 -0
  225. package/server/lib/opencode/project-icon-routes.test.js +107 -0
  226. package/server/lib/opencode/providers.js +96 -0
  227. package/server/lib/opencode/proxy.js +445 -0
  228. package/server/lib/opencode/pwa-manifest-routes.js +257 -0
  229. package/server/lib/opencode/pwa-manifest-routes.test.js +133 -0
  230. package/server/lib/opencode/routes.js +541 -0
  231. package/server/lib/opencode/server-startup-runtime.js +156 -0
  232. package/server/lib/opencode/server-utils-runtime.js +168 -0
  233. package/server/lib/opencode/server-utils-runtime.test.js +135 -0
  234. package/server/lib/opencode/session-runtime.js +356 -0
  235. package/server/lib/opencode/session-runtime.test.js +151 -0
  236. package/server/lib/opencode/settings-helpers.js +770 -0
  237. package/server/lib/opencode/settings-helpers.test.js +109 -0
  238. package/server/lib/opencode/settings-normalization-runtime.js +428 -0
  239. package/server/lib/opencode/settings-runtime.js +826 -0
  240. package/server/lib/opencode/settings-runtime.test.js +85 -0
  241. package/server/lib/opencode/shared.js +615 -0
  242. package/server/lib/opencode/shutdown-runtime.js +139 -0
  243. package/server/lib/opencode/shutdown-runtime.test.js +58 -0
  244. package/server/lib/opencode/skill-routes.js +701 -0
  245. package/server/lib/opencode/skills.js +548 -0
  246. package/server/lib/opencode/startup-pipeline-runtime.js +130 -0
  247. package/server/lib/opencode/static-routes-runtime.js +65 -0
  248. package/server/lib/opencode/theme-runtime.js +167 -0
  249. package/server/lib/opencode/tunnel-auth.js +591 -0
  250. package/server/lib/opencode/tunnel-wiring-runtime.js +94 -0
  251. package/server/lib/opencode/vinci-routes.js +76 -0
  252. package/server/lib/opencode/watcher.js +115 -0
  253. package/server/lib/opencode/watcher.test.js +239 -0
  254. package/server/lib/preview/proxy-runtime.js +1333 -0
  255. package/server/lib/preview/proxy-runtime.test.js +144 -0
  256. package/server/lib/projects/project-config.js +567 -0
  257. package/server/lib/projects/project-config.test.js +175 -0
  258. package/server/lib/projects/project-id.js +13 -0
  259. package/server/lib/quota/DOCUMENTATION.md +58 -0
  260. package/server/lib/quota/index.js +25 -0
  261. package/server/lib/quota/providers/claude.js +107 -0
  262. package/server/lib/quota/providers/codex.js +113 -0
  263. package/server/lib/quota/providers/copilot.js +165 -0
  264. package/server/lib/quota/providers/google/api.js +92 -0
  265. package/server/lib/quota/providers/google/auth.js +108 -0
  266. package/server/lib/quota/providers/google/index.js +124 -0
  267. package/server/lib/quota/providers/google/transforms.js +109 -0
  268. package/server/lib/quota/providers/index.js +168 -0
  269. package/server/lib/quota/providers/interface.js +55 -0
  270. package/server/lib/quota/providers/kimi.js +108 -0
  271. package/server/lib/quota/providers/minimax-cn-coding-plan.js +140 -0
  272. package/server/lib/quota/providers/minimax-coding-plan.js +139 -0
  273. package/server/lib/quota/providers/nanogpt.js +124 -0
  274. package/server/lib/quota/providers/ollama-cloud.js +112 -0
  275. package/server/lib/quota/providers/openai.js +91 -0
  276. package/server/lib/quota/providers/openrouter.js +92 -0
  277. package/server/lib/quota/providers/zai.js +91 -0
  278. package/server/lib/quota/providers/zhipuai-coding-plan.js +133 -0
  279. package/server/lib/quota/providers/zhipuai.js +114 -0
  280. package/server/lib/quota/routes.js +27 -0
  281. package/server/lib/quota/utils/auth.js +50 -0
  282. package/server/lib/quota/utils/formatters.js +85 -0
  283. package/server/lib/quota/utils/formatters.test.js +54 -0
  284. package/server/lib/quota/utils/index.js +10 -0
  285. package/server/lib/quota/utils/transformers.js +55 -0
  286. package/server/lib/scheduled-tasks/DOCUMENTATION.md +44 -0
  287. package/server/lib/scheduled-tasks/routes.js +235 -0
  288. package/server/lib/scheduled-tasks/runtime.js +773 -0
  289. package/server/lib/scheduled-tasks/runtime.test.js +100 -0
  290. package/server/lib/security/request-security.js +115 -0
  291. package/server/lib/session-folders/routes.js +63 -0
  292. package/server/lib/session-folders/routes.test.js +102 -0
  293. package/server/lib/skills-catalog/DOCUMENTATION.md +178 -0
  294. package/server/lib/skills-catalog/cache.js +29 -0
  295. package/server/lib/skills-catalog/clawdhub/api.js +158 -0
  296. package/server/lib/skills-catalog/clawdhub/index.js +30 -0
  297. package/server/lib/skills-catalog/clawdhub/install.js +238 -0
  298. package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
  299. package/server/lib/skills-catalog/curated-sources.js +21 -0
  300. package/server/lib/skills-catalog/git.js +77 -0
  301. package/server/lib/skills-catalog/index.js +42 -0
  302. package/server/lib/skills-catalog/install.js +294 -0
  303. package/server/lib/skills-catalog/scan.js +221 -0
  304. package/server/lib/skills-catalog/source.js +87 -0
  305. package/server/lib/terminal/DOCUMENTATION.md +76 -0
  306. package/server/lib/terminal/index.js +31 -0
  307. package/server/lib/terminal/output-replay-buffer.js +78 -0
  308. package/server/lib/terminal/output-replay-buffer.test.js +75 -0
  309. package/server/lib/terminal/runtime.js +850 -0
  310. package/server/lib/terminal/runtime.test.js +96 -0
  311. package/server/lib/terminal/terminal-ws-protocol.js +68 -0
  312. package/server/lib/terminal/terminal-ws-protocol.test.js +145 -0
  313. package/server/lib/text/DOCUMENTATION.md +35 -0
  314. package/server/lib/text/summarization.js +138 -0
  315. package/server/lib/text/summarization.test.js +34 -0
  316. package/server/lib/tts/DOCUMENTATION.md +146 -0
  317. package/server/lib/tts/base-url.js +62 -0
  318. package/server/lib/tts/capability-runtime.js +31 -0
  319. package/server/lib/tts/index.js +19 -0
  320. package/server/lib/tts/routes.js +261 -0
  321. package/server/lib/tts/routes.test.js +53 -0
  322. package/server/lib/tts/service.js +178 -0
  323. package/server/lib/tts/stt.js +75 -0
  324. package/server/lib/tunnels/DOCUMENTATION.md +18 -0
  325. package/server/lib/tunnels/index.js +166 -0
  326. package/server/lib/tunnels/managed-config.js +201 -0
  327. package/server/lib/tunnels/providers/cloudflare.js +260 -0
  328. package/server/lib/tunnels/registry.js +51 -0
  329. package/server/lib/tunnels/routes.js +605 -0
  330. package/server/lib/tunnels/types.js +219 -0
  331. package/server/lib/ui-auth/DOCUMENTATION.md +38 -0
  332. package/server/lib/ui-auth/ui-auth.js +673 -0
  333. package/server/lib/ui-auth/ui-passkeys.js +545 -0
  334. package/server/opencode-proxy.test.js +151 -0
  335. package/server/proxy-headers.js +61 -0
  336. package/server/proxy-headers.test.js +58 -0
  337. package/server/sse-routes.test.js +152 -0
@@ -0,0 +1,96 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { createTerminalRuntime } from './runtime.js';
7
+
8
+ function createResponse() {
9
+ return {
10
+ statusCode: 200,
11
+ body: null,
12
+ status(code) {
13
+ this.statusCode = code;
14
+ return this;
15
+ },
16
+ json(payload) {
17
+ this.body = payload;
18
+ return this;
19
+ },
20
+ };
21
+ }
22
+
23
+ function createRuntime(server, overrides = {}) {
24
+ const app = overrides.app ?? {
25
+ post() {},
26
+ get() {},
27
+ delete() {},
28
+ };
29
+
30
+ return createTerminalRuntime({
31
+ app,
32
+ server,
33
+ express: { text: () => (_req, _res, next) => next?.() },
34
+ fs,
35
+ path,
36
+ uiAuthController: null,
37
+ buildAugmentedPath: () => process.env.PATH || '',
38
+ searchPathFor: () => null,
39
+ isExecutable: () => false,
40
+ isRequestOriginAllowed: async () => true,
41
+ rejectWebSocketUpgrade() {},
42
+ TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS: 30_000,
43
+ TERMINAL_INPUT_WS_REBIND_WINDOW_MS: 1_000,
44
+ TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW: 3,
45
+ ...overrides,
46
+ });
47
+ }
48
+
49
+ describe('terminal runtime', () => {
50
+ it('rejects regular files as terminal working directories', async () => {
51
+ const postRoutes = new Map();
52
+ const app = {
53
+ post(route, ...handlers) {
54
+ postRoutes.set(route, handlers.at(-1));
55
+ },
56
+ get() {},
57
+ delete() {},
58
+ };
59
+ const server = new EventEmitter();
60
+ const runtime = createRuntime(server, {
61
+ app,
62
+ fs: {
63
+ promises: {
64
+ stat: async () => ({ isDirectory: () => false }),
65
+ },
66
+ },
67
+ uiAuthController: { enabled: false },
68
+ buildAugmentedPath: () => '',
69
+ TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS: 1000,
70
+ TERMINAL_INPUT_WS_REBIND_WINDOW_MS: 1000,
71
+ });
72
+
73
+ try {
74
+ const createRoute = postRoutes.get('/api/terminal/create');
75
+ const res = createResponse();
76
+
77
+ await createRoute({ body: { cwd: '/tmp/not-a-directory' } }, res);
78
+
79
+ expect(res.statusCode).toBe(400);
80
+ expect(res.body).toEqual({ error: 'Invalid working directory' });
81
+ } finally {
82
+ await runtime.shutdown();
83
+ }
84
+ });
85
+
86
+ it('removes its websocket upgrade listener on shutdown', async () => {
87
+ const server = new EventEmitter();
88
+ const runtime = createRuntime(server);
89
+
90
+ expect(server.listenerCount('upgrade')).toBe(1);
91
+
92
+ await runtime.shutdown();
93
+
94
+ expect(server.listenerCount('upgrade')).toBe(0);
95
+ });
96
+ });
@@ -0,0 +1,68 @@
1
+ export const TERMINAL_WS_PATH = '/api/terminal/ws';
2
+ export const TERMINAL_WS_CONTROL_TAG_JSON = 0x01;
3
+ export const TERMINAL_WS_MAX_PAYLOAD_BYTES = 64 * 1024;
4
+
5
+ export const parseRequestPathname = (requestUrl) => {
6
+ if (typeof requestUrl !== 'string' || requestUrl.length === 0) {
7
+ return '';
8
+ }
9
+
10
+ try {
11
+ return new URL(requestUrl, 'http://localhost').pathname;
12
+ } catch {
13
+ return '';
14
+ }
15
+ };
16
+
17
+ export const isTerminalWsPathname = (pathname) => pathname === TERMINAL_WS_PATH;
18
+
19
+ export const normalizeTerminalWsMessageToBuffer = (rawData) => {
20
+ if (Buffer.isBuffer(rawData)) {
21
+ return rawData;
22
+ }
23
+
24
+ if (Array.isArray(rawData)) {
25
+ return Buffer.concat(rawData.map((chunk) => (Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))));
26
+ }
27
+
28
+ return Buffer.from(rawData);
29
+ };
30
+
31
+ export const normalizeTerminalWsMessageToText = (rawData) => {
32
+ if (typeof rawData === 'string') {
33
+ return rawData;
34
+ }
35
+
36
+ return normalizeTerminalWsMessageToBuffer(rawData).toString('utf8');
37
+ };
38
+
39
+ export const readTerminalWsControlFrame = (rawData) => {
40
+ if (!rawData) {
41
+ return null;
42
+ }
43
+
44
+ const buffer = normalizeTerminalWsMessageToBuffer(rawData);
45
+ if (buffer.length < 2 || buffer[0] !== TERMINAL_WS_CONTROL_TAG_JSON) {
46
+ return null;
47
+ }
48
+
49
+ try {
50
+ const parsed = JSON.parse(buffer.subarray(1).toString('utf8'));
51
+ if (!parsed || typeof parsed !== 'object') {
52
+ return null;
53
+ }
54
+ return parsed;
55
+ } catch {
56
+ return null;
57
+ }
58
+ };
59
+
60
+ export const createTerminalWsControlFrame = (payload) => {
61
+ const jsonBytes = Buffer.from(JSON.stringify(payload), 'utf8');
62
+ return Buffer.concat([Buffer.from([TERMINAL_WS_CONTROL_TAG_JSON]), jsonBytes]);
63
+ };
64
+
65
+ export const pruneRebindTimestamps = (timestamps, now, windowMs) =>
66
+ timestamps.filter((timestamp) => now - timestamp < windowMs);
67
+
68
+ export const isRebindRateLimited = (timestamps, maxPerWindow) => timestamps.length >= maxPerWindow;
@@ -0,0 +1,145 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ TERMINAL_WS_PATH,
5
+ TERMINAL_WS_CONTROL_TAG_JSON,
6
+ createTerminalWsControlFrame,
7
+ isTerminalWsPathname,
8
+ isRebindRateLimited,
9
+ normalizeTerminalWsMessageToBuffer,
10
+ normalizeTerminalWsMessageToText,
11
+ parseRequestPathname,
12
+ pruneRebindTimestamps,
13
+ readTerminalWsControlFrame,
14
+ } from './terminal-ws-protocol.js';
15
+
16
+ describe('terminal websocket protocol', () => {
17
+ it('uses fixed websocket paths', () => {
18
+ expect(TERMINAL_WS_PATH).toBe('/api/terminal/ws');
19
+ });
20
+
21
+ it('matches supported websocket pathnames', () => {
22
+ expect(isTerminalWsPathname('/api/terminal/ws')).toBe(true);
23
+ expect(isTerminalWsPathname('/api/terminal/input-ws')).toBe(false);
24
+ expect(isTerminalWsPathname('/api/terminal/other')).toBe(false);
25
+ });
26
+
27
+ it('encodes control frames with control tag prefix', () => {
28
+ const frame = createTerminalWsControlFrame({ t: 'ok', v: 1 });
29
+ expect(frame[0]).toBe(TERMINAL_WS_CONTROL_TAG_JSON);
30
+ });
31
+
32
+ it('roundtrips control frame payload', () => {
33
+ const payload = { t: 'b', s: 'abc123', v: 1 };
34
+ const frame = createTerminalWsControlFrame(payload);
35
+ expect(readTerminalWsControlFrame(frame)).toEqual(payload);
36
+ });
37
+
38
+ it('rejects control frame without protocol tag', () => {
39
+ const frame = Buffer.from(JSON.stringify({ t: 'b', s: 'abc123' }), 'utf8');
40
+ expect(readTerminalWsControlFrame(frame)).toBeNull();
41
+ });
42
+
43
+ it('rejects malformed control json', () => {
44
+ const frame = Buffer.concat([
45
+ Buffer.from([TERMINAL_WS_CONTROL_TAG_JSON]),
46
+ Buffer.from('{not json', 'utf8'),
47
+ ]);
48
+ expect(readTerminalWsControlFrame(frame)).toBeNull();
49
+ });
50
+
51
+ it('rejects empty control payloads', () => {
52
+ expect(readTerminalWsControlFrame(null)).toBeNull();
53
+ expect(readTerminalWsControlFrame(undefined)).toBeNull();
54
+ expect(readTerminalWsControlFrame(Buffer.alloc(0))).toBeNull();
55
+ });
56
+
57
+ it('rejects control json that is not object', () => {
58
+ const frame = Buffer.concat([
59
+ Buffer.from([TERMINAL_WS_CONTROL_TAG_JSON]),
60
+ Buffer.from('"str"', 'utf8'),
61
+ ]);
62
+ expect(readTerminalWsControlFrame(frame)).toBeNull();
63
+ });
64
+
65
+ it('parses control frame from chunk arrays', () => {
66
+ const frame = createTerminalWsControlFrame({ t: 'bok', v: 1 });
67
+ const chunks = [frame.subarray(0, 2), frame.subarray(2)];
68
+ expect(readTerminalWsControlFrame(chunks)).toEqual({ t: 'bok', v: 1 });
69
+ });
70
+
71
+ it('normalizes buffer passthrough', () => {
72
+ const raw = Buffer.from('abc', 'utf8');
73
+ const normalized = normalizeTerminalWsMessageToBuffer(raw);
74
+ expect(normalized).toBe(raw);
75
+ expect(normalized.toString('utf8')).toBe('abc');
76
+ });
77
+
78
+ it('normalizes uint8 arrays', () => {
79
+ const normalized = normalizeTerminalWsMessageToBuffer(new Uint8Array([97, 98, 99]));
80
+ expect(normalized.toString('utf8')).toBe('abc');
81
+ });
82
+
83
+ it('normalizes array buffer payloads', () => {
84
+ const source = new Uint8Array([97, 98, 99]).buffer;
85
+ const normalized = normalizeTerminalWsMessageToBuffer(source);
86
+ expect(normalized.toString('utf8')).toBe('abc');
87
+ });
88
+
89
+ it('normalizes chunk array payloads', () => {
90
+ const normalized = normalizeTerminalWsMessageToBuffer([
91
+ Buffer.from('ab', 'utf8'),
92
+ Buffer.from('c', 'utf8'),
93
+ ]);
94
+ expect(normalized.toString('utf8')).toBe('abc');
95
+ });
96
+
97
+ it('normalizes text payload from string', () => {
98
+ expect(normalizeTerminalWsMessageToText('\u001b[A')).toBe('\u001b[A');
99
+ });
100
+
101
+ it('normalizes text payload from binary data', () => {
102
+ expect(normalizeTerminalWsMessageToText(Buffer.from('\r', 'utf8'))).toBe('\r');
103
+ });
104
+
105
+ it('parses relative request pathname', () => {
106
+ expect(parseRequestPathname('/api/terminal/ws?x=1')).toBe('/api/terminal/ws');
107
+ });
108
+
109
+ it('parses absolute request pathname', () => {
110
+ expect(parseRequestPathname('http://localhost:3000/api/terminal/ws')).toBe('/api/terminal/ws');
111
+ });
112
+
113
+ it('returns empty pathname for non-string request url', () => {
114
+ expect(parseRequestPathname(null)).toBe('');
115
+ });
116
+
117
+ it('returns empty pathname for invalid request url', () => {
118
+ expect(parseRequestPathname('http://')).toBe('');
119
+ expect(parseRequestPathname('')).toBe('');
120
+ });
121
+
122
+ it('prunes stale rebind timestamps', () => {
123
+ const now = 1_000;
124
+ const pruned = pruneRebindTimestamps([100, 200, 950, 999], now, 100);
125
+ expect(pruned).toEqual([950, 999]);
126
+ });
127
+
128
+ it('keeps rebind timestamps within active window', () => {
129
+ const now = 1_000;
130
+ const pruned = pruneRebindTimestamps([920, 950, 999], now, 100);
131
+ expect(pruned).toEqual([920, 950, 999]);
132
+ });
133
+
134
+ it('does not rate limit below threshold', () => {
135
+ expect(isRebindRateLimited([1, 2, 3], 4)).toBe(false);
136
+ });
137
+
138
+ it('does not rate limit empty window', () => {
139
+ expect(isRebindRateLimited([], 1)).toBe(false);
140
+ });
141
+
142
+ it('rate limits at threshold', () => {
143
+ expect(isRebindRateLimited([1, 2, 3, 4], 4)).toBe(true);
144
+ });
145
+ });
@@ -0,0 +1,35 @@
1
+ # Text Module Documentation
2
+
3
+ ## Purpose
4
+ This module provides shared text transformation helpers that are not owned by a single product surface. It previously proxied model-backed summarization through the opencode.ai Zen provider; that provider is no longer available for this use, so summarization now returns local sanitized/distilled fallback text only.
5
+
6
+ ## Entrypoints and structure
7
+ - `packages/web/server/lib/text/summarization.js`: Shared summarize stub + sanitize helpers. It performs no external model calls.
8
+
9
+ ## Public exports
10
+
11
+ ### Summarization (summarization.js)
12
+ - `summarizeText({ text, threshold, maxLength, zenModel, mode })`: Retired summarization entrypoint retained as an API-compatible stub. `zenModel` is ignored.
13
+ - `sanitizeForTTS(text)`: Sanitizes text for speech output.
14
+ - `sanitizeForNotification(text)`: Sanitizes text for compact notification output.
15
+ - `sanitizeForNote(text)`: Sanitizes text for short note/distillation output.
16
+
17
+ ## Modes
18
+ - `tts`: Speakable summary for TTS flows.
19
+ - `notification`: Short plain-text summary for notification bodies.
20
+ - `note`: Distilled short project-memory note.
21
+
22
+ ## Response contract
23
+
24
+ ### `summarizeText`
25
+ Returns object with:
26
+ - `summary`: Local sanitized/distilled fallback text.
27
+ - `summarized`: Always `false` while the model provider is unavailable.
28
+ - `reason`: Skip reason, usually `Model summarization provider unavailable` for text above threshold.
29
+ - `originalLength`: Optional original text length.
30
+ - `summaryLength`: Optional final summary length.
31
+
32
+ ## Notes for contributors
33
+ - Keep this module neutral. Do not re-couple it to TTS-specific naming or routing.
34
+ - Add new mode semantics here when multiple product surfaces need the same text pipeline.
35
+ - Prefer mode-specific prompt and sanitize behavior over creating duplicated summarizers in unrelated modules.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Shared text summarization service.
3
+ *
4
+ * Modes:
5
+ * - tts: concise speakable text
6
+ * - notification: concise notification text
7
+ * - note: distilled project note
8
+ */
9
+
10
+ export function sanitizeForTTS(text) {
11
+ if (!text || typeof text !== 'string') return '';
12
+
13
+ return text
14
+ .replace(/[*_~`#]/g, '')
15
+ .replace(/```[\s\S]*?```/g, '')
16
+ .replace(/`[^`]*`/g, '')
17
+ .replace(/^\s*[$#>]\s*/gm, '')
18
+ .replace(/[|&;<>]/g, ' ')
19
+ .replace(/\\/g, '')
20
+ .replace(/[\[\]{}()]/g, '')
21
+ .replace(/["']/g, '')
22
+ .replace(/https?:\/\/[^\s]+/g, ' a link ')
23
+ .replace(/\/[\w\-./]+/g, '')
24
+ .replace(/\s+/g, ' ')
25
+ .trim();
26
+ }
27
+
28
+ export function sanitizeForNotification(text) {
29
+ if (!text || typeof text !== 'string') return '';
30
+
31
+ return text
32
+ .replace(/```[\s\S]*?```/g, ' ')
33
+ .replace(/`([^`]*)`/g, '$1')
34
+ .replace(/^[\t ]*[-*+]\s+/gm, '')
35
+ .replace(/^#{1,6}\s+/gm, '')
36
+ .replace(/\*\*(.*?)\*\*/g, '$1')
37
+ .replace(/__(.*?)__/g, '$1')
38
+ .replace(/\*(.*?)\*/g, '$1')
39
+ .replace(/_(.*?)_/g, '$1')
40
+ .replace(/\[(.*?)\]\((.*?)\)/g, '$1')
41
+ .replace(/\s*\n\s*/g, ' ')
42
+ .replace(/\s+/g, ' ')
43
+ .trim();
44
+ }
45
+
46
+ export function sanitizeForNote(text) {
47
+ if (!text || typeof text !== 'string') return '';
48
+
49
+ return text
50
+ .replace(/```[\s\S]*?```/g, ' ')
51
+ .replace(/`([^`]*)`/g, '$1')
52
+ .replace(/^\s*[-*+]\s+/gm, '')
53
+ .replace(/^#{1,6}\s+/gm, '')
54
+ .replace(/\*\*(.*?)\*\*/g, '$1')
55
+ .replace(/__(.*?)__/g, '$1')
56
+ .replace(/\*(.*?)\*/g, '$1')
57
+ .replace(/_(.*?)_/g, '$1')
58
+ .replace(/\[(.*?)\]\((.*?)\)/g, '$1')
59
+ .replace(/https?:\/\/[^\s]+/g, '')
60
+ .replace(/["']/g, '')
61
+ .replace(/\s+/g, ' ')
62
+ .trim();
63
+ }
64
+
65
+ function sanitizeByMode(text, mode) {
66
+ if (mode === 'note') return sanitizeForNote(text);
67
+ if (mode === 'notification') return sanitizeForNotification(text);
68
+ return sanitizeForTTS(text);
69
+ }
70
+
71
+ function distillNoteFallback(text, maxLength) {
72
+ const sanitized = sanitizeForNote(text);
73
+ if (!sanitized) return '';
74
+
75
+ const normalized = sanitized
76
+ .replace(/^In summary[:,]?\s*/i, '')
77
+ .replace(/^Here(?:s| is) (?:a )?note[:,]?\s*/i, '')
78
+ .trim();
79
+
80
+ const sentences = normalized
81
+ .split(/(?<=[.!?])\s+/)
82
+ .map((part) => part.trim())
83
+ .filter(Boolean);
84
+
85
+ const best = (sentences[0] || normalized)
86
+ .split(/[;:()-]\s+/)[0]
87
+ .split(/,\s+/)[0]
88
+ .trim();
89
+ const idealLimit = Math.min(maxLength, Math.max(32, Math.floor(normalized.length * 0.65)));
90
+
91
+ if (best.length <= idealLimit) return best;
92
+
93
+ const clipped = best.slice(0, Math.max(0, idealLimit - 1)).trim();
94
+ return clipped ? `${clipped}…` : best.slice(0, idealLimit).trim();
95
+ }
96
+
97
+ function distillNotificationFallback(text, maxLength) {
98
+ const sanitized = sanitizeForNotification(text);
99
+ if (!sanitized) return '';
100
+
101
+ const sentences = sanitized
102
+ .split(/(?<=[.!?])\s+/)
103
+ .map((part) => part.trim())
104
+ .filter(Boolean);
105
+ const candidate = sentences.find((sentence) => sentence.length >= 20) || sentences[0] || sanitized;
106
+ const limit = Number.isFinite(maxLength) ? Math.max(20, Math.floor(maxLength)) : 100;
107
+ if (candidate.length <= limit) return candidate;
108
+
109
+ const clipped = candidate.slice(0, Math.max(0, limit - 1)).trim();
110
+ return clipped ? `${clipped}…` : candidate.slice(0, limit).trim();
111
+ }
112
+
113
+ function fallbackByMode(text, maxLength, mode) {
114
+ if (mode === 'note') return distillNoteFallback(text, maxLength);
115
+ if (mode === 'notification') return distillNotificationFallback(text, maxLength);
116
+ return sanitizeByMode(text, mode);
117
+ }
118
+
119
+ export async function summarizeText({ text, threshold = 200, maxLength = 500, zenModel, mode = 'tts' }) {
120
+ void zenModel;
121
+
122
+ const summary = fallbackByMode(text || '', maxLength, mode);
123
+ if (!text || text.length <= threshold) {
124
+ return {
125
+ summary,
126
+ summarized: false,
127
+ reason: text ? 'Text under threshold' : 'No text provided',
128
+ };
129
+ }
130
+
131
+ return {
132
+ summary,
133
+ summarized: false,
134
+ reason: 'Model summarization provider unavailable',
135
+ originalLength: text.length,
136
+ summaryLength: summary.length,
137
+ };
138
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { summarizeText } from './summarization.js';
4
+
5
+ describe('text summarization stubs', () => {
6
+ it('does not call the retired zen provider', async () => {
7
+ const result = await summarizeText({
8
+ text: 'The implementation now correctly loads notification templates before dispatching the notification. It also fetches the latest assistant message when the event payload does not include message parts. This should make completion notifications match user settings.',
9
+ threshold: 0,
10
+ maxLength: 80,
11
+ zenModel: 'gpt-5-nano',
12
+ mode: 'notification',
13
+ });
14
+
15
+ expect(result.summarized).toBe(false);
16
+ expect(result.reason).toBe('Model summarization provider unavailable');
17
+ expect(result.summary).toBe('The implementation now correctly loads notification templates before dispatchin…');
18
+ });
19
+
20
+ it('returns local note fallback while provider is unavailable', async () => {
21
+ const result = await summarizeText({
22
+ text: 'First sentence. Second sentence with the useful insight.',
23
+ threshold: 0,
24
+ maxLength: 100,
25
+ mode: 'note',
26
+ });
27
+
28
+ expect(result).toMatchObject({
29
+ summary: 'First sentence.',
30
+ summarized: false,
31
+ reason: 'Model summarization provider unavailable',
32
+ });
33
+ });
34
+ });
@@ -0,0 +1,146 @@
1
+ # TTS Module Documentation
2
+
3
+ ## Purpose
4
+ This module provides server-side Text-to-Speech services using OpenAI's TTS API. The historical shared text summarization endpoint now lives in `packages/web/server/lib/text/` as an API-compatible stub because the previous Zen model provider is unavailable.
5
+
6
+ ## Entrypoints and structure
7
+ - `packages/web/server/lib/tts/index.js`: Public entrypoint imported by `packages/web/server/index.js`.
8
+ - `packages/web/server/lib/tts/routes.js`: Express route registration for `/api/voice/*`, `/api/tts/*`, and `/api/stt/*` endpoints.
9
+ - `packages/web/server/lib/tts/capability-runtime.js`: runtime helper for probing local macOS `say` TTS voice capability.
10
+ - `packages/web/server/lib/tts/service.js`: TTS service implementation with OpenAI integration.
11
+ - `packages/web/server/lib/text/summarization.js`: Shared text summarization stub and sanitization utilities. It performs no external Zen calls.
12
+ - `packages/web/server/lib/tts/stt.js`: STT proxy for OpenAI-compatible transcription endpoints.
13
+ - `packages/web/server/lib/tts/base-url.js`: shared base URL validation and normalization for custom OpenAI-compatible endpoints.
14
+
15
+ ## Public exports
16
+
17
+ ### TTS Service (from service.js)
18
+ - `ttsService`: Singleton instance of TTSService class.
19
+ - `TTSService`: TTS service class for OpenAI audio generation.
20
+ - `TTS_VOICES`: Array of supported OpenAI voice identifiers.
21
+
22
+ ### Shared text summarization (re-exported from ../text/summarization.js)
23
+ - `summarizeText({ text, threshold, maxLength, zenModel, mode })`: Retired shared text summarizer retained as a stub. TTS uses `mode: 'tts'`; `zenModel` is ignored.
24
+ - `sanitizeForTTS(text)`: Sanitizes text by removing markdown, URLs, file paths, and other non-speakable content.
25
+ - `sanitizeForNote(text)`: Re-exported for note-mode callers that still import through the TTS surface.
26
+
27
+ ### Capability runtime (capability-runtime.js)
28
+ - `detectSayTtsCapability(processLike)`: probes local `say -v "?"` support and returns `{ available, voices, reason }`.
29
+
30
+ ## Constants
31
+
32
+ ### Voice identifiers
33
+ - `TTS_VOICES`: Array of supported OpenAI voices: `['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar']`.
34
+
35
+ ### Summarization defaults
36
+ - No model request timeout is used; the summarization provider is disabled.
37
+
38
+ ### Default values
39
+ - `summarizeText` defaults: `threshold` = 200, `maxLength` = 500, `mode` = 'tts'. `zenModel` is ignored.
40
+ - `generateSpeechStream` defaults: `voice` = 'coral', `model` = 'gpt-4o-mini-tts', `speed` = 1.0.
41
+ - `generateSpeechBuffer` defaults: `voice` = 'coral', `model` = 'gpt-4o-mini-tts', `speed` = 1.0.
42
+
43
+ ## TTSService methods
44
+
45
+ ### `isAvailable()`
46
+ Returns boolean indicating whether OpenAI API key is configured (checks environment variable `OPENAI_API_KEY` or OpenCode auth file).
47
+
48
+ ### `generateSpeechStream(options)`
49
+ Generates speech and returns as a web stream for direct streaming to clients.
50
+ - Options: `text` (required), `voice`, `model`, `speed`, `instructions`, `apiKey`.
51
+ - Returns: `{ stream: ReadableStream, contentType: 'audio/mpeg' }`.
52
+ - Throws: Error if API key not configured or text is empty.
53
+
54
+ ### `generateSpeechBuffer(options)`
55
+ Generates speech and returns as Buffer for caching purposes.
56
+ - Options: `text` (required), `voice`, `model`, `speed`, `instructions`.
57
+ - Returns: Buffer containing MP3 audio data.
58
+ - Throws: Error if API key not configured or text is empty.
59
+
60
+ ## Response contracts
61
+
62
+ ### `summarizeText`
63
+ Returns object with:
64
+ - `summary`: Sanitized or locally distilled fallback text.
65
+ - `summarized`: Always `false` while the model provider is unavailable.
66
+ - `reason`: String explaining why summarization was skipped.
67
+ - `originalLength`: Optional number for original text length.
68
+ - `summaryLength`: Optional number for summarized text length.
69
+
70
+ The route-level text summarize API is now `/api/text/summarize`.
71
+
72
+ ### `sanitizeForTTS`
73
+ Returns sanitized string with markdown, URLs, file paths, and special characters removed.
74
+
75
+ ### `generateSpeechStream`
76
+ Returns object with:
77
+ - `stream`: ReadableStream of MP3 audio data.
78
+ - `contentType`: Always 'audio/mpeg'.
79
+
80
+ ### `generateSpeechBuffer`
81
+ Returns Buffer containing MP3 audio data.
82
+
83
+ ## API key resolution
84
+ OpenAI API keys are resolved in order:
85
+ 1. Environment variable `OPENAI_API_KEY`.
86
+ 2. OpenCode auth file (`auth.openai`, `auth.codex`, or `auth.chatgpt`).
87
+ 3. Supports both string format (just token) and object format (with `access` or `token` fields).
88
+
89
+ ## Usage in web server
90
+ The TTS module is used by `packages/web/server/index.js` for:
91
+ - Generating speech streams for client playback.
92
+ - Generating speech buffers for caching.
93
+ - Sanitizing text before TTS synthesis. Historical summarization calls now return local fallback text.
94
+ - Sanitizing text to remove non-speakable content.
95
+
96
+ The historical summarization API is shared with notifications and notes, but currently acts as a no-model fallback/stub.
97
+
98
+ The server-side TTS approach bypasses mobile Safari's audio context restrictions by generating audio on the server and streaming to clients.
99
+
100
+ ## Notes for contributors
101
+
102
+ ### Adding new TTS features
103
+ 1. Add new methods to `packages/web/server/lib/tts/service.js` TTSService class.
104
+ 2. Export public functions from `packages/web/server/lib/tts/index.js`.
105
+ 3. Follow existing patterns for API key resolution and error handling.
106
+ 4. Ensure all text is sanitized before TTS synthesis.
107
+ 5. Consider adding new voice options to `TTS_VOICES` constant.
108
+
109
+ ### Text sanitization
110
+ - Always call `sanitizeForTTS` on text before passing to TTS generation.
111
+ - The sanitization removes markdown, code blocks, URLs, file paths, shell commands, and special characters.
112
+ - This prevents the TTS from reading out technical formatting that sounds unnatural.
113
+
114
+ ### Error handling
115
+ - `generateSpeechStream` and `generateSpeechBuffer` throw descriptive errors for missing API keys or empty text.
116
+ - `summarizeText` does not call Zen and returns mode-specific fallback text with `summarized: false`.
117
+ - All errors are logged to console with `[TTSService]` or `[Summarize]` prefix.
118
+
119
+ ### API key management
120
+ - TTSService caches OpenAI client instance and recreates when API key changes.
121
+ - API key changes are detected by comparing with `_lastApiKey` property.
122
+ - This allows dynamic API key updates without server restart.
123
+
124
+ ### Testing
125
+ - Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes.
126
+ - Test API key resolution with environment variable and auth file.
127
+ - Test speech generation with various text lengths and voice options.
128
+ - Test summarization stub behavior above and below threshold.
129
+ - Test sanitization with markdown, URLs, and code blocks.
130
+ - Verify streaming and buffer generation produce valid MP3 audio.
131
+
132
+ ## Verification notes
133
+
134
+ ### Manual verification
135
+ 1. Configure OpenAI API key via environment variable or OpenCode settings.
136
+ 2. Test `ttsService.isAvailable()` returns true.
137
+ 3. Call `ttsService.generateSpeechStream({ text: 'Hello world' })` and verify stream is returned.
138
+ 4. Call `ttsService.generateSpeechBuffer({ text: 'Hello world' })` and verify Buffer is returned.
139
+ 5. Test `summarizeText` with text above and below threshold.
140
+ 6. Test `sanitizeForTTS` with markdown, URLs, and code blocks.
141
+
142
+ ### API endpoint verification
143
+ 1. Start web server and access TTS endpoint via client.
144
+ 2. Verify audio plays correctly in browser.
145
+ 3. Test on mobile Safari to verify bypass of audio context restrictions.
146
+ 4. Test with long messages to verify summarization is triggered.