@stevederico/dotbot 0.16.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 (52) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +380 -0
  3. package/bin/dotbot.js +461 -0
  4. package/core/agent.js +779 -0
  5. package/core/compaction.js +261 -0
  6. package/core/cron_handler.js +262 -0
  7. package/core/events.js +229 -0
  8. package/core/failover.js +193 -0
  9. package/core/gptoss_tool_parser.js +173 -0
  10. package/core/init.js +154 -0
  11. package/core/normalize.js +324 -0
  12. package/core/trigger_handler.js +148 -0
  13. package/docs/core.md +103 -0
  14. package/docs/protected-files.md +59 -0
  15. package/examples/sqlite-session-example.js +69 -0
  16. package/index.js +341 -0
  17. package/observer/index.js +164 -0
  18. package/package.json +42 -0
  19. package/storage/CronStore.js +145 -0
  20. package/storage/EventStore.js +71 -0
  21. package/storage/MemoryStore.js +175 -0
  22. package/storage/MongoAdapter.js +291 -0
  23. package/storage/MongoCronAdapter.js +347 -0
  24. package/storage/MongoTaskAdapter.js +242 -0
  25. package/storage/MongoTriggerAdapter.js +158 -0
  26. package/storage/SQLiteAdapter.js +382 -0
  27. package/storage/SQLiteCronAdapter.js +562 -0
  28. package/storage/SQLiteEventStore.js +300 -0
  29. package/storage/SQLiteMemoryAdapter.js +240 -0
  30. package/storage/SQLiteTaskAdapter.js +419 -0
  31. package/storage/SQLiteTriggerAdapter.js +262 -0
  32. package/storage/SessionStore.js +149 -0
  33. package/storage/TaskStore.js +100 -0
  34. package/storage/TriggerStore.js +90 -0
  35. package/storage/cron_constants.js +48 -0
  36. package/storage/index.js +21 -0
  37. package/tools/appgen.js +311 -0
  38. package/tools/browser.js +634 -0
  39. package/tools/code.js +101 -0
  40. package/tools/events.js +145 -0
  41. package/tools/files.js +201 -0
  42. package/tools/images.js +253 -0
  43. package/tools/index.js +97 -0
  44. package/tools/jobs.js +159 -0
  45. package/tools/memory.js +332 -0
  46. package/tools/messages.js +135 -0
  47. package/tools/notify.js +42 -0
  48. package/tools/tasks.js +404 -0
  49. package/tools/triggers.js +159 -0
  50. package/tools/weather.js +82 -0
  51. package/tools/web.js +283 -0
  52. package/utils/providers.js +136 -0
package/core/agent.js ADDED
@@ -0,0 +1,779 @@
1
+ // agent/agent.js
2
+ // Provider-agnostic agent loop. All conversation history is stored in a
3
+ // standard format (see normalize.js). Provider-specific wire formats are
4
+ // produced just-in-time inside buildAgentRequest() via toProviderFormat().
5
+
6
+ import { AI_PROVIDERS } from "../utils/providers.js";
7
+ import { fetchWithFailover, FailoverError } from "./failover.js";
8
+ import { toProviderFormat } from "./normalize.js";
9
+ import { validateEvent, normalizeStatsEvent } from "./events.js";
10
+ import { hasToolCallMarkers, parseToolCalls, stripToolCallMarkers } from "./gptoss_tool_parser.js";
11
+
12
+ const OLLAMA_BASE = "http://localhost:11434";
13
+
14
+ /**
15
+ * Run the agent loop. Yields events for streaming to the frontend.
16
+ *
17
+ * Events yielded:
18
+ * - { type: "text_delta", text } — incremental text from the model
19
+ * - { type: "tool_start", name, input } — tool call initiated
20
+ * - { type: "tool_result", name, result } — tool call completed
21
+ * - { type: "tool_error", name, error } — tool call failed
22
+ * - { type: "stats", model, eval_count, eval_duration, total_duration }
23
+ * - { type: "done", content } — final answer, loop complete
24
+ * - { type: "max_iterations", message } — agent hit the iteration safety cap
25
+ * - { type: "thinking" } — agent is reasoning about tool results (iteration > 1)
26
+ * - { type: "error", error } — fatal error
27
+ *
28
+ * @param {Object} options
29
+ * @param {string} options.model - Model name (e.g. "llama3.3", "grok-3", "claude-sonnet-4-5")
30
+ * @param {Array} options.messages - Conversation history
31
+ * @param {Array} options.tools - Tool definitions from tools.js
32
+ * @param {AbortSignal} [options.signal] - Optional abort signal
33
+ * @param {Object} [options.provider] - Provider config from AI_PROVIDERS. Defaults to Ollama.
34
+ * @param {Object} [options.context] - Execution context passed to tool execute functions (e.g. databaseManager, dbConfig, userID).
35
+ * @yields {Object} Stream events for the frontend
36
+ */
37
+ export async function* agentLoop({ model, messages, tools, signal, provider, context, maxTurns }) {
38
+ // Default to Ollama for backward compat (cron, etc.)
39
+ if (!provider) {
40
+ provider = AI_PROVIDERS.ollama;
41
+ }
42
+
43
+ // Helper to log events (fire-and-forget, non-blocking)
44
+ const logEvent = (type, data = {}) => {
45
+ if (context?.eventStore && context?.userID) {
46
+ context.eventStore.logEvent({
47
+ userId: context.userID,
48
+ type,
49
+ data,
50
+ }).catch(() => {}); // Swallow errors to avoid breaking the agent loop
51
+ }
52
+ };
53
+
54
+ // Log message_sent for the latest user message (first iteration only)
55
+ const lastUserMsg = messages.filter(m => m.role === 'user').slice(-1)[0];
56
+ if (lastUserMsg) {
57
+ const content = typeof lastUserMsg.content === 'string'
58
+ ? lastUserMsg.content
59
+ : JSON.stringify(lastUserMsg.content);
60
+ // Full audit log: capture complete message content for debugging
61
+ logEvent('message_sent', { length: content.length, content });
62
+ }
63
+
64
+ const maxIterations = maxTurns || 10;
65
+ let iteration = 0;
66
+
67
+ while (iteration < maxIterations) {
68
+ iteration++;
69
+
70
+ // Build tool definitions in the format the provider expects
71
+ const toolDefs = tools.map((t) => ({
72
+ type: "function",
73
+ function: {
74
+ name: t.name,
75
+ description: t.description,
76
+ parameters: t.parameters,
77
+ },
78
+ }));
79
+
80
+ let response;
81
+ let activeProvider = provider;
82
+
83
+ /**
84
+ * Build a fetch request for a given target provider.
85
+ * Messages are stored in standard format and converted to provider-specific
86
+ * wire format just-in-time here via toProviderFormat().
87
+ * @param {Object} targetProvider - Provider config from AI_PROVIDERS.
88
+ * @returns {{url: string, headers: Object, body: string}}
89
+ */
90
+ const buildAgentRequest = (targetProvider) => {
91
+ const targetApiKey = targetProvider.envKey ? process.env[targetProvider.envKey] : null;
92
+ const targetIsAnthropic = targetProvider.id === "anthropic";
93
+ const targetModel = targetProvider === provider ? model : targetProvider.defaultModel;
94
+
95
+ // JIT conversion: standard format → provider wire format
96
+ const targetFormat = targetIsAnthropic ? "anthropic" : "openai";
97
+ const wireMessages = toProviderFormat(messages, targetFormat);
98
+
99
+ if (targetIsAnthropic) {
100
+ const anthropicTools = tools.map((t) => ({
101
+ name: t.name,
102
+ description: t.description,
103
+ input_schema: t.parameters,
104
+ }));
105
+ const systemMsg = wireMessages.find((m) => m.role === "system");
106
+ const chatMessages = wireMessages.filter((m) => m.role !== "system");
107
+ const supportsThinking = targetModel.includes('sonnet') || targetModel.includes('opus');
108
+ const requestBody = {
109
+ model: targetModel,
110
+ max_tokens: supportsThinking ? 16000 : 4096,
111
+ stream: true,
112
+ messages: chatMessages,
113
+ tools: anthropicTools,
114
+ };
115
+ if (supportsThinking) {
116
+ requestBody.thinking = { type: 'enabled', budget_tokens: 10000 };
117
+ }
118
+ if (systemMsg) {
119
+ requestBody.system = systemMsg.content;
120
+ }
121
+ return {
122
+ url: `${targetProvider.apiUrl}${targetProvider.endpoint}`,
123
+ headers: targetProvider.headers(targetApiKey),
124
+ body: JSON.stringify(requestBody),
125
+ };
126
+ }
127
+
128
+ // OpenAI-compatible path
129
+ let finalMessages = wireMessages;
130
+
131
+ // Local providers use text-based tool calls via system prompt, so convert
132
+ // role:"tool" messages to role:"user" and strip tool_calls from assistant
133
+ // messages — unless the model's chat template supports role:"tool" natively
134
+ // (e.g. LFM2.5). Models that support it set supportsToolRole on the provider.
135
+ if (targetProvider.local && !targetProvider.supportsToolRole) {
136
+ finalMessages = [];
137
+ const tcNameMap = {};
138
+ for (const msg of wireMessages) {
139
+ if (msg.role === 'assistant' && msg.tool_calls) {
140
+ for (const tc of msg.tool_calls) {
141
+ tcNameMap[tc.id] = tc.function?.name || 'unknown';
142
+ }
143
+ const { tool_calls, ...rest } = msg;
144
+ finalMessages.push(rest);
145
+ } else if (msg.role === 'tool') {
146
+ const name = tcNameMap[msg.tool_call_id] || 'unknown';
147
+ finalMessages.push({
148
+ role: 'user',
149
+ content: `[Tool Result for ${name}]: ${msg.content}`,
150
+ });
151
+ } else {
152
+ finalMessages.push(msg);
153
+ }
154
+ }
155
+ }
156
+
157
+ const requestBody = {
158
+ model: targetModel,
159
+ messages: finalMessages,
160
+ stream: true,
161
+ max_tokens: 8192,
162
+ };
163
+
164
+ // Include tool definitions for non-local providers and local providers
165
+ // that support native tool calling (e.g., GLM-4.7 via mlx_lm.server v0.30.7+)
166
+ if (!targetProvider.local || targetProvider.supportsToolRole) {
167
+ requestBody.tools = toolDefs;
168
+ }
169
+
170
+ return {
171
+ url: `${targetProvider.apiUrl}${targetProvider.endpoint}`,
172
+ headers: targetProvider.headers(targetApiKey),
173
+ body: JSON.stringify(requestBody),
174
+ };
175
+ };
176
+
177
+ // Local providers (ollama, dottie_desktop): direct fetch, no failover
178
+ if (provider.local) {
179
+ const { url, headers, body } = buildAgentRequest(provider);
180
+ response = await fetch(url, { method: "POST", headers, body, signal });
181
+ if (!response.ok) {
182
+ const errorEvent = { type: "error", error: `${provider.name} returned ${response.status}: ${await response.text()}` };
183
+ validateEvent(errorEvent);
184
+ yield errorEvent;
185
+ return;
186
+ }
187
+ } else {
188
+ try {
189
+ const result = await fetchWithFailover({ provider, buildRequest: buildAgentRequest, signal });
190
+ response = result.response;
191
+ activeProvider = result.activeProvider;
192
+ } catch (err) {
193
+ if (err.name === 'AbortError') return;
194
+ const msg = err instanceof FailoverError
195
+ ? `All providers failed: ${err.attempts.map(a => `${a.provider}(${a.status})`).join(', ')}`
196
+ : err.message;
197
+ const errorEvent = { type: "error", error: msg };
198
+ validateEvent(errorEvent);
199
+ yield errorEvent;
200
+ return;
201
+ }
202
+ }
203
+
204
+ // Stream parsing — two paths depending on provider wire format
205
+ let fullContent = "";
206
+ let toolCalls = [];
207
+
208
+ if (activeProvider.id === "anthropic") {
209
+ // Anthropic SSE format: content_block_start, content_block_delta, content_block_stop, message_delta
210
+ const result = yield* parseAnthropicStream(response, fullContent, toolCalls, signal, activeProvider.id);
211
+ fullContent = result.fullContent;
212
+ toolCalls = result.toolCalls;
213
+ } else if (activeProvider.id === "dottie_desktop") {
214
+ // Dottie Desktop serves local models which may use:
215
+ // 1. gpt-oss channel tokens (<|channel|>analysis/final<|message|>)
216
+ // 2. Native reasoning (delta.reasoning from parseOpenAIStream)
217
+ // 3. Plain text (LFM2.5, SmolLM, etc. — no special tokens)
218
+ // Detect format by buffering initial tokens and checking for markers.
219
+ const gen = parseOpenAIStream(response, fullContent, toolCalls, signal, activeProvider.id);
220
+ let rawBuffer = "";
221
+ let finalMarkerFound = false;
222
+ let lastFinalYieldPos = 0;
223
+ let usesNativeReasoning = false;
224
+ let usesPassthrough = false; // Models without channel tokens (LFM, SmolLM, etc.)
225
+ let analysisStarted = false;
226
+ let analysisEnded = false;
227
+ let lastThinkingYieldPos = 0;
228
+ const ANALYSIS_MARKER = "<|channel|>analysis<|message|>";
229
+ const ANALYSIS_END = "<|end|>";
230
+ const FINAL_MARKER = "<|channel|>final<|message|>";
231
+ const CHANNEL_DETECT_THRESHOLD = 200; // chars before assuming no channel tokens
232
+
233
+ while (true) {
234
+ const { value, done } = await gen.next();
235
+ if (done) {
236
+ fullContent = value.fullContent;
237
+ toolCalls = value.toolCalls;
238
+ break;
239
+ }
240
+
241
+ // If parseOpenAIStream yields thinking events, the model uses native reasoning —
242
+ // pass everything through directly (no channel token parsing needed).
243
+ if (value.type === "thinking") {
244
+ usesNativeReasoning = true;
245
+ yield value;
246
+ continue;
247
+ }
248
+
249
+ if (value.type !== "text_delta") {
250
+ yield value;
251
+ continue;
252
+ }
253
+
254
+ // Native reasoning mode: pass text_delta through directly
255
+ if (usesNativeReasoning) {
256
+ yield value;
257
+ continue;
258
+ }
259
+
260
+ // Passthrough mode: model doesn't use channel tokens, stream directly
261
+ if (usesPassthrough) {
262
+ yield value;
263
+ continue;
264
+ }
265
+
266
+ // Channel token mode: buffer and parse markers, stream thinking incrementally
267
+ rawBuffer += value.text;
268
+
269
+ // Fallback: if enough text accumulated without any channel token,
270
+ // the model doesn't use gpt-oss format (e.g. LFM2.5, SmolLM).
271
+ // Flush buffer and switch to passthrough for remaining tokens.
272
+ if (!analysisStarted && !finalMarkerFound && rawBuffer.length > CHANNEL_DETECT_THRESHOLD) {
273
+ console.log("[dottie_desktop] no channel tokens after", rawBuffer.length, "chars — switching to passthrough");
274
+ usesPassthrough = true;
275
+ const textEvent = { type: "text_delta", text: rawBuffer };
276
+ validateEvent(textEvent);
277
+ yield textEvent;
278
+ continue;
279
+ }
280
+
281
+ if (!finalMarkerFound) {
282
+ // Detect analysis channel start
283
+ if (!analysisStarted) {
284
+ const aIdx = rawBuffer.indexOf(ANALYSIS_MARKER);
285
+ if (aIdx !== -1) {
286
+ analysisStarted = true;
287
+ lastThinkingYieldPos = aIdx + ANALYSIS_MARKER.length;
288
+ console.log("[dottie_desktop] analysis marker found at", aIdx, "| yieldPos:", lastThinkingYieldPos);
289
+ }
290
+ }
291
+
292
+ // Stream thinking text incrementally while inside analysis channel
293
+ if (analysisStarted && !analysisEnded) {
294
+ const endIdx = rawBuffer.indexOf(ANALYSIS_END, lastThinkingYieldPos);
295
+ if (endIdx !== -1) {
296
+ const chunk = rawBuffer.slice(lastThinkingYieldPos, endIdx);
297
+ if (chunk) {
298
+ console.log("[dottie_desktop] thinking (final):", chunk.slice(0, 80));
299
+ const thinkingEvent = {
300
+ type: "thinking",
301
+ text: chunk,
302
+ hasNativeThinking: false, // Channel token simulation
303
+ };
304
+ validateEvent(thinkingEvent);
305
+ yield thinkingEvent;
306
+ }
307
+ lastThinkingYieldPos = endIdx + ANALYSIS_END.length;
308
+ analysisEnded = true;
309
+ } else {
310
+ const chunk = rawBuffer.slice(lastThinkingYieldPos);
311
+ if (chunk) {
312
+ console.log("[dottie_desktop] thinking (incr):", chunk.slice(0, 80));
313
+ const thinkingEvent = {
314
+ type: "thinking",
315
+ text: chunk,
316
+ hasNativeThinking: false, // Channel token simulation
317
+ };
318
+ validateEvent(thinkingEvent);
319
+ yield thinkingEvent;
320
+ }
321
+ lastThinkingYieldPos = rawBuffer.length;
322
+ }
323
+ }
324
+
325
+ // Check for final channel marker
326
+ const fIdx = rawBuffer.indexOf(FINAL_MARKER);
327
+ if (fIdx !== -1) {
328
+ console.log("[dottie_desktop] final marker found at", fIdx, "| bufLen:", rawBuffer.length);
329
+ finalMarkerFound = true;
330
+ lastFinalYieldPos = fIdx + FINAL_MARKER.length;
331
+ const pending = rawBuffer.slice(lastFinalYieldPos);
332
+ if (pending) {
333
+ const textEvent = { type: "text_delta", text: pending };
334
+ validateEvent(textEvent);
335
+ yield textEvent;
336
+ lastFinalYieldPos = rawBuffer.length;
337
+ }
338
+ }
339
+ } else {
340
+ // In final channel — yield incremental text
341
+ const newText = rawBuffer.slice(lastFinalYieldPos);
342
+ if (newText) {
343
+ const textEvent = { type: "text_delta", text: newText };
344
+ validateEvent(textEvent);
345
+ yield textEvent;
346
+ lastFinalYieldPos = rawBuffer.length;
347
+ }
348
+ }
349
+ }
350
+
351
+ // Clean fullContent for persistence (strip channel tokens)
352
+ if (!usesNativeReasoning && !usesPassthrough) fullContent = stripGptOssTokens(fullContent);
353
+
354
+ // Detect text-based tool calls from <tool_call> markers in model output.
355
+ // Models without native tool_calls support emit tool invocations as text
356
+ // when instructed via system prompt.
357
+ if (hasToolCallMarkers(fullContent)) {
358
+ const textToolCalls = parseToolCalls(fullContent);
359
+ if (textToolCalls.length > 0) {
360
+ toolCalls = textToolCalls;
361
+ fullContent = stripToolCallMarkers(fullContent);
362
+ }
363
+ }
364
+ } else {
365
+ // OpenAI-compatible SSE format (Ollama, OpenAI, xAI)
366
+ const result = yield* parseOpenAIStream(response, fullContent, toolCalls, signal, activeProvider.id);
367
+ fullContent = result.fullContent;
368
+ toolCalls = result.toolCalls;
369
+ }
370
+
371
+ // Check if the model wants to call tools
372
+ if (toolCalls.length > 0) {
373
+ // Standard format: single assistant message with toolCalls array.
374
+ // toProviderFormat() splits this into the wire format each provider expects.
375
+ const assistantMsg = {
376
+ role: "assistant",
377
+ content: fullContent || "",
378
+ toolCalls: toolCalls.map((tc) => {
379
+ let input = tc.function.arguments;
380
+ if (typeof input === "string") {
381
+ try { input = JSON.parse(input); } catch {}
382
+ }
383
+ return {
384
+ id: tc.id,
385
+ name: tc.function.name,
386
+ input,
387
+ status: "pending",
388
+ };
389
+ }),
390
+ _ts: Date.now(),
391
+ };
392
+ messages.push(assistantMsg);
393
+
394
+ // Execute each tool and update the standard-format toolCalls in place.
395
+ // No separate tool-result messages — results are stored on the toolCall object.
396
+ // toProviderFormat() will expand these into the wire format at request time.
397
+ for (let i = 0; i < assistantMsg.toolCalls.length; i++) {
398
+ const tc = assistantMsg.toolCalls[i];
399
+ const tool = tools.find((t) => t.name === tc.name);
400
+
401
+ const toolStartEvent = { type: "tool_start", name: tc.name, input: tc.input };
402
+ validateEvent(toolStartEvent);
403
+ yield toolStartEvent;
404
+
405
+ if (!tool) {
406
+ const errorResult = `Tool "${tc.name}" not found`;
407
+ const toolErrorEvent = { type: "tool_error", name: tc.name, error: errorResult };
408
+ validateEvent(toolErrorEvent);
409
+ yield toolErrorEvent;
410
+ tc.result = errorResult;
411
+ tc.status = "error";
412
+ continue;
413
+ }
414
+
415
+ try {
416
+ const result = await tool.execute(tc.input, signal, context);
417
+ const resultStr = typeof result === "string" ? result : JSON.stringify(result);
418
+
419
+ const toolResultEvent = { type: "tool_result", name: tc.name, input: tc.input, result: resultStr };
420
+ validateEvent(toolResultEvent);
421
+ yield toolResultEvent;
422
+
423
+ // Check if the result is an image and emit additional image event
424
+ try {
425
+ const parsed = JSON.parse(resultStr);
426
+ if (parsed.type === 'image' && parsed.url) {
427
+ const imageEvent = { type: 'image', url: parsed.url, prompt: parsed.prompt || '' };
428
+ validateEvent(imageEvent);
429
+ yield imageEvent;
430
+ }
431
+ } catch {
432
+ // Not JSON or not an image result, continue
433
+ }
434
+
435
+ tc.result = resultStr;
436
+ tc.status = "done";
437
+ // Full audit log: capture tool input and output for debugging
438
+ logEvent('tool_call', {
439
+ tool: tc.name,
440
+ success: true,
441
+ input: tc.input,
442
+ result: resultStr,
443
+ });
444
+ } catch (err) {
445
+ const errorResult = `Tool error: ${err.message}`;
446
+ const toolErrorEvent = { type: "tool_error", name: tc.name, error: errorResult };
447
+ validateEvent(toolErrorEvent);
448
+ yield toolErrorEvent;
449
+ tc.result = errorResult;
450
+ tc.status = "error";
451
+ // Full audit log: capture tool input and error for debugging
452
+ logEvent('tool_call', {
453
+ tool: tc.name,
454
+ success: false,
455
+ input: tc.input,
456
+ error: err.message,
457
+ stack: err.stack?.split('\n').slice(0, 5).join('\n'),
458
+ });
459
+ }
460
+ }
461
+
462
+ toolCalls = [];
463
+ fullContent = "";
464
+ } else {
465
+ // Extract follow-up suggestion before persisting
466
+ let followup = null;
467
+ const followupMatch = fullContent.match(/<followup>([\s\S]*?)<\/followup>/);
468
+ if (followupMatch) {
469
+ followup = followupMatch[1].trim();
470
+ fullContent = fullContent.replace(/<followup>[\s\S]*?<\/followup>/, '').trim();
471
+ }
472
+
473
+ // Standard format: plain string content, no provider-specific wrapping
474
+ messages.push({ role: "assistant", content: fullContent, _ts: Date.now() });
475
+ // Full audit log: capture complete response content for debugging
476
+ logEvent('message_received', {
477
+ length: fullContent.length,
478
+ content: fullContent,
479
+ });
480
+ if (followup) {
481
+ const followupEvent = { type: "followup", text: followup };
482
+ validateEvent(followupEvent);
483
+ yield followupEvent;
484
+ }
485
+ const doneEvent = { type: "done", content: fullContent };
486
+ validateEvent(doneEvent);
487
+ yield doneEvent;
488
+ return;
489
+ }
490
+ }
491
+
492
+ const maxIterEvent = { type: "max_iterations", message: `I've reached my reasoning limit (${maxIterations} steps). You can send another message to continue.` };
493
+ validateEvent(maxIterEvent);
494
+ yield maxIterEvent;
495
+ }
496
+
497
+ /**
498
+ * Parse an OpenAI-compatible SSE stream (works with Ollama, OpenAI, xAI).
499
+ *
500
+ * Tool calls arrive incrementally across chunks via delta.tool_calls with index-based assembly.
501
+ *
502
+ * @param {Response} response - Fetch response with SSE body
503
+ * @param {string} fullContent - Accumulated text content (passed by reference via return)
504
+ * @param {Array} toolCalls - Accumulated tool calls (passed by reference via return)
505
+ * @param {AbortSignal} [signal] - Optional abort signal to cancel the reader
506
+ * @param {string} [providerId] - Provider ID for stats normalization
507
+ * @yields {Object} text_delta events
508
+ * @returns {{ fullContent: string, toolCalls: Array }}
509
+ */
510
+ async function* parseOpenAIStream(response, fullContent, toolCalls, signal, providerId) {
511
+ const reader = response.body.getReader();
512
+ const decoder = new TextDecoder();
513
+ let buffer = "";
514
+ const toolCallMap = {};
515
+
516
+ while (true) {
517
+ if (signal?.aborted) {
518
+ await reader.cancel();
519
+ break;
520
+ }
521
+ const { done, value } = await reader.read();
522
+ if (done) break;
523
+
524
+ buffer += decoder.decode(value, { stream: true });
525
+ const lines = buffer.split("\n");
526
+ buffer = lines.pop() || "";
527
+
528
+ for (const line of lines) {
529
+ if (!line.startsWith("data:")) continue;
530
+ const data = line.slice(5).trim();
531
+ if (data === "[DONE]" || !data) continue;
532
+
533
+ try {
534
+ const chunk = JSON.parse(data);
535
+ const delta = chunk.choices?.[0]?.delta;
536
+ if (!delta) continue;
537
+
538
+ // Reasoning/thinking content (gpt-oss, DeepSeek, etc.)
539
+ const reasoning = delta.reasoning_content || delta.reasoning;
540
+ if (reasoning) {
541
+ const thinkingEvent = {
542
+ type: "thinking",
543
+ text: reasoning,
544
+ hasNativeThinking: true, // Native reasoning from provider
545
+ };
546
+ validateEvent(thinkingEvent);
547
+ yield thinkingEvent;
548
+ }
549
+
550
+ // Text content
551
+ if (delta.content) {
552
+ fullContent += delta.content;
553
+ const textEvent = { type: "text_delta", text: delta.content };
554
+ validateEvent(textEvent);
555
+ yield textEvent;
556
+ }
557
+
558
+ // Tool calls — assembled incrementally by index
559
+ if (delta.tool_calls) {
560
+ for (const tc of delta.tool_calls) {
561
+ const idx = tc.index ?? 0;
562
+ if (!toolCallMap[idx]) {
563
+ toolCallMap[idx] = {
564
+ id: tc.id || `call_${idx}`,
565
+ function: { name: "", arguments: "" },
566
+ };
567
+ }
568
+ if (tc.id) toolCallMap[idx].id = tc.id;
569
+ if (tc.function?.name) toolCallMap[idx].function.name += tc.function.name;
570
+ if (tc.function?.arguments) toolCallMap[idx].function.arguments += tc.function.arguments;
571
+ }
572
+ }
573
+
574
+ // Finish reason — check for stats if present
575
+ if (chunk.choices?.[0]?.finish_reason) {
576
+ // Some providers include usage stats
577
+ if (chunk.usage) {
578
+ const statsEvent = normalizeStatsEvent({
579
+ model: chunk.model,
580
+ prompt_tokens: chunk.usage.prompt_tokens,
581
+ completion_tokens: chunk.usage.completion_tokens,
582
+ }, providerId || 'openai');
583
+ validateEvent(statsEvent);
584
+ yield statsEvent;
585
+ }
586
+ }
587
+ } catch {
588
+ // Skip malformed JSON
589
+ }
590
+ }
591
+ }
592
+
593
+ // Parse accumulated tool call arguments from JSON strings to objects
594
+ toolCalls = Object.values(toolCallMap).map((tc) => {
595
+ let args = tc.function.arguments;
596
+ try {
597
+ args = JSON.parse(args);
598
+ } catch {
599
+ // Keep as string
600
+ }
601
+ return { id: tc.id, function: { name: tc.function.name, arguments: args } };
602
+ });
603
+
604
+ return { fullContent, toolCalls };
605
+ }
606
+
607
+ /**
608
+ * Parse an Anthropic SSE stream.
609
+ *
610
+ * Tool calls arrive via content_block_start (type: "tool_use") + content_block_delta (input_json_delta).
611
+ *
612
+ * @param {Response} response - Fetch response with SSE body
613
+ * @param {string} fullContent - Accumulated text content
614
+ * @param {Array} toolCalls - Accumulated tool calls
615
+ * @param {AbortSignal} [signal] - Optional abort signal to cancel the reader
616
+ * @param {string} [providerId] - Provider ID for stats normalization
617
+ * @yields {Object} text_delta events
618
+ * @returns {{ fullContent: string, toolCalls: Array }}
619
+ */
620
+ async function* parseAnthropicStream(response, fullContent, toolCalls, signal, providerId) {
621
+ const reader = response.body.getReader();
622
+ const decoder = new TextDecoder();
623
+ let buffer = "";
624
+ const contentBlocks = {};
625
+
626
+ while (true) {
627
+ if (signal?.aborted) {
628
+ await reader.cancel();
629
+ break;
630
+ }
631
+ const { done, value } = await reader.read();
632
+ if (done) break;
633
+
634
+ buffer += decoder.decode(value, { stream: true });
635
+ const lines = buffer.split("\n");
636
+ buffer = lines.pop() || "";
637
+
638
+ for (const line of lines) {
639
+ if (!line.startsWith("data:")) continue;
640
+ const data = line.slice(5).trim();
641
+ if (!data) continue;
642
+
643
+ try {
644
+ const event = JSON.parse(data);
645
+
646
+ if (event.type === "content_block_start") {
647
+ const block = event.content_block;
648
+ const idx = event.index;
649
+ if (block.type === "tool_use") {
650
+ contentBlocks[idx] = {
651
+ type: "tool_use",
652
+ id: block.id,
653
+ name: block.name,
654
+ inputJson: "",
655
+ };
656
+ } else if (block.type === "thinking") {
657
+ contentBlocks[idx] = { type: "thinking", text: "" };
658
+ } else if (block.type === "text") {
659
+ contentBlocks[idx] = { type: "text", text: "" };
660
+ }
661
+ }
662
+
663
+ if (event.type === "content_block_delta") {
664
+ const idx = event.index;
665
+ const delta = event.delta;
666
+ if (delta.type === "thinking_delta") {
667
+ if (contentBlocks[idx]) contentBlocks[idx].text += delta.thinking;
668
+ const thinkingEvent = {
669
+ type: "thinking",
670
+ text: delta.thinking,
671
+ hasNativeThinking: true, // Native thinking from Anthropic
672
+ };
673
+ validateEvent(thinkingEvent);
674
+ yield thinkingEvent;
675
+ } else if (delta.type === "text_delta") {
676
+ fullContent += delta.text;
677
+ if (contentBlocks[idx]) contentBlocks[idx].text += delta.text;
678
+ const textEvent = { type: "text_delta", text: delta.text };
679
+ validateEvent(textEvent);
680
+ yield textEvent;
681
+ } else if (delta.type === "input_json_delta") {
682
+ if (contentBlocks[idx]) contentBlocks[idx].inputJson += delta.partial_json;
683
+ }
684
+ }
685
+
686
+ if (event.type === "message_delta") {
687
+ if (event.usage) {
688
+ const statsEvent = normalizeStatsEvent({
689
+ model: event.model || "",
690
+ input_tokens: event.usage.input_tokens,
691
+ output_tokens: event.usage.output_tokens,
692
+ }, providerId || 'anthropic');
693
+ validateEvent(statsEvent);
694
+ yield statsEvent;
695
+ }
696
+ }
697
+ } catch {
698
+ // Skip malformed JSON
699
+ }
700
+ }
701
+ }
702
+
703
+ // Assemble tool calls from content blocks
704
+ toolCalls = Object.values(contentBlocks)
705
+ .filter((b) => b.type === "tool_use")
706
+ .map((b) => {
707
+ let args = {};
708
+ try {
709
+ args = JSON.parse(b.inputJson);
710
+ } catch {
711
+ // Empty or malformed
712
+ }
713
+ return { id: b.id, function: { name: b.name, arguments: args } };
714
+ });
715
+
716
+ return { fullContent, toolCalls };
717
+ }
718
+
719
+ /**
720
+ * Check if Ollama is running and list available models.
721
+ *
722
+ * @returns {Promise<{running: boolean, models: Array<{name: string, size: number, modified: string}>}>}
723
+ */
724
+ export async function getOllamaStatus() {
725
+ try {
726
+ const res = await fetch(`${OLLAMA_BASE}/api/tags`);
727
+ if (!res.ok) return { running: false, models: [] };
728
+ const data = await res.json();
729
+ return {
730
+ running: true,
731
+ models: data.models.map((m) => ({
732
+ name: m.name,
733
+ size: m.size,
734
+ modified: m.modified_at,
735
+ })),
736
+ };
737
+ } catch {
738
+ return { running: false, models: [] };
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Check if Dottie Desktop is running and list available models.
744
+ * Uses the OpenAI-compatible /v1/models endpoint.
745
+ *
746
+ * @returns {Promise<{running: boolean, models: Array<{name: string}>}>}
747
+ */
748
+ /**
749
+ * Strip gpt-oss channel tokens and extract only the final response content.
750
+ * If the text has a "final" channel, returns only that content.
751
+ * Otherwise strips all `<|...|>` tokens and returns the cleaned text.
752
+ *
753
+ * @param {string} text - Raw model output with channel tokens
754
+ * @returns {string} Cleaned text with tokens removed
755
+ */
756
+ function stripGptOssTokens(text) {
757
+ const FINAL_RE = /<\|channel\|>final<\|message\|>([\s\S]*)$/;
758
+ const TOKEN_RE = /<\|[^|]*\|>/g;
759
+
760
+ const finalMatch = text.match(FINAL_RE);
761
+ if (finalMatch) {
762
+ return finalMatch[1].replace(TOKEN_RE, "").trim();
763
+ }
764
+ // No channel markers — strip all tokens as fallback
765
+ return text.replace(TOKEN_RE, "").trim();
766
+ }
767
+
768
+ export async function getDottieDesktopStatus() {
769
+ const baseUrl = (process.env.DOTTIE_DESKTOP_URL || 'http://localhost:1316/v1').replace(/\/v1$/, '');
770
+ try {
771
+ const res = await fetch(`${baseUrl}/v1/models`);
772
+ if (!res.ok) return { running: false, models: [] };
773
+ const data = await res.json();
774
+ const models = (data.data || []).map((m) => ({ name: m.id }));
775
+ return { running: true, models };
776
+ } catch {
777
+ return { running: false, models: [] };
778
+ }
779
+ }