obol-ai 0.3.39 → 0.3.41

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,11 @@
1
+ ## 0.3.41
2
+ - changelog
3
+ - fix scheduler silent failures, remove haiku routing, improve memory format
4
+
5
+ ## 0.3.40
6
+ - changelog
7
+ - skip rate limiter for media group messages
8
+
1
9
  ## 0.3.39
2
10
  - changelog
3
11
  - sequential media downloads and image vision in read_file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.3.39",
3
+ "version": "0.3.41",
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": {
@@ -239,20 +239,38 @@ function withRuntimeContext(msgs, runtimePrefix) {
239
239
  return sanitizeMessages(copy);
240
240
  }
241
241
 
242
- function formatMemoryBlock(topFacts, selfFacts = []) {
243
- let block = null;
244
- if (topFacts.length > 0) {
245
- const lines = topFacts.map(m => {
242
+ function formatMemoryBlock(topFacts) {
243
+ if (!topFacts.length) return null;
244
+
245
+ const parts = [];
246
+
247
+ parts.push(`## Memory recall
248
+ Retrieved from your persistent memory store. These facts were selected by a combination of recency (last 7 days) and semantic similarity to this conversation, then ranked by relevance, importance, and recency. Use them as context — they represent what you know about this person from past interactions.`);
249
+
250
+ const groups = {};
251
+ for (const m of topFacts) {
252
+ const cat = m.category || 'general';
253
+ if (!groups[cat]) groups[cat] = [];
254
+ groups[cat].push(m);
255
+ }
256
+
257
+ const order = ['person', 'preference', 'fact', 'goal', 'project', 'event', 'opinion', 'emotion', 'general'];
258
+ const sortedCats = Object.keys(groups).sort((a, b) => {
259
+ const ai = order.indexOf(a), bi = order.indexOf(b);
260
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
261
+ });
262
+
263
+ for (const cat of sortedCats) {
264
+ parts.push(`\n### ${cat}`);
265
+ const lines = groups[cat].map(m => {
246
266
  const date = m.created_at ? new Date(m.created_at).toISOString().slice(0, 10) : '';
247
- return `- [${m.category}] ${m.content}${date ? ` (${date})` : ''}`;
267
+ const src = m.source ? ` [via ${m.source}]` : '';
268
+ return `- ${m.content}${date ? ` (${date})` : ''}${src}`;
248
269
  });
249
- block = `## Relevant memories\n${lines.join('\n')}`;
250
- }
251
- if (selfFacts.length > 0) {
252
- const selfLines = selfFacts.slice(0, 8).map(m => `- [${m.category}] ${m.content}`);
253
- block = (block || '') + `\n\n## Self-knowledge\n${selfLines.join('\n')}`;
270
+ parts.push(lines.join('\n'));
254
271
  }
255
- return block;
272
+
273
+ return parts.join('\n');
256
274
  }
257
275
 
258
276
  module.exports = { buildSystemPrompt, buildSystemBlock, buildRuntimePrefix, withRuntimeContext, formatMemoryBlock };
@@ -24,36 +24,6 @@ function jaccardFromSets(setA, setB) {
24
24
  return inter / (setA.size + setB.size - inter);
25
25
  }
26
26
 
27
- const TOOL_PATTERNS = [
28
- /\b(?:list|read|write|create|edit|delete|find|grep)\b.*\b(?:files?|folders?|director(?:y|ies)|scripts?|codebase)\b/,
29
- /\b(?:run|execute)\b.*\b(?:tests?|suite|script|command)\b/,
30
- /\b(?:save|store|remember)\b.*\b(?:note|that|memory|secret|password|key)\b/,
31
- /\b(?:remind|schedule|set a reminder|cancel.*(?:event|reminder))\b/,
32
- /\b(?:search the web|look up online|google)\b/,
33
- /\b(?:deploy|build|install)\b.*\b(?:app|site|dashboard|project|package)\b/,
34
- /\b(?:create|generate|make)\b.*\b(?:pdf|chart|diagram|flowchart|image)\b/,
35
- /\b(?:what|which)\b.*\b(?:api keys?|secrets?|credentials?|reminders?|events?)\b.*\b(?:stored|have|set|coming)\b/,
36
- /\b(?:what have you been|what are you)\b.*\b(?:research|learn|curious|explor)\b/,
37
- /\b(?:email|e-mail|inbox|gmail|outlook|mail)\b/,
38
- /\b(?:meeting|flight|appointment|deadline|booking|reservation|calendar|event|conference|itinerary)\b/,
39
- ];
40
-
41
- function likelyNeedsTools(message) {
42
- const lower = message.toLowerCase();
43
- return TOOL_PATTERNS.some(p => p.test(lower));
44
- }
45
-
46
- function recentlyUsedTools(history) {
47
- for (let i = history.length - 1; i >= Math.max(0, history.length - 4); i--) {
48
- const msg = history[i];
49
- if (msg.role === 'assistant' && Array.isArray(msg.content) &&
50
- msg.content.some(b => b.type === 'tool_use')) {
51
- return true;
52
- }
53
- }
54
- return false;
55
- }
56
-
57
27
  async function routeMessage(client, memory, userMessage, { vlog, onRouteDecision, onRouteUpdate, recentHistory = [], selfMemory = null }) {
58
28
  let memoryBlock = null;
59
29
  let model = null;
@@ -68,13 +38,13 @@ async function routeMessage(client, memory, userMessage, { vlog, onRouteDecision
68
38
  2. What model complexity does it need?
69
39
 
70
40
  Reply with ONLY a JSON object:
71
- {"need_memory": true/false, "search_queries": ["query1", "query2"], "model": "haiku|sonnet|opus"}
41
+ {"need_memory": true/false, "search_queries": ["query1", "query2"], "model": "sonnet|opus"}
72
42
 
73
43
  search_queries: 1-5 optimized search queries based on the full conversation context. Cover distinct topics, people, entities, time periods, or projects referenced. Single-topic messages need just one query. Use more queries when the message references multiple people, projects, or threads.
74
44
 
75
45
  Memory: casual messages (greetings, jokes, simple questions) → false. References to past, people, projects, preferences → true.
76
46
 
77
- Model: Default to "sonnet". Use "haiku" ONLY for: greetings, brief acknowledgments (thanks/ok/bye), casual chitchat, quick yes/no questions, and short single-turn exchanges that don't need any tool calling AND don't need memory. If need_memory is true, use "sonnet" minimum — haiku cannot reason over recalled context well enough. Use "sonnet" for: code generation, data analysis, content creation, explanations, creative writing, agentic tool use, general questions, opinions, advice, memory-dependent questions, and most conversational exchanges with substance. Use "opus" for: professional software engineering tasks, advanced multi-step agent work, complex reasoning, scientific or mathematical problems, tasks requiring nuanced understanding, advanced coding challenges, in-depth research, and architecture or design decisions.
47
+ Model: Default to "sonnet". Use "sonnet" for: general conversation, code generation, data analysis, content creation, explanations, creative writing, agentic tool use, questions, opinions, advice, memory-dependent questions, and most exchanges. Use "opus" for: professional software engineering tasks, advanced multi-step agent work, complex reasoning, scientific or mathematical problems, tasks requiring nuanced understanding, advanced coding challenges, in-depth research, and architecture or design decisions.
78
48
 
79
49
  If recent context shows an ongoing task (sonnet/opus was just used, multi-step work in progress), bias toward that model even for short follow-up messages.`,
80
50
  messages: buildRouterMessages(recentHistory, userMessage),
@@ -91,33 +61,25 @@ If recent context shows an ongoing task (sonnet/opus was just used, multi-step w
91
61
  ? decision.search_queries.slice(0, 3)
92
62
  : decision.search_query ? [decision.search_query] : [];
93
63
 
94
- if (decision.model === 'haiku' && likelyNeedsTools(userMessage)) {
95
- vlog('[router] haiku overridden → sonnet (tool-need heuristic)');
64
+ if (decision.model !== 'sonnet' && decision.model !== 'opus') {
96
65
  decision.model = 'sonnet';
97
66
  }
98
67
 
99
- if (decision.model === 'haiku' && recentlyUsedTools(recentHistory)) {
100
- vlog('[router] haiku overridden → sonnet (recent tool use in history)');
101
- decision.model = 'sonnet';
102
- }
103
-
104
- vlog(`[router] model=${decision.model || 'sonnet'} memory=${decision.need_memory || false}${queries.length ? ` queries=${JSON.stringify(queries)}` : ''}`);
68
+ vlog(`[router] model=${decision.model} memory=${decision.need_memory || false}${queries.length ? ` queries=${JSON.stringify(queries)}` : ''}`);
105
69
 
106
70
  onRouteDecision?.({
107
- model: decision.model || 'sonnet',
71
+ model: decision.model,
108
72
  needMemory: decision.need_memory || false,
109
73
  memoryCount: 0,
110
74
  });
111
75
 
112
76
  if (decision.model === 'opus') {
113
77
  model = 'claude-opus-4-6';
114
- } else if (decision.model === 'haiku') {
115
- model = 'claude-haiku-4-5';
116
78
  }
117
79
 
118
80
  if (decision.need_memory && memory) {
119
- const budget = decision.model === 'opus' ? 40 : decision.model === 'haiku' ? 15 : 25;
120
- const poolPerQuery = decision.model === 'opus' ? 20 : decision.model === 'haiku' ? 10 : 15;
81
+ const budget = decision.model === 'opus' ? 60 : 40;
82
+ const poolPerQuery = decision.model === 'opus' ? 25 : 20;
121
83
  const searchQueries = queries.length > 0 ? queries : [userMessage];
122
84
 
123
85
  const recentMemories = await memory.byDate('7d', { limit: Math.ceil(budget / 3) });
@@ -153,22 +115,7 @@ If recent context shows an ongoing task (sonnet/opus was just used, multi-step w
153
115
  vlog(`[memory] ${topFacts.length} facts (${recentMemories.length} recent, ${semanticMemories.length} semantic, budget=${budget})`);
154
116
  onRouteUpdate?.({ memoryCount: topFacts.length });
155
117
 
156
- let selfFacts = [];
157
- if (selfMemory) {
158
- const selfResults = await Promise.all(
159
- searchQueries.map(q => selfMemory.search(q, { limit: 5, threshold: 0.4 }))
160
- );
161
- const seen2 = new Set();
162
- for (const m of selfResults.flat()) {
163
- if (!seen2.has(m.id)) { seen2.add(m.id); selfFacts.push(m); }
164
- }
165
- if (selfFacts.length > 0) {
166
- vlog(`[memory] +${selfFacts.length} self-memory facts`);
167
- onRouteUpdate?.({ selfMemoryCount: selfFacts.length });
168
- }
169
- }
170
-
171
- memoryBlock = formatMemoryBlock(topFacts, selfFacts);
118
+ memoryBlock = formatMemoryBlock(topFacts);
172
119
  }
173
120
  } catch (e) {
174
121
  console.error('[router] Memory/routing decision failed:', e.message);
@@ -33,7 +33,7 @@ Always search memory first for the user's timezone/location.`,
33
33
  input_schema: {
34
34
  type: 'object',
35
35
  properties: {
36
- status: { type: 'string', enum: ['pending', 'sent', 'cancelled', 'completed'], description: 'Filter by status (default: pending)' },
36
+ status: { type: 'string', enum: ['pending', 'sent', 'cancelled', 'completed', 'failed'], description: 'Filter by status (default: pending)' },
37
37
  filters: { type: 'object', description: 'PostgREST column filters. Key = column name, value = operator.value. E.g. {"title":"like.*briefing*","cron_expr":"not.is.null","run_count":"gte.5"}', additionalProperties: { type: 'string' } },
38
38
  order: { type: 'string', description: 'Sort order: column.direction. E.g. "run_count.desc", "created_at.asc"' },
39
39
  },
package/src/db/migrate.js CHANGED
@@ -287,6 +287,17 @@ async function migrate(supabaseConfig) {
287
287
  WHERE id = ANY(memory_ids);
288
288
  $$;`,
289
289
 
290
+ // Event retry tracking
291
+ `ALTER TABLE obol_events ADD COLUMN IF NOT EXISTS attempts INT NOT NULL DEFAULT 0;`,
292
+ `ALTER TABLE obol_events ADD COLUMN IF NOT EXISTS last_error TEXT;`,
293
+
294
+ `DO $$ BEGIN
295
+ ALTER TABLE obol_events DROP CONSTRAINT IF EXISTS obol_events_status_check;
296
+ ALTER TABLE obol_events ADD CONSTRAINT obol_events_status_check
297
+ CHECK (status IN ('pending','sent','cancelled','completed','failed'));
298
+ EXCEPTION WHEN undefined_object THEN NULL;
299
+ END $$;`,
300
+
290
301
  // Soul backup table (one row per file key: 'soul', 'agents')
291
302
  `CREATE TABLE IF NOT EXISTS obol_soul (
292
303
  id TEXT PRIMARY KEY,
@@ -18,7 +18,7 @@ const NEWS_HOURS = new Set([8, 18]);
18
18
  const _evolutionRunning = new Set();
19
19
  const _newsRunning = new Set();
20
20
  let _curiosityRunning = false;
21
- let _schedulerBusy = false;
21
+ const _inflight = new Set();
22
22
  const _analysisRunning = new Set();
23
23
 
24
24
  function getLocalHour(timezone) {
@@ -210,7 +210,57 @@ async function runNewsForUser(bot, config, userId) {
210
210
  }
211
211
  }
212
212
 
213
- const STALE_EVENT_MS = 24 * 60 * 60 * 1000;
213
+ const MAX_ATTEMPTS = 3;
214
+ const STALE_MS = 2 * 60 * 60 * 1000;
215
+ const EVENT_TIMEOUT_MS = 30_000;
216
+
217
+ async function processEvent(bot, config, scheduler, event) {
218
+ const tz = event.timezone || 'UTC';
219
+ const age = Date.now() - new Date(event.due_at).getTime();
220
+
221
+ if (age > STALE_MS) {
222
+ console.warn(`[scheduler] Stale event ${event.id} "${event.title}" (due ${event.due_at})`);
223
+ if (event.cron_expr) {
224
+ await scheduler.reschedule(event.id, event.cron_expr, tz, event.run_count, event.max_runs, event.ends_at);
225
+ } else {
226
+ await scheduler.markFailed(event.id, 'Stale — exceeded 2h window');
227
+ }
228
+ return;
229
+ }
230
+
231
+ const attempts = (event.attempts || 0) + 1;
232
+ await scheduler.patch(event.id, { attempts });
233
+
234
+ try {
235
+ if (event.instructions) {
236
+ await runAgenticEvent(bot, config, event);
237
+ } else {
238
+ await sendReminderMessage(bot, event);
239
+ }
240
+
241
+ if (event.cron_expr) {
242
+ await scheduler.patch(event.id, { last_error: null });
243
+ await scheduler.reschedule(event.id, event.cron_expr, tz, event.run_count, event.max_runs, event.ends_at);
244
+ } else {
245
+ await scheduler.markSent(event.id);
246
+ }
247
+ } catch (e) {
248
+ const errMsg = (e.message || '').substring(0, 500);
249
+ console.error(`[scheduler] Event ${event.id} attempt ${attempts} failed:`, errMsg);
250
+ await scheduler.patch(event.id, { last_error: errMsg });
251
+
252
+ if (attempts >= MAX_ATTEMPTS) {
253
+ if (event.cron_expr) {
254
+ await scheduler.reschedule(event.id, event.cron_expr, tz, event.run_count, event.max_runs, event.ends_at);
255
+ } else {
256
+ await scheduler.markFailed(event.id, errMsg);
257
+ }
258
+ await bot.api.sendMessage(event.chat_id,
259
+ `⚠️ Scheduled event "${event.title}" failed: ${errMsg.substring(0, 200)}`
260
+ ).catch(() => {});
261
+ }
262
+ }
263
+ }
214
264
 
215
265
  function setupHeartbeat(bot, config) {
216
266
  const supabaseConfig = config?.supabase;
@@ -228,43 +278,22 @@ function setupHeartbeat(bot, config) {
228
278
  }
229
279
 
230
280
  if (!scheduler || !bot) return;
231
- if (_schedulerBusy) return;
232
- _schedulerBusy = true;
233
281
 
234
282
  try {
235
- const dueEvents = await scheduler.getDue();
283
+ const dueEvents = await scheduler.getDue(MAX_ATTEMPTS);
236
284
  if (dueEvents.length > 0) {
237
285
  console.log(`[scheduler] Processing ${dueEvents.length} due event(s)`);
238
286
  }
239
287
  for (const event of dueEvents) {
240
- try {
241
- const age = Date.now() - new Date(event.due_at).getTime();
242
- if (age > STALE_EVENT_MS) {
243
- console.warn(`[scheduler] Expiring stale event ${event.id} "${event.title}" (due ${event.due_at})`);
244
- await scheduler.markSent(event.id);
245
- continue;
246
- }
247
-
248
- if (event.instructions) {
249
- await runAgenticEvent(bot, config, event);
250
- } else {
251
- await sendReminderMessage(bot, event);
252
- }
253
-
254
- const tz = event.timezone || 'UTC';
255
- if (event.cron_expr) {
256
- await scheduler.reschedule(event.id, event.cron_expr, tz, event.run_count, event.max_runs, event.ends_at);
257
- } else {
258
- await scheduler.markSent(event.id);
259
- }
260
- } catch (e) {
261
- console.error(`[scheduler] Failed to process event ${event.id}:`, e.message);
262
- }
288
+ if (_inflight.has(event.id)) continue;
289
+
290
+ _inflight.add(event.id);
291
+ processEvent(bot, config, scheduler, event)
292
+ .catch(e => console.error(`[scheduler] Unhandled error for event ${event.id}:`, e.message))
293
+ .finally(() => _inflight.delete(event.id));
263
294
  }
264
295
  } catch (e) {
265
296
  console.error('[scheduler] Failed to check due events:', e.message);
266
- } finally {
267
- _schedulerBusy = false;
268
297
  }
269
298
  });
270
299
 
@@ -381,12 +410,20 @@ async function runAgenticEvent(bot, config, event) {
381
410
  'Do NOT output JSON, code blocks, tool calls, or structured data. Write as a direct Telegram message.'
382
411
  );
383
412
 
384
- const response = await tenant.claude.client.messages.create({
385
- model: 'claude-sonnet-4-6',
386
- max_tokens: 300,
387
- system: systemParts.join('\n\n'),
388
- messages: [{ role: 'user', content: event.instructions }],
389
- });
413
+ const controller = new AbortController();
414
+ const timeout = setTimeout(() => controller.abort(), EVENT_TIMEOUT_MS);
415
+
416
+ let response;
417
+ try {
418
+ response = await tenant.claude.client.messages.create({
419
+ model: 'claude-sonnet-4-6',
420
+ max_tokens: 300,
421
+ system: systemParts.join('\n\n'),
422
+ messages: [{ role: 'user', content: event.instructions }],
423
+ }, { signal: controller.signal });
424
+ } finally {
425
+ clearTimeout(timeout);
426
+ }
390
427
 
391
428
  const text = response.content.filter(b => b.type === 'text').map(b => b.text).join('\n').trim();
392
429
  if (!text) throw new Error('Empty response from model');
@@ -62,9 +62,9 @@ function createScheduler(supabaseConfig, userId = 0) {
62
62
  return data[0];
63
63
  }
64
64
 
65
- async function getDue() {
65
+ async function getDue(maxAttempts = 3) {
66
66
  const now = new Date().toISOString();
67
- const fetchUrl = `${url}/rest/v1/obol_events?status=eq.pending&due_at=lte.${now}`;
67
+ const fetchUrl = `${url}/rest/v1/obol_events?status=eq.pending&due_at=lte.${now}&attempts=lt.${maxAttempts}`;
68
68
  const res = await fetch(fetchUrl, { headers, signal: AbortSignal.timeout(15000) });
69
69
  const data = await res.json();
70
70
  if (!res.ok) throw new Error(JSON.stringify(data));
@@ -88,6 +88,10 @@ function createScheduler(supabaseConfig, userId = 0) {
88
88
  return patch(eventId, { status: 'sent' });
89
89
  }
90
90
 
91
+ async function markFailed(eventId, error) {
92
+ return patch(eventId, { status: 'failed', last_error: (error || '').substring(0, 500) });
93
+ }
94
+
91
95
  async function reschedule(eventId, cronExpr, timezone, runCount, maxRuns, endsAt) {
92
96
  const newRunCount = (runCount || 0) + 1;
93
97
 
@@ -107,6 +111,7 @@ function createScheduler(supabaseConfig, userId = 0) {
107
111
  run_count: newRunCount,
108
112
  last_run_at: new Date().toISOString(),
109
113
  status: 'pending',
114
+ attempts: 0,
110
115
  });
111
116
  } catch (e) {
112
117
  console.error(`[scheduler] Failed to compute next cron occurrence for event ${eventId}:`, e.message);
@@ -126,7 +131,7 @@ function createScheduler(supabaseConfig, userId = 0) {
126
131
  return data[0];
127
132
  }
128
133
 
129
- return { add, list, cancel, getDue, markSent, reschedule, update };
134
+ return { add, list, cancel, getDue, markSent, markFailed, patch, reschedule, update };
130
135
  }
131
136
 
132
137
  module.exports = { createScheduler };
@@ -166,13 +166,16 @@ function registerMediaHandler(bot, telegramConfig, deps) {
166
166
  async function handleMedia(ctx) {
167
167
  if (!ctx.from) return;
168
168
  const userId = ctx.from.id;
169
- const { createRateLimiter } = require('../rate-limit');
170
- if (!bot._rateLimiter) bot._rateLimiter = createRateLimiter();
171
- const rateResult = bot._rateLimiter.check(userId);
172
- if (rateResult) return;
173
169
  const fileInfo = media.getFileInfo(ctx);
174
170
  if (!fileInfo) return;
175
171
 
172
+ if (!ctx.message.media_group_id) {
173
+ const { createRateLimiter } = require('../rate-limit');
174
+ if (!bot._rateLimiter) bot._rateLimiter = createRateLimiter();
175
+ const rateResult = bot._rateLimiter.check(userId);
176
+ if (rateResult) return;
177
+ }
178
+
176
179
  if (fileInfo.fileSize > MAX_MEDIA_SIZE) {
177
180
  await ctx.reply(`File too large (${(fileInfo.fileSize / 1024 / 1024).toFixed(1)}MB). Max is 50MB.`).catch(() => {});
178
181
  return;