@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,419 @@
1
+ import crypto from 'crypto';
2
+ import { DatabaseSync } from 'node:sqlite';
3
+ import { TaskStore } from './TaskStore.js';
4
+
5
+ /**
6
+ * SQLite-backed TaskStore implementation
7
+ *
8
+ * Uses Node.js 22.5+ built-in sqlite module for zero-dependency task storage.
9
+ * Dates stored as INTEGER (Unix ms), steps as JSON TEXT column.
10
+ */
11
+ export class SQLiteTaskStore extends TaskStore {
12
+ constructor() {
13
+ super();
14
+ this.db = null;
15
+ }
16
+
17
+ /**
18
+ * Initialize SQLite task store
19
+ *
20
+ * @param {Object} config - Configuration object
21
+ * @param {string} config.dbPath - Path to SQLite database file
22
+ */
23
+ async init({ dbPath }) {
24
+ this.db = new DatabaseSync(dbPath);
25
+
26
+ // Migration: goals → tasks table
27
+ const goalsExists = this.db.prepare(
28
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='goals'"
29
+ ).get();
30
+ const tasksExists = this.db.prepare(
31
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'"
32
+ ).get();
33
+
34
+ if (goalsExists && !tasksExists) {
35
+ console.log('[tasks] migrating goals table to tasks...');
36
+ this.db.exec('ALTER TABLE goals RENAME TO tasks');
37
+ // Also rename indexes
38
+ this.db.exec('DROP INDEX IF EXISTS idx_goals_user_status');
39
+ this.db.exec('DROP INDEX IF EXISTS idx_goals_user_category');
40
+ this.db.exec('DROP INDEX IF EXISTS idx_goals_user_priority');
41
+ this.db.exec('DROP INDEX IF EXISTS idx_goals_user_deadline');
42
+ }
43
+
44
+ this.db.exec(`
45
+ CREATE TABLE IF NOT EXISTS tasks (
46
+ id TEXT PRIMARY KEY,
47
+ user_id TEXT NOT NULL,
48
+ description TEXT NOT NULL,
49
+ steps TEXT NOT NULL,
50
+ category TEXT DEFAULT 'general',
51
+ priority TEXT DEFAULT 'medium',
52
+ deadline INTEGER,
53
+ mode TEXT DEFAULT 'auto',
54
+ status TEXT DEFAULT 'pending',
55
+ current_step INTEGER DEFAULT 0,
56
+ progress INTEGER DEFAULT 0,
57
+ created_at INTEGER NOT NULL,
58
+ updated_at INTEGER NOT NULL,
59
+ last_worked_at INTEGER
60
+ );
61
+
62
+ CREATE INDEX IF NOT EXISTS idx_tasks_user_status ON tasks(user_id, status);
63
+ CREATE INDEX IF NOT EXISTS idx_tasks_user_category ON tasks(user_id, category);
64
+ CREATE INDEX IF NOT EXISTS idx_tasks_user_priority ON tasks(user_id, priority);
65
+ CREATE INDEX IF NOT EXISTS idx_tasks_user_deadline ON tasks(user_id, deadline);
66
+ `);
67
+
68
+ console.log('[tasks] SQLiteTaskStore initialized');
69
+ }
70
+
71
+ /**
72
+ * Create a new task
73
+ *
74
+ * @param {Object} params
75
+ * @param {string} params.userId - Owner user ID
76
+ * @param {string} params.description - Task description
77
+ * @param {Array<string|Object>} [params.steps=[]] - Step descriptions or step objects
78
+ * @param {string} [params.category='general'] - Task category
79
+ * @param {string} [params.priority='medium'] - Priority: low, medium, high
80
+ * @param {number|null} [params.deadline=null] - Unix ms deadline or null
81
+ * @param {string} [params.mode='auto'] - Execution mode: auto or manual
82
+ * @returns {Promise<Object>} Created task document
83
+ */
84
+ async createTask({ userId, description, steps = [], category = 'general', priority = 'medium', deadline = null, mode = 'auto' }) {
85
+ if (!this.db) throw new Error('Tasks not initialized. Call init() first.');
86
+
87
+ const normalizedSteps = steps.map(step => {
88
+ if (typeof step === 'string') {
89
+ return {
90
+ text: step,
91
+ action: step,
92
+ done: false,
93
+ result: null,
94
+ startedAt: null,
95
+ completedAt: null,
96
+ };
97
+ }
98
+ return {
99
+ text: step.text || step.description || '',
100
+ action: step.action || step.text || '',
101
+ done: step.done || false,
102
+ result: step.result || null,
103
+ startedAt: step.startedAt || null,
104
+ completedAt: step.completedAt || null,
105
+ };
106
+ });
107
+
108
+ const now = Date.now();
109
+ const task = {
110
+ id: crypto.randomUUID(),
111
+ userId,
112
+ description,
113
+ steps: normalizedSteps,
114
+ category,
115
+ priority,
116
+ deadline,
117
+ mode,
118
+ status: 'pending',
119
+ currentStep: 0,
120
+ progress: 0,
121
+ createdAt: now,
122
+ updatedAt: now,
123
+ lastWorkedAt: null,
124
+ };
125
+
126
+ const stmt = this.db.prepare(`
127
+ INSERT INTO tasks (id, user_id, description, steps, category, priority, deadline, mode, status, current_step, progress, created_at, updated_at, last_worked_at)
128
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
129
+ `);
130
+
131
+ stmt.run(
132
+ task.id,
133
+ task.userId,
134
+ task.description,
135
+ JSON.stringify(task.steps),
136
+ task.category,
137
+ task.priority,
138
+ task.deadline,
139
+ task.mode,
140
+ task.status,
141
+ task.currentStep,
142
+ task.progress,
143
+ task.createdAt,
144
+ task.updatedAt,
145
+ task.lastWorkedAt
146
+ );
147
+
148
+ return task;
149
+ }
150
+
151
+ /**
152
+ * Get tasks for a user, optionally filtered by status/category/priority
153
+ *
154
+ * @param {string} userId - User ID
155
+ * @param {Object} [filters={}] - Optional filters
156
+ * @param {string} [filters.status] - Filter by status
157
+ * @param {string} [filters.category] - Filter by category
158
+ * @param {string} [filters.priority] - Filter by priority
159
+ * @returns {Promise<Array>} Task list with computed progress
160
+ */
161
+ async getTasks(userId, filters = {}) {
162
+ if (!this.db) throw new Error('Tasks not initialized. Call init() first.');
163
+
164
+ let sql = 'SELECT * FROM tasks WHERE user_id = ?';
165
+ const params = [userId];
166
+
167
+ if (filters.status) {
168
+ sql += ' AND status = ?';
169
+ params.push(filters.status);
170
+ }
171
+ if (filters.category) {
172
+ sql += ' AND category = ?';
173
+ params.push(filters.category);
174
+ }
175
+ if (filters.priority) {
176
+ sql += ' AND priority = ?';
177
+ params.push(filters.priority);
178
+ }
179
+
180
+ sql += ' ORDER BY created_at DESC';
181
+
182
+ const stmt = this.db.prepare(sql);
183
+ const rows = stmt.all(...params);
184
+
185
+ return rows.map(row => {
186
+ const task = this._rowToTask(row);
187
+ task.progress = this._calculateProgress(task);
188
+ return task;
189
+ });
190
+ }
191
+
192
+ /**
193
+ * Get a single task by ID
194
+ *
195
+ * @param {string} userId - User ID
196
+ * @param {string} taskId - Task UUID
197
+ * @returns {Promise<Object|null>} Task document or null
198
+ */
199
+ async getTask(userId, taskId) {
200
+ if (!this.db) throw new Error('Tasks not initialized. Call init() first.');
201
+
202
+ const stmt = this.db.prepare('SELECT * FROM tasks WHERE id = ? AND user_id = ?');
203
+ const row = stmt.get(taskId, userId);
204
+
205
+ if (!row) return null;
206
+
207
+ const task = this._rowToTask(row);
208
+ task.progress = this._calculateProgress(task);
209
+ return task;
210
+ }
211
+
212
+ /**
213
+ * Update a task with allowed fields
214
+ *
215
+ * @param {string} userId - User ID
216
+ * @param {string} taskId - Task UUID
217
+ * @param {Object} updates - Fields to update
218
+ * @returns {Promise<Object>} Result with changes count
219
+ */
220
+ async updateTask(userId, taskId, updates) {
221
+ if (!this.db) throw new Error('Tasks not initialized. Call init() first.');
222
+
223
+ const allowedFields = [
224
+ 'description', 'steps', 'category', 'priority', 'deadline',
225
+ 'mode', 'status', 'currentStep', 'lastWorkedAt'
226
+ ];
227
+
228
+ // Map camelCase to snake_case for SQL columns
229
+ const fieldMap = {
230
+ description: 'description',
231
+ steps: 'steps',
232
+ category: 'category',
233
+ priority: 'priority',
234
+ deadline: 'deadline',
235
+ mode: 'mode',
236
+ status: 'status',
237
+ currentStep: 'current_step',
238
+ lastWorkedAt: 'last_worked_at',
239
+ };
240
+
241
+ const setClauses = [];
242
+ const values = [];
243
+
244
+ for (const field of allowedFields) {
245
+ if (updates[field] !== undefined) {
246
+ const col = fieldMap[field];
247
+ setClauses.push(`${col} = ?`);
248
+ if (field === 'steps') {
249
+ values.push(JSON.stringify(updates[field]));
250
+ } else {
251
+ values.push(updates[field]);
252
+ }
253
+ }
254
+ }
255
+
256
+ if (updates.steps) {
257
+ setClauses.push('progress = ?');
258
+ values.push(this._calculateProgressFromSteps(updates.steps));
259
+ }
260
+
261
+ setClauses.push('updated_at = ?');
262
+ values.push(Date.now());
263
+
264
+ const sql = `UPDATE tasks SET ${setClauses.join(', ')} WHERE id = ? AND user_id = ?`;
265
+ values.push(taskId, userId);
266
+
267
+ const stmt = this.db.prepare(sql);
268
+ const result = stmt.run(...values);
269
+
270
+ return { changes: result.changes };
271
+ }
272
+
273
+ /**
274
+ * Delete a task
275
+ *
276
+ * @param {string} userId - User ID
277
+ * @param {string} taskId - Task UUID
278
+ * @returns {Promise<Object>} Result with deletedCount
279
+ */
280
+ async deleteTask(userId, taskId) {
281
+ if (!this.db) throw new Error('Tasks not initialized. Call init() first.');
282
+
283
+ const stmt = this.db.prepare('DELETE FROM tasks WHERE id = ? AND user_id = ?');
284
+ const result = stmt.run(taskId, userId);
285
+
286
+ return { deletedCount: result.changes };
287
+ }
288
+
289
+ /**
290
+ * Search tasks by description or step text (case-insensitive LIKE)
291
+ *
292
+ * @param {string} userId - User ID
293
+ * @param {string} query - Search text
294
+ * @returns {Promise<Array>} Matching tasks with computed progress
295
+ */
296
+ async searchTasks(userId, query) {
297
+ if (!this.db) throw new Error('Tasks not initialized. Call init() first.');
298
+
299
+ const pattern = `%${query}%`;
300
+ const stmt = this.db.prepare(`
301
+ SELECT * FROM tasks
302
+ WHERE user_id = ? AND (description LIKE ? OR steps LIKE ?)
303
+ ORDER BY created_at DESC
304
+ `);
305
+
306
+ const rows = stmt.all(userId, pattern, pattern);
307
+
308
+ return rows.map(row => {
309
+ const task = this._rowToTask(row);
310
+ task.progress = this._calculateProgress(task);
311
+ return task;
312
+ });
313
+ }
314
+
315
+ /**
316
+ * Get aggregate task statistics for a user
317
+ *
318
+ * @param {string} userId - User ID
319
+ * @returns {Promise<Object>} Stats object with totals, breakdowns, and overdue count
320
+ */
321
+ async getTaskStats(userId) {
322
+ if (!this.db) throw new Error('Tasks not initialized. Call init() first.');
323
+
324
+ const stmt = this.db.prepare('SELECT * FROM tasks WHERE user_id = ?');
325
+ const rows = stmt.all(userId);
326
+ const tasks = rows.map(row => this._rowToTask(row));
327
+
328
+ const now = Date.now();
329
+ const stats = {
330
+ total: tasks.length,
331
+ pending: tasks.filter(g => g.status === 'pending').length,
332
+ in_progress: tasks.filter(g => g.status === 'in_progress').length,
333
+ completed: tasks.filter(g => g.status === 'completed').length,
334
+ by_category: {},
335
+ by_priority: {},
336
+ overdue: 0,
337
+ };
338
+
339
+ for (const task of tasks) {
340
+ const cat = task.category || 'general';
341
+ stats.by_category[cat] = (stats.by_category[cat] || 0) + 1;
342
+
343
+ const pri = task.priority || 'medium';
344
+ stats.by_priority[pri] = (stats.by_priority[pri] || 0) + 1;
345
+
346
+ if (task.deadline && task.deadline < now && task.status !== 'completed') {
347
+ stats.overdue++;
348
+ }
349
+ }
350
+
351
+ return stats;
352
+ }
353
+
354
+ /**
355
+ * Convert SQLite row (snake_case) to task object (camelCase)
356
+ *
357
+ * @private
358
+ * @param {Object} row - Raw SQLite row
359
+ * @returns {Object} Task object with parsed steps and camelCase keys
360
+ */
361
+ _rowToTask(row) {
362
+ return {
363
+ id: row.id,
364
+ userId: row.user_id,
365
+ description: row.description,
366
+ steps: JSON.parse(row.steps),
367
+ category: row.category,
368
+ priority: row.priority,
369
+ deadline: row.deadline,
370
+ mode: row.mode,
371
+ status: row.status,
372
+ currentStep: row.current_step,
373
+ progress: row.progress,
374
+ createdAt: row.created_at,
375
+ updatedAt: row.updated_at,
376
+ lastWorkedAt: row.last_worked_at,
377
+ };
378
+ }
379
+
380
+ /**
381
+ * Calculate progress percentage from a task object
382
+ * @private
383
+ * @param {Object} task - Task with steps array
384
+ * @returns {number} Progress 0-100
385
+ */
386
+ _calculateProgress(task) {
387
+ return this._calculateProgressFromSteps(task.steps || []);
388
+ }
389
+
390
+ /**
391
+ * Calculate progress percentage from a steps array
392
+ * @private
393
+ * @param {Array} steps - Steps array
394
+ * @returns {number} Progress 0-100
395
+ */
396
+ _calculateProgressFromSteps(steps) {
397
+ if (!steps || steps.length === 0) return 0;
398
+ const doneCount = steps.filter(s => s.done).length;
399
+ return Math.round((doneCount / steps.length) * 100);
400
+ }
401
+
402
+ /**
403
+ * Close the database connection and checkpoint WAL.
404
+ * Should be called on shutdown to ensure all changes are persisted.
405
+ */
406
+ close() {
407
+ if (this.db) {
408
+ try {
409
+ // Force WAL checkpoint before closing
410
+ this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
411
+ this.db.close();
412
+ this.db = null;
413
+ console.log('[tasks] SQLiteTaskStore closed');
414
+ } catch (err) {
415
+ console.error('[tasks] Error closing database:', err.message);
416
+ }
417
+ }
418
+ }
419
+ }
@@ -0,0 +1,262 @@
1
+ import crypto from 'crypto';
2
+ import { DatabaseSync } from 'node:sqlite';
3
+ import { TriggerStore } from './TriggerStore.js';
4
+
5
+ /**
6
+ * SQLite-backed TriggerStore implementation
7
+ *
8
+ * Uses Node.js 22.5+ built-in sqlite module for zero-dependency trigger storage.
9
+ * Dates stored as INTEGER (Unix ms timestamps), metadata as JSON TEXT.
10
+ */
11
+ export class SQLiteTriggerStore extends TriggerStore {
12
+ constructor() {
13
+ super();
14
+ this.db = null;
15
+ }
16
+
17
+ /**
18
+ * Initialize SQLite trigger store
19
+ *
20
+ * @param {Object} config - Configuration object
21
+ * @param {string} config.dbPath - Path to SQLite database file
22
+ * @param {Object} [options={}] - Reserved for future use
23
+ */
24
+ async init({ dbPath }, options = {}) {
25
+ this.db = new DatabaseSync(dbPath);
26
+
27
+ this.db.exec(`
28
+ CREATE TABLE IF NOT EXISTS triggers (
29
+ id TEXT PRIMARY KEY,
30
+ user_id TEXT NOT NULL,
31
+ event_type TEXT NOT NULL,
32
+ prompt TEXT NOT NULL,
33
+ cooldown_ms INTEGER DEFAULT 0,
34
+ metadata TEXT,
35
+ enabled INTEGER DEFAULT 1,
36
+ last_fired_at INTEGER,
37
+ fire_count INTEGER DEFAULT 0,
38
+ created_at INTEGER NOT NULL,
39
+ updated_at INTEGER NOT NULL
40
+ );
41
+
42
+ CREATE INDEX IF NOT EXISTS idx_triggers_user_event ON triggers(user_id, event_type);
43
+ CREATE INDEX IF NOT EXISTS idx_triggers_user_enabled ON triggers(user_id, enabled);
44
+ `);
45
+
46
+ console.log('[triggers] SQLiteTriggerStore initialized');
47
+ }
48
+
49
+ /**
50
+ * Create an event trigger
51
+ *
52
+ * @param {Object} params
53
+ * @param {string} params.userId - Owner user ID
54
+ * @param {string} params.eventType - Event type to trigger on
55
+ * @param {string} params.prompt - Prompt to inject when event fires
56
+ * @param {number} [params.cooldownMs=0] - Cooldown period in milliseconds
57
+ * @param {Object} [params.metadata={}] - Additional metadata
58
+ * @param {boolean} [params.enabled=true] - Whether trigger is enabled
59
+ * @returns {Promise<Object>} Created trigger document
60
+ */
61
+ async createTrigger({ userId, eventType, prompt, cooldownMs = 0, metadata = {}, enabled = true }) {
62
+ if (!this.db) throw new Error('Triggers not initialized. Call init() first.');
63
+
64
+ const now = Date.now();
65
+ const doc = {
66
+ id: crypto.randomUUID(),
67
+ userId,
68
+ eventType,
69
+ prompt,
70
+ cooldownMs,
71
+ metadata,
72
+ enabled,
73
+ lastFiredAt: null,
74
+ fireCount: 0,
75
+ createdAt: new Date(now),
76
+ updatedAt: new Date(now),
77
+ };
78
+
79
+ const stmt = this.db.prepare(`
80
+ INSERT INTO triggers (id, user_id, event_type, prompt, cooldown_ms, metadata, enabled, last_fired_at, fire_count, created_at, updated_at)
81
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
82
+ `);
83
+
84
+ stmt.run(
85
+ doc.id,
86
+ userId,
87
+ eventType,
88
+ prompt,
89
+ cooldownMs,
90
+ Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null,
91
+ enabled ? 1 : 0,
92
+ null,
93
+ 0,
94
+ now,
95
+ now
96
+ );
97
+
98
+ return doc;
99
+ }
100
+
101
+ /**
102
+ * List triggers for a user
103
+ *
104
+ * @param {string} userId - User ID
105
+ * @param {Object} [filters={}] - Optional filters
106
+ * @param {boolean} [filters.enabled] - Filter by enabled state
107
+ * @param {string} [filters.eventType] - Filter by event type
108
+ * @returns {Promise<Array>} Trigger list sorted by created_at DESC
109
+ */
110
+ async listTriggers(userId, filters = {}) {
111
+ if (!this.db) throw new Error('Triggers not initialized. Call init() first.');
112
+
113
+ let sql = 'SELECT * FROM triggers WHERE user_id = ?';
114
+ const params = [userId];
115
+
116
+ if (filters.enabled !== undefined) {
117
+ sql += ' AND enabled = ?';
118
+ params.push(filters.enabled ? 1 : 0);
119
+ }
120
+ if (filters.eventType) {
121
+ sql += ' AND event_type = ?';
122
+ params.push(filters.eventType);
123
+ }
124
+
125
+ sql += ' ORDER BY created_at DESC';
126
+
127
+ const stmt = this.db.prepare(sql);
128
+ const rows = stmt.all(...params);
129
+
130
+ return rows.map(row => this._rowToTrigger(row));
131
+ }
132
+
133
+ /**
134
+ * Find enabled triggers matching userId and eventType, filtering out
135
+ * those still within cooldown period
136
+ *
137
+ * @param {string} userId - User ID
138
+ * @param {string} eventType - Event type to match
139
+ * @param {Object} [metadata={}] - Event metadata for matching
140
+ * @returns {Promise<Array>} Matching trigger documents
141
+ */
142
+ async findMatchingTriggers(userId, eventType, metadata = {}) {
143
+ if (!this.db) throw new Error('Triggers not initialized. Call init() first.');
144
+
145
+ const now = Date.now();
146
+
147
+ const stmt = this.db.prepare(
148
+ 'SELECT * FROM triggers WHERE user_id = ? AND event_type = ? AND enabled = 1'
149
+ );
150
+ const rows = stmt.all(userId, eventType);
151
+ const triggers = rows.map(row => this._rowToTrigger(row));
152
+
153
+ // Filter by cooldown
154
+ const activeTriggers = triggers.filter(trigger => {
155
+ if (!trigger.cooldownMs) return true;
156
+ if (!trigger.lastFiredAt) return true;
157
+ const lastFiredTime = trigger.lastFiredAt.getTime();
158
+ return (lastFiredTime + trigger.cooldownMs) <= now;
159
+ });
160
+
161
+ // Filter by metadata requirements
162
+ const matchedTriggers = activeTriggers.filter(trigger => {
163
+ if (!trigger.metadata || Object.keys(trigger.metadata).length === 0) {
164
+ return true;
165
+ }
166
+ for (const [key, value] of Object.entries(trigger.metadata)) {
167
+ if (metadata[key] !== value) return false;
168
+ }
169
+ return true;
170
+ });
171
+
172
+ return matchedTriggers;
173
+ }
174
+
175
+ /**
176
+ * Toggle a trigger on/off
177
+ *
178
+ * @param {string} userId - User ID
179
+ * @param {string} triggerId - Trigger ID
180
+ * @param {boolean} enabled - Whether to enable or disable
181
+ * @returns {Promise<Object>} Update result with changes count
182
+ */
183
+ async toggleTrigger(userId, triggerId, enabled) {
184
+ if (!this.db) throw new Error('Triggers not initialized. Call init() first.');
185
+
186
+ const stmt = this.db.prepare(
187
+ 'UPDATE triggers SET enabled = ?, updated_at = ? WHERE id = ? AND user_id = ?'
188
+ );
189
+ const result = stmt.run(enabled ? 1 : 0, Date.now(), triggerId, userId);
190
+ return { changes: result.changes };
191
+ }
192
+
193
+ /**
194
+ * Delete a trigger
195
+ *
196
+ * @param {string} userId - User ID
197
+ * @param {string} triggerId - Trigger ID
198
+ * @returns {Promise<Object>} Delete result with deletedCount
199
+ */
200
+ async deleteTrigger(userId, triggerId) {
201
+ if (!this.db) throw new Error('Triggers not initialized. Call init() first.');
202
+
203
+ const stmt = this.db.prepare(
204
+ 'DELETE FROM triggers WHERE id = ? AND user_id = ?'
205
+ );
206
+ const result = stmt.run(triggerId, userId);
207
+ return { deletedCount: result.changes };
208
+ }
209
+
210
+ /**
211
+ * Record that a trigger has fired
212
+ *
213
+ * @param {string} triggerId - Trigger ID
214
+ */
215
+ async markTriggerFired(triggerId) {
216
+ if (!this.db) throw new Error('Triggers not initialized. Call init() first.');
217
+
218
+ const stmt = this.db.prepare(
219
+ 'UPDATE triggers SET last_fired_at = ?, fire_count = fire_count + 1 WHERE id = ?'
220
+ );
221
+ stmt.run(Date.now(), triggerId);
222
+ }
223
+
224
+ /**
225
+ * Convert SQLite row to trigger object
226
+ *
227
+ * @private
228
+ * @param {Object} row - Raw SQLite row
229
+ * @returns {Object} Trigger object with parsed dates and metadata
230
+ */
231
+ _rowToTrigger(row) {
232
+ return {
233
+ id: row.id,
234
+ userId: row.user_id,
235
+ eventType: row.event_type,
236
+ prompt: row.prompt,
237
+ cooldownMs: row.cooldown_ms,
238
+ metadata: row.metadata ? JSON.parse(row.metadata) : {},
239
+ enabled: row.enabled === 1,
240
+ lastFiredAt: row.last_fired_at ? new Date(row.last_fired_at) : null,
241
+ fireCount: row.fire_count,
242
+ createdAt: new Date(row.created_at),
243
+ updatedAt: new Date(row.updated_at),
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Close the database connection and checkpoint WAL.
249
+ */
250
+ close() {
251
+ if (this.db) {
252
+ try {
253
+ this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
254
+ this.db.close();
255
+ this.db = null;
256
+ console.log('[triggers] SQLiteTriggerStore closed');
257
+ } catch (err) {
258
+ console.error('[triggers] Error closing database:', err.message);
259
+ }
260
+ }
261
+ }
262
+ }