otherwise-cli 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 (81) hide show
  1. package/README.md +193 -0
  2. package/bin/otherwise.js +5 -0
  3. package/frontend/404.html +84 -0
  4. package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
  5. package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
  6. package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  7. package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
  8. package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
  9. package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
  10. package/frontend/assets/index-BLux5ps4.js +21 -0
  11. package/frontend/assets/index-Blh8_TEM.js +5272 -0
  12. package/frontend/assets/index-BpQ1PuKu.js +18 -0
  13. package/frontend/assets/index-Df737c8w.css +1 -0
  14. package/frontend/assets/index-xaYHL6wb.js +113 -0
  15. package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
  16. package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
  17. package/frontend/assets/transformers-tULNc5V3.js +31 -0
  18. package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
  19. package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
  20. package/frontend/assets/worker-2d5ABSLU.js +31 -0
  21. package/frontend/banner.png +0 -0
  22. package/frontend/favicon.svg +3 -0
  23. package/frontend/google55e5ec47ee14a5f8.html +1 -0
  24. package/frontend/index.html +234 -0
  25. package/frontend/manifest.json +17 -0
  26. package/frontend/pdf.worker.min.mjs +21 -0
  27. package/frontend/robots.txt +5 -0
  28. package/frontend/sitemap.xml +27 -0
  29. package/package.json +81 -0
  30. package/src/agent/index.js +1066 -0
  31. package/src/agent/location.js +51 -0
  32. package/src/agent/prompt.js +548 -0
  33. package/src/agent/tools.js +4372 -0
  34. package/src/browser/detect.js +68 -0
  35. package/src/browser/session.js +1109 -0
  36. package/src/config.js +137 -0
  37. package/src/email/client.js +503 -0
  38. package/src/index.js +557 -0
  39. package/src/inference/anthropic.js +113 -0
  40. package/src/inference/google.js +373 -0
  41. package/src/inference/index.js +81 -0
  42. package/src/inference/ollama.js +383 -0
  43. package/src/inference/openai.js +140 -0
  44. package/src/inference/openrouter.js +378 -0
  45. package/src/inference/xai.js +200 -0
  46. package/src/logBridge.js +9 -0
  47. package/src/models.js +146 -0
  48. package/src/remote/client.js +225 -0
  49. package/src/scheduler/cron.js +243 -0
  50. package/src/server.js +3876 -0
  51. package/src/storage/db.js +1135 -0
  52. package/src/storage/supabase.js +364 -0
  53. package/src/tunnel/cloudflare.js +241 -0
  54. package/src/ui/components/App.jsx +687 -0
  55. package/src/ui/components/BrowserSelect.jsx +111 -0
  56. package/src/ui/components/FilePicker.jsx +472 -0
  57. package/src/ui/components/Header.jsx +444 -0
  58. package/src/ui/components/HelpPanel.jsx +173 -0
  59. package/src/ui/components/HistoryPanel.jsx +158 -0
  60. package/src/ui/components/MessageList.jsx +235 -0
  61. package/src/ui/components/ModelSelector.jsx +304 -0
  62. package/src/ui/components/PromptInput.jsx +515 -0
  63. package/src/ui/components/StreamingResponse.jsx +134 -0
  64. package/src/ui/components/ThinkingIndicator.jsx +365 -0
  65. package/src/ui/components/ToolExecution.jsx +714 -0
  66. package/src/ui/components/index.js +82 -0
  67. package/src/ui/context/TerminalContext.jsx +150 -0
  68. package/src/ui/context/index.js +13 -0
  69. package/src/ui/hooks/index.js +16 -0
  70. package/src/ui/hooks/useChatState.js +675 -0
  71. package/src/ui/hooks/useCommands.js +280 -0
  72. package/src/ui/hooks/useFileAttachments.js +216 -0
  73. package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
  74. package/src/ui/hooks/useNotifications.js +185 -0
  75. package/src/ui/hooks/useTerminalSize.js +151 -0
  76. package/src/ui/hooks/useWebSocket.js +273 -0
  77. package/src/ui/index.js +94 -0
  78. package/src/ui/ink-runner.js +22 -0
  79. package/src/ui/utils/formatters.js +424 -0
  80. package/src/ui/utils/index.js +6 -0
  81. package/src/ui/utils/markdown.js +166 -0
@@ -0,0 +1,373 @@
1
+ import { GoogleGenAI } from '@google/genai';
2
+
3
+ // Retry configuration for transient errors
4
+ const RETRY_CONFIG = {
5
+ maxRetries: 3,
6
+ initialDelay: 1000,
7
+ maxDelay: 10000,
8
+ retryableErrors: ['RESOURCE_EXHAUSTED', 'UNAVAILABLE', 'DEADLINE_EXCEEDED', 'INTERNAL'],
9
+ };
10
+
11
+ /**
12
+ * Check if a model is an image generation model
13
+ */
14
+ export function isGeminiImageModel(model) {
15
+ return model.includes('-image') || model === 'gemini-3-pro-image-preview';
16
+ }
17
+
18
+ /**
19
+ * Check if a model supports extended thinking/reasoning
20
+ * @param {string} model - Model identifier
21
+ * @returns {boolean} - Whether model supports thinking
22
+ */
23
+ export function isGeminiReasoningModel(model) {
24
+ // Gemini 2.5 Pro/Flash and Gemini 3 Pro/Flash support thinking, BUT NOT:
25
+ // - gemini-2.5-flash-image (image generation model)
26
+ // - gemini-2.5-flash-lite (no reasoning support)
27
+ // - gemini-3-pro-image-preview (image generation model)
28
+ const isImageModel = model.includes('-image');
29
+ const isLiteModel = model.includes('-lite');
30
+ return !isImageModel && !isLiteModel && (
31
+ model.includes('2.5-pro') ||
32
+ model.includes('2.5-flash') ||
33
+ model.includes('3-pro') ||
34
+ model.includes('3-flash')
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Sleep helper for retry delays
40
+ * @param {number} ms - Milliseconds to sleep
41
+ */
42
+ function sleep(ms) {
43
+ return new Promise(resolve => setTimeout(resolve, ms));
44
+ }
45
+
46
+ /**
47
+ * Check if an error is retryable
48
+ * @param {Error} error - The error to check
49
+ * @returns {boolean} - Whether error is retryable
50
+ */
51
+ function isRetryableError(error) {
52
+ const message = error.message || '';
53
+ const code = error.code || error.status || '';
54
+
55
+ // Check for rate limiting
56
+ if (message.includes('429') || message.includes('rate limit') || message.includes('quota')) {
57
+ return true;
58
+ }
59
+
60
+ // Check for known retryable error codes
61
+ return RETRY_CONFIG.retryableErrors.some(e =>
62
+ message.includes(e) || String(code).includes(e)
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Normalize image data to base64 string (SDK may return string or Uint8Array)
68
+ */
69
+ function toBase64(data) {
70
+ if (typeof data === 'string') return data;
71
+ if (data instanceof Uint8Array) {
72
+ return Buffer.from(data).toString('base64');
73
+ }
74
+ if (data instanceof ArrayBuffer) {
75
+ return Buffer.from(new Uint8Array(data)).toString('base64');
76
+ }
77
+ if (Buffer.isBuffer(data)) return data.toString('base64');
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Get response parts from various possible SDK response shapes
83
+ */
84
+ function getPartsFromResponse(response) {
85
+ const c = response.candidates?.[0]?.content;
86
+ if (c?.parts) return c.parts;
87
+ if (Array.isArray(c)) return c;
88
+ const r = response.response?.candidates?.[0]?.content;
89
+ if (r?.parts) return r.parts;
90
+ if (Array.isArray(r)) return r;
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Extract image blob from a part (camelCase or snake_case, part or nested)
96
+ */
97
+ function getImageBlobFromPart(part) {
98
+ const blob = part.inlineData ?? part.inline_data ?? (part.data ? part : null);
99
+ if (!blob) return null;
100
+ const data = blob.data;
101
+ const base64 = toBase64(data);
102
+ if (!base64) return null;
103
+ const mimeType = blob.mimeType ?? blob.mime_type ?? part.mimeType ?? part.mime_type ?? 'image/png';
104
+ return { base64, mimeType };
105
+ }
106
+
107
+ /**
108
+ * Generate an image using Gemini image models
109
+ * Uses responseModalities: ['TEXT', 'IMAGE'] to enable image output
110
+ * @param {string} model - Model identifier (e.g., 'gemini-2.5-flash-image')
111
+ * @param {string} prompt - Text prompt for image generation
112
+ * @param {object} config - Configuration with API keys and settings
113
+ * @yields {object} - Chunks with type and content (image as base64)
114
+ */
115
+ export async function* generateGeminiImage(model, prompt, config) {
116
+ const apiKey = config.apiKeys?.google;
117
+ if (!apiKey) {
118
+ throw new Error('Google API key not configured. Run: otherwise config set google <key>');
119
+ }
120
+
121
+ const ai = new GoogleGenAI({ apiKey });
122
+ yield { type: 'start', model };
123
+
124
+ let lastError;
125
+ for (let attempt = 0; attempt < RETRY_CONFIG.maxRetries; attempt++) {
126
+ try {
127
+ const response = await ai.models.generateContent({
128
+ model,
129
+ contents: [{ role: 'user', parts: [{ text: prompt }] }],
130
+ config: {
131
+ response_modalities: ['TEXT', 'IMAGE'],
132
+ },
133
+ });
134
+
135
+ console.log('[Gemini image] Response top-level keys:', Object.keys(response || {}));
136
+ const parts = getPartsFromResponse(response);
137
+ if (!parts || !parts.length) {
138
+ console.warn('[Gemini image] No parts in response. Top-level keys:', Object.keys(response || {}));
139
+ if (response?.candidates?.[0]) {
140
+ console.warn('[Gemini image] candidates[0] keys:', Object.keys(response.candidates[0]));
141
+ if (response.candidates[0].content) {
142
+ console.warn('[Gemini image] content keys:', Object.keys(response.candidates[0].content));
143
+ }
144
+ }
145
+ throw new Error('No content returned from Gemini image generation');
146
+ }
147
+ console.log('[Gemini image] Parts count:', parts.length);
148
+
149
+ for (let i = 0; i < parts.length; i++) {
150
+ const part = parts[i];
151
+ const partKeys = Object.keys(part || {});
152
+ if (part.text) {
153
+ yield { type: 'text', content: part.text };
154
+ continue;
155
+ }
156
+ const blob = getImageBlobFromPart(part);
157
+ if (blob) {
158
+ console.log('[Gemini image] Yielding image chunk, base64 length:', blob.base64.length);
159
+ yield {
160
+ type: 'image',
161
+ content: blob.base64,
162
+ mimeType: blob.mimeType,
163
+ };
164
+ continue;
165
+ }
166
+ if (partKeys.length) {
167
+ console.warn('[Gemini image] Part', i, 'unknown shape, keys:', partKeys);
168
+ }
169
+ }
170
+ return;
171
+ } catch (error) {
172
+ lastError = error;
173
+ if (attempt < RETRY_CONFIG.maxRetries - 1 && isRetryableError(error)) {
174
+ const delay = Math.min(
175
+ RETRY_CONFIG.initialDelay * Math.pow(2, attempt),
176
+ RETRY_CONFIG.maxDelay
177
+ );
178
+ yield { type: 'warning', message: `Request failed, retrying in ${delay}ms...` };
179
+ await sleep(delay);
180
+ continue;
181
+ }
182
+ throw error;
183
+ }
184
+ }
185
+ throw lastError;
186
+ }
187
+
188
+ /**
189
+ * Stream chat completion from Google Gemini
190
+ * @param {string} model - Model identifier (e.g., 'gemini-2.5-pro')
191
+ * @param {Array} messages - Array of message objects with role and content
192
+ * @param {string} systemPrompt - System prompt
193
+ * @param {object} config - Configuration with API keys and settings
194
+ * @yields {object} - Chunks with type and content
195
+ */
196
+ export async function* streamGemini(model, messages, systemPrompt, config) {
197
+ const apiKey = config.apiKeys?.google;
198
+ if (!apiKey) {
199
+ throw new Error('Google API key not configured. Run: otherwise config set google <key>');
200
+ }
201
+
202
+ // Route image models to the image generation function
203
+ if (isGeminiImageModel(model)) {
204
+ // For image generation, use the last user message as the prompt
205
+ const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
206
+ const prompt = lastUserMessage?.content || 'Generate an image';
207
+ yield* generateGeminiImage(model, prompt, config);
208
+ return;
209
+ }
210
+
211
+ const ai = new GoogleGenAI({ apiKey });
212
+
213
+ // Helper: parse data URL to base64 + mimeType for Gemini
214
+ const parseDataUrl = (dataUrl) => {
215
+ if (!dataUrl || !dataUrl.startsWith('data:')) return null;
216
+ const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
217
+ if (!match) return null;
218
+ return { mimeType: match[1], data: match[2] };
219
+ };
220
+
221
+ // Convert messages to Gemini format (vision: user messages can have images)
222
+ const contents = messages.map(m => {
223
+ const role = m.role === 'assistant' ? 'model' : 'user';
224
+ if (m.images && m.images.length > 0 && m.role === 'user') {
225
+ const parts = [];
226
+ for (const imgDataUrl of m.images) {
227
+ const parsed = parseDataUrl(imgDataUrl);
228
+ if (parsed) {
229
+ parts.push({
230
+ inlineData: { mimeType: parsed.mimeType, data: parsed.data },
231
+ });
232
+ }
233
+ }
234
+ parts.push({ text: m.content || '' });
235
+ return { role, parts };
236
+ }
237
+ return { role, parts: [{ text: m.content }] };
238
+ });
239
+
240
+ // Check if this is a reasoning model that supports thoughts
241
+ const isReasoningModel = isGeminiReasoningModel(model);
242
+
243
+ const apiConfig = {
244
+ systemInstruction: systemPrompt,
245
+ temperature: config.temperature || 0.7,
246
+ maxOutputTokens: config.maxTokens || 8192,
247
+ ...(isReasoningModel && {
248
+ thinkingConfig: {
249
+ includeThoughts: true,
250
+ },
251
+ }),
252
+ };
253
+
254
+ // Signal that streaming is starting
255
+ yield { type: 'start', model, isReasoningModel };
256
+
257
+ let lastError;
258
+ for (let attempt = 0; attempt < RETRY_CONFIG.maxRetries; attempt++) {
259
+ try {
260
+ const response = await ai.models.generateContentStream({
261
+ model,
262
+ contents,
263
+ config: apiConfig,
264
+ });
265
+
266
+ let totalThinkingChars = 0;
267
+ let usageMetadata = null;
268
+ let finishReason = null;
269
+
270
+ // Text buffering for smoother output
271
+ let textBuffer = '';
272
+ let lastFlush = Date.now();
273
+ const FLUSH_INTERVAL = 50; // ms - flush buffer every 50ms or on newlines
274
+ const MIN_BUFFER_SIZE = 3; // Minimum chars before considering flush
275
+
276
+ for await (const chunk of response) {
277
+ if (chunk.candidates?.[0]?.content?.parts) {
278
+ for (const part of chunk.candidates[0].content.parts) {
279
+ if (part.text) {
280
+ if (part.thought && isReasoningModel) {
281
+ // This is thinking/reasoning content - stream immediately for responsiveness
282
+ totalThinkingChars += part.text.length;
283
+ yield { type: 'thinking', content: part.text, totalChars: totalThinkingChars };
284
+ } else {
285
+ // Regular response content - buffer for smoother output
286
+ textBuffer += part.text;
287
+ const now = Date.now();
288
+ const timeSinceFlush = now - lastFlush;
289
+
290
+ // Flush buffer if:
291
+ // 1. Contains a newline (natural break point)
292
+ // 2. Enough time has passed and buffer has content
293
+ // 3. Buffer is getting large
294
+ const shouldFlush =
295
+ textBuffer.includes('\n') ||
296
+ (timeSinceFlush >= FLUSH_INTERVAL && textBuffer.length >= MIN_BUFFER_SIZE) ||
297
+ textBuffer.length > 100;
298
+
299
+ if (shouldFlush && textBuffer.length > 0) {
300
+ yield { type: 'text', content: textBuffer };
301
+ textBuffer = '';
302
+ lastFlush = now;
303
+ }
304
+ }
305
+ }
306
+ }
307
+ }
308
+
309
+ // Capture finish reason from candidates
310
+ if (chunk.candidates?.[0]?.finishReason) {
311
+ finishReason = chunk.candidates[0].finishReason;
312
+ }
313
+
314
+ // Capture usage metadata (available on chunks, last one has final counts)
315
+ if (chunk.usageMetadata) {
316
+ usageMetadata = chunk.usageMetadata;
317
+ }
318
+ }
319
+
320
+ // Flush any remaining buffered text
321
+ if (textBuffer.length > 0) {
322
+ yield { type: 'text', content: textBuffer };
323
+ }
324
+
325
+ // Yield usage stats at the end
326
+ if (usageMetadata) {
327
+ yield {
328
+ type: 'usage',
329
+ inputTokens: usageMetadata.promptTokenCount || 0,
330
+ outputTokens: usageMetadata.candidatesTokenCount || 0,
331
+ totalTokens: usageMetadata.totalTokenCount || 0,
332
+ thinkingTokens: usageMetadata.thoughtsTokenCount || 0,
333
+ finishReason: finishReason || 'STOP',
334
+ model,
335
+ };
336
+ }
337
+
338
+ return; // Success - exit retry loop
339
+
340
+ } catch (error) {
341
+ lastError = error;
342
+
343
+ // Check if we should retry
344
+ if (attempt < RETRY_CONFIG.maxRetries - 1 && isRetryableError(error)) {
345
+ const delay = Math.min(
346
+ RETRY_CONFIG.initialDelay * Math.pow(2, attempt),
347
+ RETRY_CONFIG.maxDelay
348
+ );
349
+ yield { type: 'warning', message: `Request failed, retrying in ${delay}ms...` };
350
+ await sleep(delay);
351
+ continue;
352
+ }
353
+
354
+ // Enhance error message for common issues
355
+ let enhancedMessage = error.message;
356
+ if (error.message.includes('API key')) {
357
+ enhancedMessage = 'Invalid Google API key. Run: otherwise config set google <your-key>';
358
+ } else if (error.message.includes('quota') || error.message.includes('429')) {
359
+ enhancedMessage = 'Rate limit exceeded. Please wait a moment and try again.';
360
+ } else if (error.message.includes('not found') || error.message.includes('404')) {
361
+ enhancedMessage = `Model "${model}" not found. Check available models with: otherwise models`;
362
+ }
363
+
364
+ const enhancedError = new Error(enhancedMessage);
365
+ enhancedError.originalError = error;
366
+ throw enhancedError;
367
+ }
368
+ }
369
+
370
+ throw lastError;
371
+ }
372
+
373
+ export default { streamGemini, generateGeminiImage, isGeminiImageModel, isGeminiReasoningModel };
@@ -0,0 +1,81 @@
1
+ import { streamClaude } from './anthropic.js';
2
+ import { streamOpenAI, isOpenAIImageModel } from './openai.js';
3
+ import { streamGemini, isGeminiImageModel } from './google.js';
4
+ import { streamGrok, isGrokImageModel } from './xai.js';
5
+ import { streamOllama } from './ollama.js';
6
+ import { streamOpenRouter, isOpenRouterImageModel, fetchOpenRouterModels } from './openrouter.js';
7
+
8
+ /**
9
+ * Check if a model is an image generation model
10
+ * Image models require special handling (different endpoints, no streaming, etc.)
11
+ */
12
+ export function isImageModel(model) {
13
+ if (model.startsWith('gpt') || model.startsWith('dall-e')) {
14
+ return isOpenAIImageModel(model);
15
+ }
16
+ if (model.startsWith('gemini')) {
17
+ return isGeminiImageModel(model);
18
+ }
19
+ if (model.startsWith('grok')) {
20
+ return isGrokImageModel(model);
21
+ }
22
+ if (model.startsWith('openrouter:')) {
23
+ return isOpenRouterImageModel(model);
24
+ }
25
+ return false;
26
+ }
27
+
28
+ /**
29
+ * Route to the appropriate inference provider based on model name
30
+ * @param {string} model - Model identifier
31
+ * @param {Array} messages - Conversation messages
32
+ * @param {string} systemPrompt - System prompt
33
+ * @param {object} config - Configuration with API keys
34
+ * @returns {AsyncGenerator} - Yields chunks of response
35
+ */
36
+ export async function* streamInference(model, messages, systemPrompt, config) {
37
+ // Determine provider from model name
38
+ if (model.startsWith('openrouter:')) {
39
+ yield* streamOpenRouter(model, messages, systemPrompt, config);
40
+ } else if (model.startsWith('claude')) {
41
+ yield* streamClaude(model, messages, systemPrompt, config);
42
+ } else if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3') || model.startsWith('dall-e')) {
43
+ yield* streamOpenAI(model, messages, systemPrompt, config);
44
+ } else if (model.startsWith('gemini')) {
45
+ yield* streamGemini(model, messages, systemPrompt, config);
46
+ } else if (model.startsWith('grok')) {
47
+ yield* streamGrok(model, messages, systemPrompt, config);
48
+ } else if (model.startsWith('ollama:')) {
49
+ yield* streamOllama(model.replace('ollama:', ''), messages, systemPrompt, config);
50
+ } else {
51
+ // Default to Claude if unknown
52
+ yield* streamClaude(model, messages, systemPrompt, config);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Get provider name from model
58
+ */
59
+ export function getProviderFromModel(model) {
60
+ if (model.startsWith('openrouter:')) return 'openrouter';
61
+ if (model.startsWith('claude')) return 'anthropic';
62
+ if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3') || model.startsWith('dall-e')) return 'openai';
63
+ if (model.startsWith('gemini')) return 'google';
64
+ if (model.startsWith('grok')) return 'xai';
65
+ if (model.startsWith('ollama:')) return 'ollama';
66
+ return 'anthropic';
67
+ }
68
+
69
+ /**
70
+ * Check if required API key is configured for a model
71
+ */
72
+ export function hasRequiredApiKey(model, config) {
73
+ const provider = getProviderFromModel(model);
74
+ if (provider === 'ollama') return true; // Ollama doesn't need API key
75
+ return !!config.apiKeys?.[provider];
76
+ }
77
+
78
+ // Re-export OpenRouter model fetching for use in server
79
+ export { fetchOpenRouterModels } from './openrouter.js';
80
+
81
+ export default { streamInference, getProviderFromModel, hasRequiredApiKey, isImageModel, fetchOpenRouterModels };