obol-ai 0.3.46 → 0.3.48

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.3.47
2
+ - fix elapsed scope bug in background task completion
3
+ - fix TTS summary to speak in first person
4
+ - inject proactive follow-up messages into conversation history
5
+ - fix OAuth token expiry in heartbeat scheduled events
6
+
1
7
  ## 0.3.44
2
8
  - exclude electron from npm package
3
9
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.3.46",
3
+ "version": "0.3.48",
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": {
@@ -14,6 +14,18 @@ function buildRouterMessages(recentHistory, userMessage) {
14
14
  return [...trimmed, { role: 'user', content: userMessage }];
15
15
  }
16
16
 
17
+ function extractRecentUserMessages(recentHistory, userMessage, count = 5) {
18
+ const userMsgs = recentHistory
19
+ .filter(m => m.role === 'user')
20
+ .map(m => typeof m.content === 'string'
21
+ ? m.content
22
+ : m.content.filter(b => b.type === 'text').map(b => b.text).join(''))
23
+ .filter(Boolean)
24
+ .slice(-count);
25
+ userMsgs.push(userMessage);
26
+ return userMsgs;
27
+ }
28
+
17
29
  function tokenize(s) {
18
30
  return new Set(s.toLowerCase().split(/\W+/).filter(Boolean));
19
31
  }
@@ -38,9 +50,7 @@ async function routeMessage(client, memory, userMessage, { vlog, onRouteDecision
38
50
  2. What model complexity does it need?
39
51
 
40
52
  Reply with ONLY a JSON object:
41
- {"need_memory": true/false, "search_queries": ["query1", "query2"], "model": "sonnet|opus"}
42
-
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.
53
+ {"need_memory": true/false, "model": "sonnet|opus"}
44
54
 
45
55
  Memory: casual messages (greetings, jokes, simple questions) → false. References to past, people, projects, preferences → true.
46
56
 
@@ -57,15 +67,11 @@ If recent context shows an ongoing task (sonnet/opus was just used, multi-step w
57
67
  if (jsonStr) decision = JSON.parse(jsonStr);
58
68
  } catch {}
59
69
 
60
- const queries = Array.isArray(decision.search_queries) && decision.search_queries.length > 0
61
- ? decision.search_queries.slice(0, 3)
62
- : decision.search_query ? [decision.search_query] : [];
63
-
64
70
  if (decision.model !== 'sonnet' && decision.model !== 'opus') {
65
71
  decision.model = 'sonnet';
66
72
  }
67
73
 
68
- vlog(`[router] model=${decision.model} memory=${decision.need_memory || false}${queries.length ? ` queries=${JSON.stringify(queries)}` : ''}`);
74
+ vlog(`[router] model=${decision.model} memory=${decision.need_memory || false}`);
69
75
 
70
76
  onRouteDecision?.({
71
77
  model: decision.model,
@@ -80,23 +86,19 @@ If recent context shows an ongoing task (sonnet/opus was just used, multi-step w
80
86
  if (decision.need_memory && memory) {
81
87
  const budget = decision.model === 'opus' ? 60 : 40;
82
88
  const poolPerQuery = decision.model === 'opus' ? 25 : 20;
83
- const searchQueries = queries.length > 0 ? queries : [userMessage];
84
-
85
- const recentMemories = await memory.byDate('7d', { limit: Math.ceil(budget / 3) });
89
+ const conversationQueries = extractRecentUserMessages(recentHistory, userMessage);
86
90
 
87
91
  const semanticResults = await Promise.all(
88
- searchQueries.map(q => memory.search(q, { limit: poolPerQuery, threshold: 0.4 }))
92
+ conversationQueries.map(q => memory.search(q, { limit: poolPerQuery, threshold: 0.4 }))
89
93
  );
90
94
  const semanticMemories = semanticResults.flat();
91
95
 
92
96
  const seen = new Set();
93
97
  const combined = [];
94
- for (const m of [...recentMemories, ...semanticMemories]) {
98
+ for (const m of semanticMemories) {
95
99
  if (!seen.has(m.id)) {
96
100
  seen.add(m.id);
97
- const ageDays = m.created_at ? (Date.now() - new Date(m.created_at).getTime()) / 86400000 : 7;
98
- const recencyBonus = Math.max(0, 1 - ageDays / 7) * 0.3;
99
- m._score = (m.similarity || 0.5) * 0.5 + (m.importance || 0.5) * 0.2 + recencyBonus;
101
+ m._score = (m.similarity || 0.5) * 0.7 + (m.importance || 0.5) * 0.3;
100
102
  combined.push(m);
101
103
  }
102
104
  }
@@ -112,7 +114,7 @@ If recent context shows an ongoing task (sonnet/opus was just used, multi-step w
112
114
  if (!isDup) topFacts.push(m);
113
115
  }
114
116
 
115
- vlog(`[memory] ${topFacts.length} facts (${recentMemories.length} recent, ${semanticMemories.length} semantic, budget=${budget})`);
117
+ vlog(`[memory] ${topFacts.length} facts from ${conversationQueries.length} conversation queries (${semanticMemories.length} candidates, budget=${budget})`);
116
118
  onRouteUpdate?.({ memoryCount: topFacts.length });
117
119
 
118
120
  memoryBlock = formatMemoryBlock(topFacts);
@@ -110,6 +110,8 @@ TASK: ${task}`;
110
110
  claude.clearHistory(`bg-${taskState.id}`);
111
111
  clearStatus();
112
112
 
113
+ const elapsed = Math.floor((Date.now() - taskState.startedAt) / 1000);
114
+
113
115
  if (!result?.trim()) {
114
116
  taskState.status = 'error';
115
117
  taskState.error = 'No result returned';
@@ -119,7 +121,6 @@ TASK: ${task}`;
119
121
  } else {
120
122
  taskState.status = 'done';
121
123
  taskState.result = result;
122
- const elapsed = Math.floor((Date.now() - taskState.startedAt) / 1000);
123
124
  if (silent) {
124
125
  await sendLong(ctx, result);
125
126
  } else {
@@ -7,6 +7,7 @@ const { runAnalysis } = require('../analysis');
7
7
  const { runProactiveNews } = require('../news');
8
8
  const { createSelfMemory } = require('../memory/self');
9
9
  const { createAnthropicClient, ensureFreshToken } = require('../claude/client');
10
+ const { markdownToTelegramHtml } = require('../telegram/utils');
10
11
 
11
12
 
12
13
  const ANALYSIS_HOURS = new Set([4, 7, 10, 13, 16, 19, 22]);
@@ -145,7 +146,6 @@ async function runNewsForUser(bot, config, userId) {
145
146
 
146
147
  const MAX_ATTEMPTS = 3;
147
148
  const STALE_MS = 2 * 60 * 60 * 1000;
148
- const EVENT_TIMEOUT_MS = 30_000;
149
149
 
150
150
  async function processEvent(bot, config, scheduler, event) {
151
151
  const tz = event.timezone || 'UTC';
@@ -320,40 +320,30 @@ async function runAgenticEvent(bot, config, event) {
320
320
  const timezone = event.timezone || getUserTimezone(config, event.user_id);
321
321
 
322
322
  const query = event.description || event.instructions;
323
- const context = await buildProactiveContext(tenant, timezone, query).catch(() => '');
324
-
325
- const systemParts = [];
326
- if (tenant.personality?.soul) systemParts.push(tenant.personality.soul);
327
- if (tenant.personality?.user) systemParts.push(`About this user:\n${tenant.personality.user}`);
328
- if (context) systemParts.push(`Current context:\n${context}`);
329
- systemParts.push(
330
- 'You are executing a scheduled task. Respond ONLY with a clean, natural-language message for the user. ' +
331
- 'Do NOT output JSON, code blocks, tool calls, or structured data. Write as a direct Telegram message.'
332
- );
333
-
334
- const client = await getFreshClient(config);
335
-
336
- const controller = new AbortController();
337
- const timeout = setTimeout(() => controller.abort(), EVENT_TIMEOUT_MS);
323
+ const proactiveContext = await buildProactiveContext(tenant, timezone, query).catch(() => '');
324
+
325
+ const contextPrefix = proactiveContext
326
+ ? `[Scheduled task context — ${timezone}]\n${proactiveContext}\n\n`
327
+ : '';
328
+ const prompt = `${contextPrefix}Scheduled task "${event.title}":\n${event.instructions}`;
329
+
330
+ const chatId = `scheduled-${event.id}-${Date.now()}`;
331
+ const { text } = await tenant.claude.chat(prompt, {
332
+ chatId,
333
+ userName: 'ScheduledTask',
334
+ userId: event.user_id,
335
+ userDir: tenant.userDir,
336
+ toolPrefs: tenant.toolPrefs,
337
+ config,
338
+ scheduler: tenant.scheduler,
339
+ messageLog: tenant.messageLog,
340
+ });
338
341
 
339
- let response;
340
- try {
341
- response = await client.messages.create({
342
- model: 'claude-sonnet-4-6',
343
- max_tokens: 300,
344
- system: systemParts.join('\n\n'),
345
- messages: [{ role: 'user', content: event.instructions }],
346
- }, { signal: controller.signal });
347
- } finally {
348
- clearTimeout(timeout);
349
- }
342
+ tenant.claude.clearHistory(chatId);
350
343
 
351
- const text = response.content.filter(b => b.type === 'text').map(b => b.text).join('\n').trim();
352
- if (!text) throw new Error('Empty response from model');
344
+ if (!text?.trim()) throw new Error('Empty response from model');
353
345
 
354
- await bot.api.sendMessage(event.chat_id, text).catch(() =>
355
- bot.api.sendMessage(event.chat_id, text, { parse_mode: undefined })
356
- );
346
+ await sendScheduledMessage(bot, event.chat_id, text);
357
347
 
358
348
  tenant.claude.injectHistory(event.chat_id, 'assistant', text);
359
349
  if (tenant.messageLog) {
@@ -361,4 +351,31 @@ async function runAgenticEvent(bot, config, event) {
361
351
  }
362
352
  }
363
353
 
354
+ async function sendScheduledMessage(bot, chatId, text) {
355
+ const html = markdownToTelegramHtml(text);
356
+ if (html.length <= 4096) {
357
+ await bot.api.sendMessage(chatId, html, { parse_mode: 'HTML' }).catch(() =>
358
+ bot.api.sendMessage(chatId, text)
359
+ );
360
+ return;
361
+ }
362
+
363
+ let remaining = html;
364
+ while (remaining.length > 0) {
365
+ if (remaining.length <= 4096) {
366
+ await bot.api.sendMessage(chatId, remaining, { parse_mode: 'HTML' }).catch(() =>
367
+ bot.api.sendMessage(chatId, remaining)
368
+ );
369
+ break;
370
+ }
371
+ let splitAt = remaining.lastIndexOf('\n', 4096);
372
+ if (splitAt === -1 || splitAt < 2000) splitAt = 4096;
373
+ const chunk = remaining.substring(0, splitAt);
374
+ await bot.api.sendMessage(chatId, chunk, { parse_mode: 'HTML' }).catch(() =>
375
+ bot.api.sendMessage(chatId, chunk)
376
+ );
377
+ remaining = remaining.substring(splitAt).trimStart();
378
+ }
379
+ }
380
+
364
381
  module.exports = { setupHeartbeat };
@@ -20,7 +20,7 @@ async function sendTtsVoiceSummary(ctx, tenant, responseText) {
20
20
  max_tokens: 200,
21
21
  messages: [{
22
22
  role: 'user',
23
- content: `Summarize the following assistant message in 1-2 short spoken sentences. Use plain conversational language — no markdown, no code, no lists. Just what was said or done:\n\n${responseText.substring(0, 3000)}`,
23
+ content: `Summarize the following message in 1-2 short spoken sentences. Write in first person as if YOU are speaking directly to the user — say "I" not "the assistant". Use plain conversational language — no markdown, no code, no lists:\n\n${responseText.substring(0, 3000)}`,
24
24
  }],
25
25
  });
26
26
 
package/src/ws/server.js CHANGED
@@ -466,7 +466,7 @@ async function generateTtsAudio(ws, tenant, responseText) {
466
466
  max_tokens: 200,
467
467
  messages: [{
468
468
  role: 'user',
469
- content: `Summarize the following assistant message in 1-2 short spoken sentences. Write in first person as the assistant speaking directly. Use plain conversational language \u2014 no markdown, no code, no lists:\n\n${responseText.substring(0, 3000)}`,
469
+ content: `Summarize the following message in 1-2 short spoken sentences. Write in first person as if YOU are speaking directly to the user \u2014 say "I" not "the assistant". Use plain conversational language \u2014 no markdown, no code, no lists:\n\n${responseText.substring(0, 3000)}`,
470
470
  }],
471
471
  });
472
472