@stevederico/dotbot 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +380 -0
  3. package/bin/dotbot.js +461 -0
  4. package/core/agent.js +779 -0
  5. package/core/compaction.js +261 -0
  6. package/core/cron_handler.js +262 -0
  7. package/core/events.js +229 -0
  8. package/core/failover.js +193 -0
  9. package/core/gptoss_tool_parser.js +173 -0
  10. package/core/init.js +154 -0
  11. package/core/normalize.js +324 -0
  12. package/core/trigger_handler.js +148 -0
  13. package/docs/core.md +103 -0
  14. package/docs/protected-files.md +59 -0
  15. package/examples/sqlite-session-example.js +69 -0
  16. package/index.js +341 -0
  17. package/observer/index.js +164 -0
  18. package/package.json +42 -0
  19. package/storage/CronStore.js +145 -0
  20. package/storage/EventStore.js +71 -0
  21. package/storage/MemoryStore.js +175 -0
  22. package/storage/MongoAdapter.js +291 -0
  23. package/storage/MongoCronAdapter.js +347 -0
  24. package/storage/MongoTaskAdapter.js +242 -0
  25. package/storage/MongoTriggerAdapter.js +158 -0
  26. package/storage/SQLiteAdapter.js +382 -0
  27. package/storage/SQLiteCronAdapter.js +562 -0
  28. package/storage/SQLiteEventStore.js +300 -0
  29. package/storage/SQLiteMemoryAdapter.js +240 -0
  30. package/storage/SQLiteTaskAdapter.js +419 -0
  31. package/storage/SQLiteTriggerAdapter.js +262 -0
  32. package/storage/SessionStore.js +149 -0
  33. package/storage/TaskStore.js +100 -0
  34. package/storage/TriggerStore.js +90 -0
  35. package/storage/cron_constants.js +48 -0
  36. package/storage/index.js +21 -0
  37. package/tools/appgen.js +311 -0
  38. package/tools/browser.js +634 -0
  39. package/tools/code.js +101 -0
  40. package/tools/events.js +145 -0
  41. package/tools/files.js +201 -0
  42. package/tools/images.js +253 -0
  43. package/tools/index.js +97 -0
  44. package/tools/jobs.js +159 -0
  45. package/tools/memory.js +332 -0
  46. package/tools/messages.js +135 -0
  47. package/tools/notify.js +42 -0
  48. package/tools/tasks.js +404 -0
  49. package/tools/triggers.js +159 -0
  50. package/tools/weather.js +82 -0
  51. package/tools/web.js +283 -0
  52. package/utils/providers.js +136 -0
@@ -0,0 +1,261 @@
1
+ // core/compaction.js
2
+ // Session auto-compaction: summarizes old messages when context nears provider limits.
3
+ // Runs before trimMessages as an intelligent alternative to hard truncation.
4
+ // Operates on standard-format messages (see normalize.js).
5
+
6
+ import { AI_PROVIDERS } from "../utils/providers.js";
7
+ import { toStandardFormat, toProviderFormat } from "./normalize.js";
8
+
9
+ /** Max context window tokens per provider family. */
10
+ const CONTEXT_LIMITS = {
11
+ anthropic: 180000,
12
+ openai: 120000,
13
+ xai: 120000,
14
+ ollama: 6000,
15
+ dottie_desktop: 6000,
16
+ };
17
+
18
+ /** Number of recent messages to always preserve verbatim. */
19
+ const RECENT_TO_KEEP = 20;
20
+
21
+ /** Compaction triggers when estimated tokens exceed this fraction of the context limit. */
22
+ const COMPACT_THRESHOLD = 0.7;
23
+
24
+ /** Cheapest model per provider, used for summarization calls. */
25
+ const CHEAP_MODELS = {
26
+ xai: "grok-4-1-fast-non-reasoning",
27
+ anthropic: "claude-3-5-haiku-20241022",
28
+ openai: "gpt-5-nano",
29
+ };
30
+
31
+ /**
32
+ * Estimate token count for an array of standard-format messages.
33
+ *
34
+ * Uses a 4-chars-per-token heuristic. Counts content, toolCalls
35
+ * (name + input + result), and thinking fields.
36
+ *
37
+ * @param {Array} messages - Conversation messages in standard format.
38
+ * @returns {number} Estimated token count.
39
+ */
40
+ export function estimateTokens(messages) {
41
+ let chars = 0;
42
+
43
+ for (const msg of messages) {
44
+ if (typeof msg.content === "string") {
45
+ chars += msg.content.length;
46
+ }
47
+
48
+ if (msg.toolCalls) {
49
+ for (const tc of msg.toolCalls) {
50
+ chars += (tc.name || "").length;
51
+ chars += JSON.stringify(tc.input || {}).length;
52
+ if (tc.result) {
53
+ chars += (typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result)).length;
54
+ }
55
+ }
56
+ }
57
+
58
+ if (msg.thinking) {
59
+ chars += msg.thinking.length;
60
+ }
61
+ }
62
+
63
+ return Math.ceil(chars / 4);
64
+ }
65
+
66
+ /**
67
+ * Naive fallback summary when no AI provider is available.
68
+ *
69
+ * Extracts the first 100 characters of each user message, tool call names,
70
+ * and tool results, producing a bullet-point summary.
71
+ *
72
+ * @param {Array} messages - Standard-format messages to summarize.
73
+ * @returns {string} Plain-text summary.
74
+ */
75
+ function naiveSummary(messages) {
76
+ const lines = [];
77
+
78
+ for (const msg of messages) {
79
+ if (msg.role === "user" && msg.content) {
80
+ lines.push(`- User: ${msg.content.slice(0, 100)}`);
81
+ } else if (msg.role === "assistant") {
82
+ if (msg.toolCalls) {
83
+ for (const tc of msg.toolCalls) {
84
+ lines.push(`- Tool: ${tc.name}`);
85
+ if (tc.result) {
86
+ const resultStr = typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result);
87
+ lines.push(`- Result: ${resultStr.slice(0, 100)}`);
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ return lines.length > 0
95
+ ? lines.join("\n")
96
+ : "Previous conversation context (details unavailable).";
97
+ }
98
+
99
+ /**
100
+ * Summarize old messages using the cheapest available AI provider.
101
+ *
102
+ * Checks provider keys in order: xAI, Anthropic, OpenAI.
103
+ * Falls back to naiveSummary() if no provider is configured.
104
+ * Messages are expected in standard format; converted to provider format for the API call.
105
+ *
106
+ * @param {Array} oldMessages - Standard-format messages to summarize.
107
+ * @param {Object} [options={}]
108
+ * @param {AbortSignal} [options.signal] - Optional abort signal.
109
+ * @param {Object} [options.providers={}] - Provider config with API keys.
110
+ * @returns {Promise<string>} Summary text.
111
+ */
112
+ async function summarizeMessages(oldMessages, { signal, providers = {} } = {}) {
113
+ // Build plain-text transcript from standard-format messages
114
+ const transcript = oldMessages
115
+ .map((msg) => {
116
+ const role = msg.role || "unknown";
117
+ const parts = [];
118
+
119
+ if (msg.content) {
120
+ parts.push(msg.content);
121
+ }
122
+
123
+ if (msg.toolCalls) {
124
+ for (const tc of msg.toolCalls) {
125
+ parts.push(`[tool: ${tc.name}]`);
126
+ if (tc.result) {
127
+ const resultStr = typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result);
128
+ parts.push(`[result: ${resultStr.slice(0, 200)}]`);
129
+ }
130
+ }
131
+ }
132
+
133
+ return `${role}: ${parts.join(" ")}`;
134
+ })
135
+ .join("\n");
136
+
137
+ // Find cheapest available provider
138
+ const providerOrder = ["xai", "anthropic", "openai"];
139
+
140
+ let selectedProvider = null;
141
+ let selectedModel = null;
142
+ let apiKey = null;
143
+ for (const id of providerOrder) {
144
+ if (providers[id]?.apiKey) {
145
+ selectedProvider = AI_PROVIDERS[id];
146
+ selectedModel = CHEAP_MODELS[id];
147
+ apiKey = providers[id].apiKey;
148
+ break;
149
+ }
150
+ }
151
+
152
+ if (!selectedProvider || !apiKey) {
153
+ return naiveSummary(oldMessages);
154
+ }
155
+
156
+ const summaryPrompt =
157
+ "Summarize this conversation concisely. Preserve: key decisions, user facts, tool results, and any important outcomes. Omit: greetings, thinking steps, redundant tool calls. Output only the summary, no preamble.";
158
+
159
+ // Build summarization request in standard format, then convert for the provider
160
+ const summaryMessages = [
161
+ { role: "system", content: summaryPrompt },
162
+ { role: "user", content: transcript },
163
+ ];
164
+
165
+ const targetFormat = selectedProvider.id === "anthropic" ? "anthropic" : "openai";
166
+ const providerMessages = toProviderFormat(summaryMessages, targetFormat);
167
+
168
+ // Anthropic doesn't support system role in messages — use top-level system param
169
+ let requestBody;
170
+ if (targetFormat === "anthropic") {
171
+ requestBody = {
172
+ model: selectedModel,
173
+ max_tokens: 1024,
174
+ system: summaryPrompt,
175
+ messages: providerMessages.filter((m) => m.role !== "system"),
176
+ };
177
+ } else {
178
+ requestBody = {
179
+ model: selectedModel,
180
+ max_tokens: 1024,
181
+ messages: providerMessages,
182
+ };
183
+ }
184
+
185
+ try {
186
+ const url = `${selectedProvider.apiUrl}${selectedProvider.endpoint}`;
187
+ const headers = selectedProvider.headers(apiKey);
188
+
189
+ const res = await fetch(url, {
190
+ method: "POST",
191
+ headers,
192
+ body: JSON.stringify(requestBody),
193
+ signal,
194
+ });
195
+
196
+ if (!res.ok) {
197
+ console.error(`[compaction] summarization failed (${res.status}), using naive fallback`);
198
+ return naiveSummary(oldMessages);
199
+ }
200
+
201
+ const data = await res.json();
202
+ const text = selectedProvider.formatResponse(data);
203
+ return text || naiveSummary(oldMessages);
204
+ } catch (err) {
205
+ if (err.name === "AbortError") throw err;
206
+ console.error("[compaction] summarization error, using naive fallback:", err.message);
207
+ return naiveSummary(oldMessages);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Compact a conversation's messages if approaching the provider's context limit.
213
+ *
214
+ * Splits messages into [system prompt] + [old messages] + [recent N messages],
215
+ * summarizes old messages via the cheapest AI provider, and returns a compacted
216
+ * array. If under the threshold, returns the original messages unchanged.
217
+ *
218
+ * @param {Array} messages - Full conversation history including system prompt.
219
+ * @param {Object} [options={}]
220
+ * @param {string} [options.providerId='ollama'] - Provider ID for context limit lookup.
221
+ * @param {AbortSignal} [options.signal] - Optional abort signal.
222
+ * @param {Object} [options.providers] - Provider configuration with API keys: { anthropic: { apiKey }, openai: { apiKey }, xai: { apiKey } }
223
+ * @returns {Promise<{messages: Array, compacted: boolean}>} Compacted result.
224
+ */
225
+ export async function compactMessages(messages, { providerId = "ollama", signal, providers = {} } = {}) {
226
+ const tokens = estimateTokens(messages);
227
+ const limit = CONTEXT_LIMITS[providerId] || CONTEXT_LIMITS.ollama;
228
+
229
+ if (tokens < limit * COMPACT_THRESHOLD) {
230
+ return { messages, compacted: false };
231
+ }
232
+
233
+ // Need at least system + RECENT_TO_KEEP + 1 old message to compact
234
+ if (messages.length <= RECENT_TO_KEEP + 2) {
235
+ return { messages, compacted: false };
236
+ }
237
+
238
+ const systemPrompt = messages[0];
239
+ const recent = messages.slice(-RECENT_TO_KEEP);
240
+ const old = messages.slice(1, messages.length - RECENT_TO_KEEP);
241
+
242
+ if (old.length === 0) {
243
+ return { messages, compacted: false };
244
+ }
245
+
246
+ console.log(`[compaction] ${tokens} tokens (~${Math.round((tokens / limit) * 100)}% of ${providerId} limit), compacting ${old.length} old messages`);
247
+
248
+ const summary = await summarizeMessages(old, { signal, providers });
249
+
250
+ const summaryMessage = {
251
+ role: "user",
252
+ content: `[Context Summary]\n${summary}`,
253
+ _compaction: true,
254
+ _ts: Date.now(),
255
+ };
256
+
257
+ return {
258
+ messages: [systemPrompt, summaryMessage, ...recent],
259
+ compacted: true,
260
+ };
261
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Cron task handler for dotbot.
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.
7
+ */
8
+
9
+ import { compactMessages } from './compaction.js';
10
+
11
+ /**
12
+ * Create a cron task handler function.
13
+ *
14
+ * @param {Object} options
15
+ * @param {Object} options.sessionStore - Session store instance
16
+ * @param {Object} options.cronStore - Cron store instance
17
+ * @param {Object} options.taskStore - Task store instance (optional)
18
+ * @param {Object} options.memoryStore - Memory store instance (optional)
19
+ * @param {Object} options.providers - Provider API keys for compaction
20
+ * @param {number} [options.staleThresholdMs=86400000] - Skip heartbeat if user idle longer than this (default: 24h)
21
+ * @param {Object} [options.hooks] - Host-specific hooks
22
+ * @param {Function} [options.hooks.onNotification] - async (userId, { title, body, type }) => void
23
+ * @param {Function} [options.hooks.taskFetcher] - async (userId, taskId) => task object
24
+ * @param {Function} [options.hooks.tasksFinder] - async (userId, filter) => tasks array
25
+ * @returns {Function} Async handler for cron task execution
26
+ */
27
+ export function createCronHandler({
28
+ sessionStore,
29
+ cronStore,
30
+ taskStore,
31
+ memoryStore,
32
+ providers = {},
33
+ staleThresholdMs = 24 * 60 * 60 * 1000,
34
+ hooks = {},
35
+ }) {
36
+ // Agent reference - will be set after init() creates the agent
37
+ let agent = null;
38
+
39
+ /**
40
+ * Set the agent instance (called by init() after agent creation).
41
+ * @param {Object} agentInstance
42
+ */
43
+ function setAgent(agentInstance) {
44
+ agent = agentInstance;
45
+ }
46
+
47
+ /**
48
+ * Handle a cron task firing.
49
+ *
50
+ * @param {Object} task - Cron task object
51
+ * @param {string} task.name - Task name (e.g., "heartbeat", "task_step")
52
+ * @param {string} [task.userId] - User ID for user-level tasks
53
+ * @param {string} [task.sessionId] - Session ID for session-scoped tasks
54
+ * @param {string} [task.taskId] - Task ID for task_step cron jobs
55
+ * @param {string} [task.prompt] - Task prompt
56
+ */
57
+ async function handleTaskFire(task) {
58
+ console.log(`[cron] processing task ${task.name} (userId=${task.userId || 'none'}, sessionId=${task.sessionId || 'none'})`);
59
+
60
+ // Skip if agent not initialized yet
61
+ if (!agent) {
62
+ console.warn('[cron] task skipped - agent not initialized');
63
+ return;
64
+ }
65
+
66
+ // Resolve session: user-level heartbeats find the most recent session,
67
+ // session-scoped tasks use the explicit sessionId.
68
+ let session;
69
+ if (task.userId) {
70
+ session = await sessionStore.getOrCreateDefaultSession(task.userId);
71
+ } else if (task.sessionId) {
72
+ session = await sessionStore.getSessionInternal(task.sessionId);
73
+ }
74
+
75
+ if (!session) {
76
+ console.log(`[cron] no session found for task ${task.name}, skipping`);
77
+ return;
78
+ }
79
+
80
+ // Stale user check: skip heartbeat if user hasn't interacted recently
81
+ if (task.name === 'heartbeat' && session.updatedAt) {
82
+ const idleMs = Date.now() - new Date(session.updatedAt).getTime();
83
+ if (idleMs > staleThresholdMs) {
84
+ console.log(`[cron] skipping heartbeat for stale user ${session.owner} (idle ${Math.round(idleMs / 3600000)}h)`);
85
+ return;
86
+ }
87
+ }
88
+
89
+ // Build task content depending on task type
90
+ const taskContent = await buildTaskContent(task, session);
91
+ if (!taskContent) return;
92
+
93
+ // Add message to session
94
+ await sessionStore.addMessage(session.id, {
95
+ role: 'user',
96
+ content: taskContent,
97
+ });
98
+
99
+ // Re-fetch session to pick up the added message
100
+ const updatedSession = await sessionStore.getSessionInternal(session.id);
101
+ const providerId = updatedSession.provider || 'ollama';
102
+
103
+ // Compact old messages before running agent loop
104
+ const compacted = await compactMessages(updatedSession.messages, {
105
+ providerId,
106
+ providers,
107
+ });
108
+
109
+ if (compacted.compacted) {
110
+ updatedSession.messages = compacted.messages;
111
+ updatedSession.messages.push({
112
+ role: 'user',
113
+ content: '[Compaction] Compressed conversation history',
114
+ _ts: Date.now(),
115
+ });
116
+ }
117
+
118
+ // Run the agent chat loop
119
+ let finalText = '';
120
+ for await (const event of agent.chat({
121
+ sessionId: updatedSession.id,
122
+ message: '', // Message already added to session
123
+ provider: providerId,
124
+ model: updatedSession.model,
125
+ context: {
126
+ userID: updatedSession.owner,
127
+ sessionId: updatedSession.id,
128
+ memoryStore,
129
+ taskStore,
130
+ },
131
+ })) {
132
+ if (event.type === 'text_delta' && event.text) {
133
+ finalText += event.text;
134
+ }
135
+ }
136
+
137
+ // Create notification if the agent produced meaningful output
138
+ const trimmed = finalText.trim();
139
+ if (trimmed && trimmed.length > 10 && updatedSession.owner && hooks.onNotification) {
140
+ try {
141
+ await hooks.onNotification(updatedSession.owner, {
142
+ title: 'Dottie',
143
+ body: trimmed.slice(0, 500),
144
+ type: task.name === 'heartbeat' ? 'heartbeat' : 'cron',
145
+ });
146
+ console.log(`[cron] notification created for ${updatedSession.owner} (${trimmed.length} chars)`);
147
+ } catch (err) {
148
+ console.error('[cron] failed to create notification:', err.message);
149
+ }
150
+ } else if (task.name === 'heartbeat') {
151
+ console.log(`[cron] heartbeat for ${updatedSession.owner} produced no meaningful output (${finalText.trim().length} chars)`);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Build the content for a cron task based on its type.
157
+ *
158
+ * @param {Object} task - Cron task
159
+ * @param {Object} session - User session
160
+ * @returns {Promise<string|null>} Task content or null to skip
161
+ */
162
+ async function buildTaskContent(task, session) {
163
+ if (task.name === 'task_step' && task.taskId) {
164
+ // Task step continuation — inject targeted prompt for the specific task
165
+ return await buildTaskStepContent(task, session);
166
+ }
167
+
168
+ if (task.name === 'heartbeat' && session.owner) {
169
+ // Heartbeat — check for auto-mode tasks with pending steps
170
+ return await buildHeartbeatContent(task, session);
171
+ }
172
+
173
+ // Default: use the task prompt
174
+ return `[Heartbeat] ${task.prompt}`;
175
+ }
176
+
177
+ /**
178
+ * Build content for a task_step cron job.
179
+ */
180
+ async function buildTaskStepContent(task, session) {
181
+ try {
182
+ let taskDoc;
183
+ if (hooks.taskFetcher) {
184
+ taskDoc = await hooks.taskFetcher(session.owner, task.taskId);
185
+ } else if (taskStore) {
186
+ taskDoc = await taskStore.getTask(session.owner, task.taskId);
187
+ }
188
+
189
+ if (!taskDoc) {
190
+ console.log(`[cron] task_step: task ${task.taskId} not found, skipping`);
191
+ return null;
192
+ }
193
+
194
+ if (taskDoc.status === 'completed' || taskDoc.status === 'abandoned') {
195
+ console.log(`[cron] task_step: task ${task.taskId} already ${taskDoc.status}, skipping`);
196
+ return null;
197
+ }
198
+
199
+ const nextStep = taskDoc.steps?.find(s => !s.done);
200
+ if (!nextStep) {
201
+ console.log(`[cron] task_step: all steps done for task ${task.taskId}, skipping`);
202
+ return null;
203
+ }
204
+
205
+ const doneCount = taskDoc.steps.filter(s => s.done).length;
206
+ return `[Task Work] Continue auto-executing task "${taskDoc.description}" (${doneCount}/${taskDoc.steps.length} steps done). Call task_work with task_id "${task.taskId}" to execute the next step.`;
207
+ } catch (err) {
208
+ console.error('[cron] task_step error:', err.message);
209
+ return null;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Build content for a heartbeat cron job.
215
+ */
216
+ async function buildHeartbeatContent(task, session) {
217
+ let taskContent = `[Heartbeat] ${task.prompt}`;
218
+
219
+ try {
220
+ let tasks = [];
221
+ if (hooks.tasksFinder) {
222
+ tasks = await hooks.tasksFinder(session.owner, { status: ['pending', 'in_progress'] });
223
+ } else if (taskStore) {
224
+ tasks = await taskStore.findTasks(session.owner, { status: ['pending', 'in_progress'] });
225
+ }
226
+
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
+ }
246
+ }
247
+ return line;
248
+ });
249
+ taskContent += `\n\nActive tasks:\n${lines.join('\n')}`;
250
+ }
251
+ }
252
+ } catch (err) {
253
+ console.error('[cron] failed to fetch tasks for heartbeat:', err.message);
254
+ }
255
+
256
+ return taskContent;
257
+ }
258
+
259
+ // Return handler with setAgent method attached
260
+ handleTaskFire.setAgent = setAgent;
261
+ return handleTaskFire;
262
+ }