obol-ai 0.2.31 → 0.2.33

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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## 0.2.33
2
+ - add agentic cron jobs with instructions field and update_event tool
3
+
4
+ ## 0.2.32
5
+ - fix cron-parser v5 tz option, enforce cron_expr for recurring events
6
+
1
7
  ## 0.2.31
2
8
  - update readme: remove github/vercel from onboarding, remove vercel tool references
3
9
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.2.31",
3
+ "version": "0.2.33",
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": {
@@ -161,11 +161,12 @@ Returns the tapped button label, or \`"timeout"\` if the user doesn't respond wi
161
161
 
162
162
  ### Scheduling (\`schedule_event\`, \`list_events\`, \`cancel_event\`)
163
163
  Schedule one-time or recurring reminders. The user gets a Telegram message each time an event fires.
164
- - \`schedule_event\` — schedule a reminder with title, due_at (ISO 8601), timezone (IANA), optional description. For recurring events add \`cron_expr\` (5-field cron), optional \`max_runs\` and \`ends_at\`.
164
+ - \`schedule_event\` — schedule a reminder with title, due_at (ISO 8601), timezone (IANA), optional description
165
165
  - \`list_events\` — list pending/sent/cancelled/completed events
166
166
  - \`cancel_event\` — cancel a scheduled event by ID
167
167
 
168
- Cron examples: \`0 9 * * 1-5\` (weekdays 9am), \`0 8 * * 1\` (Mondays 8am), \`*/30 * * * *\` (every 30 min), \`0 0 1 * *\` (1st of month).
168
+ **Recurring events:** use \`cron_expr\` (5-field cron). The system auto-reschedules after each fire never chain one-time events manually.
169
+ Examples: \`*/30 * * * *\` (every 30 min), \`0 9 * * 1-5\` (weekdays 9am), \`0 8 * * 1\` (Mondays 8am), \`0 0 1 * *\` (1st of month).
169
170
 
170
171
  When scheduling: always search memory first for the user's timezone/location. If no timezone found, ask the user or default to UTC. Parse natural language dates relative to the user's timezone.
171
172
 
@@ -3,7 +3,15 @@ const { toUTC } = require('../utils');
3
3
  const definitions = [
4
4
  {
5
5
  name: 'schedule_event',
6
- description: 'Schedule a one-time or recurring reminder/event. For recurring events, provide a cron_expr (standard 5-field cron: minute hour day-of-month month day-of-week). The user will receive a Telegram message each time it fires. Always search memory first for the user\'s timezone/location.',
6
+ description: `Schedule a one-time or recurring reminder/event. The user will receive a Telegram message each time it fires.
7
+
8
+ For RECURRING events (e.g. "every 30 minutes", "daily at 9am", "every Monday"):
9
+ - ALWAYS use cron_expr — do NOT schedule one-time events and chain them manually
10
+ - cron_expr is a standard 5-field cron: minute hour day-of-month month day-of-week
11
+ - Examples: "*/30 * * * *" (every 30 min), "0 9 * * 1-5" (weekdays 9am), "0 8 * * 1" (Mondays 8am)
12
+ - The system will auto-reschedule after each fire — no need to re-schedule manually
13
+
14
+ Always search memory first for the user's timezone/location.`,
7
15
  input_schema: {
8
16
  type: 'object',
9
17
  properties: {
@@ -11,9 +19,10 @@ const definitions = [
11
19
  due_at: { type: 'string', description: 'ISO 8601 datetime for the first fire time (e.g. 2026-02-25T15:00:00)' },
12
20
  timezone: { type: 'string', description: 'IANA timezone (e.g. Europe/Brussels, America/New_York). Default: UTC' },
13
21
  description: { type: 'string', description: 'Context or details about the event' },
14
- cron_expr: { type: 'string', description: 'Cron expression for recurring events (5-field: "0 9 * * 1-5" = weekdays 9am). Omit for one-time events.' },
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.' },
15
23
  max_runs: { type: 'number', description: 'Maximum number of times to fire (omit for unlimited)' },
16
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.' },
17
26
  },
18
27
  required: ['title', 'due_at'],
19
28
  },
@@ -39,6 +48,23 @@ const definitions = [
39
48
  required: ['event_id'],
40
49
  },
41
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
+ },
42
68
  ];
43
69
 
44
70
  const handlers = {
@@ -51,7 +77,7 @@ const handlers = {
51
77
  if (input.cron_expr) {
52
78
  try {
53
79
  const { parseExpression } = require('cron-parser');
54
- parseExpression(input.cron_expr, { tz });
80
+ parseExpression(input.cron_expr, { timezone: tz });
55
81
  } catch (e) {
56
82
  return `Invalid cron expression "${input.cron_expr}": ${e.message}`;
57
83
  }
@@ -62,19 +88,21 @@ const handlers = {
62
88
  const event = await context.scheduler.add(
63
89
  context.chatId, input.title, utcDate, tz,
64
90
  input.description || null, input.cron_expr || null,
65
- input.max_runs || null, endsAtUtc
91
+ input.max_runs || null, endsAtUtc, input.instructions || null
66
92
  );
67
93
  const displayTime = new Date(utcDate).toLocaleString('en-US', { timeZone: tz });
68
94
 
95
+ const mode = input.instructions ? 'agentic' : 'reminder';
96
+
69
97
  if (input.cron_expr) {
70
- let result = `Recurring event scheduled: "${input.title}"\nFirst run: ${displayTime} (${tz})\nSchedule: ${input.cron_expr}`;
98
+ let result = `Recurring ${mode} scheduled: "${input.title}"\nFirst run: ${displayTime} (${tz})\nSchedule: ${input.cron_expr}`;
71
99
  if (input.max_runs) result += `\nMax runs: ${input.max_runs}`;
72
100
  if (input.ends_at) result += `\nEnds: ${new Date(endsAtUtc).toLocaleString('en-US', { timeZone: tz })}`;
73
101
  result += `\nID: ${event.id}`;
74
102
  return result;
75
103
  }
76
104
 
77
- return `Scheduled: "${input.title}" for ${displayTime} (${tz}) — ID: ${event.id}`;
105
+ return `Scheduled ${mode}: "${input.title}" for ${displayTime} (${tz}) — ID: ${event.id}`;
78
106
  },
79
107
 
80
108
  async list_events(input, memory, context) {
@@ -86,6 +114,7 @@ const handlers = {
86
114
  id: e.id,
87
115
  title: e.title,
88
116
  description: e.description,
117
+ instructions: e.instructions || null,
89
118
  due_at: e.due_at,
90
119
  timezone: e.timezone,
91
120
  due_local: new Date(e.due_at).toLocaleString('en-US', { timeZone: e.timezone }),
@@ -108,6 +137,22 @@ const handlers = {
108
137
  if (!cancelled) return `Event not found or not yours: ${input.event_id}`;
109
138
  return `Cancelled: "${cancelled.title}"`;
110
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
+ },
111
156
  };
112
157
 
113
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 setupHeartbeat(bot, supabaseConfig) {
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
- const dueLocal = new Date(event.due_at).toLocaleString('en-US', { timeZone: tz });
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 send event ${event.id}:`, e.message);
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
@@ -37,7 +37,7 @@ async function main() {
37
37
  checkUpgradeNotify(bot).catch(() => {});
38
38
 
39
39
  if (config.heartbeat !== false) {
40
- setupHeartbeat(bot, config.supabase);
40
+ setupHeartbeat(bot, config);
41
41
  }
42
42
 
43
43
  if (config.github) {
package/src/scheduler.js CHANGED
@@ -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,
@@ -36,7 +37,7 @@ function createScheduler(supabaseConfig, userId = 0) {
36
37
  async function list(opts = {}) {
37
38
  const status = opts.status || 'pending';
38
39
  const limit = opts.limit || 20;
39
- let fetchUrl = `${url}/rest/v1/obol_events?user_id=eq.${userId}&status=eq.${status}&order=due_at.asc&limit=${limit}&select=id,title,description,due_at,timezone,status,created_at,cron_expr,last_run_at,run_count,max_runs,ends_at`;
40
+ const fetchUrl = `${url}/rest/v1/obol_events?user_id=eq.${userId}&status=eq.${status}&order=due_at.asc&limit=${limit}&select=id,title,description,due_at,timezone,status,created_at,cron_expr,last_run_at,run_count,max_runs,ends_at`;
40
41
  const res = await fetch(fetchUrl, { headers });
41
42
  const data = await res.json();
42
43
  if (!res.ok) throw new Error(JSON.stringify(data));
@@ -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 = parseExpression(cronExpr, { currentDate: new Date(), tz: timezone || 'UTC' }).next().toDate();
91
+ const nextDate = parseExpression(cronExpr, { currentDate: new Date(), timezone: 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
- return { add, list, cancel, getDue, markSent, reschedule };
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 };