@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,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
|
+
}
|