@stevederico/dotbot 0.29.0 → 0.32.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 +28 -0
- package/README.md +3 -14
- package/bin/dotbot.js +9 -1
- package/core/agent.js +30 -13
- package/core/compaction.js +1 -1
- package/core/cron_handler.js +38 -27
- package/core/gptoss_tool_parser.js +3 -3
- package/core/init.js +6 -1
- package/core/trigger_handler.js +5 -3
- package/docs/core.md +1 -1
- package/docs/protected-files.md +5 -5
- package/package.json +1 -1
- package/storage/SQLiteAdapter.js +1 -1
- package/test/agent.test.js +192 -0
- package/test/cron_handler.test.js +116 -0
- package/tools/memory.js +1 -1
- package/utils/providers.js +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
0.32
|
|
2
|
+
|
|
3
|
+
Rename mlx_local provider to local
|
|
4
|
+
Rename MLX_LOCAL_URL env var to LOCAL_LLM_URL
|
|
5
|
+
Remove MLX references from comments and docs
|
|
6
|
+
|
|
7
|
+
0.31
|
|
8
|
+
|
|
9
|
+
Document mlx_local provider
|
|
10
|
+
Fix CLI local auth
|
|
11
|
+
Remove shields.io badges
|
|
12
|
+
Generify personal path example
|
|
13
|
+
|
|
14
|
+
0.30
|
|
15
|
+
|
|
16
|
+
Flush buffered plain text
|
|
17
|
+
Add agentLoop regression tests
|
|
18
|
+
Rename dottie_desktop to mlx_local
|
|
19
|
+
Rename getDottieDesktopStatus helper
|
|
20
|
+
Rename DOTTIE_DESKTOP_URL env var
|
|
21
|
+
Parameterize notification title
|
|
22
|
+
Default agentName to Assistant
|
|
23
|
+
Scrub host-specific doc references
|
|
24
|
+
Skip heartbeat without tasks
|
|
25
|
+
Fail closed on task fetch error
|
|
26
|
+
Add mlx_local provider entry
|
|
27
|
+
Add cron_handler regression tests
|
|
28
|
+
|
|
1
29
|
0.29
|
|
2
30
|
|
|
3
31
|
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
|
|
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, 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
|
+
LOCAL_LLM_URL Base URL for a local OpenAI-compatible LLM 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, 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
|
+
LOCAL_LLM_URL Base URL for a local local OpenAI-compatible LLM 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 (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
|
@@ -162,7 +162,7 @@ export async function* agentLoop({ model, messages, tools, signal, provider, con
|
|
|
162
162
|
};
|
|
163
163
|
|
|
164
164
|
// Include tool definitions for non-local providers and local providers
|
|
165
|
-
// that support native tool calling (e.g., GLM-4.7 via
|
|
165
|
+
// that support native tool calling (e.g., GLM-4.7 via local LLM server v0.30.7+)
|
|
166
166
|
if (!targetProvider.local || targetProvider.supportsToolRole) {
|
|
167
167
|
requestBody.tools = toolDefs;
|
|
168
168
|
}
|
|
@@ -174,7 +174,7 @@ export async function* agentLoop({ model, messages, tools, signal, provider, con
|
|
|
174
174
|
};
|
|
175
175
|
};
|
|
176
176
|
|
|
177
|
-
// Local providers (ollama,
|
|
177
|
+
// Local providers (ollama, 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 === "
|
|
214
|
-
//
|
|
213
|
+
} else if (activeProvider.id === "local") {
|
|
214
|
+
// Local 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("[
|
|
289
|
+
console.log("[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("[
|
|
304
|
+
console.log("[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("[
|
|
314
|
+
console.log("[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("[
|
|
328
|
+
console.log("[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("[
|
|
344
|
+
console.log("[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
|
|
744
|
-
*
|
|
759
|
+
* Check if a local OpenAI-compatible model server is running and list
|
|
760
|
+
* available models. Defaults to the local LLM server convention
|
|
761
|
+
* (http://localhost:1316/v1) and can be overridden with LOCAL_LLM_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
|
|
769
|
-
const baseUrl = (process.env.
|
|
785
|
+
export async function getMlxLocalStatus() {
|
|
786
|
+
const baseUrl = (process.env.LOCAL_LLM_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: [] };
|
package/core/compaction.js
CHANGED
package/core/cron_handler.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cron task handler for dotbot.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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:
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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;
|
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
* 3. LFM2.5 native format with markers:
|
|
13
13
|
* <|tool_call_start|>[tool_name(arg1="value1")]<|tool_call_end|>
|
|
14
14
|
*
|
|
15
|
-
* 4. LFM2.5 bare Pythonic format (markers stripped by
|
|
15
|
+
* 4. LFM2.5 bare Pythonic format (markers stripped by local LLM server):
|
|
16
16
|
* [tool_name(arg1="value1", arg2="value2")]
|
|
17
17
|
*
|
|
18
18
|
* Used when the model doesn't support native OpenAI-style tool calling
|
|
19
|
-
* (e.g.,
|
|
19
|
+
* (e.g., local LLM server) and tool definitions are injected via system prompt.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
const TOOL_CALL_RE = /<tool_call>([\s\S]*?)<\/tool_call>/g;
|
|
@@ -135,7 +135,7 @@ export function parseToolCalls(text) {
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
// Format 4: [func_name(key="val")] (bare Pythonic, markers stripped by
|
|
138
|
+
// Format 4: [func_name(key="val")] (bare Pythonic, markers stripped by local LLM server)
|
|
139
139
|
if (calls.length === 0) {
|
|
140
140
|
BARE_PYTHONIC_RE.lastIndex = 0;
|
|
141
141
|
while ((match = BARE_PYTHONIC_RE.exec(text)) !== null) {
|
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 (
|
|
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
|
|
package/core/trigger_handler.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Trigger handler for dotbot.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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:
|
|
138
|
+
title: notificationTitle,
|
|
137
139
|
body: trimmed.slice(0, 500),
|
|
138
140
|
type: 'trigger',
|
|
139
141
|
});
|
package/docs/core.md
CHANGED
package/docs/protected-files.md
CHANGED
|
@@ -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
|
-
|
|
51
|
+
Host-App Data (Example)
|
|
52
52
|
|
|
53
|
-
~/.
|
|
54
|
-
~/.
|
|
55
|
-
~/.
|
|
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
|
|
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
package/storage/SQLiteAdapter.js
CHANGED
|
@@ -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 = '
|
|
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 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 local-style provider for agentLoop tests.
|
|
17
|
+
* The `id` must be "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: '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 — 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', '
|
|
40
|
+
"Short tags for categorization. e.g. ['personal', 'name'] or ['project', 'myapp']",
|
|
41
41
|
},
|
|
42
42
|
},
|
|
43
43
|
required: ["content"],
|
package/utils/providers.js
CHANGED
|
@@ -133,4 +133,25 @@ export const AI_PROVIDERS = {
|
|
|
133
133
|
}),
|
|
134
134
|
formatResponse: (data) => data.choices?.[0]?.message?.content
|
|
135
135
|
},
|
|
136
|
+
local: {
|
|
137
|
+
// Local OpenAI-compatible server (e.g. local LLM server, LM Studio,
|
|
138
|
+
// vLLM, llama.cpp server). Routes through the `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 LOCAL_LLM_URL.
|
|
141
|
+
id: 'local',
|
|
142
|
+
name: 'Local',
|
|
143
|
+
apiUrl: process.env.LOCAL_LLM_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
|
};
|