@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,562 @@
1
+ import crypto from 'crypto';
2
+ import { DatabaseSync } from 'node:sqlite';
3
+ import { CronStore } from './CronStore.js';
4
+ import {
5
+ HEARTBEAT_INTERVAL_MS,
6
+ HEARTBEAT_CONCURRENCY,
7
+ HEARTBEAT_PROMPT,
8
+ runWithConcurrency,
9
+ parseInterval,
10
+ } from './cron_constants.js';
11
+
12
+ /**
13
+ * SQLite-backed CronStore implementation
14
+ *
15
+ * Uses Node.js 22.5+ built-in sqlite module for zero-dependency cron storage.
16
+ * All dates stored as INTEGER (Unix ms timestamps).
17
+ */
18
+ export class SQLiteCronStore extends CronStore {
19
+ constructor() {
20
+ super();
21
+ this.db = null;
22
+ this.onTaskFire = null;
23
+ this.pollInterval = null;
24
+ }
25
+
26
+ /**
27
+ * Initialize SQLite cron store
28
+ *
29
+ * @param {string} dbPath - Path to SQLite database file
30
+ * @param {Object} [options={}]
31
+ * @param {Function} [options.onTaskFire] - Callback when a task fires: (task) => Promise<void>
32
+ */
33
+ async init(dbPath, options = {}) {
34
+ this.db = new DatabaseSync(dbPath);
35
+ this.onTaskFire = options.onTaskFire || null;
36
+
37
+ // Migration: goal_id → task_id column
38
+ const cols = this.db.prepare("PRAGMA table_info(cron_tasks)").all();
39
+ if (cols.some(c => c.name === 'goal_id') && !cols.some(c => c.name === 'task_id')) {
40
+ console.log('[cron] migrating goal_id column to task_id...');
41
+ this.db.exec('ALTER TABLE cron_tasks RENAME COLUMN goal_id TO task_id');
42
+ }
43
+
44
+ // Migration: goal_step → task_step task type
45
+ const goalStepCount = this.db.prepare("SELECT COUNT(*) as cnt FROM cron_tasks WHERE name = 'goal_step'").get();
46
+ if (goalStepCount && goalStepCount.cnt > 0) {
47
+ console.log('[cron] migrating goal_step tasks to task_step...');
48
+ this.db.prepare("UPDATE cron_tasks SET name = 'task_step' WHERE name = 'goal_step'").run();
49
+ }
50
+
51
+ this.db.exec(`
52
+ CREATE TABLE IF NOT EXISTS cron_tasks (
53
+ id TEXT PRIMARY KEY,
54
+ name TEXT NOT NULL,
55
+ prompt TEXT NOT NULL,
56
+ session_id TEXT,
57
+ user_id TEXT,
58
+ task_id TEXT,
59
+ next_run_at INTEGER NOT NULL,
60
+ interval_ms INTEGER,
61
+ recurring INTEGER DEFAULT 0,
62
+ enabled INTEGER DEFAULT 1,
63
+ created_at INTEGER NOT NULL,
64
+ last_run_at INTEGER
65
+ );
66
+
67
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_heartbeat_user
68
+ ON cron_tasks(user_id, name) WHERE name = 'heartbeat' AND enabled = 1;
69
+
70
+ CREATE INDEX IF NOT EXISTS idx_cron_next_run ON cron_tasks(next_run_at);
71
+ CREATE INDEX IF NOT EXISTS idx_cron_session ON cron_tasks(session_id);
72
+ `);
73
+
74
+ // Deduplicate existing heartbeats before relying on the unique index
75
+ const dupes = this.db.prepare(`
76
+ SELECT user_id, GROUP_CONCAT(id) as ids, COUNT(*) as cnt
77
+ FROM cron_tasks
78
+ WHERE name = 'heartbeat' AND enabled = 1 AND user_id IS NOT NULL
79
+ GROUP BY user_id
80
+ HAVING cnt > 1
81
+ `).all();
82
+
83
+ if (dupes.length > 0) {
84
+ const deleteStmt = this.db.prepare('DELETE FROM cron_tasks WHERE id = ?');
85
+ let cleaned = 0;
86
+ for (const row of dupes) {
87
+ const ids = row.ids.split(',');
88
+ // Keep first (newest by insertion order), remove rest
89
+ for (let i = 1; i < ids.length; i++) {
90
+ deleteStmt.run(ids[i]);
91
+ cleaned++;
92
+ }
93
+ }
94
+ console.log(`[cron] cleaned up ${cleaned} duplicate heartbeat(s) for ${dupes.length} user(s)`);
95
+ }
96
+
97
+ // Start polling every 30 seconds
98
+ this.pollInterval = setInterval(() => this.checkTasks(), 30 * 1000);
99
+ // Also check immediately on startup
100
+ await this.checkTasks();
101
+
102
+ console.log('[cron] initialized with SQLite, polling every 30s');
103
+ }
104
+
105
+ stop() {
106
+ if (this.pollInterval) clearInterval(this.pollInterval);
107
+ }
108
+
109
+ /**
110
+ * Check for tasks that are due and fire them
111
+ */
112
+ async checkTasks() {
113
+ if (!this.db || !this.onTaskFire) return;
114
+
115
+ try {
116
+ const now = Date.now();
117
+
118
+ const dueTasks = this.db.prepare(
119
+ 'SELECT * FROM cron_tasks WHERE next_run_at <= ? AND enabled = 1'
120
+ ).all(now);
121
+
122
+ if (dueTasks.length === 0) return;
123
+
124
+ const heartbeats = dueTasks.filter(t => t.name === 'heartbeat');
125
+ const others = dueTasks.filter(t => t.name !== 'heartbeat');
126
+
127
+ /** Process a single task: update schedule first, then fire callback */
128
+ const processTask = async (task) => {
129
+ const mapped = this._rowToTask(task);
130
+ // Update schedule BEFORE firing to prevent duplicate picks during long-running callbacks
131
+ if (task.recurring && task.interval_ms) {
132
+ this.db.prepare(
133
+ 'UPDATE cron_tasks SET next_run_at = ? WHERE id = ?'
134
+ ).run(now + task.interval_ms, task.id);
135
+ } else {
136
+ this.db.prepare(
137
+ 'UPDATE cron_tasks SET enabled = 0 WHERE id = ?'
138
+ ).run(task.id);
139
+ }
140
+ try {
141
+ await this.onTaskFire(mapped);
142
+ // Update last_run_at after successful completion
143
+ this.db.prepare(
144
+ 'UPDATE cron_tasks SET last_run_at = ? WHERE id = ?'
145
+ ).run(Date.now(), task.id);
146
+ } catch (err) {
147
+ console.error(`[cron] error firing task ${task.name}:`, err.message);
148
+ }
149
+ };
150
+
151
+ // Heartbeats run in parallel with a concurrency cap
152
+ if (heartbeats.length > 0) {
153
+ console.log(`[cron] firing ${heartbeats.length} heartbeat(s) (concurrency: ${HEARTBEAT_CONCURRENCY})`);
154
+ await runWithConcurrency(
155
+ heartbeats.map(t => () => processTask(t)),
156
+ HEARTBEAT_CONCURRENCY
157
+ );
158
+ }
159
+
160
+ // Other tasks run sequentially
161
+ for (const task of others) {
162
+ await processTask(task);
163
+ }
164
+ } catch (err) {
165
+ console.error(`[cron] checkTasks query failed:`, err.message);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Create a scheduled task
171
+ *
172
+ * @param {Object} params - Task parameters
173
+ * @returns {Promise<Object>} Created task
174
+ */
175
+ async createTask({ name, prompt, sessionId, userId, runAt, intervalMs, recurring, taskId }) {
176
+ const id = crypto.randomUUID();
177
+ const now = Date.now();
178
+
179
+ this.db.prepare(`
180
+ INSERT INTO cron_tasks (id, name, prompt, session_id, user_id, task_id, next_run_at, interval_ms, recurring, enabled, created_at, last_run_at)
181
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, NULL)
182
+ `).run(
183
+ id,
184
+ name,
185
+ prompt,
186
+ sessionId || 'default',
187
+ userId || null,
188
+ taskId || null,
189
+ new Date(runAt).getTime(),
190
+ intervalMs || null,
191
+ recurring ? 1 : 0,
192
+ now
193
+ );
194
+
195
+ return {
196
+ id,
197
+ name,
198
+ prompt,
199
+ sessionId: sessionId || 'default',
200
+ userId: userId || null,
201
+ taskId: taskId || null,
202
+ nextRunAt: new Date(runAt),
203
+ intervalMs: intervalMs || null,
204
+ recurring: recurring || false,
205
+ enabled: true,
206
+ createdAt: new Date(now),
207
+ lastRunAt: null,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * List tasks for a session
213
+ *
214
+ * @param {string} [sessionId] - Session ID to filter by
215
+ * @returns {Promise<Array>} Task list sorted by next run time
216
+ */
217
+ async listTasks(sessionId) {
218
+ const rows = this.db.prepare(
219
+ "SELECT * FROM cron_tasks WHERE session_id = ? AND name != 'heartbeat' ORDER BY next_run_at ASC"
220
+ ).all(sessionId || 'default');
221
+
222
+ return rows.map(r => ({
223
+ id: r.id,
224
+ name: r.name,
225
+ prompt: r.prompt,
226
+ nextRunAt: new Date(r.next_run_at),
227
+ recurring: !!r.recurring,
228
+ intervalMs: r.interval_ms,
229
+ enabled: !!r.enabled,
230
+ lastRunAt: r.last_run_at ? new Date(r.last_run_at) : null,
231
+ }));
232
+ }
233
+
234
+ /**
235
+ * List tasks for multiple session IDs
236
+ *
237
+ * @param {string[]} sessionIds - Array of session IDs
238
+ * @param {string} [userId] - Optional user ID filter
239
+ * @returns {Promise<Array>} Task list sorted by next run time
240
+ */
241
+ async listTasksBySessionIds(sessionIds, userId = null) {
242
+ if (!this.db || sessionIds.length === 0) return [];
243
+
244
+ const allIds = [...sessionIds, 'default'];
245
+ const placeholders = allIds.map(() => '?').join(',');
246
+
247
+ let query = `SELECT * FROM cron_tasks WHERE session_id IN (${placeholders}) AND name != 'heartbeat'`;
248
+ const params = [...allIds];
249
+
250
+ if (userId) {
251
+ query += ' AND (user_id = ? OR user_id IS NULL)';
252
+ params.push(userId);
253
+ }
254
+
255
+ query += ' ORDER BY next_run_at ASC';
256
+
257
+ const rows = this.db.prepare(query).all(...params);
258
+
259
+ return rows.map(r => ({
260
+ id: r.id,
261
+ name: r.name,
262
+ prompt: r.prompt,
263
+ sessionId: r.session_id,
264
+ nextRunAt: new Date(r.next_run_at),
265
+ recurring: !!r.recurring,
266
+ intervalMs: r.interval_ms,
267
+ enabled: !!r.enabled,
268
+ lastRunAt: r.last_run_at ? new Date(r.last_run_at) : null,
269
+ createdAt: new Date(r.created_at),
270
+ }));
271
+ }
272
+
273
+ /**
274
+ * Get a task by ID
275
+ *
276
+ * @param {string} id - Task ID
277
+ * @returns {Promise<Object|null>} Task or null
278
+ */
279
+ async getTask(id) {
280
+ const row = this.db.prepare('SELECT * FROM cron_tasks WHERE id = ?').get(id);
281
+ if (!row) return null;
282
+ return this._rowToTask(row);
283
+ }
284
+
285
+ /**
286
+ * Delete a task by ID
287
+ *
288
+ * @param {string} id - Task ID
289
+ * @returns {Promise<Object>} Delete result with changes count
290
+ */
291
+ async deleteTask(id) {
292
+ const result = this.db.prepare('DELETE FROM cron_tasks WHERE id = ?').run(id);
293
+ return { deletedCount: result.changes };
294
+ }
295
+
296
+ /**
297
+ * Toggle a task's enabled state
298
+ *
299
+ * @param {string} id - Task ID
300
+ * @param {boolean} enabled - New enabled state
301
+ * @returns {Promise<Object>} Update result
302
+ */
303
+ async toggleTask(id, enabled) {
304
+ const result = this.db.prepare(
305
+ 'UPDATE cron_tasks SET enabled = ? WHERE id = ?'
306
+ ).run(enabled ? 1 : 0, id);
307
+ return { modifiedCount: result.changes };
308
+ }
309
+
310
+ /**
311
+ * Update a task's details
312
+ *
313
+ * @param {string} id - Task ID
314
+ * @param {Object} updates - Fields to update
315
+ * @returns {Promise<Object>} Update result
316
+ */
317
+ async updateTask(id, updates) {
318
+ const sets = [];
319
+ const params = [];
320
+
321
+ if (updates.name !== undefined) { sets.push('name = ?'); params.push(updates.name); }
322
+ if (updates.prompt !== undefined) { sets.push('prompt = ?'); params.push(updates.prompt); }
323
+ if (updates.runAt !== undefined) { sets.push('next_run_at = ?'); params.push(new Date(updates.runAt).getTime()); }
324
+ if (updates.intervalMs !== undefined) { sets.push('interval_ms = ?'); params.push(updates.intervalMs); }
325
+ if (updates.recurring !== undefined) { sets.push('recurring = ?'); params.push(updates.recurring ? 1 : 0); }
326
+
327
+ if (sets.length === 0) return { modifiedCount: 0 };
328
+
329
+ params.push(id);
330
+ const result = this.db.prepare(
331
+ `UPDATE cron_tasks SET ${sets.join(', ')} WHERE id = ?`
332
+ ).run(...params);
333
+ return { modifiedCount: result.changes };
334
+ }
335
+
336
+ /**
337
+ * Ensure a single recurring heartbeat exists for a user.
338
+ * Uses INSERT OR IGNORE for atomicity against the unique partial index.
339
+ *
340
+ * @param {string} userId - User ID
341
+ * @returns {Promise<Object|null>} Created task or null if already exists
342
+ */
343
+ async ensureHeartbeat(userId) {
344
+ if (!this.db || !userId) {
345
+ console.log(`[cron] ensureHeartbeat skipped: db=${!!this.db}, userId=${userId}`);
346
+ return null;
347
+ }
348
+
349
+ const jitter = Math.floor(Math.random() * HEARTBEAT_INTERVAL_MS);
350
+ const now = Date.now();
351
+ const id = crypto.randomUUID();
352
+
353
+ const result = this.db.prepare(`
354
+ INSERT OR IGNORE INTO cron_tasks (id, name, prompt, session_id, user_id, next_run_at, interval_ms, recurring, enabled, created_at, last_run_at)
355
+ VALUES (?, 'heartbeat', ?, 'default', ?, ?, ?, 1, 1, ?, NULL)
356
+ `).run(id, HEARTBEAT_PROMPT, userId, now + jitter, HEARTBEAT_INTERVAL_MS, now);
357
+
358
+ if (result.changes > 0) {
359
+ console.log(`[cron] created heartbeat for user ${userId}, first run in ${Math.round(jitter / 60000)}m`);
360
+ return { id };
361
+ }
362
+
363
+ // Auto-update stale prompt
364
+ const existing = this.db.prepare(
365
+ "SELECT id, prompt FROM cron_tasks WHERE user_id = ? AND name = 'heartbeat' AND enabled = 1"
366
+ ).get(userId);
367
+
368
+ if (existing && existing.prompt !== HEARTBEAT_PROMPT) {
369
+ this.db.prepare('UPDATE cron_tasks SET prompt = ? WHERE id = ?').run(HEARTBEAT_PROMPT, existing.id);
370
+ console.log(`[cron] updated heartbeat prompt for user ${userId}`);
371
+ }
372
+
373
+ return null;
374
+ }
375
+
376
+ /**
377
+ * Ensure a Morning Brief job exists for the user (disabled by default).
378
+ * Creates a daily recurring job at 8:00 AM if not present.
379
+ *
380
+ * @param {string} userId - User ID
381
+ * @returns {Promise<Object|null>} Created task or null if already exists
382
+ */
383
+ async ensureMorningBrief(userId) {
384
+ if (!this.db || !userId) return null;
385
+
386
+ // Check if Morning Brief already exists for this user
387
+ const existing = this.db.prepare(
388
+ `SELECT id FROM cron_tasks WHERE user_id = ? AND name = 'Morning Brief' LIMIT 1`
389
+ ).get(userId);
390
+ if (existing) return null;
391
+
392
+ const DAY_MS = 24 * 60 * 60 * 1000;
393
+ const MORNING_BRIEF_PROMPT = `Good morning! Give me a brief summary to start my day:
394
+ 1. What's on my calendar today?
395
+ 2. Any important reminders or tasks due?
396
+ 3. A quick weather update for my location.
397
+ Keep it concise and actionable.`;
398
+
399
+ // Calculate next 8:00 AM
400
+ const now = new Date();
401
+ const today8AM = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0, 0, 0);
402
+ const nextRun = now.getTime() < today8AM.getTime()
403
+ ? today8AM.getTime()
404
+ : today8AM.getTime() + DAY_MS;
405
+
406
+ const id = crypto.randomUUID();
407
+ const nowMs = Date.now();
408
+
409
+ const result = this.db.prepare(`
410
+ INSERT OR IGNORE INTO cron_tasks (id, name, prompt, session_id, user_id, next_run_at, interval_ms, recurring, enabled, created_at, last_run_at)
411
+ VALUES (?, 'Morning Brief', ?, 'default', ?, ?, ?, 1, 0, ?, NULL)
412
+ `).run(id, MORNING_BRIEF_PROMPT, userId, nextRun, DAY_MS, nowMs);
413
+
414
+ if (result.changes > 0) {
415
+ const runTime = new Date(nextRun);
416
+ console.log(`[cron] created Morning Brief for user ${userId}, next run at ${runTime.toLocaleTimeString()} (disabled by default)`);
417
+ return { id };
418
+ }
419
+
420
+ return null;
421
+ }
422
+
423
+ /**
424
+ * Get heartbeat status for a user
425
+ *
426
+ * @param {string} userId - User ID
427
+ * @returns {Promise<Object|null>} Heartbeat info or null
428
+ */
429
+ async getHeartbeatStatus(userId) {
430
+ if (!this.db || !userId) return null;
431
+
432
+ const row = this.db.prepare(
433
+ "SELECT * FROM cron_tasks WHERE user_id = ? AND name = 'heartbeat'"
434
+ ).get(userId);
435
+
436
+ if (!row) return null;
437
+ return {
438
+ id: row.id,
439
+ enabled: !!row.enabled,
440
+ nextRunAt: new Date(row.next_run_at),
441
+ lastRunAt: row.last_run_at ? new Date(row.last_run_at) : null,
442
+ createdAt: new Date(row.created_at),
443
+ intervalMs: row.interval_ms,
444
+ prompt: row.prompt,
445
+ };
446
+ }
447
+
448
+ /**
449
+ * Delete existing heartbeat(s) and create a fresh one
450
+ *
451
+ * @param {string} userId - User ID
452
+ * @returns {Promise<Object|null>} New heartbeat task or null
453
+ */
454
+ async resetHeartbeat(userId) {
455
+ if (!this.db || !userId) return null;
456
+
457
+ const deleted = this.db.prepare(
458
+ "DELETE FROM cron_tasks WHERE user_id = ? AND name = 'heartbeat'"
459
+ ).run(userId);
460
+ console.log(`[cron] deleted existing heartbeat(s) for user ${userId}`);
461
+
462
+ const jitter = Math.floor(Math.random() * HEARTBEAT_INTERVAL_MS);
463
+ const now = Date.now();
464
+ const id = crypto.randomUUID();
465
+
466
+ this.db.prepare(`
467
+ INSERT INTO cron_tasks (id, name, prompt, session_id, user_id, next_run_at, interval_ms, recurring, enabled, created_at, last_run_at)
468
+ VALUES (?, 'heartbeat', ?, 'default', ?, ?, ?, 1, 1, ?, NULL)
469
+ `).run(id, HEARTBEAT_PROMPT, userId, now + jitter, HEARTBEAT_INTERVAL_MS, now);
470
+
471
+ console.log(`[cron] created new heartbeat for user ${userId}, first run in ${Math.round(jitter / 60000)}m`);
472
+
473
+ return {
474
+ id,
475
+ name: 'heartbeat',
476
+ prompt: HEARTBEAT_PROMPT,
477
+ userId,
478
+ sessionId: 'default',
479
+ nextRunAt: new Date(now + jitter),
480
+ intervalMs: HEARTBEAT_INTERVAL_MS,
481
+ recurring: true,
482
+ enabled: true,
483
+ createdAt: new Date(now),
484
+ lastRunAt: null,
485
+ };
486
+ }
487
+
488
+ /**
489
+ * Manually trigger the heartbeat task immediately
490
+ *
491
+ * @param {string} userId - User ID
492
+ * @returns {Promise<boolean>} True if heartbeat was fired
493
+ */
494
+ async triggerHeartbeatNow(userId) {
495
+ if (!this.db || !userId || !this.onTaskFire) return false;
496
+
497
+ const row = this.db.prepare(
498
+ "SELECT * FROM cron_tasks WHERE user_id = ? AND name = 'heartbeat' AND enabled = 1"
499
+ ).get(userId);
500
+
501
+ if (!row) {
502
+ console.log(`[cron] manual trigger failed: no enabled heartbeat for user ${userId}`);
503
+ return false;
504
+ }
505
+
506
+ console.log(`[cron] manually triggering heartbeat for user ${userId}`);
507
+ try {
508
+ await this.onTaskFire(this._rowToTask(row));
509
+ this.db.prepare(
510
+ 'UPDATE cron_tasks SET last_run_at = ? WHERE id = ?'
511
+ ).run(Date.now(), row.id);
512
+ return true;
513
+ } catch (err) {
514
+ console.error(`[cron] manual trigger error:`, err.message);
515
+ return false;
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Convert a raw SQLite row to a task object with JS types
521
+ *
522
+ * @private
523
+ * @param {Object} row - Raw SQLite row
524
+ * @returns {Object} Task with Date objects and booleans
525
+ */
526
+ _rowToTask(row) {
527
+ return {
528
+ id: row.id,
529
+ name: row.name,
530
+ prompt: row.prompt,
531
+ sessionId: row.session_id,
532
+ userId: row.user_id,
533
+ taskId: row.task_id,
534
+ nextRunAt: new Date(row.next_run_at),
535
+ intervalMs: row.interval_ms,
536
+ recurring: !!row.recurring,
537
+ enabled: !!row.enabled,
538
+ createdAt: new Date(row.created_at),
539
+ lastRunAt: row.last_run_at ? new Date(row.last_run_at) : null,
540
+ };
541
+ }
542
+
543
+ /**
544
+ * Close the database connection and checkpoint WAL.
545
+ */
546
+ close() {
547
+ this.stop();
548
+ if (this.db) {
549
+ try {
550
+ this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
551
+ this.db.close();
552
+ this.db = null;
553
+ console.log('[cron] SQLiteCronStore closed');
554
+ } catch (err) {
555
+ console.error('[cron] Error closing database:', err.message);
556
+ }
557
+ }
558
+ }
559
+ }
560
+
561
+ // Re-export utility functions for tool definitions
562
+ export { parseInterval, HEARTBEAT_INTERVAL_MS, HEARTBEAT_PROMPT } from './cron_constants.js';