@stevederico/dotbot 0.29.0 → 0.31.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,25 @@
1
+ 0.31
2
+
3
+ Document mlx_local provider
4
+ Fix CLI local auth
5
+ Remove shields.io badges
6
+ Generify personal path example
7
+
8
+ 0.30
9
+
10
+ Flush buffered plain text
11
+ Add agentLoop regression tests
12
+ Rename dottie_desktop to mlx_local
13
+ Rename getDottieDesktopStatus helper
14
+ Rename DOTTIE_DESKTOP_URL env var
15
+ Parameterize notification title
16
+ Default agentName to Assistant
17
+ Scrub host-specific doc references
18
+ Skip heartbeat without tasks
19
+ Fail closed on task fetch error
20
+ Add mlx_local provider entry
21
+ Add cron_handler regression tests
22
+
1
23
  0.29
2
24
 
3
25
  Extract shared streamEvents
package/README.md CHANGED
@@ -5,18 +5,6 @@
5
5
  The ultra-lean AI agent.<br>
6
6
  11k lines. 53 tools. 0 dependencies.
7
7
  </h3>
8
- <p align="center">
9
- <a href="https://opensource.org/licenses/mit">
10
- <img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT License">
11
- </a>
12
- <a href="https://github.com/stevederico/dotbot/stargazers">
13
- <img src="https://img.shields.io/github/stars/stevederico/dotbot?style=social" alt="GitHub stars">
14
- </a>
15
- <a href="https://github.com/stevederico/dotbot">
16
- <img src="https://img.shields.io/badge/version-0.28-green" alt="version">
17
- </a>
18
- <img src="https://img.shields.io/badge/LOC-11k-orange" alt="Lines of Code">
19
- </p>
20
8
  </div>
21
9
 
22
10
  <br />
@@ -204,7 +192,7 @@ for await (const event of agent.chat({
204
192
  ## CLI Reference
205
193
 
206
194
  ```
207
- dotbot v0.28 — AI agent CLI
195
+ dotbot — AI agent CLI
208
196
 
209
197
  Usage:
210
198
  dotbot "message" One-shot query
@@ -229,7 +217,7 @@ Commands:
229
217
  events [--summary] View audit log
230
218
 
231
219
  Options:
232
- --provider, -p AI provider: xai, anthropic, openai, ollama (default: xai)
220
+ --provider, -p AI provider: xai, anthropic, openai, ollama, mlx_local (default: xai)
233
221
  --model, -m Model name (default: grok-4-1-fast-reasoning)
234
222
  --system, -s Custom system prompt (prepended to default)
235
223
  --session Resume a specific session by ID
@@ -248,6 +236,7 @@ Environment Variables:
248
236
  ANTHROPIC_API_KEY API key for Anthropic
249
237
  OPENAI_API_KEY API key for OpenAI
250
238
  OLLAMA_BASE_URL Base URL for Ollama (default: http://localhost:11434)
239
+ MLX_LOCAL_URL Base URL for a local MLX-style OpenAI-compatible server (default: http://127.0.0.1:1316/v1)
251
240
 
252
241
  Config File:
253
242
  ~/.dotbotrc JSON config for defaults (provider, model, db, sandbox)
package/bin/dotbot.js CHANGED
@@ -115,7 +115,7 @@ Commands:
115
115
  events [--summary] View audit log
116
116
 
117
117
  Options:
118
- --provider, -p AI provider: xai, anthropic, openai, ollama (default: xai)
118
+ --provider, -p AI provider: xai, anthropic, openai, ollama, mlx_local (default: xai)
119
119
  --model, -m Model name (default: grok-4-1-fast-reasoning)
120
120
  --system, -s Custom system prompt (prepended to default)
121
121
  --session Resume a specific session by ID
@@ -134,6 +134,7 @@ Environment Variables:
134
134
  ANTHROPIC_API_KEY API key for Anthropic
135
135
  OPENAI_API_KEY API key for OpenAI
136
136
  OLLAMA_BASE_URL Base URL for Ollama (default: http://localhost:11434)
137
+ MLX_LOCAL_URL Base URL for a local MLX-style OpenAI-compatible server (default: http://127.0.0.1:1316/v1)
137
138
 
138
139
  Config File:
139
140
  ~/.dotbotrc JSON config for defaults (provider, model, db)
@@ -295,6 +296,13 @@ async function getProviderConfig(providerId) {
295
296
  return { ...base, apiUrl: `${baseUrl}/api/chat` };
296
297
  }
297
298
 
299
+ // Local OpenAI-compatible servers (mlx_local, etc.) don't use API keys —
300
+ // they're served from localhost and the apiUrl is already baked into the
301
+ // provider config (or overridden via env var inside providers.js).
302
+ if (base.local) {
303
+ return base;
304
+ }
305
+
298
306
  const envKey = base.envKey;
299
307
  let apiKey = process.env[envKey];
300
308
 
package/core/agent.js CHANGED
@@ -174,7 +174,7 @@ export async function* agentLoop({ model, messages, tools, signal, provider, con
174
174
  };
175
175
  };
176
176
 
177
- // Local providers (ollama, dottie_desktop): direct fetch, no failover
177
+ // Local providers (ollama, mlx_local): direct fetch, no failover
178
178
  if (provider.local) {
179
179
  const { url, headers, body } = buildAgentRequest(provider);
180
180
  response = await fetch(url, { method: "POST", headers, body, signal });
@@ -210,8 +210,9 @@ export async function* agentLoop({ model, messages, tools, signal, provider, con
210
210
  const result = yield* parseAnthropicStream(response, fullContent, toolCalls, signal, activeProvider.id);
211
211
  fullContent = result.fullContent;
212
212
  toolCalls = result.toolCalls;
213
- } else if (activeProvider.id === "dottie_desktop") {
214
- // Dottie Desktop serves local models which may use:
213
+ } else if (activeProvider.id === "mlx_local") {
214
+ // Local MLX-style OpenAI-compatible server. Models served this way
215
+ // may emit output in one of three formats:
215
216
  // 1. gpt-oss channel tokens (<|channel|>analysis/final<|message|>)
216
217
  // 2. Native reasoning (delta.reasoning from parseOpenAIStream)
217
218
  // 3. Plain text (LFM2.5, SmolLM, etc. — no special tokens)
@@ -235,6 +236,21 @@ export async function* agentLoop({ model, messages, tools, signal, provider, con
235
236
  if (done) {
236
237
  fullContent = value.fullContent;
237
238
  toolCalls = value.toolCalls;
239
+ // Flush buffered plain-text responses that never hit the
240
+ // CHANNEL_DETECT_THRESHOLD. Happens for short greetings and
241
+ // small-talk from models that don't emit gpt-oss channel tokens
242
+ // (Gemma 4 E2B, LFM2.5, SmolLM, etc.). Without this flush, the
243
+ // rawBuffer is silently discarded and the downstream consumer
244
+ // never receives any text_delta — the UI renders an empty bubble.
245
+ // Skip if the buffer contains tool call markers so the existing
246
+ // post-loop parseToolCalls() below can handle them.
247
+ if (!usesPassthrough && !usesNativeReasoning && !analysisStarted && !finalMarkerFound && rawBuffer.length > 0) {
248
+ if (!hasToolCallMarkers(rawBuffer)) {
249
+ const textEvent = { type: "text_delta", text: rawBuffer };
250
+ validateEvent(textEvent);
251
+ yield textEvent;
252
+ }
253
+ }
238
254
  break;
239
255
  }
240
256
 
@@ -270,7 +286,7 @@ export async function* agentLoop({ model, messages, tools, signal, provider, con
270
286
  // the model doesn't use gpt-oss format (e.g. LFM2.5, SmolLM).
271
287
  // Flush buffer and switch to passthrough for remaining tokens.
272
288
  if (!analysisStarted && !finalMarkerFound && rawBuffer.length > CHANNEL_DETECT_THRESHOLD) {
273
- console.log("[dottie_desktop] no channel tokens after", rawBuffer.length, "chars — switching to passthrough");
289
+ console.log("[mlx_local] no channel tokens after", rawBuffer.length, "chars — switching to passthrough");
274
290
  usesPassthrough = true;
275
291
  const textEvent = { type: "text_delta", text: rawBuffer };
276
292
  validateEvent(textEvent);
@@ -285,7 +301,7 @@ export async function* agentLoop({ model, messages, tools, signal, provider, con
285
301
  if (aIdx !== -1) {
286
302
  analysisStarted = true;
287
303
  lastThinkingYieldPos = aIdx + ANALYSIS_MARKER.length;
288
- console.log("[dottie_desktop] analysis marker found at", aIdx, "| yieldPos:", lastThinkingYieldPos);
304
+ console.log("[mlx_local] analysis marker found at", aIdx, "| yieldPos:", lastThinkingYieldPos);
289
305
  }
290
306
  }
291
307
 
@@ -295,7 +311,7 @@ export async function* agentLoop({ model, messages, tools, signal, provider, con
295
311
  if (endIdx !== -1) {
296
312
  const chunk = rawBuffer.slice(lastThinkingYieldPos, endIdx);
297
313
  if (chunk) {
298
- console.log("[dottie_desktop] thinking (final):", chunk.slice(0, 80));
314
+ console.log("[mlx_local] thinking (final):", chunk.slice(0, 80));
299
315
  const thinkingEvent = {
300
316
  type: "thinking",
301
317
  text: chunk,
@@ -309,7 +325,7 @@ export async function* agentLoop({ model, messages, tools, signal, provider, con
309
325
  } else {
310
326
  const chunk = rawBuffer.slice(lastThinkingYieldPos);
311
327
  if (chunk) {
312
- console.log("[dottie_desktop] thinking (incr):", chunk.slice(0, 80));
328
+ console.log("[mlx_local] thinking (incr):", chunk.slice(0, 80));
313
329
  const thinkingEvent = {
314
330
  type: "thinking",
315
331
  text: chunk,
@@ -325,7 +341,7 @@ export async function* agentLoop({ model, messages, tools, signal, provider, con
325
341
  // Check for final channel marker
326
342
  const fIdx = rawBuffer.indexOf(FINAL_MARKER);
327
343
  if (fIdx !== -1) {
328
- console.log("[dottie_desktop] final marker found at", fIdx, "| bufLen:", rawBuffer.length);
344
+ console.log("[mlx_local] final marker found at", fIdx, "| bufLen:", rawBuffer.length);
329
345
  finalMarkerFound = true;
330
346
  lastFinalYieldPos = fIdx + FINAL_MARKER.length;
331
347
  const pending = rawBuffer.slice(lastFinalYieldPos);
@@ -740,8 +756,9 @@ export async function getOllamaStatus() {
740
756
  }
741
757
 
742
758
  /**
743
- * Check if Dottie Desktop is running and list available models.
744
- * Uses the OpenAI-compatible /v1/models endpoint.
759
+ * Check if a local OpenAI-compatible model server is running and list
760
+ * available models. Defaults to the MLX LM server convention
761
+ * (http://localhost:1316/v1) and can be overridden with MLX_LOCAL_URL.
745
762
  *
746
763
  * @returns {Promise<{running: boolean, models: Array<{name: string}>}>}
747
764
  */
@@ -765,8 +782,8 @@ function stripGptOssTokens(text) {
765
782
  return text.replace(TOKEN_RE, "").trim();
766
783
  }
767
784
 
768
- export async function getDottieDesktopStatus() {
769
- const baseUrl = (process.env.DOTTIE_DESKTOP_URL || 'http://localhost:1316/v1').replace(/\/v1$/, '');
785
+ export async function getMlxLocalStatus() {
786
+ const baseUrl = (process.env.MLX_LOCAL_URL || 'http://localhost:1316/v1').replace(/\/v1$/, '');
770
787
  try {
771
788
  const res = await fetch(`${baseUrl}/v1/models`);
772
789
  if (!res.ok) return { running: false, models: [] };
@@ -12,7 +12,7 @@ const CONTEXT_LIMITS = {
12
12
  openai: 120000,
13
13
  xai: 120000,
14
14
  ollama: 6000,
15
- dottie_desktop: 6000,
15
+ mlx_local: 6000,
16
16
  };
17
17
 
18
18
  /** Number of recent messages to always preserve verbatim. */
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Cron task handler for dotbot.
3
3
  *
4
- * Extracted from dottie-os server.js to provide a reusable cron task executor
5
- * that handles session resolution, stale user gates, task injection, and
6
- * notification hooks.
4
+ * Reusable cron task executor that handles session resolution, stale user
5
+ * gates, task injection, and notification hooks.
7
6
  */
8
7
 
9
8
  import { compactMessages } from './compaction.js';
@@ -18,6 +17,7 @@ import { compactMessages } from './compaction.js';
18
17
  * @param {Object} options.memoryStore - Memory store instance (optional)
19
18
  * @param {Object} options.providers - Provider API keys for compaction
20
19
  * @param {number} [options.staleThresholdMs=86400000] - Skip heartbeat if user idle longer than this (default: 24h)
20
+ * @param {string} [options.notificationTitle='Assistant'] - Title used when dispatching notifications via hooks.onNotification
21
21
  * @param {Object} [options.hooks] - Host-specific hooks
22
22
  * @param {Function} [options.hooks.onNotification] - async (userId, { title, body, type }) => void
23
23
  * @param {Function} [options.hooks.taskFetcher] - async (userId, taskId) => task object
@@ -31,6 +31,7 @@ export function createCronHandler({
31
31
  memoryStore,
32
32
  providers = {},
33
33
  staleThresholdMs = 24 * 60 * 60 * 1000,
34
+ notificationTitle = 'Assistant',
34
35
  hooks = {},
35
36
  }) {
36
37
  // Agent reference - will be set after init() creates the agent
@@ -139,7 +140,7 @@ export function createCronHandler({
139
140
  if (trimmed && trimmed.length > 10 && updatedSession.owner && hooks.onNotification) {
140
141
  try {
141
142
  await hooks.onNotification(updatedSession.owner, {
142
- title: 'Dottie',
143
+ title: notificationTitle,
143
144
  body: trimmed.slice(0, 500),
144
145
  type: task.name === 'heartbeat' ? 'heartbeat' : 'cron',
145
146
  });
@@ -224,33 +225,43 @@ export function createCronHandler({
224
225
  tasks = await taskStore.findTasks(session.owner, { status: ['pending', 'in_progress'] });
225
226
  }
226
227
 
227
- if (tasks.length > 0) {
228
- // Check if any task is in auto mode with pending steps
229
- const autoTask = tasks.find(t => t.mode === 'auto' && t.steps?.some(s => !s.done));
230
- if (autoTask) {
231
- const doneCount = autoTask.steps.filter(s => s.done).length;
232
- const nextStep = autoTask.steps.find(s => !s.done);
233
- taskContent = `[Heartbeat] Auto-mode task "${autoTask.description}" has pending steps (${doneCount}/${autoTask.steps.length} done). Call task_work with task_id "${autoTask._id || autoTask.id}" to execute: "${nextStep.text}"`;
234
- } else {
235
- // List all active tasks
236
- const lines = tasks.map(t => {
237
- let line = `• [${t.priority}] ${t.description}`;
238
- if (t.mode) line += ` [${t.mode}]`;
239
- if (t.deadline) line += ` (due: ${t.deadline})`;
240
- if (t.steps && t.steps.length > 0) {
241
- const done = t.steps.filter(s => s.done).length;
242
- line += ` (${done}/${t.steps.length} steps)`;
243
- for (const step of t.steps) {
244
- line += `\n ${step.done ? '[x]' : '[ ]'} ${step.text}`;
245
- }
228
+ // Skip the LLM call entirely when there's nothing to discuss. A heartbeat
229
+ // with no active tasks is a waste of tokens on every provider (and is
230
+ // especially expensive on cloud providers that charge per call). The
231
+ // caller at handleTaskFire() treats a null return as "skip this tick".
232
+ if (tasks.length === 0) {
233
+ console.log(`[cron] heartbeat for ${session.owner}: no active tasks, skipping AI call`);
234
+ return null;
235
+ }
236
+
237
+ // Check if any task is in auto mode with pending steps
238
+ const autoTask = tasks.find(t => t.mode === 'auto' && t.steps?.some(s => !s.done));
239
+ if (autoTask) {
240
+ const doneCount = autoTask.steps.filter(s => s.done).length;
241
+ const nextStep = autoTask.steps.find(s => !s.done);
242
+ taskContent = `[Heartbeat] Auto-mode task "${autoTask.description}" has pending steps (${doneCount}/${autoTask.steps.length} done). Call task_work with task_id "${autoTask._id || autoTask.id}" to execute: "${nextStep.text}"`;
243
+ } else {
244
+ // List all active tasks
245
+ const lines = tasks.map(t => {
246
+ let line = `• [${t.priority}] ${t.description}`;
247
+ if (t.mode) line += ` [${t.mode}]`;
248
+ if (t.deadline) line += ` (due: ${t.deadline})`;
249
+ if (t.steps && t.steps.length > 0) {
250
+ const done = t.steps.filter(s => s.done).length;
251
+ line += ` (${done}/${t.steps.length} steps)`;
252
+ for (const step of t.steps) {
253
+ line += `\n ${step.done ? '[x]' : '[ ]'} ${step.text}`;
246
254
  }
247
- return line;
248
- });
249
- taskContent += `\n\nActive tasks:\n${lines.join('\n')}`;
250
- }
255
+ }
256
+ return line;
257
+ });
258
+ taskContent += `\n\nActive tasks:\n${lines.join('\n')}`;
251
259
  }
252
260
  } catch (err) {
261
+ // Fail closed: if we can't fetch tasks, skip this heartbeat rather
262
+ // than call the LLM with a meaningless default prompt.
253
263
  console.error('[cron] failed to fetch tasks for heartbeat:', err.message);
264
+ return null;
254
265
  }
255
266
 
256
267
  return taskContent;
package/core/init.js CHANGED
@@ -25,6 +25,7 @@ import { createTriggerHandler } from './trigger_handler.js';
25
25
  * @param {Object} [options.providers] - Provider API keys: { anthropic: { apiKey }, openai: { apiKey }, xai: { apiKey } }
26
26
  * @param {Array} [options.tools] - Tool definitions (default: coreTools)
27
27
  * @param {number} [options.staleThresholdMs=86400000] - Skip heartbeat if user idle longer than this (default: 24h)
28
+ * @param {string} [options.notificationTitle='Assistant'] - Title used when cron/trigger handlers dispatch notifications
28
29
  * @param {Function} [options.systemPrompt] - System prompt builder function
29
30
  * @param {Function} [options.screenshotUrlPattern] - Screenshot URL pattern function
30
31
  * @param {Object} [options.compaction] - Compaction settings
@@ -42,6 +43,7 @@ export async function init({
42
43
  providers = {},
43
44
  tools = coreTools,
44
45
  staleThresholdMs = 24 * 60 * 60 * 1000,
46
+ notificationTitle = 'Assistant',
45
47
  systemPrompt,
46
48
  screenshotUrlPattern,
47
49
  compaction = { enabled: true },
@@ -68,7 +70,8 @@ export async function init({
68
70
  memory: memoryStore,
69
71
  };
70
72
 
71
- // For stores-only mode (e.g., dottie-desktop), skip session/cron/agent setup
73
+ // For stores-only mode (host manages sessions/cron/agent itself),
74
+ // skip session/cron/agent setup
72
75
  if (storesOnly) {
73
76
  return {
74
77
  stores,
@@ -101,6 +104,7 @@ export async function init({
101
104
  memoryStore,
102
105
  providers,
103
106
  staleThresholdMs,
107
+ notificationTitle,
104
108
  hooks,
105
109
  });
106
110
 
@@ -134,6 +138,7 @@ export async function init({
134
138
  triggerStore,
135
139
  memoryStore,
136
140
  providers,
141
+ notificationTitle,
137
142
  hooks,
138
143
  });
139
144
 
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Trigger handler for dotbot.
3
3
  *
4
- * Extracted from dottie-os server.js to provide a reusable trigger executor
5
- * that handles event matching, firing, and notification hooks.
4
+ * Reusable trigger executor that handles event matching, firing, and
5
+ * notification hooks.
6
6
  */
7
7
 
8
8
  import { compactMessages } from './compaction.js';
@@ -16,6 +16,7 @@ import { compactMessages } from './compaction.js';
16
16
  * @param {Object} options.triggerStore - Trigger store instance
17
17
  * @param {Object} options.memoryStore - Memory store instance (optional)
18
18
  * @param {Object} options.providers - Provider API keys for compaction
19
+ * @param {string} [options.notificationTitle='Assistant'] - Title used when dispatching notifications via hooks.onNotification
19
20
  * @param {Object} [options.hooks] - Host-specific hooks
20
21
  * @param {Function} [options.hooks.onNotification] - async (userId, { title, body, type }) => void
21
22
  * @returns {Function} Async function: (eventType, userId, eventData?) => Promise<void>
@@ -26,6 +27,7 @@ export function createTriggerHandler({
26
27
  triggerStore,
27
28
  memoryStore,
28
29
  providers = {},
30
+ notificationTitle = 'Assistant',
29
31
  hooks = {},
30
32
  }) {
31
33
  /**
@@ -133,7 +135,7 @@ export function createTriggerHandler({
133
135
  if (trimmed && trimmed.length > 10 && updatedSession.owner && hooks.onNotification) {
134
136
  try {
135
137
  await hooks.onNotification(updatedSession.owner, {
136
- title: 'Dottie',
138
+ title: notificationTitle,
137
139
  body: trimmed.slice(0, 500),
138
140
  type: 'trigger',
139
141
  });
package/docs/core.md CHANGED
@@ -50,7 +50,7 @@ Standard AI Agent Tools (Industry Common)
50
50
  14. Notifications - Push alerts to users
51
51
  15. Weather - Current conditions/forecasts
52
52
 
53
- Your Library (@dottie/agent) Has:
53
+ dotbot Has:
54
54
 
55
55
  ✅ Memory (6 tools)
56
56
  ✅ Web (3 tools)
@@ -48,12 +48,12 @@ Absolutely! Yes, you should definitely protect .ssh and similar system-level dir
48
48
  ~/Library/Application Support/Firefox/
49
49
 
50
50
 
51
- Dottie-Specific (Your App)
51
+ Host-App Data (Example)
52
52
 
53
- ~/.dottie/logs/ # May contain user conversations
54
- ~/.dottie/chat_history.json
55
- ~/.dottie/*.db
53
+ ~/.myapp/logs/ # May contain user conversations
54
+ ~/.myapp/chat_history.json
55
+ ~/.myapp/*.db
56
56
 
57
57
 
58
- Bottom line: Any path under ~ (home directory) that contains credentials, personal data, command history, or configuration files should be protected. The general rule is: never search/glob/grep from ~ or /Users/sd root — only within specific project directories.
58
+ Bottom line: Any path under ~ (home directory) that contains credentials, personal data, command history, or configuration files should be protected. The general rule is: never search/glob/grep from the home directory root — only within specific project directories.
59
59
  ctrl+q to copy · 6 snippets
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevederico/dotbot",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "AI agent CLI and library for Node.js — streaming, multi-provider, tool execution, autonomous tasks",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -11,7 +11,7 @@ import { toStandardFormat } from '../core/normalize.js';
11
11
  * @param {string} options.agentPersonality - Personality description
12
12
  * @returns {string} System prompt
13
13
  */
14
- export function defaultSystemPrompt({ agentName = 'Dottie', agentPersonality = '' } = {}) {
14
+ export function defaultSystemPrompt({ agentName = 'Assistant', agentPersonality = '' } = {}) {
15
15
  const now = new Date().toISOString();
16
16
  return `You are a helpful personal AI assistant called ${agentName}.${agentPersonality ? `\nYour personality and tone: ${agentPersonality}. Embody this in all responses.` : ''}
17
17
  You have access to tools for searching the web, reading/writing files, fetching URLs, running code, long-term memory, and scheduled tasks.
@@ -0,0 +1,192 @@
1
+ import { test, describe, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { agentLoop } from '../core/agent.js';
4
+
5
+ /**
6
+ * Regression tests for the mlx_local provider branch of agentLoop.
7
+ *
8
+ * These cover the flush branch added in 0.30 that handles short plain-text
9
+ * responses from local models that never emit gpt-oss channel tokens
10
+ * (Gemma 4 E2B, LFM2.5, SmolLM). Without the flush, the rawBuffer was
11
+ * silently discarded on stream end and the downstream consumer received
12
+ * zero text_delta events — empty assistant bubbles in the UI.
13
+ */
14
+
15
+ /**
16
+ * Build a minimal mlx_local-style provider for agentLoop tests.
17
+ * The `id` must be "mlx_local" to hit the buffered-parsing branch,
18
+ * and `local: true` skips the failover path for a direct fetch.
19
+ */
20
+ function makeLocalProvider() {
21
+ return {
22
+ id: 'mlx_local',
23
+ name: 'Test Local',
24
+ apiUrl: 'http://127.0.0.1:1316/v1',
25
+ endpoint: '/chat/completions',
26
+ local: true,
27
+ headers: () => ({ 'Content-Type': 'application/json' }),
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Mock a fetch Response carrying an OpenAI-style SSE stream.
33
+ * Accepts an array of {content?, finish_reason?} deltas. Each becomes one
34
+ * SSE data line. A final "data: [DONE]" terminator is appended automatically.
35
+ */
36
+ function mockSSEResponse(deltas) {
37
+ const encoder = new TextEncoder();
38
+ const body = new ReadableStream({
39
+ start(controller) {
40
+ for (const delta of deltas) {
41
+ const chunk = { choices: [{ delta }] };
42
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
43
+ }
44
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
45
+ controller.close();
46
+ },
47
+ });
48
+ return new Response(body, {
49
+ status: 200,
50
+ headers: { 'Content-Type': 'text/event-stream' },
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Replace globalThis.fetch with a mock that returns the given Response
56
+ * for every call. Returns a restore function to put the original back.
57
+ */
58
+ function stubFetch(response) {
59
+ const original = globalThis.fetch;
60
+ globalThis.fetch = async () => response;
61
+ return () => { globalThis.fetch = original; };
62
+ }
63
+
64
+ describe('agentLoop — mlx_local short plain-text response flush', () => {
65
+ let restoreFetch;
66
+
67
+ afterEach(() => {
68
+ if (restoreFetch) {
69
+ restoreFetch();
70
+ restoreFetch = null;
71
+ }
72
+ });
73
+
74
+ test('yields text_delta for a <200-char greeting that never hits passthrough threshold', async () => {
75
+ // Gemma 4 E2B greetings are 30-150 chars and emit no <|channel|> markers.
76
+ // Pre-0.30: rawBuffer accumulated silently, never yielded, full response 0 chars.
77
+ // Post-0.30: the stream-done handler flushes the buffer to a text_delta.
78
+ restoreFetch = stubFetch(mockSSEResponse([
79
+ { content: 'Hi' },
80
+ { content: ' there!' },
81
+ { content: ' How can I help?' },
82
+ { finish_reason: 'stop' },
83
+ ]));
84
+
85
+ const gen = agentLoop({
86
+ model: 'test-model',
87
+ messages: [
88
+ { role: 'system', content: 'test' },
89
+ { role: 'user', content: 'hi' },
90
+ ],
91
+ tools: [],
92
+ provider: makeLocalProvider(),
93
+ });
94
+
95
+ const events = [];
96
+ let fullResponse = '';
97
+ for await (const event of gen) {
98
+ events.push(event);
99
+ if (event.type === 'text_delta' && event.text) {
100
+ fullResponse += event.text;
101
+ }
102
+ if (event.type === 'done') break;
103
+ }
104
+
105
+ assert.strictEqual(fullResponse, 'Hi there! How can I help?');
106
+ const textDeltas = events.filter((e) => e.type === 'text_delta');
107
+ assert.ok(textDeltas.length >= 1, 'expected at least one text_delta event');
108
+ const doneEvents = events.filter((e) => e.type === 'done');
109
+ assert.strictEqual(doneEvents.length, 1);
110
+ });
111
+
112
+ test('does not flush when the buffer contains tool call markers', async () => {
113
+ // Guards against false-positive text emission when the model emits a
114
+ // text-based tool call — those are handled by the post-loop parseToolCalls()
115
+ // branch, not the flush path.
116
+ restoreFetch = stubFetch(mockSSEResponse([
117
+ { content: '<tool_call>' },
118
+ { content: '{"name":"web_search","arguments":{"query":"weather"}}' },
119
+ { content: '</tool_call>' },
120
+ { finish_reason: 'stop' },
121
+ ]));
122
+
123
+ const gen = agentLoop({
124
+ model: 'test-model',
125
+ messages: [
126
+ { role: 'system', content: 'test' },
127
+ { role: 'user', content: 'weather?' },
128
+ ],
129
+ tools: [
130
+ {
131
+ name: 'web_search',
132
+ description: 'Search',
133
+ parameters: { type: 'object' },
134
+ execute: async () => 'sunny',
135
+ },
136
+ ],
137
+ provider: makeLocalProvider(),
138
+ maxTurns: 1, // Cap after the first iteration so the loop exits
139
+ });
140
+
141
+ const events = [];
142
+ for await (const event of gen) {
143
+ events.push(event);
144
+ if (events.length > 20) break; // Safety cap in case tool loop misbehaves
145
+ }
146
+
147
+ // Critical assertion: no text_delta should carry the raw <tool_call> markup.
148
+ // If the flush branch fires unguarded, the user would see literal
149
+ // "<tool_call>..." in their chat bubble.
150
+ const textWithMarkers = events
151
+ .filter((e) => e.type === 'text_delta')
152
+ .filter((e) => e.text && e.text.includes('<tool_call>'));
153
+ assert.strictEqual(textWithMarkers.length, 0,
154
+ 'tool_call markup must not leak through the flush branch');
155
+ });
156
+
157
+ test('end-to-end text accumulation matches the realtime consumer pattern', async () => {
158
+ // Simulates a streaming consumer (e.g. a WebSocket bridge): accumulate
159
+ // text from text_delta events, break on done. Pre-0.30 the accumulated
160
+ // string was empty. Post-0.30 it matches the model's full utterance.
161
+ restoreFetch = stubFetch(mockSSEResponse([
162
+ { content: 'Hello' },
163
+ { content: '!' },
164
+ { finish_reason: 'stop' },
165
+ ]));
166
+
167
+ const gen = agentLoop({
168
+ model: 'test-model',
169
+ messages: [{ role: 'user', content: 'hi' }],
170
+ tools: [],
171
+ provider: makeLocalProvider(),
172
+ });
173
+
174
+ let fullResponse = '';
175
+ let textDeltaCount = 0;
176
+ let sawDone = false;
177
+ for await (const event of gen) {
178
+ if (event.type === 'text_delta') {
179
+ fullResponse += event.text;
180
+ textDeltaCount++;
181
+ }
182
+ if (event.type === 'done') {
183
+ sawDone = true;
184
+ break;
185
+ }
186
+ }
187
+
188
+ assert.strictEqual(fullResponse, 'Hello!');
189
+ assert.ok(textDeltaCount > 0, 'expected at least one text_delta');
190
+ assert.strictEqual(sawDone, true);
191
+ });
192
+ });
@@ -0,0 +1,116 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { createCronHandler } from '../core/cron_handler.js';
4
+
5
+ /**
6
+ * Regression tests for the cron heartbeat path.
7
+ *
8
+ * These cover the skip-when-no-tasks optimization added in 0.30: a heartbeat
9
+ * firing with zero active tasks used to send a pointless "[Heartbeat] ..."
10
+ * message to the LLM on every tick. Now it returns null from
11
+ * buildHeartbeatContent before the agent is ever called, saving a round trip
12
+ * on every provider (and real dollars on cloud providers).
13
+ */
14
+
15
+ /**
16
+ * Build a minimal sessionStore stub that returns a fixed session. The session
17
+ * has no `updatedAt` so the stale-user check is skipped.
18
+ */
19
+ function makeSessionStore(owner = 'user-1') {
20
+ return {
21
+ async getOrCreateDefaultSession() {
22
+ return { id: 'session-1', owner, messages: [] };
23
+ },
24
+ async getSessionInternal(id) {
25
+ return { id, owner, messages: [], provider: 'ollama', model: 'test' };
26
+ },
27
+ async addMessage() {},
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Track whether agent.chat was invoked, without any real streaming.
33
+ */
34
+ function makeAgentSpy() {
35
+ const calls = [];
36
+ return {
37
+ calls,
38
+ async *chat(opts) {
39
+ calls.push(opts);
40
+ yield { type: 'done', content: '' };
41
+ },
42
+ };
43
+ }
44
+
45
+ describe('cron_handler — heartbeat skip optimization', () => {
46
+ test('skips the agent call entirely when there are no active tasks', async () => {
47
+ const sessionStore = makeSessionStore();
48
+ const agent = makeAgentSpy();
49
+
50
+ const handleTask = createCronHandler({
51
+ sessionStore,
52
+ cronStore: {},
53
+ taskStore: null,
54
+ memoryStore: null,
55
+ providers: {},
56
+ hooks: {
57
+ tasksFinder: async () => [], // the key condition — zero tasks
58
+ },
59
+ });
60
+ handleTask.setAgent(agent);
61
+
62
+ await handleTask({ name: 'heartbeat', userId: 'user-1', prompt: 'Any updates?' });
63
+
64
+ // Agent must NOT have been called since there's nothing to discuss.
65
+ assert.strictEqual(agent.calls.length, 0,
66
+ 'agent.chat must not be invoked when tasksFinder returns []');
67
+ });
68
+
69
+ test('still calls the agent when there is at least one active task', async () => {
70
+ const sessionStore = makeSessionStore();
71
+ const agent = makeAgentSpy();
72
+
73
+ const handleTask = createCronHandler({
74
+ sessionStore,
75
+ cronStore: {},
76
+ taskStore: null,
77
+ memoryStore: null,
78
+ providers: {},
79
+ hooks: {
80
+ tasksFinder: async () => [
81
+ { id: 't1', description: 'Ship the scrub', priority: 'high' },
82
+ ],
83
+ },
84
+ });
85
+ handleTask.setAgent(agent);
86
+
87
+ await handleTask({ name: 'heartbeat', userId: 'user-1', prompt: 'Any updates?' });
88
+
89
+ assert.strictEqual(agent.calls.length, 1,
90
+ 'agent.chat should be invoked when at least one active task exists');
91
+ });
92
+
93
+ test('skips the agent call if tasksFinder throws', async () => {
94
+ // Fail-closed guard: if the task store is down, a heartbeat should not
95
+ // degrade to a meaningless default prompt sent to the LLM.
96
+ const sessionStore = makeSessionStore();
97
+ const agent = makeAgentSpy();
98
+
99
+ const handleTask = createCronHandler({
100
+ sessionStore,
101
+ cronStore: {},
102
+ taskStore: null,
103
+ memoryStore: null,
104
+ providers: {},
105
+ hooks: {
106
+ tasksFinder: async () => { throw new Error('db unreachable'); },
107
+ },
108
+ });
109
+ handleTask.setAgent(agent);
110
+
111
+ await handleTask({ name: 'heartbeat', userId: 'user-1', prompt: 'Any updates?' });
112
+
113
+ assert.strictEqual(agent.calls.length, 0,
114
+ 'agent.chat must not be invoked when tasksFinder throws');
115
+ });
116
+ });
package/tools/memory.js CHANGED
@@ -37,7 +37,7 @@ export const memoryTools = [
37
37
  type: "array",
38
38
  items: { type: "string" },
39
39
  description:
40
- "Short tags for categorization. e.g. ['personal', 'name'] or ['project', 'dottie']",
40
+ "Short tags for categorization. e.g. ['personal', 'name'] or ['project', 'myapp']",
41
41
  },
42
42
  },
43
43
  required: ["content"],
@@ -133,4 +133,25 @@ export const AI_PROVIDERS = {
133
133
  }),
134
134
  formatResponse: (data) => data.choices?.[0]?.message?.content
135
135
  },
136
+ mlx_local: {
137
+ // Local MLX-style OpenAI-compatible server (e.g. mlx_lm.server, LM Studio,
138
+ // vLLM, llama.cpp server). Routes through the `mlx_local` branch in
139
+ // core/agent.js which auto-detects gpt-oss channel tokens, native
140
+ // reasoning, and plain-text responses. Override the URL with MLX_LOCAL_URL.
141
+ id: 'mlx_local',
142
+ name: 'Local (MLX)',
143
+ apiUrl: process.env.MLX_LOCAL_URL || 'http://127.0.0.1:1316/v1',
144
+ defaultModel: '',
145
+ models: [],
146
+ local: true,
147
+ headers: () => ({
148
+ 'Content-Type': 'application/json'
149
+ }),
150
+ endpoint: '/chat/completions',
151
+ formatRequest: (messages, model) => ({
152
+ model,
153
+ messages
154
+ }),
155
+ formatResponse: (data) => data.choices?.[0]?.message?.content
156
+ },
136
157
  };
@@ -1,7 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(grep -r \"grok-3\" /Users/sd/Desktop/projects/dotbot --include=\"*.js\" --include=\"*.md\" 2>/dev/null | grep -v node_modules)"
5
- ]
6
- }
7
- }
package/dotbot.db DELETED
Binary file