@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,382 @@
1
+ import crypto from 'crypto';
2
+ import { DatabaseSync } from 'node:sqlite';
3
+ import { SessionStore } from './SessionStore.js';
4
+ import { defaultSystemPrompt } from './MongoAdapter.js';
5
+ import { toStandardFormat } from '../core/normalize.js';
6
+
7
+ /**
8
+ * SQLite-backed SessionStore implementation
9
+ *
10
+ * Uses Node.js 22.5+ built-in sqlite module for zero-dependency session storage.
11
+ * All dates stored as ISO 8601 strings, messages as JSON TEXT column.
12
+ */
13
+ export class SQLiteSessionStore extends SessionStore {
14
+ constructor() {
15
+ super();
16
+ this.db = null;
17
+ this.prefsFetcher = null;
18
+ this.systemPromptBuilder = defaultSystemPrompt;
19
+ this.heartbeatEnsurer = null;
20
+ }
21
+
22
+ /**
23
+ * Initialize SQLite session store
24
+ *
25
+ * @param {string} dbPath - Path to SQLite database file
26
+ * @param {Object} [options={}] - Initialization options
27
+ * @param {Function} [options.prefsFetcher] - Async function (userId) => { agentName, agentPersonality }
28
+ * @param {Function} [options.systemPromptBuilder] - Function ({ agentName, agentPersonality }) => string
29
+ * @param {Function} [options.heartbeatEnsurer] - Async function (userId) => Promise<Object|null>
30
+ */
31
+ async init(dbPath, options = {}) {
32
+ this.db = new DatabaseSync(dbPath);
33
+ this.prefsFetcher = options.prefsFetcher || null;
34
+ this.systemPromptBuilder = options.systemPromptBuilder || defaultSystemPrompt;
35
+ this.heartbeatEnsurer = options.heartbeatEnsurer || null;
36
+
37
+ // Create schema
38
+ this.db.exec(`
39
+ CREATE TABLE IF NOT EXISTS sessions (
40
+ id TEXT PRIMARY KEY,
41
+ owner TEXT NOT NULL,
42
+ title TEXT DEFAULT '',
43
+ messages TEXT NOT NULL,
44
+ model TEXT NOT NULL,
45
+ provider TEXT DEFAULT 'ollama',
46
+ createdAt TEXT NOT NULL,
47
+ updatedAt TEXT NOT NULL
48
+ );
49
+
50
+ CREATE INDEX IF NOT EXISTS idx_sessions_owner_updated
51
+ ON sessions(owner, updatedAt DESC);
52
+
53
+ CREATE INDEX IF NOT EXISTS idx_sessions_id
54
+ ON sessions(id);
55
+ `);
56
+
57
+ console.log('[sessions] initialized with SQLite (multi-session)');
58
+ }
59
+
60
+ /**
61
+ * Build system prompt with current timestamp
62
+ *
63
+ * @param {string} owner - User ID
64
+ * @returns {Promise<string>} System prompt
65
+ */
66
+ async buildSystemPrompt(owner) {
67
+ const prefs = this.prefsFetcher ? await this.prefsFetcher(owner) : {};
68
+ return this.systemPromptBuilder(prefs);
69
+ }
70
+
71
+ async createSession(owner, model = 'gpt-oss:20b', provider = 'ollama') {
72
+ if (!this.db) throw new Error('Sessions not initialized. Call init() first.');
73
+
74
+ const now = new Date();
75
+ const session = {
76
+ id: crypto.randomUUID(),
77
+ owner,
78
+ title: '',
79
+ messages: [{ role: 'system', content: await this.buildSystemPrompt(owner) }],
80
+ model,
81
+ provider,
82
+ createdAt: now.toISOString(),
83
+ updatedAt: now.toISOString(),
84
+ };
85
+
86
+ const stmt = this.db.prepare(`
87
+ INSERT INTO sessions (id, owner, title, messages, model, provider, createdAt, updatedAt)
88
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
89
+ `);
90
+
91
+ stmt.run(
92
+ session.id,
93
+ session.owner,
94
+ session.title,
95
+ JSON.stringify(session.messages),
96
+ session.model,
97
+ session.provider,
98
+ session.createdAt,
99
+ session.updatedAt
100
+ );
101
+
102
+ return session;
103
+ }
104
+
105
+ async getOrCreateDefaultSession(owner) {
106
+ if (!this.db) throw new Error('Sessions not initialized. Call init() first.');
107
+
108
+ const stmt = this.db.prepare(`
109
+ SELECT * FROM sessions WHERE owner = ? ORDER BY updatedAt DESC LIMIT 1
110
+ `);
111
+
112
+ const row = stmt.get(owner);
113
+
114
+ let session;
115
+ if (!row) {
116
+ session = await this.createSession(owner);
117
+ } else {
118
+ session = this._rowToSession(row);
119
+ // Refresh system prompt timestamp
120
+ session.messages[0] = { role: 'system', content: await this.buildSystemPrompt(owner) };
121
+ }
122
+
123
+ if (this.heartbeatEnsurer) {
124
+ this.heartbeatEnsurer(owner).catch((err) => {
125
+ console.error(`[session] failed to ensure heartbeat for ${owner}:`, err.message);
126
+ });
127
+ }
128
+
129
+ return session;
130
+ }
131
+
132
+ async getSession(sessionId, owner) {
133
+ if (!this.db) throw new Error('Sessions not initialized. Call init() first.');
134
+
135
+ const stmt = this.db.prepare(`
136
+ SELECT * FROM sessions WHERE id = ? AND owner = ?
137
+ `);
138
+
139
+ const row = stmt.get(sessionId, owner);
140
+ if (!row) return null;
141
+
142
+ const session = this._rowToSession(row);
143
+ // Refresh system prompt timestamp
144
+ session.messages[0] = { role: 'system', content: await this.buildSystemPrompt(owner) };
145
+ return session;
146
+ }
147
+
148
+ async getSessionInternal(sessionId) {
149
+ if (!this.db) throw new Error('Sessions not initialized. Call init() first.');
150
+
151
+ const stmt = this.db.prepare(`
152
+ SELECT * FROM sessions WHERE id = ?
153
+ `);
154
+
155
+ const row = stmt.get(sessionId);
156
+ if (!row) return null;
157
+
158
+ const session = this._rowToSession(row);
159
+ session.messages[0] = { role: 'system', content: await this.buildSystemPrompt(session.owner) };
160
+ return session;
161
+ }
162
+
163
+ /**
164
+ * Save session with normalized messages.
165
+ * Converts any provider-specific message formats to standard format before persisting.
166
+ *
167
+ * @param {string} sessionId - Session UUID
168
+ * @param {Array} messages - Messages (provider-specific or standard format)
169
+ * @param {string} model - Model identifier
170
+ * @param {string} [provider] - Provider name
171
+ */
172
+ async saveSession(sessionId, messages, model, provider) {
173
+ const normalized = toStandardFormat(messages);
174
+ const updateFields = {
175
+ messages: JSON.stringify(normalized),
176
+ model,
177
+ updatedAt: new Date().toISOString(),
178
+ };
179
+
180
+ if (provider) {
181
+ updateFields.provider = provider;
182
+ }
183
+
184
+ // Auto-populate title from first user message if empty
185
+ const titleStmt = this.db.prepare('SELECT title FROM sessions WHERE id = ?');
186
+ const titleRow = titleStmt.get(sessionId);
187
+
188
+ if (titleRow && !titleRow.title) {
189
+ const firstUserMsg = normalized.find((m) => m.role === 'user');
190
+ if (firstUserMsg && typeof firstUserMsg.content === 'string') {
191
+ const rawTitle = firstUserMsg.content.slice(0, 60).trim();
192
+ // Skip generic/short titles
193
+ if (rawTitle.length >= 5 && !/^(msg|test|hi|hey|hello|ok|yo|sup)\d*$/i.test(rawTitle)) {
194
+ updateFields.title = rawTitle;
195
+ }
196
+ }
197
+ }
198
+
199
+ // Build dynamic UPDATE query
200
+ const setClause = Object.keys(updateFields).map(key => `${key} = ?`).join(', ');
201
+ const values = Object.values(updateFields);
202
+
203
+ const stmt = this.db.prepare(`
204
+ UPDATE sessions SET ${setClause} WHERE id = ?
205
+ `);
206
+
207
+ stmt.run(...values, sessionId);
208
+ }
209
+
210
+ /**
211
+ * Add a message to a session, normalizing to standard format before saving.
212
+ *
213
+ * @param {string} sessionId - Session UUID
214
+ * @param {Object} message - Message object (any provider format)
215
+ * @returns {Promise<Object>} Updated session
216
+ */
217
+ async addMessage(sessionId, message) {
218
+ const session = await this.getSessionInternal(sessionId);
219
+ if (!session) throw new Error(`Session ${sessionId} not found`);
220
+ if (!message._ts) message._ts = Date.now();
221
+ const normalized = toStandardFormat([message]);
222
+ session.messages.push(...normalized);
223
+ await this.saveSession(sessionId, session.messages, session.model);
224
+ return session;
225
+ }
226
+
227
+ async setModel(sessionId, model) {
228
+ const stmt = this.db.prepare(`
229
+ UPDATE sessions SET model = ?, updatedAt = ? WHERE id = ?
230
+ `);
231
+ stmt.run(model, new Date().toISOString(), sessionId);
232
+ }
233
+
234
+ async setProvider(sessionId, provider) {
235
+ const stmt = this.db.prepare(`
236
+ UPDATE sessions SET provider = ?, updatedAt = ? WHERE id = ?
237
+ `);
238
+ stmt.run(provider, new Date().toISOString(), sessionId);
239
+ }
240
+
241
+ /**
242
+ * Update session title.
243
+ *
244
+ * @param {string} sessionId - Session UUID
245
+ * @param {string} title - New title
246
+ */
247
+ async updateTitle(sessionId, title) {
248
+ const stmt = this.db.prepare(`
249
+ UPDATE sessions SET title = ?, updatedAt = ? WHERE id = ?
250
+ `);
251
+ stmt.run(title, new Date().toISOString(), sessionId);
252
+ }
253
+
254
+ async clearSession(sessionId) {
255
+ const ownerStmt = this.db.prepare('SELECT owner FROM sessions WHERE id = ?');
256
+ const ownerRow = ownerStmt.get(sessionId);
257
+
258
+ const messages = [{ role: 'system', content: await this.buildSystemPrompt(ownerRow?.owner) }];
259
+
260
+ const stmt = this.db.prepare(`
261
+ UPDATE sessions SET messages = ?, updatedAt = ? WHERE id = ?
262
+ `);
263
+
264
+ stmt.run(JSON.stringify(messages), new Date().toISOString(), sessionId);
265
+ }
266
+
267
+ async listSessions(owner) {
268
+ const stmt = this.db.prepare(`
269
+ SELECT id, title, model, provider, messages, createdAt, updatedAt
270
+ FROM sessions
271
+ WHERE owner = ?
272
+ ORDER BY updatedAt DESC
273
+ LIMIT 50
274
+ `);
275
+
276
+ const rows = stmt.all(owner);
277
+
278
+ return rows.map((row) => {
279
+ let parsedMessages = [];
280
+ try {
281
+ parsedMessages = JSON.parse(row.messages || '[]');
282
+ } catch {
283
+ parsedMessages = [];
284
+ }
285
+ return {
286
+ id: row.id,
287
+ owner: owner,
288
+ title: row.title || '',
289
+ model: row.model,
290
+ provider: row.provider || 'ollama',
291
+ messages: parsedMessages,
292
+ createdAt: new Date(row.createdAt).toISOString(),
293
+ updatedAt: new Date(row.updatedAt).toISOString(),
294
+ messageCount: parsedMessages.length,
295
+ };
296
+ });
297
+ }
298
+
299
+ async deleteSession(sessionId, owner) {
300
+ const stmt = this.db.prepare(`
301
+ DELETE FROM sessions WHERE id = ? AND owner = ?
302
+ `);
303
+
304
+ const result = stmt.run(sessionId, owner);
305
+ return { deletedCount: result.changes };
306
+ }
307
+
308
+ /**
309
+ * Upsert a session by Swift's conversation ID.
310
+ * Creates a new session or updates an existing one with the given messages.
311
+ * Used to sync Swift conversations to the agent SQLite store.
312
+ *
313
+ * @param {string} sessionId - Swift conversation UUID (used as session ID)
314
+ * @param {string} owner - User ID
315
+ * @param {Array} messages - Full message array from Swift (already normalized)
316
+ * @param {string} model - Model identifier
317
+ * @param {string} [provider='ollama'] - Provider name
318
+ */
319
+ async upsertSession(sessionId, owner, messages, model, provider = 'ollama') {
320
+ if (!this.db) throw new Error('Sessions not initialized. Call init() first.');
321
+
322
+ const now = new Date().toISOString();
323
+ const messagesJson = JSON.stringify(messages);
324
+
325
+ // Auto-title from first user message (only if descriptive enough)
326
+ const firstUser = messages.find((m) => m.role === 'user');
327
+ const rawTitle = (firstUser?.content || '').slice(0, 60).trim();
328
+ // Skip generic/short titles - require at least 5 chars and not look like test input
329
+ const title = rawTitle.length >= 5 && !/^(msg|test|hi|hey|hello|ok|yo|sup)\d*$/i.test(rawTitle) ? rawTitle : '';
330
+
331
+ // Try UPDATE first
332
+ const updateStmt = this.db.prepare(`
333
+ UPDATE sessions SET messages=?, model=?, provider=?, title=?, updatedAt=? WHERE id=?
334
+ `);
335
+ const result = updateStmt.run(messagesJson, model, provider, title, now, sessionId);
336
+
337
+ // If no row updated, INSERT
338
+ if (result.changes === 0) {
339
+ const insertStmt = this.db.prepare(`
340
+ INSERT INTO sessions (id, owner, title, messages, model, provider, createdAt, updatedAt)
341
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
342
+ `);
343
+ insertStmt.run(sessionId, owner, title, messagesJson, model, provider, now, now);
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Convert SQLite row to session object
349
+ *
350
+ * @private
351
+ * @param {Object} row - Raw SQLite row
352
+ * @returns {Object} Session object with parsed dates and messages
353
+ */
354
+ _rowToSession(row) {
355
+ return {
356
+ id: row.id,
357
+ owner: row.owner,
358
+ title: row.title,
359
+ messages: JSON.parse(row.messages),
360
+ model: row.model,
361
+ provider: row.provider,
362
+ createdAt: new Date(row.createdAt).toISOString(),
363
+ updatedAt: new Date(row.updatedAt).toISOString(),
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Close the database connection and checkpoint WAL.
369
+ */
370
+ close() {
371
+ if (this.db) {
372
+ try {
373
+ this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
374
+ this.db.close();
375
+ this.db = null;
376
+ console.log('[session] SQLiteSessionStore closed');
377
+ } catch (err) {
378
+ console.error('[session] Error closing database:', err.message);
379
+ }
380
+ }
381
+ }
382
+ }