@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,291 @@
1
+ import crypto from 'crypto';
2
+ import { SessionStore } from './SessionStore.js';
3
+ import { toStandardFormat } from '../core/normalize.js';
4
+
5
+ /**
6
+ * Default system prompt builder for DotBot agent
7
+ *
8
+ * @param {Object} options - Agent identity overrides
9
+ * @param {string} [options.agentName='Dottie'] - Display name
10
+ * @param {string} [options.agentPersonality=''] - Personality/tone
11
+ * @returns {string} System prompt
12
+ */
13
+ export function defaultSystemPrompt({ agentName = 'Dottie', agentPersonality = '' } = {}) {
14
+ const now = new Date().toISOString();
15
+ return `You are a helpful personal AI assistant called ${agentName}.${agentPersonality ? `\nYour personality and tone: ${agentPersonality}. Embody this in all responses.` : ''}
16
+ You have access to tools for searching the web, reading/writing files, fetching URLs, running code, long-term memory, and scheduled tasks.
17
+ The current date and time is ${now}.
18
+
19
+ Use tools when they would help answer the user's question — don't guess when you can look things up.
20
+ Keep responses concise and useful. When you use a tool, explain what you found.
21
+
22
+ Memory guidelines:
23
+ - When the user shares personal info (name, preferences, projects, goals), save it with memory_save.
24
+ - When the user references past conversations or asks "do you remember", search with memory_search.
25
+ - When the user asks to forget something, use memory_search to find the key, then memory_delete to remove it.
26
+ - Be selective — only save things worth recalling in future conversations.
27
+ - Don't announce every memory save unless the user would want to know.
28
+
29
+ Scheduling guidelines:
30
+ - When the user asks for a reminder, periodic check, or recurring job, use schedule_job.
31
+ - Write the prompt as if the user is asking you to do something when the job fires.
32
+ - For recurring jobs, suggest a reasonable interval if the user doesn't specify one.
33
+
34
+ Follow-up suggestions:
35
+ - At the end of every response, suggest one natural follow-up question the user might ask next.
36
+ - Format: <followup>Your suggested question here</followup>
37
+ - Keep it short, specific to the conversation context, and genuinely useful.
38
+ - Do not include the followup tag when using tools or in error responses.`;
39
+ }
40
+
41
+ /**
42
+ * MongoDB-backed SessionStore implementation
43
+ */
44
+ export class MongoSessionStore extends SessionStore {
45
+ constructor() {
46
+ super();
47
+ this.collection = null;
48
+ this.prefsFetcher = null;
49
+ this.systemPromptBuilder = defaultSystemPrompt;
50
+ this.heartbeatEnsurer = null;
51
+ }
52
+
53
+ /**
54
+ * Initialize MongoDB session store
55
+ *
56
+ * @param {import('mongodb').Db} db - MongoDB database instance
57
+ * @param {Object} [options={}] - Initialization options
58
+ * @param {Function} [options.prefsFetcher] - Async function (userId) => { agentName, agentPersonality }
59
+ * @param {Function} [options.systemPromptBuilder] - Function ({ agentName, agentPersonality }) => string
60
+ * @param {Function} [options.heartbeatEnsurer] - Async function (userId) => Promise<Object|null>
61
+ */
62
+ async init(db, options = {}) {
63
+ this.collection = db.collection('sessions');
64
+ this.prefsFetcher = options.prefsFetcher || null;
65
+ this.systemPromptBuilder = options.systemPromptBuilder || defaultSystemPrompt;
66
+ this.heartbeatEnsurer = options.heartbeatEnsurer || null;
67
+
68
+ await this.collection.createIndex({ id: 1 }, { unique: true }).catch(() => {});
69
+ await this.collection.createIndex({ owner: 1, updatedAt: -1 }).catch(() => {});
70
+
71
+ // Migrate legacy sessions: documents without an `owner` field
72
+ const legacy = await this.collection.find({ owner: { $exists: false } }).toArray();
73
+ for (const doc of legacy) {
74
+ const oldId = doc.id;
75
+ const newId = crypto.randomUUID();
76
+ const firstUserMsg = doc.messages?.find((m) => m.role === 'user');
77
+ const title = firstUserMsg ? firstUserMsg.content.slice(0, 60) : '';
78
+ await this.collection.updateOne(
79
+ { _id: doc._id },
80
+ { $set: { id: newId, owner: oldId, title, updatedAt: doc.updatedAt || new Date() } }
81
+ );
82
+ }
83
+
84
+ if (legacy.length > 0) {
85
+ console.log(`[sessions] migrated ${legacy.length} legacy session(s)`);
86
+ }
87
+
88
+ // Migrate existing sessions to standard message format.
89
+ // Detects provider-specific messages by checking for Anthropic content arrays
90
+ // or OpenAI tool_calls properties, then normalizes them. Idempotent — already
91
+ // normalized sessions pass through unchanged.
92
+ const allSessions = await this.collection.find({}).toArray();
93
+ let migrated = 0;
94
+ for (const doc of allSessions) {
95
+ if (!Array.isArray(doc.messages) || doc.messages.length === 0) continue;
96
+
97
+ const needsNormalization = doc.messages.some(
98
+ (m) => (m.role === 'assistant' && Array.isArray(m.content)) ||
99
+ (m.role === 'assistant' && m.tool_calls) ||
100
+ (m.role === 'tool') ||
101
+ (m.role === 'user' && Array.isArray(m.content) && m.content.some(b => b.type === 'tool_result'))
102
+ );
103
+
104
+ if (needsNormalization) {
105
+ const normalized = toStandardFormat(doc.messages);
106
+ await this.collection.updateOne(
107
+ { _id: doc._id },
108
+ { $set: { messages: normalized } }
109
+ );
110
+ migrated++;
111
+ }
112
+ }
113
+
114
+ if (migrated > 0) {
115
+ console.log(`[sessions] normalized messages in ${migrated} session(s)`);
116
+ }
117
+
118
+ console.log('[sessions] initialized with MongoDB (multi-session)');
119
+ }
120
+
121
+ /**
122
+ * Build system prompt with current timestamp
123
+ *
124
+ * @param {string} owner - User ID
125
+ * @returns {Promise<string>} System prompt
126
+ */
127
+ async buildSystemPrompt(owner) {
128
+ const prefs = this.prefsFetcher ? await this.prefsFetcher(owner) : {};
129
+ return this.systemPromptBuilder(prefs);
130
+ }
131
+
132
+ async createSession(owner, model = 'gpt-oss:20b', provider = 'ollama') {
133
+ if (!this.collection) throw new Error('Sessions not initialized. Call init() first.');
134
+
135
+ const session = {
136
+ id: crypto.randomUUID(),
137
+ owner,
138
+ title: '',
139
+ messages: [{ role: 'system', content: await this.buildSystemPrompt(owner) }],
140
+ model,
141
+ provider,
142
+ createdAt: new Date(),
143
+ updatedAt: new Date(),
144
+ };
145
+ await this.collection.insertOne(session);
146
+ return session;
147
+ }
148
+
149
+ async getOrCreateDefaultSession(owner) {
150
+ if (!this.collection) throw new Error('Sessions not initialized. Call init() first.');
151
+
152
+ let session = await this.collection.findOne({ owner }, { sort: { updatedAt: -1 } });
153
+ if (!session) {
154
+ session = await this.createSession(owner);
155
+ } else {
156
+ // Refresh system prompt timestamp
157
+ session.messages[0] = { role: 'system', content: await this.buildSystemPrompt(owner) };
158
+ }
159
+ if (this.heartbeatEnsurer) {
160
+ this.heartbeatEnsurer(owner).catch((err) => {
161
+ console.error(`[session] failed to ensure heartbeat for ${owner}:`, err.message);
162
+ });
163
+ }
164
+ return session;
165
+ }
166
+
167
+ async getSession(sessionId, owner) {
168
+ if (!this.collection) throw new Error('Sessions not initialized. Call init() first.');
169
+
170
+ const session = await this.collection.findOne({ id: sessionId, owner });
171
+ if (!session) return null;
172
+
173
+ // Refresh system prompt timestamp
174
+ session.messages[0] = { role: 'system', content: await this.buildSystemPrompt(owner) };
175
+ return session;
176
+ }
177
+
178
+ async getSessionInternal(sessionId) {
179
+ if (!this.collection) throw new Error('Sessions not initialized. Call init() first.');
180
+
181
+ const session = await this.collection.findOne({ id: sessionId });
182
+ if (!session) return null;
183
+
184
+ session.messages[0] = { role: 'system', content: await this.buildSystemPrompt(session.owner) };
185
+ return session;
186
+ }
187
+
188
+ /**
189
+ * Save session with normalized messages.
190
+ * Converts any provider-specific message formats to standard format before persisting.
191
+ *
192
+ * @param {string} sessionId - Session UUID
193
+ * @param {Array} messages - Messages (provider-specific or standard format)
194
+ * @param {string} model - Model identifier
195
+ * @param {string} [provider] - Provider name
196
+ */
197
+ async saveSession(sessionId, messages, model, provider) {
198
+ const update = {
199
+ messages: toStandardFormat(messages),
200
+ model,
201
+ updatedAt: new Date(),
202
+ };
203
+
204
+ if (provider) {
205
+ update.provider = provider;
206
+ }
207
+
208
+ // Auto-populate title from first user message if empty
209
+ const session = await this.collection.findOne({ id: sessionId });
210
+ if (session && !session.title) {
211
+ const firstUserMsg = update.messages.find((m) => m.role === 'user');
212
+ if (firstUserMsg && typeof firstUserMsg.content === 'string') {
213
+ update.title = firstUserMsg.content.slice(0, 60);
214
+ }
215
+ }
216
+
217
+ await this.collection.updateOne({ id: sessionId }, { $set: update });
218
+ }
219
+
220
+ /**
221
+ * Add a message to a session, normalizing to standard format before saving.
222
+ *
223
+ * @param {string} sessionId - Session UUID
224
+ * @param {Object} message - Message object (any provider format)
225
+ * @returns {Promise<Object>} Updated session
226
+ */
227
+ async addMessage(sessionId, message) {
228
+ const session = await this.getSessionInternal(sessionId);
229
+ if (!session) throw new Error(`Session ${sessionId} not found`);
230
+ if (!message._ts) message._ts = Date.now();
231
+ // Normalize the single message by running it through toStandardFormat,
232
+ // then append all resulting messages (may be 0 if it was a bare tool_result)
233
+ const normalized = toStandardFormat([message]);
234
+ session.messages.push(...normalized);
235
+ await this.saveSession(sessionId, session.messages, session.model);
236
+ return session;
237
+ }
238
+
239
+ async setModel(sessionId, model) {
240
+ await this.collection.updateOne({ id: sessionId }, { $set: { model, updatedAt: new Date() } });
241
+ }
242
+
243
+ async setProvider(sessionId, provider) {
244
+ await this.collection.updateOne({ id: sessionId }, { $set: { provider, updatedAt: new Date() } });
245
+ }
246
+
247
+ async clearSession(sessionId) {
248
+ const session = await this.collection.findOne({ id: sessionId });
249
+ const messages = [{ role: 'system', content: await this.buildSystemPrompt(session?.owner) }];
250
+ await this.collection.updateOne(
251
+ { id: sessionId },
252
+ { $set: { messages, updatedAt: new Date() } }
253
+ );
254
+ }
255
+
256
+ async listSessions(owner) {
257
+ return await this.collection
258
+ .aggregate([
259
+ { $match: { owner } },
260
+ { $sort: { updatedAt: -1 } },
261
+ { $limit: 50 },
262
+ {
263
+ $project: {
264
+ id: 1,
265
+ title: 1,
266
+ model: 1,
267
+ provider: 1,
268
+ createdAt: 1,
269
+ updatedAt: 1,
270
+ messageCount: { $size: { $ifNull: ['$messages', []] } }
271
+ }
272
+ }
273
+ ])
274
+ .toArray()
275
+ .then((docs) =>
276
+ docs.map((d) => ({
277
+ id: d.id,
278
+ title: d.title || '',
279
+ model: d.model,
280
+ provider: d.provider || 'ollama',
281
+ createdAt: d.createdAt,
282
+ updatedAt: d.updatedAt,
283
+ messageCount: d.messageCount || 0,
284
+ }))
285
+ );
286
+ }
287
+
288
+ async deleteSession(sessionId, owner) {
289
+ return await this.collection.deleteOne({ id: sessionId, owner });
290
+ }
291
+ }
@@ -0,0 +1,347 @@
1
+ import { CronStore } from './CronStore.js';
2
+ import {
3
+ HEARTBEAT_INTERVAL_MS,
4
+ HEARTBEAT_CONCURRENCY,
5
+ HEARTBEAT_PROMPT,
6
+ runWithConcurrency,
7
+ parseInterval,
8
+ } from './cron_constants.js';
9
+
10
+ /**
11
+ * MongoDB-backed CronStore implementation
12
+ */
13
+ export class MongoCronStore extends CronStore {
14
+ constructor() {
15
+ super();
16
+ this.collection = null;
17
+ this.onTaskFire = null;
18
+ this.pollInterval = null;
19
+ }
20
+
21
+ /**
22
+ * Initialize MongoDB cron store
23
+ *
24
+ * @param {import('mongodb').Db} db - MongoDB database instance
25
+ * @param {Object} options
26
+ * @param {Function} options.onTaskFire - Callback when a task fires: (task) => Promise<void>
27
+ */
28
+ async init(db, options = {}) {
29
+ this.collection = db.collection('cron_tasks');
30
+ this.onTaskFire = options.onTaskFire;
31
+
32
+ await this.collection.createIndex({ nextRunAt: 1 }).catch(() => {});
33
+ await this.collection.createIndex({ sessionId: 1 }).catch(() => {});
34
+ await this.collection.createIndex({ userId: 1, name: 1 }).catch(() => {});
35
+
36
+ // Deduplicate existing heartbeats before adding the unique index
37
+ const dupes = await this.collection.aggregate([
38
+ { $match: { name: 'heartbeat', enabled: true, userId: { $exists: true } } },
39
+ { $sort: { createdAt: -1 } },
40
+ { $group: { _id: '$userId', ids: { $push: '$_id' }, count: { $sum: 1 } } },
41
+ { $match: { count: { $gt: 1 } } },
42
+ ]).toArray();
43
+ if (dupes.length > 0) {
44
+ const idsToRemove = dupes.flatMap(d => d.ids.slice(1));
45
+ const result = await this.collection.deleteMany({ _id: { $in: idsToRemove } });
46
+ console.log(`[cron] cleaned up ${result.deletedCount} duplicate heartbeat(s) for ${dupes.length} user(s)`);
47
+ }
48
+
49
+ // One enabled heartbeat per user — enforced at DB level
50
+ await this.collection.createIndex(
51
+ { userId: 1, name: 1, enabled: 1 },
52
+ { unique: true, partialFilterExpression: { name: 'heartbeat', enabled: true } }
53
+ ).catch(() => {});
54
+
55
+ // Start polling every 30 seconds
56
+ this.pollInterval = setInterval(() => this.checkTasks(), 30 * 1000);
57
+ // Also check immediately on startup
58
+ await this.checkTasks();
59
+
60
+ console.log('[cron] initialized with MongoDB, polling every 30s');
61
+ }
62
+
63
+ stop() {
64
+ if (this.pollInterval) clearInterval(this.pollInterval);
65
+ }
66
+
67
+ /**
68
+ * Check for tasks that are due and fire them
69
+ */
70
+ async checkTasks() {
71
+ if (!this.collection || !this.onTaskFire) return;
72
+
73
+ try {
74
+ const now = new Date();
75
+
76
+ const dueTasks = await this.collection
77
+ .find({ nextRunAt: { $lte: now }, enabled: true })
78
+ .toArray();
79
+
80
+ if (dueTasks.length === 0) return;
81
+
82
+ const heartbeats = dueTasks.filter(t => t.name === 'heartbeat');
83
+ const others = dueTasks.filter(t => t.name !== 'heartbeat');
84
+
85
+ /** Process a single task: fire callback, then update schedule */
86
+ const processTask = async (task) => {
87
+ try {
88
+ await this.onTaskFire(task);
89
+ if (task.recurring && task.intervalMs) {
90
+ const nextRun = new Date(now.getTime() + task.intervalMs);
91
+ await this.collection.updateOne(
92
+ { _id: task._id },
93
+ { $set: { nextRunAt: nextRun, lastRunAt: now } }
94
+ );
95
+ } else {
96
+ await this.collection.updateOne(
97
+ { _id: task._id },
98
+ { $set: { enabled: false, lastRunAt: now } }
99
+ );
100
+ }
101
+ } catch (err) {
102
+ console.error(`[cron] error firing task ${task.name}:`, err.message);
103
+ }
104
+ };
105
+
106
+ // Heartbeats run in parallel with a concurrency cap
107
+ if (heartbeats.length > 0) {
108
+ console.log(`[cron] firing ${heartbeats.length} heartbeat(s) (concurrency: ${HEARTBEAT_CONCURRENCY})`);
109
+ await runWithConcurrency(
110
+ heartbeats.map(t => () => processTask(t)),
111
+ HEARTBEAT_CONCURRENCY
112
+ );
113
+ }
114
+
115
+ // Other tasks (user-scheduled) run sequentially
116
+ for (const task of others) {
117
+ await processTask(task);
118
+ }
119
+ } catch (err) {
120
+ console.error(`[cron] checkTasks query failed:`, err.message);
121
+ }
122
+ }
123
+
124
+ async createTask({ name, prompt, sessionId, userId, runAt, intervalMs, recurring, taskId }) {
125
+ const task = {
126
+ name,
127
+ prompt,
128
+ sessionId: sessionId || 'default',
129
+ nextRunAt: new Date(runAt),
130
+ intervalMs: intervalMs || null,
131
+ recurring: recurring || false,
132
+ enabled: true,
133
+ createdAt: new Date(),
134
+ lastRunAt: null,
135
+ };
136
+ if (userId) task.userId = userId;
137
+ if (taskId) task.taskId = taskId;
138
+
139
+ const result = await this.collection.insertOne(task);
140
+ return { id: result.insertedId, ...task };
141
+ }
142
+
143
+ async listTasks(sessionId) {
144
+ return await this.collection
145
+ .find({ sessionId: sessionId || 'default', name: { $ne: 'heartbeat' } })
146
+ .sort({ nextRunAt: 1 })
147
+ .toArray()
148
+ .then((docs) =>
149
+ docs.map((d) => ({
150
+ id: d._id.toString(),
151
+ name: d.name,
152
+ prompt: d.prompt,
153
+ nextRunAt: d.nextRunAt,
154
+ recurring: d.recurring,
155
+ intervalMs: d.intervalMs,
156
+ enabled: d.enabled,
157
+ lastRunAt: d.lastRunAt,
158
+ }))
159
+ );
160
+ }
161
+
162
+ async listTasksBySessionIds(sessionIds, userId = null) {
163
+ if (!this.collection || sessionIds.length === 0) return [];
164
+ const query = { sessionId: { $in: [...sessionIds, 'default'] }, name: { $ne: 'heartbeat' } };
165
+ if (userId) {
166
+ query.$or = [
167
+ { userId: userId },
168
+ { userId: { $exists: false } },
169
+ { userId: null }
170
+ ];
171
+ }
172
+ return await this.collection
173
+ .find(query)
174
+ .sort({ nextRunAt: 1 })
175
+ .toArray()
176
+ .then(docs => docs.map(d => ({
177
+ id: d._id.toString(),
178
+ name: d.name,
179
+ prompt: d.prompt,
180
+ sessionId: d.sessionId,
181
+ nextRunAt: d.nextRunAt,
182
+ recurring: d.recurring,
183
+ intervalMs: d.intervalMs,
184
+ enabled: d.enabled,
185
+ lastRunAt: d.lastRunAt,
186
+ createdAt: d.createdAt,
187
+ })));
188
+ }
189
+
190
+ async getTask(id) {
191
+ const { ObjectId } = await import('mongodb');
192
+ const task = await this.collection.findOne({ _id: new ObjectId(id) });
193
+ if (!task) return null;
194
+ return {
195
+ id: task._id.toString(),
196
+ name: task.name,
197
+ prompt: task.prompt,
198
+ sessionId: task.sessionId,
199
+ nextRunAt: task.nextRunAt,
200
+ recurring: task.recurring,
201
+ intervalMs: task.intervalMs,
202
+ enabled: task.enabled,
203
+ lastRunAt: task.lastRunAt,
204
+ createdAt: task.createdAt,
205
+ };
206
+ }
207
+
208
+ async deleteTask(id) {
209
+ const { ObjectId } = await import('mongodb');
210
+ return await this.collection.deleteOne({ _id: new ObjectId(id) });
211
+ }
212
+
213
+ async toggleTask(id, enabled) {
214
+ const { ObjectId } = await import('mongodb');
215
+ return await this.collection.updateOne(
216
+ { _id: new ObjectId(id) },
217
+ { $set: { enabled } }
218
+ );
219
+ }
220
+
221
+ async updateTask(id, updates) {
222
+ const { ObjectId } = await import('mongodb');
223
+ const updateFields = {};
224
+ if (updates.name !== undefined) updateFields.name = updates.name;
225
+ if (updates.prompt !== undefined) updateFields.prompt = updates.prompt;
226
+ if (updates.runAt !== undefined) updateFields.nextRunAt = new Date(updates.runAt);
227
+ if (updates.intervalMs !== undefined) updateFields.intervalMs = updates.intervalMs;
228
+ if (updates.recurring !== undefined) updateFields.recurring = updates.recurring;
229
+
230
+ return await this.collection.updateOne(
231
+ { _id: new ObjectId(id) },
232
+ { $set: updateFields }
233
+ );
234
+ }
235
+
236
+ async ensureHeartbeat(userId) {
237
+ if (!this.collection || !userId) {
238
+ console.log(`[cron] ensureHeartbeat skipped: collection=${!!this.collection}, userId=${userId}`);
239
+ return null;
240
+ }
241
+
242
+ const jitter = Math.floor(Math.random() * HEARTBEAT_INTERVAL_MS);
243
+ const now = new Date();
244
+
245
+ // Atomic upsert — eliminates race conditions
246
+ const result = await this.collection.updateOne(
247
+ { userId, name: 'heartbeat', enabled: true },
248
+ {
249
+ $setOnInsert: {
250
+ name: 'heartbeat',
251
+ prompt: HEARTBEAT_PROMPT,
252
+ userId,
253
+ sessionId: 'default',
254
+ nextRunAt: new Date(now.getTime() + jitter),
255
+ intervalMs: HEARTBEAT_INTERVAL_MS,
256
+ recurring: true,
257
+ enabled: true,
258
+ createdAt: now,
259
+ lastRunAt: null,
260
+ },
261
+ },
262
+ { upsert: true }
263
+ );
264
+
265
+ if (result.upsertedId) {
266
+ console.log(`[cron] created heartbeat for user ${userId}, first run in ${Math.round(jitter / 60000)}m`);
267
+ return { id: result.upsertedId };
268
+ }
269
+
270
+ // Auto-update stale prompt
271
+ const existing = await this.collection.findOne({ userId, name: 'heartbeat', enabled: true });
272
+ if (existing && existing.prompt !== HEARTBEAT_PROMPT) {
273
+ await this.collection.updateOne({ _id: existing._id }, { $set: { prompt: HEARTBEAT_PROMPT } });
274
+ console.log(`[cron] updated heartbeat prompt for user ${userId}`);
275
+ }
276
+
277
+ return null;
278
+ }
279
+
280
+ async getHeartbeatStatus(userId) {
281
+ if (!this.collection || !userId) return null;
282
+ const task = await this.collection.findOne({ userId, name: 'heartbeat' });
283
+ if (!task) return null;
284
+ return {
285
+ id: task._id.toString(),
286
+ enabled: task.enabled,
287
+ nextRunAt: task.nextRunAt,
288
+ lastRunAt: task.lastRunAt,
289
+ createdAt: task.createdAt,
290
+ intervalMs: task.intervalMs,
291
+ prompt: task.prompt,
292
+ };
293
+ }
294
+
295
+ async resetHeartbeat(userId) {
296
+ if (!this.collection || !userId) return null;
297
+
298
+ // Delete existing heartbeat(s)
299
+ await this.collection.deleteMany({ userId, name: 'heartbeat' });
300
+ console.log(`[cron] deleted existing heartbeat(s) for user ${userId}`);
301
+
302
+ // Create fresh heartbeat
303
+ const jitter = Math.floor(Math.random() * HEARTBEAT_INTERVAL_MS);
304
+ const now = new Date();
305
+ const task = {
306
+ name: 'heartbeat',
307
+ prompt: HEARTBEAT_PROMPT,
308
+ userId,
309
+ sessionId: 'default',
310
+ nextRunAt: new Date(now.getTime() + jitter),
311
+ intervalMs: HEARTBEAT_INTERVAL_MS,
312
+ recurring: true,
313
+ enabled: true,
314
+ createdAt: now,
315
+ lastRunAt: null,
316
+ };
317
+ const result = await this.collection.insertOne(task);
318
+ console.log(`[cron] created new heartbeat for user ${userId}, first run in ${Math.round(jitter / 60000)}m`);
319
+ return { id: result.insertedId, ...task };
320
+ }
321
+
322
+ async triggerHeartbeatNow(userId) {
323
+ if (!this.collection || !userId || !this.onTaskFire) return false;
324
+
325
+ const heartbeat = await this.collection.findOne({ userId, name: 'heartbeat', enabled: true });
326
+ if (!heartbeat) {
327
+ console.log(`[cron] manual trigger failed: no enabled heartbeat for user ${userId}`);
328
+ return false;
329
+ }
330
+
331
+ console.log(`[cron] manually triggering heartbeat for user ${userId}`);
332
+ try {
333
+ await this.onTaskFire(heartbeat);
334
+ await this.collection.updateOne(
335
+ { _id: heartbeat._id },
336
+ { $set: { lastRunAt: new Date() } }
337
+ );
338
+ return true;
339
+ } catch (err) {
340
+ console.error(`[cron] manual trigger error:`, err.message);
341
+ return false;
342
+ }
343
+ }
344
+ }
345
+
346
+ // Re-export utility functions for tool definitions (from cron_constants.js)
347
+ export { parseInterval, HEARTBEAT_INTERVAL_MS, HEARTBEAT_PROMPT } from './cron_constants.js';