@shykaruu/jarvis-brain 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/LICENSE +153 -0
  2. package/README.md +428 -0
  3. package/bin/jarvis.ts +449 -0
  4. package/package.json +79 -0
  5. package/roles/activity-observer.yaml +60 -0
  6. package/roles/ceo-founder.yaml +144 -0
  7. package/roles/chief-of-staff.yaml +158 -0
  8. package/roles/dev-lead.yaml +182 -0
  9. package/roles/executive-assistant.yaml +77 -0
  10. package/roles/marketing-director.yaml +168 -0
  11. package/roles/personal-assistant.yaml +266 -0
  12. package/roles/research-specialist.yaml +60 -0
  13. package/roles/specialists/content-writer.yaml +53 -0
  14. package/roles/specialists/customer-support.yaml +57 -0
  15. package/roles/specialists/data-analyst.yaml +57 -0
  16. package/roles/specialists/financial-analyst.yaml +56 -0
  17. package/roles/specialists/hr-specialist.yaml +55 -0
  18. package/roles/specialists/legal-advisor.yaml +58 -0
  19. package/roles/specialists/marketing-strategist.yaml +56 -0
  20. package/roles/specialists/project-coordinator.yaml +55 -0
  21. package/roles/specialists/research-analyst.yaml +58 -0
  22. package/roles/specialists/software-engineer.yaml +57 -0
  23. package/roles/specialists/system-administrator.yaml +57 -0
  24. package/roles/system-admin.yaml +76 -0
  25. package/scripts/ensure-bun.cjs +16 -0
  26. package/src/actions/README.md +421 -0
  27. package/src/actions/app-control/desktop-controller.test.ts +26 -0
  28. package/src/actions/app-control/desktop-controller.ts +438 -0
  29. package/src/actions/app-control/interface.ts +64 -0
  30. package/src/actions/app-control/linux.ts +273 -0
  31. package/src/actions/app-control/macos.ts +54 -0
  32. package/src/actions/app-control/sidecar-launcher.test.ts +23 -0
  33. package/src/actions/app-control/sidecar-launcher.ts +286 -0
  34. package/src/actions/app-control/windows.ts +44 -0
  35. package/src/actions/browser/cdp.ts +138 -0
  36. package/src/actions/browser/chrome-launcher.ts +261 -0
  37. package/src/actions/browser/session.ts +506 -0
  38. package/src/actions/browser/stealth.ts +49 -0
  39. package/src/actions/index.ts +20 -0
  40. package/src/actions/terminal/executor.ts +157 -0
  41. package/src/actions/terminal/wsl-bridge.ts +126 -0
  42. package/src/actions/test.ts +93 -0
  43. package/src/actions/tools/agents.ts +363 -0
  44. package/src/actions/tools/builtin.ts +950 -0
  45. package/src/actions/tools/commitments.ts +192 -0
  46. package/src/actions/tools/content.ts +217 -0
  47. package/src/actions/tools/delegate.ts +147 -0
  48. package/src/actions/tools/desktop.test.ts +55 -0
  49. package/src/actions/tools/desktop.ts +305 -0
  50. package/src/actions/tools/documents.ts +169 -0
  51. package/src/actions/tools/goals.ts +376 -0
  52. package/src/actions/tools/local-tools-guard.ts +31 -0
  53. package/src/actions/tools/registry.ts +173 -0
  54. package/src/actions/tools/research.ts +111 -0
  55. package/src/actions/tools/sidecar-list.ts +57 -0
  56. package/src/actions/tools/sidecar-route.ts +105 -0
  57. package/src/actions/tools/workflows.ts +216 -0
  58. package/src/agents/agent.ts +132 -0
  59. package/src/agents/delegation.ts +107 -0
  60. package/src/agents/hierarchy.ts +113 -0
  61. package/src/agents/index.ts +19 -0
  62. package/src/agents/messaging.ts +125 -0
  63. package/src/agents/orchestrator.ts +592 -0
  64. package/src/agents/role-discovery.ts +61 -0
  65. package/src/agents/sub-agent-runner.ts +309 -0
  66. package/src/agents/task-manager.ts +151 -0
  67. package/src/authority/approval-delivery.ts +59 -0
  68. package/src/authority/approval.ts +196 -0
  69. package/src/authority/audit.ts +158 -0
  70. package/src/authority/authority.test.ts +519 -0
  71. package/src/authority/deferred-executor.ts +103 -0
  72. package/src/authority/emergency.ts +66 -0
  73. package/src/authority/engine.ts +301 -0
  74. package/src/authority/index.ts +12 -0
  75. package/src/authority/learning.ts +111 -0
  76. package/src/authority/tool-action-map.ts +74 -0
  77. package/src/awareness/analytics.ts +466 -0
  78. package/src/awareness/awareness.test.ts +332 -0
  79. package/src/awareness/capture-engine.ts +305 -0
  80. package/src/awareness/context-graph.ts +130 -0
  81. package/src/awareness/context-tracker.ts +349 -0
  82. package/src/awareness/index.ts +25 -0
  83. package/src/awareness/intelligence.ts +321 -0
  84. package/src/awareness/ocr-engine.ts +88 -0
  85. package/src/awareness/service.ts +528 -0
  86. package/src/awareness/struggle-detector.ts +342 -0
  87. package/src/awareness/suggestion-engine.ts +476 -0
  88. package/src/awareness/types.ts +201 -0
  89. package/src/cli/autostart.ts +417 -0
  90. package/src/cli/deps.ts +449 -0
  91. package/src/cli/doctor.ts +238 -0
  92. package/src/cli/helpers.ts +401 -0
  93. package/src/cli/onboard.ts +827 -0
  94. package/src/cli/uninstall.test.ts +37 -0
  95. package/src/cli/uninstall.ts +202 -0
  96. package/src/comms/README.md +329 -0
  97. package/src/comms/auth-error.html +48 -0
  98. package/src/comms/channels/discord.ts +228 -0
  99. package/src/comms/channels/signal.ts +56 -0
  100. package/src/comms/channels/telegram.ts +316 -0
  101. package/src/comms/channels/whatsapp.ts +60 -0
  102. package/src/comms/channels.test.ts +173 -0
  103. package/src/comms/dashboard-auth.ts +75 -0
  104. package/src/comms/desktop-notify.ts +114 -0
  105. package/src/comms/example.ts +129 -0
  106. package/src/comms/index.ts +129 -0
  107. package/src/comms/streaming.ts +149 -0
  108. package/src/comms/voice.test.ts +504 -0
  109. package/src/comms/voice.ts +341 -0
  110. package/src/comms/websocket.test.ts +409 -0
  111. package/src/comms/websocket.ts +669 -0
  112. package/src/config/README.md +389 -0
  113. package/src/config/index.ts +6 -0
  114. package/src/config/loader.test.ts +183 -0
  115. package/src/config/loader.ts +148 -0
  116. package/src/config/types.ts +293 -0
  117. package/src/daemon/README.md +232 -0
  118. package/src/daemon/agent-service-interface.ts +9 -0
  119. package/src/daemon/agent-service.ts +667 -0
  120. package/src/daemon/api-routes.ts +3067 -0
  121. package/src/daemon/background-agent-service.ts +396 -0
  122. package/src/daemon/background-agent.test.ts +78 -0
  123. package/src/daemon/channel-service.ts +201 -0
  124. package/src/daemon/commitment-executor.ts +297 -0
  125. package/src/daemon/dashboard-auth.test.ts +170 -0
  126. package/src/daemon/event-classifier.ts +239 -0
  127. package/src/daemon/event-coalescer.ts +123 -0
  128. package/src/daemon/event-reactor.ts +214 -0
  129. package/src/daemon/flock.c +7 -0
  130. package/src/daemon/health.ts +220 -0
  131. package/src/daemon/index.ts +1070 -0
  132. package/src/daemon/llm-settings.test.ts +78 -0
  133. package/src/daemon/llm-settings.ts +450 -0
  134. package/src/daemon/observer-service.ts +150 -0
  135. package/src/daemon/pid.test.ts +283 -0
  136. package/src/daemon/pid.ts +224 -0
  137. package/src/daemon/research-queue.ts +155 -0
  138. package/src/daemon/services.ts +175 -0
  139. package/src/daemon/ws-service.ts +926 -0
  140. package/src/global.d.ts +4 -0
  141. package/src/goals/accountability.ts +240 -0
  142. package/src/goals/awareness-bridge.ts +185 -0
  143. package/src/goals/estimator.ts +185 -0
  144. package/src/goals/events.ts +28 -0
  145. package/src/goals/goals.test.ts +400 -0
  146. package/src/goals/integration.test.ts +329 -0
  147. package/src/goals/nl-builder.test.ts +220 -0
  148. package/src/goals/nl-builder.ts +256 -0
  149. package/src/goals/rhythm.test.ts +177 -0
  150. package/src/goals/rhythm.ts +275 -0
  151. package/src/goals/service.test.ts +135 -0
  152. package/src/goals/service.ts +407 -0
  153. package/src/goals/types.ts +106 -0
  154. package/src/goals/workflow-bridge.ts +96 -0
  155. package/src/integrations/google-api.ts +134 -0
  156. package/src/integrations/google-auth.ts +175 -0
  157. package/src/llm/README.md +291 -0
  158. package/src/llm/anthropic.ts +400 -0
  159. package/src/llm/gemini.ts +380 -0
  160. package/src/llm/groq.ts +406 -0
  161. package/src/llm/history.ts +147 -0
  162. package/src/llm/index.ts +21 -0
  163. package/src/llm/manager.ts +226 -0
  164. package/src/llm/ollama.ts +316 -0
  165. package/src/llm/openai.ts +411 -0
  166. package/src/llm/openrouter.ts +390 -0
  167. package/src/llm/provider.test.ts +487 -0
  168. package/src/llm/provider.ts +61 -0
  169. package/src/llm/test.ts +88 -0
  170. package/src/observers/README.md +278 -0
  171. package/src/observers/calendar.ts +113 -0
  172. package/src/observers/clipboard.ts +136 -0
  173. package/src/observers/email.ts +109 -0
  174. package/src/observers/example.ts +58 -0
  175. package/src/observers/file-watcher.ts +124 -0
  176. package/src/observers/index.ts +159 -0
  177. package/src/observers/notifications.ts +197 -0
  178. package/src/observers/observers.test.ts +203 -0
  179. package/src/observers/processes.ts +225 -0
  180. package/src/personality/README.md +61 -0
  181. package/src/personality/adapter.ts +196 -0
  182. package/src/personality/index.ts +20 -0
  183. package/src/personality/learner.ts +209 -0
  184. package/src/personality/model.ts +132 -0
  185. package/src/personality/personality.test.ts +236 -0
  186. package/src/roles/README.md +252 -0
  187. package/src/roles/authority.ts +120 -0
  188. package/src/roles/example-usage.ts +198 -0
  189. package/src/roles/index.ts +42 -0
  190. package/src/roles/loader.ts +143 -0
  191. package/src/roles/prompt-builder.ts +218 -0
  192. package/src/roles/test-multi.ts +102 -0
  193. package/src/roles/test-role.yaml +77 -0
  194. package/src/roles/test-utils.ts +93 -0
  195. package/src/roles/test.ts +106 -0
  196. package/src/roles/tool-guide.ts +195 -0
  197. package/src/roles/types.ts +36 -0
  198. package/src/roles/utils.ts +200 -0
  199. package/src/scripts/google-setup.ts +168 -0
  200. package/src/sidecar/connection.ts +179 -0
  201. package/src/sidecar/index.ts +6 -0
  202. package/src/sidecar/manager.ts +542 -0
  203. package/src/sidecar/protocol.ts +85 -0
  204. package/src/sidecar/rpc.ts +161 -0
  205. package/src/sidecar/scheduler.ts +136 -0
  206. package/src/sidecar/types.ts +112 -0
  207. package/src/sidecar/validator.ts +144 -0
  208. package/src/sites/builder-tools.ts +215 -0
  209. package/src/sites/dev-server-manager.ts +286 -0
  210. package/src/sites/fixtures/security-test-site/.jarvis-project.json +6 -0
  211. package/src/sites/fixtures/security-test-site/Makefile +15 -0
  212. package/src/sites/fixtures/security-test-site/README.md +18 -0
  213. package/src/sites/fixtures/security-test-site/index.html +12 -0
  214. package/src/sites/fixtures/security-test-site/index.ts +16 -0
  215. package/src/sites/fixtures/security-test-site/package.json +13 -0
  216. package/src/sites/fixtures/security-test-site/src/app.tsx +780 -0
  217. package/src/sites/fixtures/security-test-site/tsconfig.json +10 -0
  218. package/src/sites/git-manager.ts +240 -0
  219. package/src/sites/github-manager.ts +355 -0
  220. package/src/sites/index.ts +25 -0
  221. package/src/sites/project-manager.ts +389 -0
  222. package/src/sites/proxy.ts +133 -0
  223. package/src/sites/service.ts +136 -0
  224. package/src/sites/templates.ts +169 -0
  225. package/src/sites/types.ts +89 -0
  226. package/src/user/profile-followup.test.ts +84 -0
  227. package/src/user/profile-followup.ts +185 -0
  228. package/src/user/profile.ts +224 -0
  229. package/src/vault/README.md +110 -0
  230. package/src/vault/awareness.ts +341 -0
  231. package/src/vault/commitments.ts +299 -0
  232. package/src/vault/content-pipeline.ts +270 -0
  233. package/src/vault/conversations.ts +173 -0
  234. package/src/vault/dashboard-sessions.ts +44 -0
  235. package/src/vault/documents.ts +130 -0
  236. package/src/vault/entities.ts +185 -0
  237. package/src/vault/extractor.test.ts +356 -0
  238. package/src/vault/extractor.ts +345 -0
  239. package/src/vault/facts.ts +190 -0
  240. package/src/vault/goals.ts +477 -0
  241. package/src/vault/index.ts +87 -0
  242. package/src/vault/keychain.ts +99 -0
  243. package/src/vault/observations.ts +115 -0
  244. package/src/vault/relationships.ts +178 -0
  245. package/src/vault/retrieval.test.ts +139 -0
  246. package/src/vault/retrieval.ts +258 -0
  247. package/src/vault/schema.ts +709 -0
  248. package/src/vault/settings.ts +38 -0
  249. package/src/vault/user-profile.test.ts +113 -0
  250. package/src/vault/user-profile.ts +176 -0
  251. package/src/vault/vectors.ts +92 -0
  252. package/src/vault/webapp-template-seeds.ts +116 -0
  253. package/src/vault/webapp-templates.ts +244 -0
  254. package/src/vault/workflows.ts +403 -0
  255. package/src/workflows/auto-suggest.ts +290 -0
  256. package/src/workflows/engine.ts +366 -0
  257. package/src/workflows/events.ts +24 -0
  258. package/src/workflows/executor.ts +207 -0
  259. package/src/workflows/nl-builder.ts +198 -0
  260. package/src/workflows/nodes/actions/agent-task.ts +73 -0
  261. package/src/workflows/nodes/actions/calendar-action.ts +85 -0
  262. package/src/workflows/nodes/actions/code-execution.ts +73 -0
  263. package/src/workflows/nodes/actions/discord.ts +77 -0
  264. package/src/workflows/nodes/actions/file-write.ts +73 -0
  265. package/src/workflows/nodes/actions/gmail.ts +69 -0
  266. package/src/workflows/nodes/actions/http-request.ts +117 -0
  267. package/src/workflows/nodes/actions/notification.ts +85 -0
  268. package/src/workflows/nodes/actions/run-tool.ts +55 -0
  269. package/src/workflows/nodes/actions/send-message.ts +82 -0
  270. package/src/workflows/nodes/actions/shell-command.ts +76 -0
  271. package/src/workflows/nodes/actions/telegram.ts +60 -0
  272. package/src/workflows/nodes/builtin.ts +119 -0
  273. package/src/workflows/nodes/error/error-handler.ts +37 -0
  274. package/src/workflows/nodes/error/fallback.ts +47 -0
  275. package/src/workflows/nodes/error/retry.ts +82 -0
  276. package/src/workflows/nodes/logic/delay.ts +42 -0
  277. package/src/workflows/nodes/logic/if-else.ts +41 -0
  278. package/src/workflows/nodes/logic/loop.ts +90 -0
  279. package/src/workflows/nodes/logic/merge.ts +38 -0
  280. package/src/workflows/nodes/logic/race.ts +40 -0
  281. package/src/workflows/nodes/logic/switch.ts +59 -0
  282. package/src/workflows/nodes/logic/template-render.ts +53 -0
  283. package/src/workflows/nodes/logic/variable-get.ts +37 -0
  284. package/src/workflows/nodes/logic/variable-set.ts +59 -0
  285. package/src/workflows/nodes/registry.ts +99 -0
  286. package/src/workflows/nodes/transform/aggregate.ts +99 -0
  287. package/src/workflows/nodes/transform/csv-parse.ts +70 -0
  288. package/src/workflows/nodes/transform/json-parse.ts +63 -0
  289. package/src/workflows/nodes/transform/map-filter.ts +84 -0
  290. package/src/workflows/nodes/transform/regex-match.ts +89 -0
  291. package/src/workflows/nodes/triggers/calendar.ts +33 -0
  292. package/src/workflows/nodes/triggers/clipboard.ts +32 -0
  293. package/src/workflows/nodes/triggers/cron.ts +40 -0
  294. package/src/workflows/nodes/triggers/email.ts +40 -0
  295. package/src/workflows/nodes/triggers/file-change.ts +45 -0
  296. package/src/workflows/nodes/triggers/git.ts +46 -0
  297. package/src/workflows/nodes/triggers/manual.ts +23 -0
  298. package/src/workflows/nodes/triggers/poll.ts +81 -0
  299. package/src/workflows/nodes/triggers/process.ts +44 -0
  300. package/src/workflows/nodes/triggers/screen-event.ts +37 -0
  301. package/src/workflows/nodes/triggers/webhook.ts +39 -0
  302. package/src/workflows/safe-eval.ts +139 -0
  303. package/src/workflows/template.ts +118 -0
  304. package/src/workflows/triggers/cron.ts +311 -0
  305. package/src/workflows/triggers/manager.ts +285 -0
  306. package/src/workflows/triggers/observer-bridge.ts +172 -0
  307. package/src/workflows/triggers/poller.ts +201 -0
  308. package/src/workflows/triggers/screen-condition.ts +218 -0
  309. package/src/workflows/triggers/triggers.test.ts +740 -0
  310. package/src/workflows/triggers/webhook.ts +191 -0
  311. package/src/workflows/types.ts +133 -0
  312. package/src/workflows/variables.ts +72 -0
  313. package/src/workflows/workflows.test.ts +383 -0
  314. package/src/workflows/yaml.ts +104 -0
  315. package/ui/dist/index-3gr23jt9.js +112614 -0
  316. package/ui/dist/index-9vmj8127.css +14239 -0
  317. package/ui/dist/index-hy9pc1gm.js +112873 -0
  318. package/ui/dist/index-j2ep5d1w.js +112374 -0
  319. package/ui/dist/index-jt00vjqs.js +112858 -0
  320. package/ui/dist/index-k9ymx5qb.js +112374 -0
  321. package/ui/dist/index.html +16 -0
  322. package/ui/public/audio/pcm-capture-processor.js +11 -0
  323. package/ui/public/openwakeword/models/embedding_model.onnx +0 -0
  324. package/ui/public/openwakeword/models/hey_jarvis_v0.1.onnx +0 -0
  325. package/ui/public/openwakeword/models/melspectrogram.onnx +0 -0
  326. package/ui/public/openwakeword/models/silero_vad.onnx +0 -0
  327. package/ui/public/ort/ort-wasm-simd-threaded.jsep.mjs +106 -0
  328. package/ui/public/ort/ort-wasm-simd-threaded.jsep.wasm +0 -0
  329. package/ui/public/ort/ort-wasm-simd-threaded.mjs +59 -0
  330. package/ui/public/ort/ort-wasm-simd-threaded.wasm +0 -0
@@ -0,0 +1,149 @@
1
+ import type { LLMStreamEvent } from '../llm/provider.ts';
2
+ import type { WebSocketServer, WSMessage } from './websocket.ts';
3
+
4
+ export type RelayOptions = {
5
+ /** Called each time a complete sentence is available during streaming. */
6
+ onSentence?: (sentence: string) => void;
7
+ /** Called when all text is done streaming. */
8
+ onTextDone?: () => void;
9
+ };
10
+
11
+ // Sentence boundary: period, exclamation, question mark, colon followed by whitespace or end
12
+ const SENTENCE_END_RE = /[.!?:]\s/;
13
+
14
+ export class StreamRelay {
15
+ private wsServer: WebSocketServer;
16
+
17
+ constructor(wsServer: WebSocketServer) {
18
+ this.wsServer = wsServer;
19
+ }
20
+
21
+ /**
22
+ * Relay LLM stream events to all connected WebSocket clients.
23
+ * Accumulates and returns the complete response text.
24
+ * Optionally fires onSentence callback as complete sentences arrive.
25
+ */
26
+ async relayStream(
27
+ stream: AsyncIterable<LLMStreamEvent>,
28
+ requestId: string,
29
+ options?: RelayOptions,
30
+ ): Promise<string> {
31
+ let fullText = '';
32
+ let sentenceBuffer = '';
33
+ let streamError: string | null = null;
34
+
35
+ try {
36
+ for await (const event of stream) {
37
+ if (event.type === 'text') {
38
+ fullText += event.text;
39
+
40
+ // Sentence-level TTS callback
41
+ if (options?.onSentence) {
42
+ sentenceBuffer += event.text;
43
+ // Flush complete sentences from the buffer
44
+ let match: RegExpExecArray | null;
45
+ while ((match = SENTENCE_END_RE.exec(sentenceBuffer)) !== null) {
46
+ const end = match.index + match[0].length;
47
+ const sentence = sentenceBuffer.slice(0, end).trim();
48
+ if (sentence) {
49
+ options.onSentence(sentence);
50
+ }
51
+ sentenceBuffer = sentenceBuffer.slice(end);
52
+ }
53
+ }
54
+
55
+ // Broadcast chunk to all connected clients
56
+ const message: WSMessage = {
57
+ type: 'stream',
58
+ payload: {
59
+ text: event.text,
60
+ requestId,
61
+ accumulated: fullText,
62
+ },
63
+ id: requestId,
64
+ timestamp: Date.now(),
65
+ };
66
+
67
+ this.wsServer.broadcast(message);
68
+ } else if (event.type === 'tool_call') {
69
+ // Broadcast tool call notification to clients
70
+ const toolMessage: WSMessage = {
71
+ type: 'stream',
72
+ payload: {
73
+ tool_call: {
74
+ name: event.tool_call.name,
75
+ arguments: event.tool_call.arguments,
76
+ },
77
+ requestId,
78
+ },
79
+ id: requestId,
80
+ timestamp: Date.now(),
81
+ };
82
+
83
+ this.wsServer.broadcast(toolMessage);
84
+ } else if (event.type === 'error') {
85
+ console.error('[StreamRelay] Stream error:', event.error);
86
+
87
+ const errorMessage: WSMessage = {
88
+ type: 'error',
89
+ payload: {
90
+ message: event.error,
91
+ requestId,
92
+ },
93
+ id: requestId,
94
+ timestamp: Date.now(),
95
+ };
96
+
97
+ this.wsServer.broadcast(errorMessage);
98
+ streamError = event.error;
99
+ break;
100
+ } else if (event.type === 'done') {
101
+ // Flush remaining sentence buffer
102
+ if (options?.onSentence && sentenceBuffer.trim()) {
103
+ options.onSentence(sentenceBuffer.trim());
104
+ sentenceBuffer = '';
105
+ }
106
+ options?.onTextDone?.();
107
+
108
+ console.log('[StreamRelay] Stream complete for request:', requestId);
109
+
110
+ const doneMessage: WSMessage = {
111
+ type: 'status',
112
+ payload: {
113
+ status: 'done',
114
+ requestId,
115
+ fullText,
116
+ usage: event.response.usage,
117
+ },
118
+ id: requestId,
119
+ timestamp: Date.now(),
120
+ };
121
+
122
+ this.wsServer.broadcast(doneMessage);
123
+ }
124
+ }
125
+
126
+ if (streamError) {
127
+ throw new Error(streamError);
128
+ }
129
+ } catch (error) {
130
+ console.error('[StreamRelay] Error relaying stream:', error);
131
+
132
+ const errorMessage: WSMessage = {
133
+ type: 'error',
134
+ payload: {
135
+ message: error instanceof Error ? error.message : 'Stream relay error',
136
+ requestId,
137
+ },
138
+ id: requestId,
139
+ timestamp: Date.now(),
140
+ };
141
+
142
+ this.wsServer.broadcast(errorMessage);
143
+
144
+ throw error;
145
+ }
146
+
147
+ return fullText;
148
+ }
149
+ }
@@ -0,0 +1,504 @@
1
+ import { test, expect, describe, mock, afterEach } from 'bun:test';
2
+ import {
3
+ createSTTProvider,
4
+ createTTSProvider,
5
+ OpenAIWhisperSTT,
6
+ GroqWhisperSTT,
7
+ LocalWhisperSTT,
8
+ EdgeTTSProvider,
9
+ splitIntoSentences,
10
+ } from './voice.ts';
11
+ import type { STTConfig, TTSConfig } from '../config/types.ts';
12
+
13
+ /** Build a minimal valid WAV buffer */
14
+ function makeWavBuffer(pcmBytes = 100): Buffer {
15
+ const dataSize = pcmBytes;
16
+ const buf = Buffer.alloc(44 + dataSize);
17
+ buf.write('RIFF', 0, 'ascii');
18
+ buf.writeUInt32LE(36 + dataSize, 4);
19
+ buf.write('WAVE', 8, 'ascii');
20
+ buf.write('fmt ', 12, 'ascii');
21
+ buf.writeUInt32LE(16, 16); // subchunk1 size
22
+ buf.writeUInt16LE(1, 20); // PCM
23
+ buf.writeUInt16LE(1, 22); // mono
24
+ buf.writeUInt32LE(16000, 24); // sample rate
25
+ buf.writeUInt32LE(32000, 28); // byte rate
26
+ buf.writeUInt16LE(2, 32); // block align
27
+ buf.writeUInt16LE(16, 34); // bits per sample
28
+ buf.write('data', 36, 'ascii');
29
+ buf.writeUInt32LE(dataSize, 40);
30
+ return buf;
31
+ }
32
+
33
+ describe('createSTTProvider factory', () => {
34
+ test('returns OpenAIWhisperSTT when provider=openai and key present', () => {
35
+ const config: STTConfig = {
36
+ provider: 'openai',
37
+ openai: { api_key: 'test-openai-key-not-real' },
38
+ };
39
+ const provider = createSTTProvider(config);
40
+ expect(provider).toBeInstanceOf(OpenAIWhisperSTT);
41
+ });
42
+
43
+ test('passes custom OpenAI-compatible base URL to OpenAIWhisperSTT', () => {
44
+ const config: STTConfig = {
45
+ provider: 'openai',
46
+ openai: {
47
+ api_key: 'test-openai-key-not-real',
48
+ base_url: 'https://gateway.example.com/openai',
49
+ },
50
+ };
51
+ const provider = createSTTProvider(config) as any;
52
+ expect(provider).toBeInstanceOf(OpenAIWhisperSTT);
53
+ expect(provider.apiUrl).toBe('https://gateway.example.com/openai/audio/transcriptions');
54
+ });
55
+
56
+ test('returns null when provider=openai and no key', () => {
57
+ const config: STTConfig = { provider: 'openai' };
58
+ const provider = createSTTProvider(config);
59
+ expect(provider).toBeNull();
60
+ });
61
+
62
+ test('returns GroqWhisperSTT when provider=groq and key present', () => {
63
+ const config: STTConfig = {
64
+ provider: 'groq',
65
+ groq: { api_key: 'gtest-openai-key-not-real' },
66
+ };
67
+ const provider = createSTTProvider(config);
68
+ expect(provider).toBeInstanceOf(GroqWhisperSTT);
69
+ });
70
+
71
+ test('returns null when provider=groq and no key', () => {
72
+ const config: STTConfig = { provider: 'groq' };
73
+ const provider = createSTTProvider(config);
74
+ expect(provider).toBeNull();
75
+ });
76
+
77
+ test('returns LocalWhisperSTT when provider=local (no key needed)', () => {
78
+ const config: STTConfig = { provider: 'local' };
79
+ const provider = createSTTProvider(config);
80
+ expect(provider).toBeInstanceOf(LocalWhisperSTT);
81
+ });
82
+
83
+ test('returns LocalWhisperSTT with custom endpoint', () => {
84
+ const config: STTConfig = {
85
+ provider: 'local',
86
+ local: { endpoint: 'http://my-server:9000' },
87
+ };
88
+ const provider = createSTTProvider(config);
89
+ expect(provider).toBeInstanceOf(LocalWhisperSTT);
90
+ });
91
+
92
+ test('passes server_type through to LocalWhisperSTT', () => {
93
+ const config: STTConfig = {
94
+ provider: 'local',
95
+ local: { endpoint: 'http://my-server:9000', server_type: 'openai_compatible' },
96
+ };
97
+ const provider = createSTTProvider(config);
98
+ expect(provider).toBeInstanceOf(LocalWhisperSTT);
99
+ });
100
+
101
+ test('returns null for unknown provider', () => {
102
+ const config = { provider: 'unknown' } as any;
103
+ const provider = createSTTProvider(config);
104
+ expect(provider).toBeNull();
105
+ });
106
+
107
+ test('returns OpenAI with custom model', () => {
108
+ const config: STTConfig = {
109
+ provider: 'openai',
110
+ openai: { api_key: 'test-key-not-real', model: 'whisper-large-v3' },
111
+ };
112
+ const provider = createSTTProvider(config);
113
+ expect(provider).toBeInstanceOf(OpenAIWhisperSTT);
114
+ });
115
+ });
116
+
117
+ describe('createTTSProvider factory', () => {
118
+ test('returns null when tts disabled', () => {
119
+ const config: TTSConfig = { enabled: false };
120
+ expect(createTTSProvider(config)).toBeNull();
121
+ });
122
+
123
+ test('returns EdgeTTSProvider when enabled', () => {
124
+ const config: TTSConfig = { enabled: true };
125
+ const provider = createTTSProvider(config);
126
+ expect(provider).toBeInstanceOf(EdgeTTSProvider);
127
+ });
128
+
129
+ test('passes voice config to provider', () => {
130
+ const config: TTSConfig = { enabled: true, voice: 'en-GB-SoniaNeural' };
131
+ const provider = createTTSProvider(config);
132
+ expect(provider).toBeInstanceOf(EdgeTTSProvider);
133
+ });
134
+
135
+ test('passes rate and volume config', () => {
136
+ const config: TTSConfig = { enabled: true, rate: '+20%', volume: '-10%' };
137
+ const provider = createTTSProvider(config);
138
+ expect(provider).not.toBeNull();
139
+ });
140
+ });
141
+
142
+ describe('EdgeTTSProvider', () => {
143
+ test('implements TTSProvider interface', () => {
144
+ const provider = new EdgeTTSProvider();
145
+ expect(typeof provider.synthesize).toBe('function');
146
+ expect(typeof provider.synthesizeStream).toBe('function');
147
+ });
148
+
149
+ test('constructor accepts custom voice/rate/volume', () => {
150
+ const provider = new EdgeTTSProvider('en-GB-SoniaNeural', '+10%', '-5%');
151
+ expect(provider).toBeInstanceOf(EdgeTTSProvider);
152
+ });
153
+ });
154
+
155
+ describe('splitIntoSentences', () => {
156
+ test('splits on period + capital letter', () => {
157
+ const result = splitIntoSentences('Hello there. World is great. This works.');
158
+ expect(result.length).toBe(3);
159
+ expect(result[0]).toBe('Hello there.');
160
+ expect(result[1]).toBe('World is great.');
161
+ expect(result[2]).toBe('This works.');
162
+ });
163
+
164
+ test('splits on exclamation and question marks', () => {
165
+ const result = splitIntoSentences('Wait! Are you sure? Yes I am.');
166
+ expect(result.length).toBe(3);
167
+ });
168
+
169
+ test('handles single sentence', () => {
170
+ const result = splitIntoSentences('Just one sentence.');
171
+ expect(result).toEqual(['Just one sentence.']);
172
+ });
173
+
174
+ test('handles empty string', () => {
175
+ const result = splitIntoSentences('');
176
+ expect(result).toEqual(['']);
177
+ });
178
+
179
+ test('collapses code blocks', () => {
180
+ const result = splitIntoSentences('Here is code:\n```\nconst x = 1;\n```\nDone.');
181
+ // Should not split inside code block
182
+ expect(result.length).toBeLessThanOrEqual(3);
183
+ });
184
+
185
+ test('splits on double newlines (paragraph breaks)', () => {
186
+ const result = splitIntoSentences('First paragraph\n\nSecond paragraph');
187
+ expect(result.length).toBe(2);
188
+ });
189
+
190
+ test('handles text with no sentence-ending punctuation', () => {
191
+ const result = splitIntoSentences('just some words without punctuation');
192
+ expect(result).toEqual(['just some words without punctuation']);
193
+ });
194
+ });
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // LocalWhisperSTT – transcribe() tests
198
+ // ---------------------------------------------------------------------------
199
+
200
+ describe('LocalWhisperSTT.transcribe', () => {
201
+ const originalFetch = globalThis.fetch;
202
+
203
+ afterEach(() => {
204
+ globalThis.fetch = originalFetch;
205
+ });
206
+
207
+ // -- Server type defaults ------------------------------------------------
208
+
209
+ test('defaults to whisper_cpp server type', async () => {
210
+ const stt = new LocalWhisperSTT('http://localhost:8189');
211
+ const wav = makeWavBuffer();
212
+
213
+ globalThis.fetch = mock(async (_url: string, init: any) => {
214
+ const body = init.body as FormData;
215
+ expect(body.has('response_format')).toBe(true);
216
+ expect(body.has('model')).toBe(false);
217
+ return new Response(JSON.stringify({ text: 'ok' }), {
218
+ headers: { 'content-type': 'application/json' },
219
+ });
220
+ }) as any;
221
+
222
+ await stt.transcribe(wav);
223
+ });
224
+
225
+ // -- whisper_cpp mode ----------------------------------------------------
226
+
227
+ test('whisper_cpp: appends /inference to bare-host endpoint', async () => {
228
+ const stt = new LocalWhisperSTT('http://localhost:8189');
229
+ const wav = makeWavBuffer();
230
+ let calledUrl = '';
231
+
232
+ globalThis.fetch = mock(async (url: string) => {
233
+ calledUrl = url;
234
+ return new Response(JSON.stringify({ text: 'ok' }), {
235
+ headers: { 'content-type': 'application/json' },
236
+ });
237
+ }) as any;
238
+
239
+ await stt.transcribe(wav);
240
+ expect(calledUrl).toBe('http://localhost:8189/inference');
241
+ });
242
+
243
+ test('whisper_cpp: uses endpoint as-is when it has explicit path', async () => {
244
+ const stt = new LocalWhisperSTT('http://localhost:8189/inference');
245
+ const wav = makeWavBuffer();
246
+ let calledUrl = '';
247
+
248
+ globalThis.fetch = mock(async (url: string) => {
249
+ calledUrl = url;
250
+ return new Response(JSON.stringify({ text: 'ok' }), {
251
+ headers: { 'content-type': 'application/json' },
252
+ });
253
+ }) as any;
254
+
255
+ await stt.transcribe(wav);
256
+ expect(calledUrl).toBe('http://localhost:8189/inference');
257
+ });
258
+
259
+ test('whisper_cpp: strips trailing slashes from endpoint', async () => {
260
+ const stt = new LocalWhisperSTT('http://localhost:8189///');
261
+ const wav = makeWavBuffer();
262
+ let calledUrl = '';
263
+
264
+ globalThis.fetch = mock(async (url: string) => {
265
+ calledUrl = url;
266
+ return new Response(JSON.stringify({ text: 'ok' }), {
267
+ headers: { 'content-type': 'application/json' },
268
+ });
269
+ }) as any;
270
+
271
+ await stt.transcribe(wav);
272
+ expect(calledUrl).toBe('http://localhost:8189/inference');
273
+ });
274
+
275
+ test('whisper_cpp: sends response_format and temperature fields', async () => {
276
+ const stt = new LocalWhisperSTT('http://localhost:8189/inference', undefined, 'whisper_cpp');
277
+ const wav = makeWavBuffer();
278
+
279
+ globalThis.fetch = mock(async (_url: string, init: any) => {
280
+ const body = init.body as FormData;
281
+ expect(body.has('response_format')).toBe(true);
282
+ expect(body.get('response_format')).toBe('json');
283
+ expect(body.has('temperature')).toBe(true);
284
+ expect(body.has('model')).toBe(false);
285
+ return new Response(JSON.stringify({ text: 'ok' }), {
286
+ headers: { 'content-type': 'application/json' },
287
+ });
288
+ }) as any;
289
+
290
+ await stt.transcribe(wav);
291
+ });
292
+
293
+ // -- openai_compatible mode ----------------------------------------------
294
+
295
+ test('openai_compatible: uses endpoint verbatim', async () => {
296
+ const stt = new LocalWhisperSTT('http://localhost:8080/v1/audio/transcriptions', undefined, 'openai_compatible');
297
+ const wav = makeWavBuffer();
298
+ let calledUrl = '';
299
+
300
+ globalThis.fetch = mock(async (url: string) => {
301
+ calledUrl = url;
302
+ return new Response(JSON.stringify({ text: 'ok' }), {
303
+ headers: { 'content-type': 'application/json' },
304
+ });
305
+ }) as any;
306
+
307
+ await stt.transcribe(wav);
308
+ expect(calledUrl).toBe('http://localhost:8080/v1/audio/transcriptions');
309
+ });
310
+
311
+ test('openai_compatible: sends model and language fields', async () => {
312
+ const stt = new LocalWhisperSTT('http://localhost:8080/v1/audio/transcriptions', 'whisper-1', 'openai_compatible');
313
+ const wav = makeWavBuffer();
314
+
315
+ globalThis.fetch = mock(async (_url: string, init: any) => {
316
+ const body = init.body as FormData;
317
+ expect(body.has('model')).toBe(true);
318
+ expect(body.get('model')).toBe('whisper-1');
319
+ expect(body.has('language')).toBe(true);
320
+ expect(body.has('response_format')).toBe(false);
321
+ return new Response(JSON.stringify({ text: 'ok' }), {
322
+ headers: { 'content-type': 'application/json' },
323
+ });
324
+ }) as any;
325
+
326
+ await stt.transcribe(wav);
327
+ });
328
+
329
+ // -- Response shape parsing ---------------------------------------------
330
+
331
+ test('parses JSON response with "text" field', async () => {
332
+ const stt = new LocalWhisperSTT('http://localhost:8189/inference');
333
+ const wav = makeWavBuffer();
334
+
335
+ globalThis.fetch = mock(async () =>
336
+ new Response(JSON.stringify({ text: 'hello world' }), {
337
+ headers: { 'content-type': 'application/json' },
338
+ })
339
+ ) as any;
340
+
341
+ expect(await stt.transcribe(wav)).toBe('hello world');
342
+ });
343
+
344
+ test('parses JSON response with "transcript" field', async () => {
345
+ const stt = new LocalWhisperSTT('http://localhost:8189/inference');
346
+ const wav = makeWavBuffer();
347
+
348
+ globalThis.fetch = mock(async () =>
349
+ new Response(JSON.stringify({ transcript: 'transcript field' }), {
350
+ headers: { 'content-type': 'application/json' },
351
+ })
352
+ ) as any;
353
+
354
+ expect(await stt.transcribe(wav)).toBe('transcript field');
355
+ });
356
+
357
+ test('parses JSON response with nested "data.text" field', async () => {
358
+ const stt = new LocalWhisperSTT('http://localhost:8189/inference');
359
+ const wav = makeWavBuffer();
360
+
361
+ globalThis.fetch = mock(async () =>
362
+ new Response(JSON.stringify({ data: { text: 'nested text' } }), {
363
+ headers: { 'content-type': 'application/json' },
364
+ })
365
+ ) as any;
366
+
367
+ expect(await stt.transcribe(wav)).toBe('nested text');
368
+ });
369
+
370
+ test('parses plain-text response body', async () => {
371
+ const stt = new LocalWhisperSTT('http://localhost:8189/inference');
372
+ const wav = makeWavBuffer();
373
+
374
+ globalThis.fetch = mock(async () =>
375
+ new Response('plain text result', {
376
+ headers: { 'content-type': 'text/plain' },
377
+ })
378
+ ) as any;
379
+
380
+ expect(await stt.transcribe(wav)).toBe('plain text result');
381
+ });
382
+
383
+ test('trims whitespace from transcription result', async () => {
384
+ const stt = new LocalWhisperSTT('http://localhost:8189/inference');
385
+ const wav = makeWavBuffer();
386
+
387
+ globalThis.fetch = mock(async () =>
388
+ new Response(JSON.stringify({ text: ' hello \n' }), {
389
+ headers: { 'content-type': 'application/json' },
390
+ })
391
+ ) as any;
392
+
393
+ expect(await stt.transcribe(wav)).toBe('hello');
394
+ });
395
+
396
+ // -- Error handling -----------------------------------------------------
397
+
398
+ test('throws on HTTP error with status and body', async () => {
399
+ const stt = new LocalWhisperSTT('http://localhost:8189');
400
+ const wav = makeWavBuffer();
401
+
402
+ globalThis.fetch = mock(async () =>
403
+ new Response('server error', { status: 500 })
404
+ ) as any;
405
+
406
+ try {
407
+ await stt.transcribe(wav);
408
+ expect(true).toBe(false);
409
+ } catch (err: any) {
410
+ expect(err.message).toContain('Local Whisper STT error');
411
+ expect(err.message).toContain('500');
412
+ }
413
+ });
414
+
415
+ test('throws on empty transcription', async () => {
416
+ const stt = new LocalWhisperSTT('http://localhost:8189/inference');
417
+ const wav = makeWavBuffer();
418
+
419
+ globalThis.fetch = mock(async () =>
420
+ new Response(JSON.stringify({ text: '' }), {
421
+ headers: { 'content-type': 'application/json' },
422
+ })
423
+ ) as any;
424
+
425
+ try {
426
+ await stt.transcribe(wav);
427
+ expect(true).toBe(false);
428
+ } catch (err: any) {
429
+ expect(err.message).toContain('empty transcription');
430
+ }
431
+ });
432
+
433
+ test('propagates network errors', async () => {
434
+ const stt = new LocalWhisperSTT('http://localhost:8189/inference');
435
+ const wav = makeWavBuffer();
436
+
437
+ globalThis.fetch = mock(async () => {
438
+ throw new Error('Connection refused');
439
+ }) as any;
440
+
441
+ try {
442
+ await stt.transcribe(wav);
443
+ expect(true).toBe(false);
444
+ } catch (err: any) {
445
+ expect(err.message).toBe('Connection refused');
446
+ }
447
+ });
448
+ });
449
+
450
+ describe('OpenAIWhisperSTT.transcribe', () => {
451
+ const originalFetch = globalThis.fetch;
452
+
453
+ afterEach(() => {
454
+ globalThis.fetch = originalFetch;
455
+ });
456
+
457
+ test('defaults to the official OpenAI transcription endpoint', async () => {
458
+ const stt = new OpenAIWhisperSTT('test-key');
459
+ const wav = makeWavBuffer();
460
+ let calledUrl = '';
461
+
462
+ globalThis.fetch = mock(async (url: string) => {
463
+ calledUrl = url;
464
+ return new Response(JSON.stringify({ text: 'ok' }), {
465
+ headers: { 'content-type': 'application/json' },
466
+ });
467
+ }) as any;
468
+
469
+ await stt.transcribe(wav);
470
+ expect(calledUrl).toBe('https://api.openai.com/v1/audio/transcriptions');
471
+ });
472
+
473
+ test('uses a configured OpenAI-compatible base URL for transcriptions', async () => {
474
+ const stt = new OpenAIWhisperSTT('test-key', 'whisper-1', 'https://gateway.example.com/openai');
475
+ const wav = makeWavBuffer();
476
+ let calledUrl = '';
477
+
478
+ globalThis.fetch = mock(async (url: string) => {
479
+ calledUrl = url;
480
+ return new Response(JSON.stringify({ text: 'ok' }), {
481
+ headers: { 'content-type': 'application/json' },
482
+ });
483
+ }) as any;
484
+
485
+ await stt.transcribe(wav);
486
+ expect(calledUrl).toBe('https://gateway.example.com/openai/audio/transcriptions');
487
+ });
488
+
489
+ test('accepts non-standard API key formats for compatible gateways', async () => {
490
+ const stt = new OpenAIWhisperSTT('nvapi-custom-key', 'whisper-1', 'https://gateway.example.com/openai');
491
+ const wav = makeWavBuffer();
492
+ let authHeader = '';
493
+
494
+ globalThis.fetch = mock(async (_url: string, init?: RequestInit) => {
495
+ authHeader = String((init?.headers as Record<string, string> | undefined)?.Authorization ?? '');
496
+ return new Response(JSON.stringify({ text: 'ok' }), {
497
+ headers: { 'content-type': 'application/json' },
498
+ });
499
+ }) as any;
500
+
501
+ await stt.transcribe(wav);
502
+ expect(authHeader).toBe('Bearer nvapi-custom-key');
503
+ });
504
+ });