@usejarvis/brain 0.1.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 (266) hide show
  1. package/LICENSE +153 -0
  2. package/README.md +278 -0
  3. package/bin/jarvis.ts +413 -0
  4. package/package.json +74 -0
  5. package/scripts/ensure-bun.cjs +8 -0
  6. package/src/actions/README.md +421 -0
  7. package/src/actions/app-control/desktop-controller.test.ts +26 -0
  8. package/src/actions/app-control/desktop-controller.ts +438 -0
  9. package/src/actions/app-control/interface.ts +64 -0
  10. package/src/actions/app-control/linux.ts +273 -0
  11. package/src/actions/app-control/macos.ts +54 -0
  12. package/src/actions/app-control/sidecar-launcher.test.ts +23 -0
  13. package/src/actions/app-control/sidecar-launcher.ts +286 -0
  14. package/src/actions/app-control/windows.ts +44 -0
  15. package/src/actions/browser/cdp.ts +138 -0
  16. package/src/actions/browser/chrome-launcher.ts +252 -0
  17. package/src/actions/browser/session.ts +437 -0
  18. package/src/actions/browser/stealth.ts +49 -0
  19. package/src/actions/index.ts +20 -0
  20. package/src/actions/terminal/executor.ts +157 -0
  21. package/src/actions/terminal/wsl-bridge.ts +126 -0
  22. package/src/actions/test.ts +93 -0
  23. package/src/actions/tools/agents.ts +321 -0
  24. package/src/actions/tools/builtin.ts +846 -0
  25. package/src/actions/tools/commitments.ts +192 -0
  26. package/src/actions/tools/content.ts +217 -0
  27. package/src/actions/tools/delegate.ts +147 -0
  28. package/src/actions/tools/desktop.test.ts +55 -0
  29. package/src/actions/tools/desktop.ts +305 -0
  30. package/src/actions/tools/goals.ts +376 -0
  31. package/src/actions/tools/local-tools-guard.ts +20 -0
  32. package/src/actions/tools/registry.ts +171 -0
  33. package/src/actions/tools/research.ts +111 -0
  34. package/src/actions/tools/sidecar-list.ts +57 -0
  35. package/src/actions/tools/sidecar-route.ts +105 -0
  36. package/src/actions/tools/workflows.ts +216 -0
  37. package/src/agents/agent.ts +132 -0
  38. package/src/agents/delegation.ts +107 -0
  39. package/src/agents/hierarchy.ts +113 -0
  40. package/src/agents/index.ts +19 -0
  41. package/src/agents/messaging.ts +125 -0
  42. package/src/agents/orchestrator.ts +576 -0
  43. package/src/agents/role-discovery.ts +61 -0
  44. package/src/agents/sub-agent-runner.ts +307 -0
  45. package/src/agents/task-manager.ts +151 -0
  46. package/src/authority/approval-delivery.ts +59 -0
  47. package/src/authority/approval.ts +196 -0
  48. package/src/authority/audit.ts +158 -0
  49. package/src/authority/authority.test.ts +519 -0
  50. package/src/authority/deferred-executor.ts +103 -0
  51. package/src/authority/emergency.ts +66 -0
  52. package/src/authority/engine.ts +297 -0
  53. package/src/authority/index.ts +12 -0
  54. package/src/authority/learning.ts +111 -0
  55. package/src/authority/tool-action-map.ts +74 -0
  56. package/src/awareness/analytics.ts +466 -0
  57. package/src/awareness/awareness.test.ts +332 -0
  58. package/src/awareness/capture-engine.ts +305 -0
  59. package/src/awareness/context-graph.ts +130 -0
  60. package/src/awareness/context-tracker.ts +349 -0
  61. package/src/awareness/index.ts +25 -0
  62. package/src/awareness/intelligence.ts +321 -0
  63. package/src/awareness/ocr-engine.ts +88 -0
  64. package/src/awareness/service.ts +528 -0
  65. package/src/awareness/struggle-detector.ts +342 -0
  66. package/src/awareness/suggestion-engine.ts +476 -0
  67. package/src/awareness/types.ts +201 -0
  68. package/src/cli/autostart.ts +241 -0
  69. package/src/cli/deps.ts +449 -0
  70. package/src/cli/doctor.ts +230 -0
  71. package/src/cli/helpers.ts +401 -0
  72. package/src/cli/onboard.ts +580 -0
  73. package/src/comms/README.md +329 -0
  74. package/src/comms/auth-error.html +48 -0
  75. package/src/comms/channels/discord.ts +228 -0
  76. package/src/comms/channels/signal.ts +56 -0
  77. package/src/comms/channels/telegram.ts +316 -0
  78. package/src/comms/channels/whatsapp.ts +60 -0
  79. package/src/comms/channels.test.ts +173 -0
  80. package/src/comms/desktop-notify.ts +114 -0
  81. package/src/comms/example.ts +129 -0
  82. package/src/comms/index.ts +129 -0
  83. package/src/comms/streaming.ts +142 -0
  84. package/src/comms/voice.test.ts +152 -0
  85. package/src/comms/voice.ts +291 -0
  86. package/src/comms/websocket.test.ts +409 -0
  87. package/src/comms/websocket.ts +473 -0
  88. package/src/config/README.md +387 -0
  89. package/src/config/index.ts +6 -0
  90. package/src/config/loader.test.ts +137 -0
  91. package/src/config/loader.ts +142 -0
  92. package/src/config/types.ts +260 -0
  93. package/src/daemon/README.md +232 -0
  94. package/src/daemon/agent-service-interface.ts +9 -0
  95. package/src/daemon/agent-service.ts +600 -0
  96. package/src/daemon/api-routes.ts +2119 -0
  97. package/src/daemon/background-agent-service.ts +396 -0
  98. package/src/daemon/background-agent.test.ts +78 -0
  99. package/src/daemon/channel-service.ts +201 -0
  100. package/src/daemon/commitment-executor.ts +297 -0
  101. package/src/daemon/event-classifier.ts +239 -0
  102. package/src/daemon/event-coalescer.ts +123 -0
  103. package/src/daemon/event-reactor.ts +214 -0
  104. package/src/daemon/health.ts +220 -0
  105. package/src/daemon/index.ts +1004 -0
  106. package/src/daemon/llm-settings.ts +316 -0
  107. package/src/daemon/observer-service.ts +150 -0
  108. package/src/daemon/pid.ts +98 -0
  109. package/src/daemon/research-queue.ts +155 -0
  110. package/src/daemon/services.ts +175 -0
  111. package/src/daemon/ws-service.ts +788 -0
  112. package/src/goals/accountability.ts +240 -0
  113. package/src/goals/awareness-bridge.ts +185 -0
  114. package/src/goals/estimator.ts +185 -0
  115. package/src/goals/events.ts +28 -0
  116. package/src/goals/goals.test.ts +400 -0
  117. package/src/goals/integration.test.ts +329 -0
  118. package/src/goals/nl-builder.test.ts +220 -0
  119. package/src/goals/nl-builder.ts +256 -0
  120. package/src/goals/rhythm.test.ts +177 -0
  121. package/src/goals/rhythm.ts +275 -0
  122. package/src/goals/service.test.ts +135 -0
  123. package/src/goals/service.ts +348 -0
  124. package/src/goals/types.ts +106 -0
  125. package/src/goals/workflow-bridge.ts +96 -0
  126. package/src/integrations/google-api.ts +134 -0
  127. package/src/integrations/google-auth.ts +175 -0
  128. package/src/llm/README.md +291 -0
  129. package/src/llm/anthropic.ts +386 -0
  130. package/src/llm/gemini.ts +371 -0
  131. package/src/llm/index.ts +19 -0
  132. package/src/llm/manager.ts +153 -0
  133. package/src/llm/ollama.ts +307 -0
  134. package/src/llm/openai.ts +350 -0
  135. package/src/llm/provider.test.ts +231 -0
  136. package/src/llm/provider.ts +60 -0
  137. package/src/llm/test.ts +87 -0
  138. package/src/observers/README.md +278 -0
  139. package/src/observers/calendar.ts +113 -0
  140. package/src/observers/clipboard.ts +136 -0
  141. package/src/observers/email.ts +109 -0
  142. package/src/observers/example.ts +58 -0
  143. package/src/observers/file-watcher.ts +124 -0
  144. package/src/observers/index.ts +159 -0
  145. package/src/observers/notifications.ts +197 -0
  146. package/src/observers/observers.test.ts +203 -0
  147. package/src/observers/processes.ts +225 -0
  148. package/src/personality/README.md +61 -0
  149. package/src/personality/adapter.ts +196 -0
  150. package/src/personality/index.ts +20 -0
  151. package/src/personality/learner.ts +209 -0
  152. package/src/personality/model.ts +132 -0
  153. package/src/personality/personality.test.ts +236 -0
  154. package/src/roles/README.md +252 -0
  155. package/src/roles/authority.ts +119 -0
  156. package/src/roles/example-usage.ts +198 -0
  157. package/src/roles/index.ts +42 -0
  158. package/src/roles/loader.ts +143 -0
  159. package/src/roles/prompt-builder.ts +194 -0
  160. package/src/roles/test-multi.ts +102 -0
  161. package/src/roles/test-role.yaml +77 -0
  162. package/src/roles/test-utils.ts +93 -0
  163. package/src/roles/test.ts +106 -0
  164. package/src/roles/tool-guide.ts +190 -0
  165. package/src/roles/types.ts +36 -0
  166. package/src/roles/utils.ts +200 -0
  167. package/src/scripts/google-setup.ts +168 -0
  168. package/src/sidecar/connection.ts +179 -0
  169. package/src/sidecar/index.ts +6 -0
  170. package/src/sidecar/manager.ts +542 -0
  171. package/src/sidecar/protocol.ts +85 -0
  172. package/src/sidecar/rpc.ts +161 -0
  173. package/src/sidecar/scheduler.ts +136 -0
  174. package/src/sidecar/types.ts +112 -0
  175. package/src/sidecar/validator.ts +144 -0
  176. package/src/vault/README.md +110 -0
  177. package/src/vault/awareness.ts +341 -0
  178. package/src/vault/commitments.ts +299 -0
  179. package/src/vault/content-pipeline.ts +260 -0
  180. package/src/vault/conversations.ts +173 -0
  181. package/src/vault/entities.ts +180 -0
  182. package/src/vault/extractor.test.ts +356 -0
  183. package/src/vault/extractor.ts +345 -0
  184. package/src/vault/facts.ts +190 -0
  185. package/src/vault/goals.ts +477 -0
  186. package/src/vault/index.ts +87 -0
  187. package/src/vault/keychain.ts +99 -0
  188. package/src/vault/observations.ts +115 -0
  189. package/src/vault/relationships.ts +178 -0
  190. package/src/vault/retrieval.test.ts +126 -0
  191. package/src/vault/retrieval.ts +227 -0
  192. package/src/vault/schema.ts +658 -0
  193. package/src/vault/settings.ts +38 -0
  194. package/src/vault/vectors.ts +92 -0
  195. package/src/vault/workflows.ts +403 -0
  196. package/src/workflows/auto-suggest.ts +290 -0
  197. package/src/workflows/engine.ts +366 -0
  198. package/src/workflows/events.ts +24 -0
  199. package/src/workflows/executor.ts +207 -0
  200. package/src/workflows/nl-builder.ts +198 -0
  201. package/src/workflows/nodes/actions/agent-task.ts +73 -0
  202. package/src/workflows/nodes/actions/calendar-action.ts +85 -0
  203. package/src/workflows/nodes/actions/code-execution.ts +73 -0
  204. package/src/workflows/nodes/actions/discord.ts +77 -0
  205. package/src/workflows/nodes/actions/file-write.ts +73 -0
  206. package/src/workflows/nodes/actions/gmail.ts +69 -0
  207. package/src/workflows/nodes/actions/http-request.ts +117 -0
  208. package/src/workflows/nodes/actions/notification.ts +85 -0
  209. package/src/workflows/nodes/actions/run-tool.ts +55 -0
  210. package/src/workflows/nodes/actions/send-message.ts +82 -0
  211. package/src/workflows/nodes/actions/shell-command.ts +76 -0
  212. package/src/workflows/nodes/actions/telegram.ts +60 -0
  213. package/src/workflows/nodes/builtin.ts +119 -0
  214. package/src/workflows/nodes/error/error-handler.ts +37 -0
  215. package/src/workflows/nodes/error/fallback.ts +47 -0
  216. package/src/workflows/nodes/error/retry.ts +82 -0
  217. package/src/workflows/nodes/logic/delay.ts +42 -0
  218. package/src/workflows/nodes/logic/if-else.ts +41 -0
  219. package/src/workflows/nodes/logic/loop.ts +90 -0
  220. package/src/workflows/nodes/logic/merge.ts +38 -0
  221. package/src/workflows/nodes/logic/race.ts +40 -0
  222. package/src/workflows/nodes/logic/switch.ts +59 -0
  223. package/src/workflows/nodes/logic/template-render.ts +53 -0
  224. package/src/workflows/nodes/logic/variable-get.ts +37 -0
  225. package/src/workflows/nodes/logic/variable-set.ts +59 -0
  226. package/src/workflows/nodes/registry.ts +99 -0
  227. package/src/workflows/nodes/transform/aggregate.ts +99 -0
  228. package/src/workflows/nodes/transform/csv-parse.ts +70 -0
  229. package/src/workflows/nodes/transform/json-parse.ts +63 -0
  230. package/src/workflows/nodes/transform/map-filter.ts +84 -0
  231. package/src/workflows/nodes/transform/regex-match.ts +89 -0
  232. package/src/workflows/nodes/triggers/calendar.ts +33 -0
  233. package/src/workflows/nodes/triggers/clipboard.ts +32 -0
  234. package/src/workflows/nodes/triggers/cron.ts +40 -0
  235. package/src/workflows/nodes/triggers/email.ts +40 -0
  236. package/src/workflows/nodes/triggers/file-change.ts +45 -0
  237. package/src/workflows/nodes/triggers/git.ts +46 -0
  238. package/src/workflows/nodes/triggers/manual.ts +23 -0
  239. package/src/workflows/nodes/triggers/poll.ts +81 -0
  240. package/src/workflows/nodes/triggers/process.ts +44 -0
  241. package/src/workflows/nodes/triggers/screen-event.ts +37 -0
  242. package/src/workflows/nodes/triggers/webhook.ts +39 -0
  243. package/src/workflows/safe-eval.ts +139 -0
  244. package/src/workflows/template.ts +118 -0
  245. package/src/workflows/triggers/cron.ts +311 -0
  246. package/src/workflows/triggers/manager.ts +285 -0
  247. package/src/workflows/triggers/observer-bridge.ts +172 -0
  248. package/src/workflows/triggers/poller.ts +201 -0
  249. package/src/workflows/triggers/screen-condition.ts +218 -0
  250. package/src/workflows/triggers/triggers.test.ts +740 -0
  251. package/src/workflows/triggers/webhook.ts +191 -0
  252. package/src/workflows/types.ts +133 -0
  253. package/src/workflows/variables.ts +72 -0
  254. package/src/workflows/workflows.test.ts +383 -0
  255. package/src/workflows/yaml.ts +104 -0
  256. package/ui/dist/index-j75njzc1.css +1199 -0
  257. package/ui/dist/index-p2zh407q.js +80603 -0
  258. package/ui/dist/index.html +13 -0
  259. package/ui/public/openwakeword/models/embedding_model.onnx +0 -0
  260. package/ui/public/openwakeword/models/hey_jarvis_v0.1.onnx +0 -0
  261. package/ui/public/openwakeword/models/melspectrogram.onnx +0 -0
  262. package/ui/public/openwakeword/models/silero_vad.onnx +0 -0
  263. package/ui/public/ort/ort-wasm-simd-threaded.jsep.mjs +106 -0
  264. package/ui/public/ort/ort-wasm-simd-threaded.jsep.wasm +0 -0
  265. package/ui/public/ort/ort-wasm-simd-threaded.mjs +59 -0
  266. package/ui/public/ort/ort-wasm-simd-threaded.wasm +0 -0
@@ -0,0 +1,371 @@
1
+ import type {
2
+ LLMProvider,
3
+ LLMMessage,
4
+ LLMOptions,
5
+ LLMResponse,
6
+ LLMStreamEvent,
7
+ LLMTool,
8
+ LLMToolCall,
9
+ } from './provider.ts';
10
+
11
+ type GeminiPart =
12
+ | { text: string }
13
+ | { functionCall: { name: string; args: Record<string, unknown> } }
14
+ | { functionResponse: { name: string; response: Record<string, unknown> } };
15
+
16
+ type GeminiContent = {
17
+ role: 'user' | 'model';
18
+ parts: GeminiPart[];
19
+ };
20
+
21
+ type GeminiFunctionDeclaration = {
22
+ name: string;
23
+ description: string;
24
+ parameters: Record<string, unknown>;
25
+ };
26
+
27
+ type GeminiResponse = {
28
+ candidates: Array<{
29
+ content: { parts: GeminiPart[]; role: 'model' };
30
+ finishReason: 'STOP' | 'MAX_TOKENS' | 'SAFETY' | 'RECITATION' | 'OTHER' | null;
31
+ }>;
32
+ usageMetadata?: {
33
+ promptTokenCount: number;
34
+ candidatesTokenCount: number;
35
+ totalTokenCount: number;
36
+ };
37
+ modelVersion?: string;
38
+ };
39
+
40
+ type GeminiStreamChunk = {
41
+ candidates?: Array<{
42
+ content?: { parts?: GeminiPart[]; role?: 'model' };
43
+ finishReason?: string | null;
44
+ }>;
45
+ usageMetadata?: {
46
+ promptTokenCount: number;
47
+ candidatesTokenCount: number;
48
+ totalTokenCount: number;
49
+ };
50
+ };
51
+
52
+ const MAX_RETRIES = 3;
53
+ const RETRY_BASE_DELAY_MS = 5000;
54
+
55
+ export class GeminiProvider implements LLMProvider {
56
+ name = 'gemini';
57
+ private apiKey: string;
58
+ private defaultModel: string;
59
+ private baseUrl = 'https://generativelanguage.googleapis.com/v1beta';
60
+
61
+ constructor(apiKey: string, defaultModel = 'gemini-3-flash-preview') {
62
+ this.apiKey = apiKey;
63
+ this.defaultModel = defaultModel;
64
+ }
65
+
66
+ private async fetchWithRetry(url: string, body: string): Promise<Response> {
67
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
68
+ const response = await fetch(url, {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body,
72
+ });
73
+
74
+ if (response.ok) return response;
75
+
76
+ if ((response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES) {
77
+ const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
78
+ console.log(`[Gemini] ${response.status} — retrying in ${delay / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})`);
79
+ await Bun.sleep(delay);
80
+ continue;
81
+ }
82
+
83
+ const errorText = await response.text();
84
+ throw new Error(`Gemini API error (${response.status}): ${errorText}`);
85
+ }
86
+
87
+ throw new Error('Gemini API: max retries exceeded');
88
+ }
89
+
90
+ async chat(messages: LLMMessage[], options: LLMOptions = {}): Promise<LLMResponse> {
91
+ const { model = this.defaultModel, temperature, max_tokens, tools } = options;
92
+ const url = `${this.baseUrl}/models/${model}:generateContent?key=${this.apiKey}`;
93
+
94
+ const { systemInstruction, contents } = this.convertMessages(messages);
95
+ const body: Record<string, unknown> = { contents };
96
+
97
+ if (systemInstruction) body.systemInstruction = systemInstruction;
98
+
99
+ const generationConfig: Record<string, unknown> = {};
100
+ if (temperature !== undefined) generationConfig.temperature = temperature;
101
+ if (max_tokens !== undefined) generationConfig.maxOutputTokens = max_tokens;
102
+ if (Object.keys(generationConfig).length > 0) body.generationConfig = generationConfig;
103
+
104
+ if (tools && tools.length > 0) {
105
+ body.tools = [{ functionDeclarations: this.convertTools(tools) }];
106
+ }
107
+
108
+ const response = await this.fetchWithRetry(url, JSON.stringify(body));
109
+ const data = await response.json() as GeminiResponse;
110
+ return this.convertResponse(data, model);
111
+ }
112
+
113
+ async *stream(messages: LLMMessage[], options: LLMOptions = {}): AsyncIterable<LLMStreamEvent> {
114
+ const { model = this.defaultModel, temperature, max_tokens, tools } = options;
115
+ const url = `${this.baseUrl}/models/${model}:streamGenerateContent?alt=sse&key=${this.apiKey}`;
116
+
117
+ const { systemInstruction, contents } = this.convertMessages(messages);
118
+ const body: Record<string, unknown> = { contents };
119
+
120
+ if (systemInstruction) body.systemInstruction = systemInstruction;
121
+
122
+ const generationConfig: Record<string, unknown> = {};
123
+ if (temperature !== undefined) generationConfig.temperature = temperature;
124
+ if (max_tokens !== undefined) generationConfig.maxOutputTokens = max_tokens;
125
+ if (Object.keys(generationConfig).length > 0) body.generationConfig = generationConfig;
126
+
127
+ if (tools && tools.length > 0) {
128
+ body.tools = [{ functionDeclarations: this.convertTools(tools) }];
129
+ }
130
+
131
+ let response: Response;
132
+ try {
133
+ response = await this.fetchWithRetry(url, JSON.stringify(body));
134
+ } catch (err) {
135
+ yield { type: 'error', error: err instanceof Error ? err.message : String(err) };
136
+ return;
137
+ }
138
+
139
+ if (!response.body) {
140
+ yield { type: 'error', error: 'No response body' };
141
+ return;
142
+ }
143
+
144
+ let accumulatedText = '';
145
+ const toolCalls: LLMToolCall[] = [];
146
+ let finishReason: string | null = null;
147
+ let usage = { input_tokens: 0, output_tokens: 0 };
148
+
149
+ try {
150
+ const reader = response.body.getReader();
151
+ const decoder = new TextDecoder();
152
+ let buffer = '';
153
+
154
+ while (true) {
155
+ const { done, value } = await reader.read();
156
+ if (done) break;
157
+
158
+ buffer += decoder.decode(value, { stream: true });
159
+ const lines = buffer.split('\n');
160
+ buffer = lines.pop() || '';
161
+
162
+ for (const line of lines) {
163
+ if (!line.trim() || !line.startsWith('data: ')) continue;
164
+
165
+ const data = line.slice(6);
166
+ if (data === '[DONE]') continue;
167
+
168
+ try {
169
+ const chunk = JSON.parse(data) as GeminiStreamChunk;
170
+
171
+ if (chunk.usageMetadata) {
172
+ usage.input_tokens = chunk.usageMetadata.promptTokenCount ?? 0;
173
+ usage.output_tokens = chunk.usageMetadata.candidatesTokenCount ?? 0;
174
+ }
175
+
176
+ if (chunk.candidates && chunk.candidates.length > 0) {
177
+ const candidate = chunk.candidates[0]!;
178
+
179
+ if (candidate.finishReason) {
180
+ finishReason = candidate.finishReason;
181
+ }
182
+
183
+ if (candidate.content?.parts) {
184
+ for (const part of candidate.content.parts) {
185
+ if ('text' in part) {
186
+ accumulatedText += part.text;
187
+ yield { type: 'text', text: part.text };
188
+ } else if ('functionCall' in part) {
189
+ const toolCall: LLMToolCall = {
190
+ id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
191
+ name: part.functionCall.name,
192
+ arguments: part.functionCall.args,
193
+ };
194
+ toolCalls.push(toolCall);
195
+ yield { type: 'tool_call', tool_call: toolCall };
196
+ }
197
+ }
198
+ }
199
+ }
200
+ } catch {
201
+ // Skip invalid JSON lines
202
+ }
203
+ }
204
+ }
205
+
206
+ yield {
207
+ type: 'done',
208
+ response: {
209
+ content: accumulatedText,
210
+ tool_calls: toolCalls,
211
+ usage,
212
+ model,
213
+ finish_reason: this.mapFinishReason(finishReason),
214
+ },
215
+ };
216
+ } catch (err) {
217
+ yield { type: 'error', error: `Stream error: ${err}` };
218
+ }
219
+ }
220
+
221
+ async listModels(): Promise<string[]> {
222
+ try {
223
+ const response = await fetch(
224
+ `${this.baseUrl}/models?key=${this.apiKey}`,
225
+ );
226
+
227
+ if (!response.ok) throw new Error(`Failed to list models: ${response.status}`);
228
+
229
+ const data = await response.json() as { models: Array<{ name: string }> };
230
+ return data.models
231
+ .map(m => m.name.replace('models/', ''))
232
+ .filter(id => id.startsWith('gemini'))
233
+ .sort();
234
+ } catch {
235
+ return [
236
+ 'gemini-3.1-pro-preview',
237
+ 'gemini-3-deep-think',
238
+ 'gemini-3-flash-preview',
239
+ 'gemini-3-1-flash-lite-preview',
240
+ 'gemini-2.5-pro',
241
+ 'gemini-2.5-flash',
242
+ ];
243
+ }
244
+ }
245
+
246
+ private convertMessages(messages: LLMMessage[]): {
247
+ systemInstruction?: { parts: Array<{ text: string }> };
248
+ contents: GeminiContent[];
249
+ } {
250
+ const systemMessages = messages.filter(m => m.role === 'system');
251
+ const systemText = systemMessages
252
+ .map(m => typeof m.content === 'string' ? m.content : m.content.filter(b => b.type === 'text').map(b => (b as { text: string }).text).join('\n'))
253
+ .join('\n\n');
254
+
255
+ const systemInstruction = systemText
256
+ ? { parts: [{ text: systemText }] }
257
+ : undefined;
258
+
259
+ const contents: GeminiContent[] = [];
260
+ const nonSystem = messages.filter(m => m.role !== 'system');
261
+
262
+ for (const msg of nonSystem) {
263
+ if (msg.role === 'assistant') {
264
+ const parts: GeminiPart[] = [];
265
+
266
+ if (msg.content) {
267
+ const text = typeof msg.content === 'string'
268
+ ? msg.content
269
+ : msg.content.filter(b => b.type === 'text').map(b => (b as { text: string }).text).join('\n');
270
+ if (text) parts.push({ text });
271
+ }
272
+
273
+ if (msg.tool_calls) {
274
+ for (const tc of msg.tool_calls) {
275
+ parts.push({
276
+ functionCall: { name: tc.name, args: tc.arguments },
277
+ });
278
+ }
279
+ }
280
+
281
+ if (parts.length > 0) {
282
+ contents.push({ role: 'model', parts });
283
+ }
284
+ } else if (msg.role === 'tool') {
285
+ // Tool results go as user role with functionResponse
286
+ const responseContent = typeof msg.content === 'string'
287
+ ? (() => { try { return JSON.parse(msg.content); } catch { return { result: msg.content }; } })()
288
+ : { result: msg.content };
289
+
290
+ contents.push({
291
+ role: 'user',
292
+ parts: [{
293
+ functionResponse: {
294
+ name: msg.tool_call_id ?? 'unknown',
295
+ response: responseContent,
296
+ },
297
+ }],
298
+ });
299
+ } else {
300
+ // User message
301
+ const text = typeof msg.content === 'string'
302
+ ? msg.content
303
+ : msg.content.filter(b => b.type === 'text').map(b => (b as { text: string }).text).join('\n');
304
+
305
+ contents.push({ role: 'user', parts: [{ text }] });
306
+ }
307
+ }
308
+
309
+ return { systemInstruction, contents };
310
+ }
311
+
312
+ private convertTools(tools: LLMTool[]): GeminiFunctionDeclaration[] {
313
+ return tools.map(tool => ({
314
+ name: tool.name,
315
+ description: tool.description,
316
+ parameters: tool.parameters,
317
+ }));
318
+ }
319
+
320
+ private convertResponse(response: GeminiResponse, model: string): LLMResponse {
321
+ const candidate = response.candidates?.[0];
322
+ let content = '';
323
+ const tool_calls: LLMToolCall[] = [];
324
+
325
+ if (candidate?.content?.parts) {
326
+ for (const part of candidate.content.parts) {
327
+ if ('text' in part) {
328
+ content += part.text;
329
+ } else if ('functionCall' in part) {
330
+ tool_calls.push({
331
+ id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
332
+ name: part.functionCall.name,
333
+ arguments: part.functionCall.args,
334
+ });
335
+ }
336
+ }
337
+ }
338
+
339
+ return {
340
+ content,
341
+ tool_calls,
342
+ usage: {
343
+ input_tokens: response.usageMetadata?.promptTokenCount ?? 0,
344
+ output_tokens: response.usageMetadata?.candidatesTokenCount ?? 0,
345
+ },
346
+ model: response.modelVersion ?? model,
347
+ finish_reason: this.mapFinishReason(candidate?.finishReason ?? null),
348
+ };
349
+ }
350
+
351
+ private mapFinishReason(reason: string | null): 'stop' | 'tool_use' | 'length' | 'error' {
352
+ switch (reason) {
353
+ case 'STOP':
354
+ return 'stop';
355
+ case 'MAX_TOKENS':
356
+ return 'length';
357
+ case 'SAFETY':
358
+ case 'RECITATION':
359
+ return 'error';
360
+ default:
361
+ return tool_calls_present(reason) ? 'tool_use' : 'stop';
362
+ }
363
+ }
364
+ }
365
+
366
+ // Gemini doesn't have a distinct "tool_use" finish reason — we detect it
367
+ // from the response content instead. This helper is only for the fallback
368
+ // in mapFinishReason; the actual tool_call detection happens in convertResponse.
369
+ function tool_calls_present(_reason: string | null): boolean {
370
+ return false;
371
+ }
@@ -0,0 +1,19 @@
1
+ // Provider types and interfaces
2
+ export type {
3
+ LLMMessage,
4
+ LLMTool,
5
+ LLMToolCall,
6
+ LLMResponse,
7
+ LLMStreamEvent,
8
+ LLMOptions,
9
+ LLMProvider,
10
+ } from './provider.ts';
11
+
12
+ // Provider implementations
13
+ export { AnthropicProvider } from './anthropic.ts';
14
+ export { OpenAIProvider } from './openai.ts';
15
+ export { GeminiProvider } from './gemini.ts';
16
+ export { OllamaProvider } from './ollama.ts';
17
+
18
+ // Manager
19
+ export { LLMManager } from './manager.ts';
@@ -0,0 +1,153 @@
1
+ import type {
2
+ LLMProvider,
3
+ LLMMessage,
4
+ LLMOptions,
5
+ LLMResponse,
6
+ LLMStreamEvent,
7
+ } from './provider.ts';
8
+
9
+ export class LLMManager {
10
+ private providers: Map<string, LLMProvider> = new Map();
11
+ private primaryProvider = '';
12
+ private fallbackChain: string[] = [];
13
+
14
+ constructor() {}
15
+
16
+ registerProvider(provider: LLMProvider): void {
17
+ this.providers.set(provider.name, provider);
18
+
19
+ // Set as primary if it's the first provider
20
+ if (!this.primaryProvider) {
21
+ this.primaryProvider = provider.name;
22
+ }
23
+ }
24
+
25
+ setPrimary(name: string): void {
26
+ if (!this.providers.has(name)) {
27
+ throw new Error(`Provider '${name}' not registered`);
28
+ }
29
+ this.primaryProvider = name;
30
+ }
31
+
32
+ setFallbackChain(names: string[]): void {
33
+ for (const name of names) {
34
+ if (!this.providers.has(name)) {
35
+ throw new Error(`Provider '${name}' not registered`);
36
+ }
37
+ }
38
+ this.fallbackChain = names;
39
+ }
40
+
41
+ getProvider(name: string): LLMProvider | undefined {
42
+ return this.providers.get(name);
43
+ }
44
+
45
+ getPrimary(): string {
46
+ return this.primaryProvider;
47
+ }
48
+
49
+ getFallbackChain(): string[] {
50
+ return [...this.fallbackChain];
51
+ }
52
+
53
+ getProviderNames(): string[] {
54
+ return [...this.providers.keys()];
55
+ }
56
+
57
+ /**
58
+ * Atomically replace all providers. Safe for in-flight requests because
59
+ * JS is single-threaded and the map assignment is atomic.
60
+ */
61
+ replaceProviders(providers: LLMProvider[], primary: string, fallback: string[]): void {
62
+ const newMap = new Map<string, LLMProvider>();
63
+ for (const p of providers) {
64
+ newMap.set(p.name, p);
65
+ }
66
+ this.providers = newMap;
67
+ this.primaryProvider = newMap.has(primary) ? primary : (providers[0]?.name ?? '');
68
+ this.fallbackChain = fallback.filter(n => newMap.has(n));
69
+ }
70
+
71
+ async chat(messages: LLMMessage[], options?: LLMOptions): Promise<LLMResponse> {
72
+ const providerNames = [this.primaryProvider, ...this.fallbackChain];
73
+ const errors: Array<{ provider: string; error: string }> = [];
74
+
75
+ for (const providerName of providerNames) {
76
+ const provider = this.providers.get(providerName);
77
+ if (!provider) continue;
78
+
79
+ try {
80
+ return await provider.chat(messages, options);
81
+ } catch (err) {
82
+ const errorMsg = err instanceof Error ? err.message : String(err);
83
+ errors.push({ provider: providerName, error: errorMsg });
84
+ console.error(`Provider ${providerName} failed:`, errorMsg);
85
+ }
86
+ }
87
+
88
+ throw new Error(
89
+ `All providers failed:\n${errors.map(e => ` ${e.provider}: ${e.error}`).join('\n')}`
90
+ );
91
+ }
92
+
93
+ async *stream(messages: LLMMessage[], options?: LLMOptions): AsyncIterable<LLMStreamEvent> {
94
+ const providerNames = [this.primaryProvider, ...this.fallbackChain];
95
+ const errors: Array<{ provider: string; error: string }> = [];
96
+ const MAX_RETRIES_PER_PROVIDER = 3;
97
+ const RETRY_BASE_MS = 5000;
98
+
99
+ for (const providerName of providerNames) {
100
+ const provider = this.providers.get(providerName);
101
+ if (!provider) continue;
102
+
103
+ for (let attempt = 0; attempt <= MAX_RETRIES_PER_PROVIDER; attempt++) {
104
+ try {
105
+ let hasError = false;
106
+ let isRetryable = false;
107
+ for await (const event of provider.stream(messages, options)) {
108
+ if (event.type === 'error') {
109
+ hasError = true;
110
+ // Retry on transient errors (overloaded, rate limit, server errors)
111
+ isRetryable = /overloaded|rate.limit|529|5\d\d|timeout/i.test(event.error);
112
+ if (isRetryable && attempt < MAX_RETRIES_PER_PROVIDER) {
113
+ const delay = RETRY_BASE_MS * Math.pow(2, attempt);
114
+ console.log(`[LLMManager] ${providerName} stream error (${event.error}) — retrying in ${delay / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES_PER_PROVIDER})`);
115
+ await Bun.sleep(delay);
116
+ } else {
117
+ errors.push({ provider: providerName, error: event.error });
118
+ console.error(`Provider ${providerName} stream error:`, event.error);
119
+ }
120
+ break;
121
+ }
122
+ yield event;
123
+ }
124
+
125
+ if (!hasError) {
126
+ return; // Successful stream completion
127
+ }
128
+ if (isRetryable && attempt < MAX_RETRIES_PER_PROVIDER) {
129
+ continue; // Retry same provider
130
+ }
131
+ break; // Non-retryable error, try next provider
132
+ } catch (err) {
133
+ const errorMsg = err instanceof Error ? err.message : String(err);
134
+ const isRetryable = /overloaded|rate.limit|529|5\d\d|timeout|ECONNRESET/i.test(errorMsg);
135
+ if (isRetryable && attempt < MAX_RETRIES_PER_PROVIDER) {
136
+ const delay = RETRY_BASE_MS * Math.pow(2, attempt);
137
+ console.log(`[LLMManager] ${providerName} stream failed (${errorMsg}) — retrying in ${delay / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES_PER_PROVIDER})`);
138
+ await Bun.sleep(delay);
139
+ continue;
140
+ }
141
+ errors.push({ provider: providerName, error: errorMsg });
142
+ console.error(`Provider ${providerName} stream failed:`, errorMsg);
143
+ break; // Try next provider
144
+ }
145
+ }
146
+ }
147
+
148
+ yield {
149
+ type: 'error',
150
+ error: `All providers failed:\n${errors.map(e => ` ${e.provider}: ${e.error}`).join('\n')}`,
151
+ };
152
+ }
153
+ }