@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/events.js ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * SSE Event Schema Definitions
3
+ *
4
+ * All events emitted by the agent loop conform to these schemas.
5
+ * Provider-specific differences are normalized before emission.
6
+ */
7
+
8
+ /**
9
+ * Text delta event - incremental text from the model
10
+ * @typedef {Object} TextDeltaEvent
11
+ * @property {'text_delta'} type
12
+ * @property {string} text - Incremental text chunk
13
+ */
14
+
15
+ /**
16
+ * Thinking event - model's internal reasoning
17
+ * @typedef {Object} ThinkingEvent
18
+ * @property {'thinking'} type
19
+ * @property {string} text - Reasoning text (empty string if no thinking available)
20
+ * @property {boolean} hasNativeThinking - True if provider natively supports thinking
21
+ */
22
+
23
+ /**
24
+ * Tool start event - tool execution beginning
25
+ * @typedef {Object} ToolStartEvent
26
+ * @property {'tool_start'} type
27
+ * @property {string} name - Tool name
28
+ * @property {Object} input - Tool input parameters (already parsed)
29
+ */
30
+
31
+ /**
32
+ * Tool result event - tool execution completed successfully
33
+ * @typedef {Object} ToolResultEvent
34
+ * @property {'tool_result'} type
35
+ * @property {string} name - Tool name
36
+ * @property {Object} input - Tool input parameters
37
+ * @property {string} result - Tool result (JSON string if object)
38
+ */
39
+
40
+ /**
41
+ * Tool error event - tool execution failed
42
+ * @typedef {Object} ToolErrorEvent
43
+ * @property {'tool_error'} type
44
+ * @property {string} name - Tool name
45
+ * @property {string} error - Error message
46
+ */
47
+
48
+ /**
49
+ * Done event - agent loop completed
50
+ * @typedef {Object} DoneEvent
51
+ * @property {'done'} type
52
+ * @property {string} content - Final assistant response text
53
+ */
54
+
55
+ /**
56
+ * Max iterations event - loop exhausted iteration limit
57
+ * @typedef {Object} MaxIterationsEvent
58
+ * @property {'max_iterations'} type
59
+ * @property {string} message - Warning message
60
+ */
61
+
62
+ /**
63
+ * Error event - fatal error occurred
64
+ * @typedef {Object} ErrorEvent
65
+ * @property {'error'} type
66
+ * @property {string} error - Error message
67
+ */
68
+
69
+ /**
70
+ * Stats event - token usage statistics (standardized across providers)
71
+ * @typedef {Object} StatsEvent
72
+ * @property {'stats'} type
73
+ * @property {string} model - Model name
74
+ * @property {number} inputTokens - Input tokens consumed
75
+ * @property {number} outputTokens - Output tokens generated
76
+ */
77
+
78
+ /**
79
+ * Followup event - suggested followup question
80
+ * @typedef {Object} FollowupEvent
81
+ * @property {'followup'} type
82
+ * @property {string} text - Suggested followup text
83
+ */
84
+
85
+ /**
86
+ * Image event - generated image from tool
87
+ * @typedef {Object} ImageEvent
88
+ * @property {'image'} type
89
+ * @property {string} url - Image URL
90
+ * @property {string} prompt - Generation prompt
91
+ */
92
+
93
+ /**
94
+ * @typedef {TextDeltaEvent|ThinkingEvent|ToolStartEvent|ToolResultEvent|ToolErrorEvent|DoneEvent|MaxIterationsEvent|ErrorEvent|StatsEvent|FollowupEvent|ImageEvent} AgentEvent
95
+ */
96
+
97
+ /**
98
+ * Validate an event against the schema
99
+ * @param {AgentEvent} event - Event to validate
100
+ * @returns {boolean} True if valid
101
+ * @throws {Error} If validation fails
102
+ */
103
+ export function validateEvent(event) {
104
+ if (!event || typeof event !== 'object') {
105
+ throw new Error('Event must be an object');
106
+ }
107
+
108
+ if (!event.type) {
109
+ throw new Error('Event must have a type property');
110
+ }
111
+
112
+ switch (event.type) {
113
+ case 'text_delta':
114
+ if (typeof event.text !== 'string') {
115
+ throw new Error('text_delta event must have text string');
116
+ }
117
+ break;
118
+
119
+ case 'thinking':
120
+ if (typeof event.text !== 'string') {
121
+ throw new Error('thinking event must have text string');
122
+ }
123
+ if (typeof event.hasNativeThinking !== 'boolean') {
124
+ throw new Error('thinking event must have hasNativeThinking boolean');
125
+ }
126
+ break;
127
+
128
+ case 'tool_start':
129
+ if (typeof event.name !== 'string') {
130
+ throw new Error('tool_start event must have name string');
131
+ }
132
+ if (typeof event.input !== 'object') {
133
+ throw new Error('tool_start event must have input object');
134
+ }
135
+ break;
136
+
137
+ case 'tool_result':
138
+ if (typeof event.name !== 'string') {
139
+ throw new Error('tool_result event must have name string');
140
+ }
141
+ if (typeof event.input !== 'object') {
142
+ throw new Error('tool_result event must have input object');
143
+ }
144
+ if (typeof event.result !== 'string') {
145
+ throw new Error('tool_result event must have result string');
146
+ }
147
+ break;
148
+
149
+ case 'tool_error':
150
+ if (typeof event.name !== 'string') {
151
+ throw new Error('tool_error event must have name string');
152
+ }
153
+ if (typeof event.error !== 'string') {
154
+ throw new Error('tool_error event must have error string');
155
+ }
156
+ break;
157
+
158
+ case 'done':
159
+ if (typeof event.content !== 'string') {
160
+ throw new Error('done event must have content string');
161
+ }
162
+ break;
163
+
164
+ case 'max_iterations':
165
+ if (typeof event.message !== 'string') {
166
+ throw new Error('max_iterations event must have message string');
167
+ }
168
+ break;
169
+
170
+ case 'error':
171
+ if (typeof event.error !== 'string') {
172
+ throw new Error('error event must have error string');
173
+ }
174
+ break;
175
+
176
+ case 'stats':
177
+ if (typeof event.model !== 'string') {
178
+ throw new Error('stats event must have model string');
179
+ }
180
+ if (typeof event.inputTokens !== 'number') {
181
+ throw new Error('stats event must have inputTokens number');
182
+ }
183
+ if (typeof event.outputTokens !== 'number') {
184
+ throw new Error('stats event must have outputTokens number');
185
+ }
186
+ break;
187
+
188
+ case 'followup':
189
+ if (typeof event.text !== 'string') {
190
+ throw new Error('followup event must have text string');
191
+ }
192
+ break;
193
+
194
+ case 'compaction':
195
+ // Compaction events don't have strict schema requirements
196
+ break;
197
+
198
+ case 'image':
199
+ if (typeof event.url !== 'string') {
200
+ throw new Error('image event must have url string');
201
+ }
202
+ if (typeof event.prompt !== 'string') {
203
+ throw new Error('image event must have prompt string');
204
+ }
205
+ break;
206
+
207
+ default:
208
+ throw new Error(`Unknown event type: ${event.type}`);
209
+ }
210
+
211
+ return true;
212
+ }
213
+
214
+ /**
215
+ * Normalize a stats event from provider-specific format to standard format
216
+ * @param {Object} stats - Raw stats from provider
217
+ * @param {string} provider - Provider ID ('anthropic', 'openai', 'xai', 'ollama')
218
+ * @returns {StatsEvent} Standardized stats event
219
+ */
220
+ export function normalizeStatsEvent(stats, provider) {
221
+ const isAnthropic = provider === 'anthropic';
222
+
223
+ return {
224
+ type: 'stats',
225
+ model: stats.model,
226
+ inputTokens: isAnthropic ? stats.input_tokens : stats.prompt_tokens,
227
+ outputTokens: isAnthropic ? stats.output_tokens : stats.completion_tokens,
228
+ };
229
+ }
@@ -0,0 +1,193 @@
1
+ // failover.js
2
+ // Model failover: retry on transient errors, fall back to alternative providers.
3
+
4
+ import { AI_PROVIDERS } from "../utils/providers.js";
5
+
6
+ /** Ordered list of cloud providers to try during failover. Local providers excluded. */
7
+ const FALLBACK_ORDER = ['xai', 'anthropic', 'openai'];
8
+
9
+ /** How long (ms) a failed provider stays cooled down. */
10
+ const COOLDOWN_MS = 5 * 60 * 1000;
11
+
12
+ /** Default retry delay (ms) when Retry-After header is absent. */
13
+ const DEFAULT_RETRY_DELAY_MS = 1500;
14
+
15
+ /** HTTP status codes that warrant a retry/failover. */
16
+ const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
17
+
18
+ /** In-memory cooldown map: providerId -> expiresAt timestamp. Resets on restart. */
19
+ const cooldownMap = new Map();
20
+
21
+ /**
22
+ * Custom error thrown when all providers (primary + fallbacks) are exhausted.
23
+ * @extends Error
24
+ */
25
+ class FailoverError extends Error {
26
+ /**
27
+ * @param {string} message - Error summary.
28
+ * @param {Array<{provider: string, status: number, body: string}>} attempts - Record of each failed attempt.
29
+ */
30
+ constructor(message, attempts) {
31
+ super(message);
32
+ this.name = 'FailoverError';
33
+ this.attempts = attempts;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Check whether a provider is currently in cooldown.
39
+ * @param {string} providerId - Provider identifier (e.g. 'anthropic').
40
+ * @returns {boolean}
41
+ */
42
+ function isProviderCooledDown(providerId) {
43
+ const expiresAt = cooldownMap.get(providerId);
44
+ if (!expiresAt) return false;
45
+ if (Date.now() >= expiresAt) {
46
+ cooldownMap.delete(providerId);
47
+ return false;
48
+ }
49
+ return true;
50
+ }
51
+
52
+ /**
53
+ * Mark a provider as failed, placing it in cooldown for COOLDOWN_MS.
54
+ * @param {string} providerId - Provider identifier.
55
+ */
56
+ function markProviderFailed(providerId) {
57
+ cooldownMap.set(providerId, Date.now() + COOLDOWN_MS);
58
+ }
59
+
60
+ /**
61
+ * Sleep for a given duration, respecting an AbortSignal.
62
+ * @param {number} ms - Milliseconds to sleep.
63
+ * @param {AbortSignal} [signal] - Optional abort signal.
64
+ * @returns {Promise<void>}
65
+ */
66
+ function abortableSleep(ms, signal) {
67
+ return new Promise((resolve, reject) => {
68
+ if (signal?.aborted) {
69
+ reject(new DOMException('Aborted', 'AbortError'));
70
+ return;
71
+ }
72
+ const timer = setTimeout(resolve, ms);
73
+ const onAbort = () => {
74
+ clearTimeout(timer);
75
+ reject(new DOMException('Aborted', 'AbortError'));
76
+ };
77
+ signal?.addEventListener('abort', onAbort, { once: true });
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Attempt a fetch with retry and cross-provider failover.
83
+ *
84
+ * On retryable HTTP errors (429, 5xx): waits Retry-After or 1.5s, retries once.
85
+ * If still failing: marks the provider cooled down, tries the next cloud provider.
86
+ * On non-retryable errors (400, 401, 403): throws immediately (no failover).
87
+ * On all providers exhausted: throws FailoverError with attempts array.
88
+ *
89
+ * @param {Object} options
90
+ * @param {Object} options.provider - Primary provider config from AI_PROVIDERS.
91
+ * @param {function(Object): {url: string, headers: Object, body: string}} options.buildRequest
92
+ * Callback that builds fetch params for any target provider.
93
+ * @param {AbortSignal} [options.signal] - Optional abort signal.
94
+ * @param {Object} [options.logger] - Optional logger with .info() and .error().
95
+ * @returns {Promise<{response: Response, activeProvider: Object}>}
96
+ * The successful HTTP response and the provider that served it.
97
+ * @throws {FailoverError} When all providers are exhausted.
98
+ * @throws {DOMException} When aborted via signal (name: 'AbortError').
99
+ */
100
+ async function fetchWithFailover({ provider, buildRequest, signal, logger }) {
101
+ const attempts = [];
102
+
103
+ // Build ordered list: primary first, then fallbacks (skip local, skip duplicates)
104
+ const providersToTry = [provider];
105
+ for (const id of FALLBACK_ORDER) {
106
+ if (id === provider.id) continue;
107
+ const p = AI_PROVIDERS[id];
108
+ if (p && !p.local) providersToTry.push(p);
109
+ }
110
+
111
+ for (const targetProvider of providersToTry) {
112
+ // Skip cooled-down providers (unless it's the primary — always try primary once)
113
+ if (targetProvider !== provider && isProviderCooledDown(targetProvider.id)) {
114
+ continue;
115
+ }
116
+
117
+ // Check that the target provider has an API key configured
118
+ if (targetProvider.envKey && !process.env[targetProvider.envKey]) {
119
+ continue;
120
+ }
121
+
122
+ const { url, headers, body } = buildRequest(targetProvider);
123
+ let lastStatus = 0;
124
+ let lastBody = '';
125
+
126
+ // Up to 2 attempts per provider (initial + 1 retry)
127
+ for (let attempt = 0; attempt < 2; attempt++) {
128
+ try {
129
+ const response = await fetch(url, {
130
+ method: 'POST',
131
+ headers,
132
+ body,
133
+ signal,
134
+ });
135
+
136
+ if (response.ok) {
137
+ return { response, activeProvider: targetProvider };
138
+ }
139
+
140
+ lastStatus = response.status;
141
+ lastBody = await response.text();
142
+
143
+ // Non-retryable — throw immediately, no failover
144
+ if (!RETRYABLE_STATUSES.has(lastStatus)) {
145
+ console.error(`[failover] ${targetProvider.name} returned ${lastStatus}`);
146
+ console.error(`[failover] Error body:`, lastBody);
147
+ console.error(`[failover] Request URL:`, url);
148
+ console.error(`[failover] Request body:`, body.slice(0, 500));
149
+ throw new FailoverError(
150
+ `${targetProvider.name} returned ${lastStatus}: ${lastBody}`,
151
+ [{ provider: targetProvider.id, status: lastStatus, body: lastBody }]
152
+ );
153
+ }
154
+
155
+ // Retryable — wait and retry (only on first attempt)
156
+ if (attempt === 0) {
157
+ const retryAfter = response.headers.get('retry-after');
158
+ const delayMs = retryAfter
159
+ ? Math.min(parseInt(retryAfter, 10) * 1000 || DEFAULT_RETRY_DELAY_MS, 10000)
160
+ : DEFAULT_RETRY_DELAY_MS;
161
+
162
+ if (logger) {
163
+ logger.info(`[failover] ${targetProvider.name} returned ${lastStatus}, retrying in ${delayMs}ms`);
164
+ }
165
+ await abortableSleep(delayMs, signal);
166
+ }
167
+ } catch (err) {
168
+ // Re-throw abort errors and non-retryable FailoverErrors
169
+ if (err.name === 'AbortError' || err instanceof FailoverError) throw err;
170
+
171
+ // Network error — treat as retryable
172
+ lastStatus = 0;
173
+ lastBody = err.message;
174
+ if (attempt === 0) {
175
+ await abortableSleep(DEFAULT_RETRY_DELAY_MS, signal);
176
+ }
177
+ }
178
+ }
179
+
180
+ // Both attempts failed for this provider
181
+ attempts.push({ provider: targetProvider.id, status: lastStatus, body: lastBody });
182
+
183
+ markProviderFailed(targetProvider.id);
184
+
185
+ if (logger) {
186
+ logger.error(`[failover] ${targetProvider.name} exhausted (${lastStatus}), trying next provider`);
187
+ }
188
+ }
189
+
190
+ throw new FailoverError('All providers exhausted', attempts);
191
+ }
192
+
193
+ export { fetchWithFailover, FailoverError };
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Text-Based Tool Call Parser
3
+ *
4
+ * Parses tool calls from model output in four formats:
5
+ *
6
+ * 1. Instructed format (via system prompt):
7
+ * <tool_call>{"name": "tool_name", "arguments": {"key": "value"}}</tool_call>
8
+ *
9
+ * 2. Native gpt-oss format (from model fine-tuning):
10
+ * commentary to=tool_name json{"key": "value"}
11
+ *
12
+ * 3. LFM2.5 native format with markers:
13
+ * <|tool_call_start|>[tool_name(arg1="value1")]<|tool_call_end|>
14
+ *
15
+ * 4. LFM2.5 bare Pythonic format (markers stripped by mlx_lm.server):
16
+ * [tool_name(arg1="value1", arg2="value2")]
17
+ *
18
+ * Used when the model doesn't support native OpenAI-style tool calling
19
+ * (e.g., mlx_lm.server) and tool definitions are injected via system prompt.
20
+ */
21
+
22
+ const TOOL_CALL_RE = /<tool_call>([\s\S]*?)<\/tool_call>/g;
23
+ const NATIVE_TOOL_CALL_RE = /commentary\s+to=(\w+)\s+json(\{[\s\S]*?\})(?:\s|$)/g;
24
+ const LFM_TOOL_CALL_RE = /<\|tool_call_start\|>\[(\w+)\(([\s\S]*?)\)\]<\|tool_call_end\|>/g;
25
+
26
+ // Bare Pythonic: [func_name(key="val")] or [func_name(key='val')]
27
+ // Requires at least one key=quoted_value pair to avoid false positives on markdown links
28
+ const BARE_PYTHONIC_RE = /\[(\w+)\((\w+\s*=\s*(?:"[^"]*"|'[^']*')(?:\s*,\s*\w+\s*=\s*(?:"[^"]*"|'[^']*'|[\w.+-]+))*)\)\]/g;
29
+
30
+ /**
31
+ * Detect if text contains tool call markers in any supported format.
32
+ *
33
+ * @param {string} text - Model output text
34
+ * @returns {boolean} True if at least one tool call pattern is found
35
+ */
36
+ export function hasToolCallMarkers(text) {
37
+ if (text.includes('<tool_call>') && text.includes('</tool_call>')) return true;
38
+ if (/commentary\s+to=\w+\s+json\{/.test(text)) return true;
39
+ if (text.includes('<|tool_call_start|>') && text.includes('<|tool_call_end|>')) return true;
40
+ // Bare Pythonic: [word(word="...")]
41
+ if (/\[\w+\(\w+\s*=\s*["']/.test(text)) return true;
42
+ return false;
43
+ }
44
+
45
+ /**
46
+ * Parse Pythonic keyword arguments from LFM tool call format.
47
+ * Handles: key="value", key='value', key=123, key=true
48
+ *
49
+ * @param {string} argsStr - Raw arguments string (e.g., 'location="New York", units="fahrenheit"')
50
+ * @returns {Object} Parsed key-value pairs
51
+ */
52
+ function parsePythonicArgs(argsStr) {
53
+ const args = {};
54
+ if (!argsStr || !argsStr.trim()) return args;
55
+
56
+ // Match key=value pairs where value can be quoted string, number, or boolean
57
+ const argRe = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([\w.+-]+))/g;
58
+ let m;
59
+ while ((m = argRe.exec(argsStr)) !== null) {
60
+ const key = m[1];
61
+ // Prefer double-quoted, then single-quoted, then unquoted
62
+ const val = m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4];
63
+ args[key] = val;
64
+ }
65
+ return args;
66
+ }
67
+
68
+ /**
69
+ * Extract all tool calls from text, returning them in the same shape
70
+ * that parseOpenAIStream produces so the existing execution loop works unchanged.
71
+ * Handles four formats: `<tool_call>` XML, gpt-oss `commentary to=`,
72
+ * LFM `<|tool_call_start|>`, and bare Pythonic `[func(args)]`.
73
+ *
74
+ * @param {string} text - Model output containing tool call patterns
75
+ * @returns {Array<{id: string, function: {name: string, arguments: Object}}>}
76
+ */
77
+ export function parseToolCalls(text) {
78
+ const calls = [];
79
+ let idx = 0;
80
+
81
+ // Format 1: <tool_call>{"name":"...","arguments":{...}}</tool_call>
82
+ TOOL_CALL_RE.lastIndex = 0;
83
+ let match;
84
+ while ((match = TOOL_CALL_RE.exec(text)) !== null) {
85
+ try {
86
+ const parsed = JSON.parse(match[1].trim());
87
+ const name = parsed.name || parsed.function;
88
+ let args = parsed.arguments || parsed.params || parsed.input || {};
89
+ if (typeof args === 'string') {
90
+ try { args = JSON.parse(args); } catch {}
91
+ }
92
+ calls.push({
93
+ id: `text_call_${idx}`,
94
+ function: { name, arguments: args },
95
+ });
96
+ idx++;
97
+ } catch (err) {
98
+ console.warn('[tool_parser] Failed to parse <tool_call> JSON:', match[1], err.message);
99
+ }
100
+ }
101
+
102
+ // Format 2: commentary to=TOOL_NAME json{...} (gpt-oss native)
103
+ if (calls.length === 0) {
104
+ NATIVE_TOOL_CALL_RE.lastIndex = 0;
105
+ while ((match = NATIVE_TOOL_CALL_RE.exec(text)) !== null) {
106
+ try {
107
+ const name = match[1];
108
+ const args = JSON.parse(match[2]);
109
+ calls.push({
110
+ id: `native_call_${idx}`,
111
+ function: { name, arguments: args },
112
+ });
113
+ idx++;
114
+ } catch (err) {
115
+ console.warn('[tool_parser] Failed to parse native tool call:', match[0], err.message);
116
+ }
117
+ }
118
+ }
119
+
120
+ // Format 3: <|tool_call_start|>[func_name(args)]<|tool_call_end|> (LFM with markers)
121
+ if (calls.length === 0) {
122
+ LFM_TOOL_CALL_RE.lastIndex = 0;
123
+ while ((match = LFM_TOOL_CALL_RE.exec(text)) !== null) {
124
+ try {
125
+ const name = match[1];
126
+ const args = parsePythonicArgs(match[2]);
127
+ calls.push({
128
+ id: `lfm_call_${idx}`,
129
+ function: { name, arguments: args },
130
+ });
131
+ idx++;
132
+ } catch (err) {
133
+ console.warn('[tool_parser] Failed to parse LFM tool call:', match[0], err.message);
134
+ }
135
+ }
136
+ }
137
+
138
+ // Format 4: [func_name(key="val")] (bare Pythonic, markers stripped by mlx_lm.server)
139
+ if (calls.length === 0) {
140
+ BARE_PYTHONIC_RE.lastIndex = 0;
141
+ while ((match = BARE_PYTHONIC_RE.exec(text)) !== null) {
142
+ try {
143
+ const name = match[1];
144
+ const args = parsePythonicArgs(match[2]);
145
+ calls.push({
146
+ id: `lfm_call_${idx}`,
147
+ function: { name, arguments: args },
148
+ });
149
+ idx++;
150
+ } catch (err) {
151
+ console.warn('[tool_parser] Failed to parse bare Pythonic tool call:', match[0], err.message);
152
+ }
153
+ }
154
+ }
155
+
156
+ return calls;
157
+ }
158
+
159
+ /**
160
+ * Remove all tool call patterns from text (all four formats),
161
+ * leaving only the surrounding natural language content.
162
+ *
163
+ * @param {string} text - Model output containing tool call markers
164
+ * @returns {string} Text with all tool call blocks removed
165
+ */
166
+ export function stripToolCallMarkers(text) {
167
+ let cleaned = text;
168
+ cleaned = cleaned.replace(TOOL_CALL_RE, '');
169
+ cleaned = cleaned.replace(NATIVE_TOOL_CALL_RE, '');
170
+ cleaned = cleaned.replace(LFM_TOOL_CALL_RE, '');
171
+ cleaned = cleaned.replace(BARE_PYTHONIC_RE, '');
172
+ return cleaned.trim();
173
+ }