@stevederico/dotbot 0.28.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.
@@ -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/appgen.js CHANGED
@@ -57,14 +57,7 @@ export function cleanGeneratedCode(code) {
57
57
  if (!code) return { code: '', windowSize: { width: 800, height: 650 } };
58
58
 
59
59
  let cleanCode = code
60
- // Remove markdown code blocks
61
- .replace(/```javascript/gi, '')
62
- .replace(/```jsx/gi, '')
63
- .replace(/```js/gi, '')
64
- .replace(/```react/gi, '')
65
- .replace(/```typescript/gi, '')
66
- .replace(/```tsx/gi, '')
67
- .replace(/```/g, '')
60
+ .replace(/```(?:javascript|jsx|js|react|typescript|tsx)?/gi, '')
68
61
  // Remove HTML document wrappers
69
62
  .replace(/<html[^>]*>[\s\S]*<\/html>/gi, '')
70
63
  .replace(/<head[^>]*>[\s\S]*<\/head>/gi, '')
@@ -307,5 +300,3 @@ export const appgenTools = [
307
300
  }
308
301
  }
309
302
  ];
310
-
311
- export default appgenTools;
package/tools/browser.js CHANGED
@@ -616,21 +616,6 @@ export function createBrowserTools(screenshotUrlPattern = (filename) => `/api/ag
616
616
  pageSummary += `\n\nPage structure:\n${trimmed}`;
617
617
  }
618
618
 
619
- // Log to activity so Photos app can list the screenshot
620
- if (context?.databaseManager) {
621
- try {
622
- await context.databaseManager.logAgentActivity(
623
- context.dbConfig.dbType,
624
- context.dbConfig.db,
625
- context.dbConfig.connectionString,
626
- context.userID,
627
- { type: 'image_generation', prompt: `Screenshot: ${title}`, url: screenshotUrl, source: 'browser' }
628
- );
629
- } catch {
630
- /* best effort */
631
- }
632
- }
633
-
634
619
  // Return image JSON so frontend renders the screenshot inline
635
620
  return JSON.stringify({ type: 'image', url: screenshotUrl, prompt: pageSummary });
636
621
  } catch (err) {
package/tools/code.js CHANGED
@@ -52,20 +52,6 @@ export const codeTools = [
52
52
 
53
53
  await unlink(tmpFile).catch(() => {});
54
54
 
55
- if (context?.databaseManager) {
56
- try {
57
- await context.databaseManager.logAgentActivity(
58
- context.dbConfig.dbType, context.dbConfig.db, context.dbConfig.connectionString,
59
- context.userID, {
60
- type: 'code_execution',
61
- code: input.code.slice(0, 500),
62
- output: (stdout || stderr || '').slice(0, 500),
63
- success: !stderr
64
- }
65
- );
66
- } catch (e) { /* best effort */ }
67
- }
68
-
69
55
  if (stderr) {
70
56
  return `Stderr:\n${stderr}\n\nStdout:\n${stdout}`;
71
57
  }
@@ -74,20 +60,6 @@ export const codeTools = [
74
60
  } catch (err) {
75
61
  await unlink(tmpFile).catch(() => {});
76
62
 
77
- if (context?.databaseManager) {
78
- try {
79
- await context.databaseManager.logAgentActivity(
80
- context.dbConfig.dbType, context.dbConfig.db, context.dbConfig.connectionString,
81
- context.userID, {
82
- type: 'code_execution',
83
- code: input.code.slice(0, 500),
84
- output: (err.stderr || err.message || '').slice(0, 500),
85
- success: false
86
- }
87
- );
88
- } catch (e) { /* best effort */ }
89
- }
90
-
91
63
  if (err.killed) {
92
64
  return "Error: code execution timed out (10s limit)";
93
65
  }
package/tools/images.js CHANGED
@@ -176,16 +176,6 @@ export const imageTools = [
176
176
  return result.error;
177
177
  }
178
178
 
179
- // Log to activity for persistence
180
- if (context?.databaseManager) {
181
- try {
182
- await context.databaseManager.logAgentActivity(
183
- context.dbConfig.dbType, context.dbConfig.db, context.dbConfig.connectionString,
184
- context.userID, { type: 'image_generation', prompt: input.prompt, url: result.url, source: 'agent' }
185
- );
186
- } catch (e) { /* best effort */ }
187
- }
188
-
189
179
  return JSON.stringify({ type: 'image', url: result.url, prompt: input.prompt });
190
180
  },
191
181
  },
package/tools/index.js CHANGED
@@ -13,9 +13,9 @@ import { imageTools } from './images.js';
13
13
  import { weatherTools } from './weather.js';
14
14
  import { notifyTools } from './notify.js';
15
15
  import { browserTools, createBrowserTools } from './browser.js';
16
- import { taskTools, goalTools } from './tasks.js';
16
+ import { taskTools } from './tasks.js';
17
17
  import { triggerTools } from './triggers.js';
18
- import { jobTools, cronTools } from './jobs.js';
18
+ import { jobTools } from './jobs.js';
19
19
  import { eventTools } from './events.js';
20
20
  import { appgenTools } from './appgen.js';
21
21
 
@@ -88,10 +88,8 @@ export {
88
88
  browserTools,
89
89
  createBrowserTools,
90
90
  taskTools,
91
- goalTools, // backwards compatibility alias
92
91
  triggerTools,
93
92
  jobTools,
94
- cronTools, // backwards compatibility alias
95
93
  eventTools,
96
94
  appgenTools,
97
95
  };
package/tools/jobs.js CHANGED
@@ -155,5 +155,3 @@ export const jobTools = [
155
155
  },
156
156
  ];
157
157
 
158
- // Backwards compatibility alias
159
- export const cronTools = jobTools;
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"],
package/tools/tasks.js CHANGED
@@ -400,5 +400,3 @@ export const taskTools = [
400
400
  },
401
401
  ];
402
402
 
403
- // Backwards compatibility: export goalTools as alias
404
- export const goalTools = taskTools;
package/tools/web.js CHANGED
@@ -70,15 +70,6 @@ export const webTools = [
70
70
  result += "\n\nSources:\n" + [...citations].slice(0, 5).map((url, i) => `${i + 1}. ${url}`).join("\n");
71
71
  }
72
72
 
73
- if (context?.databaseManager) {
74
- try {
75
- await context.databaseManager.logAgentActivity(
76
- context.dbConfig.dbType, context.dbConfig.db, context.dbConfig.connectionString,
77
- context.userID, { type: 'web_search', query: input.query, provider: 'grok', resultPreview: result.slice(0, 300) }
78
- );
79
- } catch (e) { /* best effort */ }
80
- }
81
-
82
73
  return result || "No results found.";
83
74
  } else {
84
75
  const errText = await res.text();
@@ -120,15 +111,6 @@ export const webTools = [
120
111
 
121
112
  const result = parts.join("\n\n");
122
113
 
123
- if (context?.databaseManager) {
124
- try {
125
- await context.databaseManager.logAgentActivity(
126
- context.dbConfig.dbType, context.dbConfig.db, context.dbConfig.connectionString,
127
- context.userID, { type: 'web_search', query: input.query, provider: 'duckduckgo', resultPreview: parts.slice(0, 2).join('\n').slice(0, 300) }
128
- );
129
- } catch (e) { /* best effort */ }
130
- }
131
-
132
114
  return result;
133
115
  },
134
116
  },
@@ -176,15 +158,6 @@ export const webTools = [
176
158
  text = text.slice(0, maxChars) + `\n\n... [truncated, ${text.length} chars total]`;
177
159
  }
178
160
 
179
- if (context?.databaseManager) {
180
- try {
181
- await context.databaseManager.logAgentActivity(
182
- context.dbConfig.dbType, context.dbConfig.db, context.dbConfig.connectionString,
183
- context.userID, { type: 'grokipedia_search', query: input.query, url }
184
- );
185
- } catch (e) { /* best effort */ }
186
- }
187
-
188
161
  return text;
189
162
  } catch (err) {
190
163
  return `Error looking up Grokipedia: ${err.message}`;
@@ -260,15 +233,6 @@ export const webTools = [
260
233
  .trim();
261
234
  }
262
235
 
263
- if (context?.databaseManager) {
264
- try {
265
- await context.databaseManager.logAgentActivity(
266
- context.dbConfig.dbType, context.dbConfig.db, context.dbConfig.connectionString,
267
- context.userID, { type: 'web_fetch', url: input.url, status: res.status }
268
- );
269
- } catch (e) { /* best effort */ }
270
- }
271
-
272
236
  const maxChars = 8000;
273
237
  if (text.length > maxChars) {
274
238
  return text.slice(0, maxChars) + `\n\n... [truncated, ${text.length} chars total]`;
@@ -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
@@ -1,69 +0,0 @@
1
- /**
2
- * SQLiteSessionStore Usage Example
3
- *
4
- * Demonstrates how to use SQLite as a session storage backend
5
- * for the @dottie/agent library. Requires Node.js 22.5+.
6
- */
7
-
8
- import { createAgent, SQLiteSessionStore, coreTools } from '@dottie/agent';
9
-
10
- // Initialize SQLite session store
11
- const sessionStore = new SQLiteSessionStore();
12
- await sessionStore.init('./sessions.db', {
13
- // Optional: Fetch user preferences from your database
14
- prefsFetcher: async (userId) => {
15
- // Example: fetch from a user database
16
- return {
17
- agentName: 'Dottie',
18
- agentPersonality: 'helpful and concise',
19
- };
20
- },
21
- // Optional: Ensure user heartbeat for cron tasks
22
- heartbeatEnsurer: async (userId) => {
23
- // Example: update last_seen timestamp in user database
24
- console.log(`User ${userId} active`);
25
- return null;
26
- },
27
- });
28
-
29
- // Create agent with SQLite session storage
30
- const agent = createAgent({
31
- sessionStore,
32
- providers: {
33
- anthropic: { apiKey: process.env.ANTHROPIC_API_KEY },
34
- },
35
- tools: coreTools,
36
- });
37
-
38
- // Example 1: Create a new session
39
- const session = await agent.createSession('user-123', 'claude-sonnet-4-5', 'anthropic');
40
- console.log('Created session:', session.id);
41
-
42
- // Example 2: Chat with the agent
43
- for await (const event of agent.chat({
44
- sessionId: session.id,
45
- message: 'What can you help me with?',
46
- provider: 'anthropic',
47
- model: 'claude-sonnet-4-5',
48
- })) {
49
- if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
50
- process.stdout.write(event.delta.text);
51
- }
52
- }
53
- console.log('\n');
54
-
55
- // Example 3: List all sessions for a user
56
- const sessions = await sessionStore.listSessions('user-123');
57
- console.log('User sessions:', sessions);
58
-
59
- // Example 4: Get or create default session
60
- const defaultSession = await sessionStore.getOrCreateDefaultSession('user-123');
61
- console.log('Default session:', defaultSession.id);
62
-
63
- // Example 5: Clear session history
64
- await sessionStore.clearSession(session.id);
65
- console.log('Session cleared');
66
-
67
- // Example 6: Delete a session
68
- await sessionStore.deleteSession(session.id, 'user-123');
69
- console.log('Session deleted');
package/observer/index.js DELETED
@@ -1,164 +0,0 @@
1
- /**
2
- * Browser Observer — in-memory snapshot store and agent tool.
3
- *
4
- * The frontend pushes structured browser-state snapshots via POST /api/agent/observer.
5
- * The agent reads the latest snapshot via the `browser_observe` tool to understand
6
- * what the user is currently doing in the browser.
7
- */
8
-
9
- const SNAPSHOT_TTL_MS = 5 * 60 * 1000; // 5 minutes
10
-
11
- /** @type {Map<string, Object>} userID → { ...snapshot, receivedAt } */
12
- const snapshots = new Map();
13
-
14
- /**
15
- * Store the latest browser snapshot for a user.
16
- *
17
- * @param {string} userID - Authenticated user ID
18
- * @param {Object} snapshot - Structured browser state from the frontend
19
- */
20
- export function storeSnapshot(userID, snapshot) {
21
- snapshots.set(userID, { ...snapshot, receivedAt: Date.now() });
22
- }
23
-
24
- /**
25
- * Retrieve the latest snapshot for a user, or null if stale/missing.
26
- *
27
- * @param {string} userID - Authenticated user ID
28
- * @returns {Object|null} Snapshot with receivedAt, or null
29
- */
30
- export function getSnapshot(userID) {
31
- const entry = snapshots.get(userID);
32
- if (!entry) return null;
33
- if (Date.now() - entry.receivedAt > SNAPSHOT_TTL_MS) {
34
- snapshots.delete(userID);
35
- return null;
36
- }
37
- return entry;
38
- }
39
-
40
- /**
41
- * Remove a user's snapshot (cleanup on logout, etc.).
42
- *
43
- * @param {string} userID - Authenticated user ID
44
- */
45
- export function clearSnapshot(userID) {
46
- snapshots.delete(userID);
47
- }
48
-
49
- /**
50
- * Format a snapshot into plain-text for LLM consumption.
51
- *
52
- * @param {Object} snap - Snapshot object from the store
53
- * @param {boolean} includeActions - Whether to include recent actions
54
- * @returns {string} Human-readable state description
55
- */
56
- function formatSnapshot(snap, includeActions = true) {
57
- const ageSec = Math.round((Date.now() - snap.timestamp) / 1000);
58
- const lines = [];
59
-
60
- lines.push(`Browser state (${ageSec}s ago):`);
61
- lines.push('');
62
-
63
- // Windows
64
- if (snap.windows && snap.windows.length > 0) {
65
- lines.push(`Open apps (${snap.windowCount || snap.windows.length}):`);
66
- for (const w of snap.windows) {
67
- const focus = w.isFocused ? ' [focused]' : '';
68
- lines.push(` - ${w.app}${w.title ? ': ' + w.title : ''}${focus}`);
69
- }
70
- } else {
71
- lines.push('No apps open.');
72
- }
73
-
74
- if (snap.focusedApp) {
75
- lines.push(`Focused: ${snap.focusedApp}`);
76
- }
77
-
78
- // Docked panel
79
- if (snap.isDottieDocked) {
80
- lines.push('DotBot panel: docked (sidebar)');
81
- }
82
-
83
- // Input bar
84
- if (snap.isInputElevated) {
85
- lines.push(`Input bar: elevated${snap.inputValue ? ' — "' + snap.inputValue + '"' : ''}`);
86
- }
87
-
88
- // Voice
89
- if (snap.voiceState && snap.voiceState !== 'idle') {
90
- lines.push(`Voice: ${snap.voiceState}`);
91
- }
92
-
93
- // Streaming
94
- if (snap.isStreaming) {
95
- lines.push('Agent: streaming response');
96
- }
97
-
98
- // Last tool call
99
- if (snap.lastToolCall) {
100
- const tc = snap.lastToolCall;
101
- const tcAge = Math.round((Date.now() - tc.timestamp) / 1000);
102
- lines.push(`Last tool: ${tc.name} (${tc.status}, ${tcAge}s ago)`);
103
- }
104
-
105
- // Messages
106
- if (snap.messageCount > 0) {
107
- lines.push(`Messages in session: ${snap.messageCount}`);
108
- }
109
-
110
- // Provider/model
111
- if (snap.currentProvider || snap.currentModel) {
112
- lines.push(`Model: ${snap.currentProvider || '?'}/${snap.currentModel || '?'}`);
113
- }
114
-
115
- // Layout + dock
116
- lines.push(`Layout: ${snap.layoutMode || 'desktop'}`);
117
- if (snap.dockApps && snap.dockApps.length > 0) {
118
- lines.push(`Dock: ${snap.dockApps.join(', ')}`);
119
- }
120
-
121
- // Viewport
122
- if (snap.viewport) {
123
- lines.push(`Viewport: ${snap.viewport.width}x${snap.viewport.height}`);
124
- }
125
-
126
- // Recent actions
127
- if (includeActions && snap.recentActions && snap.recentActions.length > 0) {
128
- lines.push('');
129
- lines.push('Recent actions:');
130
- for (const a of snap.recentActions) {
131
- const aAge = Math.round((Date.now() - a.timestamp) / 1000);
132
- const detail = a.app ? ` (${a.app})` : a.tool ? ` (${a.tool})` : '';
133
- lines.push(` - ${a.action}${detail} — ${aAge}s ago`);
134
- }
135
- }
136
-
137
- return lines.join('\n');
138
- }
139
-
140
- /** Agent tool definitions for the browser observer. */
141
- export const observerTools = [
142
- {
143
- name: 'browser_observe',
144
- description:
145
- "See what the user is currently doing in Dottie OS — open apps, focused window, voice state, recent actions. " +
146
- "Call this when you need context about the user's current activity, or when they reference 'this', 'what I'm looking at', or 'current'.",
147
- parameters: {
148
- type: 'object',
149
- properties: {
150
- include_actions: {
151
- type: 'boolean',
152
- description: 'Include recent user actions (default true)',
153
- },
154
- },
155
- },
156
- execute: async (input, signal, context) => {
157
- if (!context?.userID) return 'Error: user context not available';
158
- const snap = getSnapshot(context.userID);
159
- if (!snap) return 'No browser state available. The user may have the tab in the background or just opened the page.';
160
- const includeActions = input.include_actions !== false;
161
- return formatSnapshot(snap, includeActions);
162
- },
163
- },
164
- ];