@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.
- package/CHANGELOG.md +32 -0
- package/README.md +65 -24
- package/bin/dotbot.js +63 -93
- package/core/agent.js +30 -13
- package/core/cdp.js +5 -58
- package/core/compaction.js +1 -1
- package/core/cron_handler.js +38 -27
- 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/index.js +0 -7
- package/package.json +1 -1
- package/storage/SQLiteAdapter.js +1 -1
- package/storage/SQLiteCronAdapter.js +8 -92
- package/storage/index.js +0 -3
- package/test/agent.test.js +192 -0
- package/test/cron_handler.test.js +116 -0
- package/tools/appgen.js +1 -10
- package/tools/browser.js +0 -15
- package/tools/code.js +0 -28
- package/tools/images.js +0 -10
- package/tools/index.js +2 -4
- package/tools/jobs.js +0 -2
- package/tools/memory.js +1 -1
- package/tools/tasks.js +0 -2
- package/tools/web.js +0 -36
- package/utils/providers.js +21 -0
- package/.claude/settings.local.json +0 -7
- package/dotbot.db +0 -0
- package/examples/sqlite-session-example.js +0 -69
- package/observer/index.js +0 -164
|
@@ -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
|
-
|
|
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
|
|
16
|
+
import { taskTools } from './tasks.js';
|
|
17
17
|
import { triggerTools } from './triggers.js';
|
|
18
|
-
import { jobTools
|
|
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
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/tools/tasks.js
CHANGED
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]`;
|
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
|
+
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
|
};
|
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
|
-
];
|