obol-ai 0.2.32 → 0.2.34
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 +6 -0
- package/package.json +1 -1
- package/src/claude/tools/scheduler.js +42 -5
- package/src/db/migrate.js +3 -0
- package/src/heartbeat.js +51 -14
- package/src/index.js +1 -1
- package/src/scheduler.js +17 -5
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "obol-ai",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.34",
|
|
4
4
|
"description": "Self-evolving AI assistant that learns, remembers, and acts on its own. Persistent vector memory, self-rewriting personality, proactive heartbeats.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,7 @@ Always search memory first for the user's timezone/location.`,
|
|
|
22
22
|
cron_expr: { type: 'string', description: 'Cron expression for recurring events (5-field). REQUIRED for any repeating schedule — do not omit and chain one-time events instead.' },
|
|
23
23
|
max_runs: { type: 'number', description: 'Maximum number of times to fire (omit for unlimited)' },
|
|
24
24
|
ends_at: { type: 'string', description: 'ISO 8601 datetime after which the recurring event stops' },
|
|
25
|
+
instructions: { type: 'string', description: 'LLM instructions to execute when the event fires. If set, the bot will run these as an agentic task instead of sending a plain reminder message. Use for automations like "check email", "fetch weather", etc.' },
|
|
25
26
|
},
|
|
26
27
|
required: ['title', 'due_at'],
|
|
27
28
|
},
|
|
@@ -47,6 +48,23 @@ Always search memory first for the user's timezone/location.`,
|
|
|
47
48
|
required: ['event_id'],
|
|
48
49
|
},
|
|
49
50
|
},
|
|
51
|
+
{
|
|
52
|
+
name: 'update_event',
|
|
53
|
+
description: 'Update fields on an existing scheduled event. Only provided fields are changed.',
|
|
54
|
+
input_schema: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: {
|
|
57
|
+
event_id: { type: 'string', description: 'UUID of the event to update' },
|
|
58
|
+
title: { type: 'string' },
|
|
59
|
+
description: { type: 'string' },
|
|
60
|
+
instructions: { type: 'string', description: 'LLM instructions to execute when the event fires. Set to empty string to clear.' },
|
|
61
|
+
timezone: { type: 'string' },
|
|
62
|
+
cron_expr: { type: 'string' },
|
|
63
|
+
max_runs: { type: 'number' },
|
|
64
|
+
},
|
|
65
|
+
required: ['event_id'],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
50
68
|
];
|
|
51
69
|
|
|
52
70
|
const handlers = {
|
|
@@ -58,8 +76,8 @@ const handlers = {
|
|
|
58
76
|
|
|
59
77
|
if (input.cron_expr) {
|
|
60
78
|
try {
|
|
61
|
-
const {
|
|
62
|
-
|
|
79
|
+
const { CronExpressionParser } = require('cron-parser');
|
|
80
|
+
CronExpressionParser.parse(input.cron_expr, { tz });
|
|
63
81
|
} catch (e) {
|
|
64
82
|
return `Invalid cron expression "${input.cron_expr}": ${e.message}`;
|
|
65
83
|
}
|
|
@@ -70,19 +88,21 @@ const handlers = {
|
|
|
70
88
|
const event = await context.scheduler.add(
|
|
71
89
|
context.chatId, input.title, utcDate, tz,
|
|
72
90
|
input.description || null, input.cron_expr || null,
|
|
73
|
-
input.max_runs || null, endsAtUtc
|
|
91
|
+
input.max_runs || null, endsAtUtc, input.instructions || null
|
|
74
92
|
);
|
|
75
93
|
const displayTime = new Date(utcDate).toLocaleString('en-US', { timeZone: tz });
|
|
76
94
|
|
|
95
|
+
const mode = input.instructions ? 'agentic' : 'reminder';
|
|
96
|
+
|
|
77
97
|
if (input.cron_expr) {
|
|
78
|
-
let result = `Recurring
|
|
98
|
+
let result = `Recurring ${mode} scheduled: "${input.title}"\nFirst run: ${displayTime} (${tz})\nSchedule: ${input.cron_expr}`;
|
|
79
99
|
if (input.max_runs) result += `\nMax runs: ${input.max_runs}`;
|
|
80
100
|
if (input.ends_at) result += `\nEnds: ${new Date(endsAtUtc).toLocaleString('en-US', { timeZone: tz })}`;
|
|
81
101
|
result += `\nID: ${event.id}`;
|
|
82
102
|
return result;
|
|
83
103
|
}
|
|
84
104
|
|
|
85
|
-
return `Scheduled: "${input.title}" for ${displayTime} (${tz}) — ID: ${event.id}`;
|
|
105
|
+
return `Scheduled ${mode}: "${input.title}" for ${displayTime} (${tz}) — ID: ${event.id}`;
|
|
86
106
|
},
|
|
87
107
|
|
|
88
108
|
async list_events(input, memory, context) {
|
|
@@ -94,6 +114,7 @@ const handlers = {
|
|
|
94
114
|
id: e.id,
|
|
95
115
|
title: e.title,
|
|
96
116
|
description: e.description,
|
|
117
|
+
instructions: e.instructions || null,
|
|
97
118
|
due_at: e.due_at,
|
|
98
119
|
timezone: e.timezone,
|
|
99
120
|
due_local: new Date(e.due_at).toLocaleString('en-US', { timeZone: e.timezone }),
|
|
@@ -116,6 +137,22 @@ const handlers = {
|
|
|
116
137
|
if (!cancelled) return `Event not found or not yours: ${input.event_id}`;
|
|
117
138
|
return `Cancelled: "${cancelled.title}"`;
|
|
118
139
|
},
|
|
140
|
+
|
|
141
|
+
async update_event(input, memory, context) {
|
|
142
|
+
if (!context.scheduler) return 'Scheduler not available (Supabase not configured).';
|
|
143
|
+
const { event_id, ...rest } = input;
|
|
144
|
+
const fields = {};
|
|
145
|
+
if (rest.title !== undefined) fields.title = rest.title;
|
|
146
|
+
if (rest.description !== undefined) fields.description = rest.description;
|
|
147
|
+
if (rest.instructions !== undefined) fields.instructions = rest.instructions || null;
|
|
148
|
+
if (rest.timezone !== undefined) fields.timezone = rest.timezone;
|
|
149
|
+
if (rest.cron_expr !== undefined) fields.cron_expr = rest.cron_expr;
|
|
150
|
+
if (rest.max_runs !== undefined) fields.max_runs = rest.max_runs;
|
|
151
|
+
if (Object.keys(fields).length === 0) return 'No fields to update.';
|
|
152
|
+
const updated = await context.scheduler.update(event_id, fields);
|
|
153
|
+
if (!updated) return `Event not found or not yours: ${event_id}`;
|
|
154
|
+
return `Updated: "${updated.title}"`;
|
|
155
|
+
},
|
|
119
156
|
};
|
|
120
157
|
|
|
121
158
|
module.exports = { definitions, handlers };
|
package/src/db/migrate.js
CHANGED
|
@@ -197,6 +197,9 @@ async function migrate(supabaseConfig) {
|
|
|
197
197
|
`DROP FUNCTION IF EXISTS match_obol_messages(VECTOR(384), FLOAT, INT, BIGINT);`,
|
|
198
198
|
`DROP INDEX IF EXISTS obol_messages_embedding_idx;`,
|
|
199
199
|
`ALTER TABLE obol_messages DROP COLUMN IF EXISTS embedding;`,
|
|
200
|
+
|
|
201
|
+
// Instructions column for agentic cron jobs
|
|
202
|
+
`ALTER TABLE obol_events ADD COLUMN IF NOT EXISTS instructions TEXT;`,
|
|
200
203
|
];
|
|
201
204
|
|
|
202
205
|
// Save SQL file for manual fallback
|
package/src/heartbeat.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
const cron = require('node-cron');
|
|
2
2
|
const { createScheduler } = require('./scheduler');
|
|
3
|
+
const { getTenant } = require('./tenant');
|
|
3
4
|
|
|
4
|
-
function
|
|
5
|
+
function makeFakeCtx(bot, chatId) {
|
|
6
|
+
return {
|
|
7
|
+
chat: { id: chatId },
|
|
8
|
+
reply: (text, opts) => bot.api.sendMessage(chatId, text, opts),
|
|
9
|
+
api: bot.api,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function setupHeartbeat(bot, config) {
|
|
14
|
+
const supabaseConfig = config?.supabase;
|
|
5
15
|
let scheduler = null;
|
|
6
16
|
if (supabaseConfig?.url && supabaseConfig?.serviceKey) {
|
|
7
17
|
scheduler = createScheduler(supabaseConfig);
|
|
@@ -21,25 +31,20 @@ function setupHeartbeat(bot, supabaseConfig) {
|
|
|
21
31
|
const dueEvents = await scheduler.getDue();
|
|
22
32
|
for (const event of dueEvents) {
|
|
23
33
|
try {
|
|
34
|
+
if (event.instructions) {
|
|
35
|
+
await runAgenticEvent(bot, config, event);
|
|
36
|
+
} else {
|
|
37
|
+
await sendReminderMessage(bot, event);
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
const tz = event.timezone || 'UTC';
|
|
25
|
-
|
|
26
|
-
const isRecurring = !!event.cron_expr;
|
|
27
|
-
const prefix = isRecurring ? '🔄 *Recurring Reminder:*' : '⏰ *Reminder:*';
|
|
28
|
-
let text = `${prefix} ${event.title}`;
|
|
29
|
-
if (event.description) text += `\n${event.description}`;
|
|
30
|
-
text += `\n_${dueLocal} (${tz})_`;
|
|
31
|
-
|
|
32
|
-
await bot.api.sendMessage(event.chat_id, text, { parse_mode: 'Markdown' }).catch(() =>
|
|
33
|
-
bot.api.sendMessage(event.chat_id, `${isRecurring ? '🔄 Recurring Reminder' : '⏰ Reminder'}: ${event.title}${event.description ? '\n' + event.description : ''}`)
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
if (isRecurring) {
|
|
41
|
+
if (event.cron_expr) {
|
|
37
42
|
await scheduler.reschedule(event.id, event.cron_expr, tz, event.run_count, event.max_runs, event.ends_at);
|
|
38
43
|
} else {
|
|
39
44
|
await scheduler.markSent(event.id);
|
|
40
45
|
}
|
|
41
46
|
} catch (e) {
|
|
42
|
-
console.error(`[scheduler] Failed to
|
|
47
|
+
console.error(`[scheduler] Failed to process event ${event.id}:`, e.message);
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
} catch (e) {
|
|
@@ -50,4 +55,36 @@ function setupHeartbeat(bot, supabaseConfig) {
|
|
|
50
55
|
console.log(' ✅ Heartbeat running (every 1min)');
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
async function sendReminderMessage(bot, event) {
|
|
59
|
+
const tz = event.timezone || 'UTC';
|
|
60
|
+
const dueLocal = new Date(event.due_at).toLocaleString('en-US', { timeZone: tz });
|
|
61
|
+
const isRecurring = !!event.cron_expr;
|
|
62
|
+
const prefix = isRecurring ? '🔄 *Recurring Reminder:*' : '⏰ *Reminder:*';
|
|
63
|
+
let text = `${prefix} ${event.title}`;
|
|
64
|
+
if (event.description) text += `\n${event.description}`;
|
|
65
|
+
text += `\n_${dueLocal} (${tz})_`;
|
|
66
|
+
|
|
67
|
+
await bot.api.sendMessage(event.chat_id, text, { parse_mode: 'Markdown' }).catch(() =>
|
|
68
|
+
bot.api.sendMessage(event.chat_id, `${isRecurring ? '🔄 Recurring Reminder' : '⏰ Reminder'}: ${event.title}${event.description ? '\n' + event.description : ''}`)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function runAgenticEvent(bot, config, event) {
|
|
73
|
+
const tenant = await getTenant(event.user_id, config);
|
|
74
|
+
const fakeCtx = makeFakeCtx(bot, event.chat_id);
|
|
75
|
+
|
|
76
|
+
const taskId = tenant.bg.spawn(
|
|
77
|
+
tenant.claude,
|
|
78
|
+
event.instructions,
|
|
79
|
+
fakeCtx,
|
|
80
|
+
tenant.memory,
|
|
81
|
+
null,
|
|
82
|
+
{}
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (taskId === null) {
|
|
86
|
+
await bot.api.sendMessage(event.chat_id, `⚠️ Could not run "${event.title}" — too many background tasks already running.`).catch(() => {});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
53
90
|
module.exports = { setupHeartbeat };
|
package/src/index.js
CHANGED
package/src/scheduler.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { CronExpressionParser } = require('cron-parser');
|
|
2
2
|
|
|
3
3
|
function createScheduler(supabaseConfig, userId = 0) {
|
|
4
4
|
const { url, serviceKey } = supabaseConfig;
|
|
@@ -10,7 +10,7 @@ function createScheduler(supabaseConfig, userId = 0) {
|
|
|
10
10
|
'Prefer': 'return=representation',
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
-
async function add(chatId, title, dueAt, timezone = 'UTC', description = null, cronExpr = null, maxRuns = null, endsAt = null) {
|
|
13
|
+
async function add(chatId, title, dueAt, timezone = 'UTC', description = null, cronExpr = null, maxRuns = null, endsAt = null, instructions = null) {
|
|
14
14
|
const body = {
|
|
15
15
|
user_id: userId,
|
|
16
16
|
chat_id: chatId,
|
|
@@ -23,6 +23,7 @@ function createScheduler(supabaseConfig, userId = 0) {
|
|
|
23
23
|
if (cronExpr) body.cron_expr = cronExpr;
|
|
24
24
|
if (maxRuns != null) body.max_runs = maxRuns;
|
|
25
25
|
if (endsAt) body.ends_at = endsAt;
|
|
26
|
+
if (instructions) body.instructions = instructions;
|
|
26
27
|
const res = await fetch(`${url}/rest/v1/obol_events`, {
|
|
27
28
|
method: 'POST',
|
|
28
29
|
headers,
|
|
@@ -56,7 +57,7 @@ function createScheduler(supabaseConfig, userId = 0) {
|
|
|
56
57
|
|
|
57
58
|
async function getDue() {
|
|
58
59
|
const now = new Date().toISOString();
|
|
59
|
-
const fetchUrl = `${url}/rest/v1/obol_events?status=eq.pending&due_at=lte.${now}&select=id,user_id,chat_id,title,description,due_at,timezone,cron_expr,run_count,max_runs,ends_at`;
|
|
60
|
+
const fetchUrl = `${url}/rest/v1/obol_events?status=eq.pending&due_at=lte.${now}&select=id,user_id,chat_id,title,description,due_at,timezone,cron_expr,run_count,max_runs,ends_at,instructions`;
|
|
60
61
|
const res = await fetch(fetchUrl, { headers });
|
|
61
62
|
const data = await res.json();
|
|
62
63
|
if (!res.ok) throw new Error(JSON.stringify(data));
|
|
@@ -87,7 +88,7 @@ function createScheduler(supabaseConfig, userId = 0) {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
try {
|
|
90
|
-
const nextDate =
|
|
91
|
+
const nextDate = CronExpressionParser.parse(cronExpr, { currentDate: new Date(), tz: timezone || 'UTC' }).next().toDate();
|
|
91
92
|
|
|
92
93
|
if (endsAt && nextDate > new Date(endsAt)) {
|
|
93
94
|
return patch(eventId, { status: 'completed', run_count: newRunCount, last_run_at: new Date().toISOString() });
|
|
@@ -105,7 +106,18 @@ function createScheduler(supabaseConfig, userId = 0) {
|
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
async function update(eventId, fields) {
|
|
110
|
+
const res = await fetch(`${url}/rest/v1/obol_events?id=eq.${eventId}&user_id=eq.${userId}`, {
|
|
111
|
+
method: 'PATCH',
|
|
112
|
+
headers,
|
|
113
|
+
body: JSON.stringify(fields),
|
|
114
|
+
});
|
|
115
|
+
const data = await res.json();
|
|
116
|
+
if (!res.ok) throw new Error(JSON.stringify(data));
|
|
117
|
+
return data[0];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { add, list, cancel, getDue, markSent, reschedule, update };
|
|
109
121
|
}
|
|
110
122
|
|
|
111
123
|
module.exports = { createScheduler };
|