@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.
- package/CHANGELOG.md +136 -0
- package/README.md +380 -0
- package/bin/dotbot.js +461 -0
- package/core/agent.js +779 -0
- package/core/compaction.js +261 -0
- package/core/cron_handler.js +262 -0
- package/core/events.js +229 -0
- package/core/failover.js +193 -0
- package/core/gptoss_tool_parser.js +173 -0
- package/core/init.js +154 -0
- package/core/normalize.js +324 -0
- package/core/trigger_handler.js +148 -0
- package/docs/core.md +103 -0
- package/docs/protected-files.md +59 -0
- package/examples/sqlite-session-example.js +69 -0
- package/index.js +341 -0
- package/observer/index.js +164 -0
- package/package.json +42 -0
- package/storage/CronStore.js +145 -0
- package/storage/EventStore.js +71 -0
- package/storage/MemoryStore.js +175 -0
- package/storage/MongoAdapter.js +291 -0
- package/storage/MongoCronAdapter.js +347 -0
- package/storage/MongoTaskAdapter.js +242 -0
- package/storage/MongoTriggerAdapter.js +158 -0
- package/storage/SQLiteAdapter.js +382 -0
- package/storage/SQLiteCronAdapter.js +562 -0
- package/storage/SQLiteEventStore.js +300 -0
- package/storage/SQLiteMemoryAdapter.js +240 -0
- package/storage/SQLiteTaskAdapter.js +419 -0
- package/storage/SQLiteTriggerAdapter.js +262 -0
- package/storage/SessionStore.js +149 -0
- package/storage/TaskStore.js +100 -0
- package/storage/TriggerStore.js +90 -0
- package/storage/cron_constants.js +48 -0
- package/storage/index.js +21 -0
- package/tools/appgen.js +311 -0
- package/tools/browser.js +634 -0
- package/tools/code.js +101 -0
- package/tools/events.js +145 -0
- package/tools/files.js +201 -0
- package/tools/images.js +253 -0
- package/tools/index.js +97 -0
- package/tools/jobs.js +159 -0
- package/tools/memory.js +332 -0
- package/tools/messages.js +135 -0
- package/tools/notify.js +42 -0
- package/tools/tasks.js +404 -0
- package/tools/triggers.js +159 -0
- package/tools/weather.js +82 -0
- package/tools/web.js +283 -0
- 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';
|