@stevederico/dotbot 0.19.0 → 0.20.1
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 +12 -0
- package/README.md +3 -2
- package/bin/dotbot.js +8 -2
- package/core/browser-launcher.js +246 -0
- package/core/cdp.js +617 -0
- package/dotbot.db +0 -0
- package/index.js +0 -5
- package/package.json +4 -6
- package/storage/MemoryStore.js +1 -1
- package/storage/SQLiteAdapter.js +36 -1
- package/storage/index.js +2 -7
- package/tools/browser.js +479 -384
- package/storage/MongoAdapter.js +0 -291
- package/storage/MongoCronAdapter.js +0 -347
- package/storage/MongoTaskAdapter.js +0 -242
- package/storage/MongoTriggerAdapter.js +0 -158
package/storage/MongoAdapter.js
DELETED
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
import crypto from 'crypto';
|
|
2
|
-
import { SessionStore } from './SessionStore.js';
|
|
3
|
-
import { toStandardFormat } from '../core/normalize.js';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Default system prompt builder for DotBot agent
|
|
7
|
-
*
|
|
8
|
-
* @param {Object} options - Agent identity overrides
|
|
9
|
-
* @param {string} [options.agentName='Dottie'] - Display name
|
|
10
|
-
* @param {string} [options.agentPersonality=''] - Personality/tone
|
|
11
|
-
* @returns {string} System prompt
|
|
12
|
-
*/
|
|
13
|
-
export function defaultSystemPrompt({ agentName = 'Dottie', agentPersonality = '' } = {}) {
|
|
14
|
-
const now = new Date().toISOString();
|
|
15
|
-
return `You are a helpful personal AI assistant called ${agentName}.${agentPersonality ? `\nYour personality and tone: ${agentPersonality}. Embody this in all responses.` : ''}
|
|
16
|
-
You have access to tools for searching the web, reading/writing files, fetching URLs, running code, long-term memory, and scheduled tasks.
|
|
17
|
-
The current date and time is ${now}.
|
|
18
|
-
|
|
19
|
-
Use tools when they would help answer the user's question — don't guess when you can look things up.
|
|
20
|
-
Keep responses concise and useful. When you use a tool, explain what you found.
|
|
21
|
-
|
|
22
|
-
Memory guidelines:
|
|
23
|
-
- When the user shares personal info (name, preferences, projects, goals), save it with memory_save.
|
|
24
|
-
- When the user references past conversations or asks "do you remember", search with memory_search.
|
|
25
|
-
- When the user asks to forget something, use memory_search to find the key, then memory_delete to remove it.
|
|
26
|
-
- Be selective — only save things worth recalling in future conversations.
|
|
27
|
-
- Don't announce every memory save unless the user would want to know.
|
|
28
|
-
|
|
29
|
-
Scheduling guidelines:
|
|
30
|
-
- When the user asks for a reminder, periodic check, or recurring job, use schedule_job.
|
|
31
|
-
- Write the prompt as if the user is asking you to do something when the job fires.
|
|
32
|
-
- For recurring jobs, suggest a reasonable interval if the user doesn't specify one.
|
|
33
|
-
|
|
34
|
-
Follow-up suggestions:
|
|
35
|
-
- At the end of every response, suggest one natural follow-up question the user might ask next.
|
|
36
|
-
- Format: <followup>Your suggested question here</followup>
|
|
37
|
-
- Keep it short, specific to the conversation context, and genuinely useful.
|
|
38
|
-
- Do not include the followup tag when using tools or in error responses.`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* MongoDB-backed SessionStore implementation
|
|
43
|
-
*/
|
|
44
|
-
export class MongoSessionStore extends SessionStore {
|
|
45
|
-
constructor() {
|
|
46
|
-
super();
|
|
47
|
-
this.collection = null;
|
|
48
|
-
this.prefsFetcher = null;
|
|
49
|
-
this.systemPromptBuilder = defaultSystemPrompt;
|
|
50
|
-
this.heartbeatEnsurer = null;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Initialize MongoDB session store
|
|
55
|
-
*
|
|
56
|
-
* @param {import('mongodb').Db} db - MongoDB database instance
|
|
57
|
-
* @param {Object} [options={}] - Initialization options
|
|
58
|
-
* @param {Function} [options.prefsFetcher] - Async function (userId) => { agentName, agentPersonality }
|
|
59
|
-
* @param {Function} [options.systemPromptBuilder] - Function ({ agentName, agentPersonality }) => string
|
|
60
|
-
* @param {Function} [options.heartbeatEnsurer] - Async function (userId) => Promise<Object|null>
|
|
61
|
-
*/
|
|
62
|
-
async init(db, options = {}) {
|
|
63
|
-
this.collection = db.collection('sessions');
|
|
64
|
-
this.prefsFetcher = options.prefsFetcher || null;
|
|
65
|
-
this.systemPromptBuilder = options.systemPromptBuilder || defaultSystemPrompt;
|
|
66
|
-
this.heartbeatEnsurer = options.heartbeatEnsurer || null;
|
|
67
|
-
|
|
68
|
-
await this.collection.createIndex({ id: 1 }, { unique: true }).catch(() => {});
|
|
69
|
-
await this.collection.createIndex({ owner: 1, updatedAt: -1 }).catch(() => {});
|
|
70
|
-
|
|
71
|
-
// Migrate legacy sessions: documents without an `owner` field
|
|
72
|
-
const legacy = await this.collection.find({ owner: { $exists: false } }).toArray();
|
|
73
|
-
for (const doc of legacy) {
|
|
74
|
-
const oldId = doc.id;
|
|
75
|
-
const newId = crypto.randomUUID();
|
|
76
|
-
const firstUserMsg = doc.messages?.find((m) => m.role === 'user');
|
|
77
|
-
const title = firstUserMsg ? firstUserMsg.content.slice(0, 60) : '';
|
|
78
|
-
await this.collection.updateOne(
|
|
79
|
-
{ _id: doc._id },
|
|
80
|
-
{ $set: { id: newId, owner: oldId, title, updatedAt: doc.updatedAt || new Date() } }
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (legacy.length > 0) {
|
|
85
|
-
console.log(`[sessions] migrated ${legacy.length} legacy session(s)`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Migrate existing sessions to standard message format.
|
|
89
|
-
// Detects provider-specific messages by checking for Anthropic content arrays
|
|
90
|
-
// or OpenAI tool_calls properties, then normalizes them. Idempotent — already
|
|
91
|
-
// normalized sessions pass through unchanged.
|
|
92
|
-
const allSessions = await this.collection.find({}).toArray();
|
|
93
|
-
let migrated = 0;
|
|
94
|
-
for (const doc of allSessions) {
|
|
95
|
-
if (!Array.isArray(doc.messages) || doc.messages.length === 0) continue;
|
|
96
|
-
|
|
97
|
-
const needsNormalization = doc.messages.some(
|
|
98
|
-
(m) => (m.role === 'assistant' && Array.isArray(m.content)) ||
|
|
99
|
-
(m.role === 'assistant' && m.tool_calls) ||
|
|
100
|
-
(m.role === 'tool') ||
|
|
101
|
-
(m.role === 'user' && Array.isArray(m.content) && m.content.some(b => b.type === 'tool_result'))
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
if (needsNormalization) {
|
|
105
|
-
const normalized = toStandardFormat(doc.messages);
|
|
106
|
-
await this.collection.updateOne(
|
|
107
|
-
{ _id: doc._id },
|
|
108
|
-
{ $set: { messages: normalized } }
|
|
109
|
-
);
|
|
110
|
-
migrated++;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (migrated > 0) {
|
|
115
|
-
console.log(`[sessions] normalized messages in ${migrated} session(s)`);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
console.log('[sessions] initialized with MongoDB (multi-session)');
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Build system prompt with current timestamp
|
|
123
|
-
*
|
|
124
|
-
* @param {string} owner - User ID
|
|
125
|
-
* @returns {Promise<string>} System prompt
|
|
126
|
-
*/
|
|
127
|
-
async buildSystemPrompt(owner) {
|
|
128
|
-
const prefs = this.prefsFetcher ? await this.prefsFetcher(owner) : {};
|
|
129
|
-
return this.systemPromptBuilder(prefs);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
async createSession(owner, model = 'gpt-oss:20b', provider = 'ollama') {
|
|
133
|
-
if (!this.collection) throw new Error('Sessions not initialized. Call init() first.');
|
|
134
|
-
|
|
135
|
-
const session = {
|
|
136
|
-
id: crypto.randomUUID(),
|
|
137
|
-
owner,
|
|
138
|
-
title: '',
|
|
139
|
-
messages: [{ role: 'system', content: await this.buildSystemPrompt(owner) }],
|
|
140
|
-
model,
|
|
141
|
-
provider,
|
|
142
|
-
createdAt: new Date(),
|
|
143
|
-
updatedAt: new Date(),
|
|
144
|
-
};
|
|
145
|
-
await this.collection.insertOne(session);
|
|
146
|
-
return session;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async getOrCreateDefaultSession(owner) {
|
|
150
|
-
if (!this.collection) throw new Error('Sessions not initialized. Call init() first.');
|
|
151
|
-
|
|
152
|
-
let session = await this.collection.findOne({ owner }, { sort: { updatedAt: -1 } });
|
|
153
|
-
if (!session) {
|
|
154
|
-
session = await this.createSession(owner);
|
|
155
|
-
} else {
|
|
156
|
-
// Refresh system prompt timestamp
|
|
157
|
-
session.messages[0] = { role: 'system', content: await this.buildSystemPrompt(owner) };
|
|
158
|
-
}
|
|
159
|
-
if (this.heartbeatEnsurer) {
|
|
160
|
-
this.heartbeatEnsurer(owner).catch((err) => {
|
|
161
|
-
console.error(`[session] failed to ensure heartbeat for ${owner}:`, err.message);
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
return session;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async getSession(sessionId, owner) {
|
|
168
|
-
if (!this.collection) throw new Error('Sessions not initialized. Call init() first.');
|
|
169
|
-
|
|
170
|
-
const session = await this.collection.findOne({ id: sessionId, owner });
|
|
171
|
-
if (!session) return null;
|
|
172
|
-
|
|
173
|
-
// Refresh system prompt timestamp
|
|
174
|
-
session.messages[0] = { role: 'system', content: await this.buildSystemPrompt(owner) };
|
|
175
|
-
return session;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async getSessionInternal(sessionId) {
|
|
179
|
-
if (!this.collection) throw new Error('Sessions not initialized. Call init() first.');
|
|
180
|
-
|
|
181
|
-
const session = await this.collection.findOne({ id: sessionId });
|
|
182
|
-
if (!session) return null;
|
|
183
|
-
|
|
184
|
-
session.messages[0] = { role: 'system', content: await this.buildSystemPrompt(session.owner) };
|
|
185
|
-
return session;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Save session with normalized messages.
|
|
190
|
-
* Converts any provider-specific message formats to standard format before persisting.
|
|
191
|
-
*
|
|
192
|
-
* @param {string} sessionId - Session UUID
|
|
193
|
-
* @param {Array} messages - Messages (provider-specific or standard format)
|
|
194
|
-
* @param {string} model - Model identifier
|
|
195
|
-
* @param {string} [provider] - Provider name
|
|
196
|
-
*/
|
|
197
|
-
async saveSession(sessionId, messages, model, provider) {
|
|
198
|
-
const update = {
|
|
199
|
-
messages: toStandardFormat(messages),
|
|
200
|
-
model,
|
|
201
|
-
updatedAt: new Date(),
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
if (provider) {
|
|
205
|
-
update.provider = provider;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Auto-populate title from first user message if empty
|
|
209
|
-
const session = await this.collection.findOne({ id: sessionId });
|
|
210
|
-
if (session && !session.title) {
|
|
211
|
-
const firstUserMsg = update.messages.find((m) => m.role === 'user');
|
|
212
|
-
if (firstUserMsg && typeof firstUserMsg.content === 'string') {
|
|
213
|
-
update.title = firstUserMsg.content.slice(0, 60);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
await this.collection.updateOne({ id: sessionId }, { $set: update });
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Add a message to a session, normalizing to standard format before saving.
|
|
222
|
-
*
|
|
223
|
-
* @param {string} sessionId - Session UUID
|
|
224
|
-
* @param {Object} message - Message object (any provider format)
|
|
225
|
-
* @returns {Promise<Object>} Updated session
|
|
226
|
-
*/
|
|
227
|
-
async addMessage(sessionId, message) {
|
|
228
|
-
const session = await this.getSessionInternal(sessionId);
|
|
229
|
-
if (!session) throw new Error(`Session ${sessionId} not found`);
|
|
230
|
-
if (!message._ts) message._ts = Date.now();
|
|
231
|
-
// Normalize the single message by running it through toStandardFormat,
|
|
232
|
-
// then append all resulting messages (may be 0 if it was a bare tool_result)
|
|
233
|
-
const normalized = toStandardFormat([message]);
|
|
234
|
-
session.messages.push(...normalized);
|
|
235
|
-
await this.saveSession(sessionId, session.messages, session.model);
|
|
236
|
-
return session;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async setModel(sessionId, model) {
|
|
240
|
-
await this.collection.updateOne({ id: sessionId }, { $set: { model, updatedAt: new Date() } });
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
async setProvider(sessionId, provider) {
|
|
244
|
-
await this.collection.updateOne({ id: sessionId }, { $set: { provider, updatedAt: new Date() } });
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
async clearSession(sessionId) {
|
|
248
|
-
const session = await this.collection.findOne({ id: sessionId });
|
|
249
|
-
const messages = [{ role: 'system', content: await this.buildSystemPrompt(session?.owner) }];
|
|
250
|
-
await this.collection.updateOne(
|
|
251
|
-
{ id: sessionId },
|
|
252
|
-
{ $set: { messages, updatedAt: new Date() } }
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
async listSessions(owner) {
|
|
257
|
-
return await this.collection
|
|
258
|
-
.aggregate([
|
|
259
|
-
{ $match: { owner } },
|
|
260
|
-
{ $sort: { updatedAt: -1 } },
|
|
261
|
-
{ $limit: 50 },
|
|
262
|
-
{
|
|
263
|
-
$project: {
|
|
264
|
-
id: 1,
|
|
265
|
-
title: 1,
|
|
266
|
-
model: 1,
|
|
267
|
-
provider: 1,
|
|
268
|
-
createdAt: 1,
|
|
269
|
-
updatedAt: 1,
|
|
270
|
-
messageCount: { $size: { $ifNull: ['$messages', []] } }
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
])
|
|
274
|
-
.toArray()
|
|
275
|
-
.then((docs) =>
|
|
276
|
-
docs.map((d) => ({
|
|
277
|
-
id: d.id,
|
|
278
|
-
title: d.title || '',
|
|
279
|
-
model: d.model,
|
|
280
|
-
provider: d.provider || 'ollama',
|
|
281
|
-
createdAt: d.createdAt,
|
|
282
|
-
updatedAt: d.updatedAt,
|
|
283
|
-
messageCount: d.messageCount || 0,
|
|
284
|
-
}))
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
async deleteSession(sessionId, owner) {
|
|
289
|
-
return await this.collection.deleteOne({ id: sessionId, owner });
|
|
290
|
-
}
|
|
291
|
-
}
|
|
@@ -1,347 +0,0 @@
|
|
|
1
|
-
import { CronStore } from './CronStore.js';
|
|
2
|
-
import {
|
|
3
|
-
HEARTBEAT_INTERVAL_MS,
|
|
4
|
-
HEARTBEAT_CONCURRENCY,
|
|
5
|
-
HEARTBEAT_PROMPT,
|
|
6
|
-
runWithConcurrency,
|
|
7
|
-
parseInterval,
|
|
8
|
-
} from './cron_constants.js';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* MongoDB-backed CronStore implementation
|
|
12
|
-
*/
|
|
13
|
-
export class MongoCronStore extends CronStore {
|
|
14
|
-
constructor() {
|
|
15
|
-
super();
|
|
16
|
-
this.collection = null;
|
|
17
|
-
this.onTaskFire = null;
|
|
18
|
-
this.pollInterval = null;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Initialize MongoDB cron store
|
|
23
|
-
*
|
|
24
|
-
* @param {import('mongodb').Db} db - MongoDB database instance
|
|
25
|
-
* @param {Object} options
|
|
26
|
-
* @param {Function} options.onTaskFire - Callback when a task fires: (task) => Promise<void>
|
|
27
|
-
*/
|
|
28
|
-
async init(db, options = {}) {
|
|
29
|
-
this.collection = db.collection('cron_tasks');
|
|
30
|
-
this.onTaskFire = options.onTaskFire;
|
|
31
|
-
|
|
32
|
-
await this.collection.createIndex({ nextRunAt: 1 }).catch(() => {});
|
|
33
|
-
await this.collection.createIndex({ sessionId: 1 }).catch(() => {});
|
|
34
|
-
await this.collection.createIndex({ userId: 1, name: 1 }).catch(() => {});
|
|
35
|
-
|
|
36
|
-
// Deduplicate existing heartbeats before adding the unique index
|
|
37
|
-
const dupes = await this.collection.aggregate([
|
|
38
|
-
{ $match: { name: 'heartbeat', enabled: true, userId: { $exists: true } } },
|
|
39
|
-
{ $sort: { createdAt: -1 } },
|
|
40
|
-
{ $group: { _id: '$userId', ids: { $push: '$_id' }, count: { $sum: 1 } } },
|
|
41
|
-
{ $match: { count: { $gt: 1 } } },
|
|
42
|
-
]).toArray();
|
|
43
|
-
if (dupes.length > 0) {
|
|
44
|
-
const idsToRemove = dupes.flatMap(d => d.ids.slice(1));
|
|
45
|
-
const result = await this.collection.deleteMany({ _id: { $in: idsToRemove } });
|
|
46
|
-
console.log(`[cron] cleaned up ${result.deletedCount} duplicate heartbeat(s) for ${dupes.length} user(s)`);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// One enabled heartbeat per user — enforced at DB level
|
|
50
|
-
await this.collection.createIndex(
|
|
51
|
-
{ userId: 1, name: 1, enabled: 1 },
|
|
52
|
-
{ unique: true, partialFilterExpression: { name: 'heartbeat', enabled: true } }
|
|
53
|
-
).catch(() => {});
|
|
54
|
-
|
|
55
|
-
// Start polling every 30 seconds
|
|
56
|
-
this.pollInterval = setInterval(() => this.checkTasks(), 30 * 1000);
|
|
57
|
-
// Also check immediately on startup
|
|
58
|
-
await this.checkTasks();
|
|
59
|
-
|
|
60
|
-
console.log('[cron] initialized with MongoDB, polling every 30s');
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
stop() {
|
|
64
|
-
if (this.pollInterval) clearInterval(this.pollInterval);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Check for tasks that are due and fire them
|
|
69
|
-
*/
|
|
70
|
-
async checkTasks() {
|
|
71
|
-
if (!this.collection || !this.onTaskFire) return;
|
|
72
|
-
|
|
73
|
-
try {
|
|
74
|
-
const now = new Date();
|
|
75
|
-
|
|
76
|
-
const dueTasks = await this.collection
|
|
77
|
-
.find({ nextRunAt: { $lte: now }, enabled: true })
|
|
78
|
-
.toArray();
|
|
79
|
-
|
|
80
|
-
if (dueTasks.length === 0) return;
|
|
81
|
-
|
|
82
|
-
const heartbeats = dueTasks.filter(t => t.name === 'heartbeat');
|
|
83
|
-
const others = dueTasks.filter(t => t.name !== 'heartbeat');
|
|
84
|
-
|
|
85
|
-
/** Process a single task: fire callback, then update schedule */
|
|
86
|
-
const processTask = async (task) => {
|
|
87
|
-
try {
|
|
88
|
-
await this.onTaskFire(task);
|
|
89
|
-
if (task.recurring && task.intervalMs) {
|
|
90
|
-
const nextRun = new Date(now.getTime() + task.intervalMs);
|
|
91
|
-
await this.collection.updateOne(
|
|
92
|
-
{ _id: task._id },
|
|
93
|
-
{ $set: { nextRunAt: nextRun, lastRunAt: now } }
|
|
94
|
-
);
|
|
95
|
-
} else {
|
|
96
|
-
await this.collection.updateOne(
|
|
97
|
-
{ _id: task._id },
|
|
98
|
-
{ $set: { enabled: false, lastRunAt: now } }
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
} catch (err) {
|
|
102
|
-
console.error(`[cron] error firing task ${task.name}:`, err.message);
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
// Heartbeats run in parallel with a concurrency cap
|
|
107
|
-
if (heartbeats.length > 0) {
|
|
108
|
-
console.log(`[cron] firing ${heartbeats.length} heartbeat(s) (concurrency: ${HEARTBEAT_CONCURRENCY})`);
|
|
109
|
-
await runWithConcurrency(
|
|
110
|
-
heartbeats.map(t => () => processTask(t)),
|
|
111
|
-
HEARTBEAT_CONCURRENCY
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Other tasks (user-scheduled) run sequentially
|
|
116
|
-
for (const task of others) {
|
|
117
|
-
await processTask(task);
|
|
118
|
-
}
|
|
119
|
-
} catch (err) {
|
|
120
|
-
console.error(`[cron] checkTasks query failed:`, err.message);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async createTask({ name, prompt, sessionId, userId, runAt, intervalMs, recurring, taskId }) {
|
|
125
|
-
const task = {
|
|
126
|
-
name,
|
|
127
|
-
prompt,
|
|
128
|
-
sessionId: sessionId || 'default',
|
|
129
|
-
nextRunAt: new Date(runAt),
|
|
130
|
-
intervalMs: intervalMs || null,
|
|
131
|
-
recurring: recurring || false,
|
|
132
|
-
enabled: true,
|
|
133
|
-
createdAt: new Date(),
|
|
134
|
-
lastRunAt: null,
|
|
135
|
-
};
|
|
136
|
-
if (userId) task.userId = userId;
|
|
137
|
-
if (taskId) task.taskId = taskId;
|
|
138
|
-
|
|
139
|
-
const result = await this.collection.insertOne(task);
|
|
140
|
-
return { id: result.insertedId, ...task };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async listTasks(sessionId) {
|
|
144
|
-
return await this.collection
|
|
145
|
-
.find({ sessionId: sessionId || 'default', name: { $ne: 'heartbeat' } })
|
|
146
|
-
.sort({ nextRunAt: 1 })
|
|
147
|
-
.toArray()
|
|
148
|
-
.then((docs) =>
|
|
149
|
-
docs.map((d) => ({
|
|
150
|
-
id: d._id.toString(),
|
|
151
|
-
name: d.name,
|
|
152
|
-
prompt: d.prompt,
|
|
153
|
-
nextRunAt: d.nextRunAt,
|
|
154
|
-
recurring: d.recurring,
|
|
155
|
-
intervalMs: d.intervalMs,
|
|
156
|
-
enabled: d.enabled,
|
|
157
|
-
lastRunAt: d.lastRunAt,
|
|
158
|
-
}))
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async listTasksBySessionIds(sessionIds, userId = null) {
|
|
163
|
-
if (!this.collection || sessionIds.length === 0) return [];
|
|
164
|
-
const query = { sessionId: { $in: [...sessionIds, 'default'] }, name: { $ne: 'heartbeat' } };
|
|
165
|
-
if (userId) {
|
|
166
|
-
query.$or = [
|
|
167
|
-
{ userId: userId },
|
|
168
|
-
{ userId: { $exists: false } },
|
|
169
|
-
{ userId: null }
|
|
170
|
-
];
|
|
171
|
-
}
|
|
172
|
-
return await this.collection
|
|
173
|
-
.find(query)
|
|
174
|
-
.sort({ nextRunAt: 1 })
|
|
175
|
-
.toArray()
|
|
176
|
-
.then(docs => docs.map(d => ({
|
|
177
|
-
id: d._id.toString(),
|
|
178
|
-
name: d.name,
|
|
179
|
-
prompt: d.prompt,
|
|
180
|
-
sessionId: d.sessionId,
|
|
181
|
-
nextRunAt: d.nextRunAt,
|
|
182
|
-
recurring: d.recurring,
|
|
183
|
-
intervalMs: d.intervalMs,
|
|
184
|
-
enabled: d.enabled,
|
|
185
|
-
lastRunAt: d.lastRunAt,
|
|
186
|
-
createdAt: d.createdAt,
|
|
187
|
-
})));
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
async getTask(id) {
|
|
191
|
-
const { ObjectId } = await import('mongodb');
|
|
192
|
-
const task = await this.collection.findOne({ _id: new ObjectId(id) });
|
|
193
|
-
if (!task) return null;
|
|
194
|
-
return {
|
|
195
|
-
id: task._id.toString(),
|
|
196
|
-
name: task.name,
|
|
197
|
-
prompt: task.prompt,
|
|
198
|
-
sessionId: task.sessionId,
|
|
199
|
-
nextRunAt: task.nextRunAt,
|
|
200
|
-
recurring: task.recurring,
|
|
201
|
-
intervalMs: task.intervalMs,
|
|
202
|
-
enabled: task.enabled,
|
|
203
|
-
lastRunAt: task.lastRunAt,
|
|
204
|
-
createdAt: task.createdAt,
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
async deleteTask(id) {
|
|
209
|
-
const { ObjectId } = await import('mongodb');
|
|
210
|
-
return await this.collection.deleteOne({ _id: new ObjectId(id) });
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
async toggleTask(id, enabled) {
|
|
214
|
-
const { ObjectId } = await import('mongodb');
|
|
215
|
-
return await this.collection.updateOne(
|
|
216
|
-
{ _id: new ObjectId(id) },
|
|
217
|
-
{ $set: { enabled } }
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
async updateTask(id, updates) {
|
|
222
|
-
const { ObjectId } = await import('mongodb');
|
|
223
|
-
const updateFields = {};
|
|
224
|
-
if (updates.name !== undefined) updateFields.name = updates.name;
|
|
225
|
-
if (updates.prompt !== undefined) updateFields.prompt = updates.prompt;
|
|
226
|
-
if (updates.runAt !== undefined) updateFields.nextRunAt = new Date(updates.runAt);
|
|
227
|
-
if (updates.intervalMs !== undefined) updateFields.intervalMs = updates.intervalMs;
|
|
228
|
-
if (updates.recurring !== undefined) updateFields.recurring = updates.recurring;
|
|
229
|
-
|
|
230
|
-
return await this.collection.updateOne(
|
|
231
|
-
{ _id: new ObjectId(id) },
|
|
232
|
-
{ $set: updateFields }
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
async ensureHeartbeat(userId) {
|
|
237
|
-
if (!this.collection || !userId) {
|
|
238
|
-
console.log(`[cron] ensureHeartbeat skipped: collection=${!!this.collection}, userId=${userId}`);
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const jitter = Math.floor(Math.random() * HEARTBEAT_INTERVAL_MS);
|
|
243
|
-
const now = new Date();
|
|
244
|
-
|
|
245
|
-
// Atomic upsert — eliminates race conditions
|
|
246
|
-
const result = await this.collection.updateOne(
|
|
247
|
-
{ userId, name: 'heartbeat', enabled: true },
|
|
248
|
-
{
|
|
249
|
-
$setOnInsert: {
|
|
250
|
-
name: 'heartbeat',
|
|
251
|
-
prompt: HEARTBEAT_PROMPT,
|
|
252
|
-
userId,
|
|
253
|
-
sessionId: 'default',
|
|
254
|
-
nextRunAt: new Date(now.getTime() + jitter),
|
|
255
|
-
intervalMs: HEARTBEAT_INTERVAL_MS,
|
|
256
|
-
recurring: true,
|
|
257
|
-
enabled: true,
|
|
258
|
-
createdAt: now,
|
|
259
|
-
lastRunAt: null,
|
|
260
|
-
},
|
|
261
|
-
},
|
|
262
|
-
{ upsert: true }
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
if (result.upsertedId) {
|
|
266
|
-
console.log(`[cron] created heartbeat for user ${userId}, first run in ${Math.round(jitter / 60000)}m`);
|
|
267
|
-
return { id: result.upsertedId };
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Auto-update stale prompt
|
|
271
|
-
const existing = await this.collection.findOne({ userId, name: 'heartbeat', enabled: true });
|
|
272
|
-
if (existing && existing.prompt !== HEARTBEAT_PROMPT) {
|
|
273
|
-
await this.collection.updateOne({ _id: existing._id }, { $set: { prompt: HEARTBEAT_PROMPT } });
|
|
274
|
-
console.log(`[cron] updated heartbeat prompt for user ${userId}`);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
return null;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
async getHeartbeatStatus(userId) {
|
|
281
|
-
if (!this.collection || !userId) return null;
|
|
282
|
-
const task = await this.collection.findOne({ userId, name: 'heartbeat' });
|
|
283
|
-
if (!task) return null;
|
|
284
|
-
return {
|
|
285
|
-
id: task._id.toString(),
|
|
286
|
-
enabled: task.enabled,
|
|
287
|
-
nextRunAt: task.nextRunAt,
|
|
288
|
-
lastRunAt: task.lastRunAt,
|
|
289
|
-
createdAt: task.createdAt,
|
|
290
|
-
intervalMs: task.intervalMs,
|
|
291
|
-
prompt: task.prompt,
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
async resetHeartbeat(userId) {
|
|
296
|
-
if (!this.collection || !userId) return null;
|
|
297
|
-
|
|
298
|
-
// Delete existing heartbeat(s)
|
|
299
|
-
await this.collection.deleteMany({ userId, name: 'heartbeat' });
|
|
300
|
-
console.log(`[cron] deleted existing heartbeat(s) for user ${userId}`);
|
|
301
|
-
|
|
302
|
-
// Create fresh heartbeat
|
|
303
|
-
const jitter = Math.floor(Math.random() * HEARTBEAT_INTERVAL_MS);
|
|
304
|
-
const now = new Date();
|
|
305
|
-
const task = {
|
|
306
|
-
name: 'heartbeat',
|
|
307
|
-
prompt: HEARTBEAT_PROMPT,
|
|
308
|
-
userId,
|
|
309
|
-
sessionId: 'default',
|
|
310
|
-
nextRunAt: new Date(now.getTime() + jitter),
|
|
311
|
-
intervalMs: HEARTBEAT_INTERVAL_MS,
|
|
312
|
-
recurring: true,
|
|
313
|
-
enabled: true,
|
|
314
|
-
createdAt: now,
|
|
315
|
-
lastRunAt: null,
|
|
316
|
-
};
|
|
317
|
-
const result = await this.collection.insertOne(task);
|
|
318
|
-
console.log(`[cron] created new heartbeat for user ${userId}, first run in ${Math.round(jitter / 60000)}m`);
|
|
319
|
-
return { id: result.insertedId, ...task };
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
async triggerHeartbeatNow(userId) {
|
|
323
|
-
if (!this.collection || !userId || !this.onTaskFire) return false;
|
|
324
|
-
|
|
325
|
-
const heartbeat = await this.collection.findOne({ userId, name: 'heartbeat', enabled: true });
|
|
326
|
-
if (!heartbeat) {
|
|
327
|
-
console.log(`[cron] manual trigger failed: no enabled heartbeat for user ${userId}`);
|
|
328
|
-
return false;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
console.log(`[cron] manually triggering heartbeat for user ${userId}`);
|
|
332
|
-
try {
|
|
333
|
-
await this.onTaskFire(heartbeat);
|
|
334
|
-
await this.collection.updateOne(
|
|
335
|
-
{ _id: heartbeat._id },
|
|
336
|
-
{ $set: { lastRunAt: new Date() } }
|
|
337
|
-
);
|
|
338
|
-
return true;
|
|
339
|
-
} catch (err) {
|
|
340
|
-
console.error(`[cron] manual trigger error:`, err.message);
|
|
341
|
-
return false;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// Re-export utility functions for tool definitions (from cron_constants.js)
|
|
347
|
-
export { parseInterval, HEARTBEAT_INTERVAL_MS, HEARTBEAT_PROMPT } from './cron_constants.js';
|