obol-ai 0.2.4 → 0.2.6

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.
@@ -14,7 +14,8 @@
14
14
  "Bash(git -C /Users/jovinkenroye/Sites/obol diff --stat)",
15
15
  "Bash(git -C /Users/jovinkenroye/Sites/obol add:*)",
16
16
  "Bash(git -C:*)",
17
- "Bash(pass ls:*)"
17
+ "Bash(pass ls:*)",
18
+ "mcp__context7__query-docs"
18
19
  ]
19
20
  }
20
21
  }
package/README.md CHANGED
@@ -556,6 +556,20 @@ obol start -d
556
556
  | **Cron** | Basic node-cron | Full scheduler |
557
557
  | **Cost** | ~$9/mo | ~$9/mo+ |
558
558
 
559
+ ### Performance
560
+
561
+ | | **OBOL** | **OpenClaw** (estimated) |
562
+ |---|---|---|
563
+ | **Cold start** | ~400ms | ~3-8s |
564
+ | **Per-message overhead** | ~400-650ms | ~500-1100ms |
565
+ | **Heap usage** | ~16 MB | ~80-200 MB |
566
+ | **RSS** | ~109 MB | ~300-600 MB |
567
+ | **node_modules** | 354 MB / 9 deps | ~1-2 GB / 50-100+ deps |
568
+ | **Source code** | ~5,100 lines (plain JS) | Tens of thousands (TypeScript monorepo) |
569
+ | **Native apps** | None | Swift (macOS/iOS), Kotlin (Android) |
570
+
571
+ The Claude API call dominates response time at 1-5s for both — that's ~85-90% of total latency. User-perceived speed difference is ~10-20%. Where OBOL wins is cold start (10-20x), memory footprint (5-10x), and operational simplicity. On a $5/mo VPS, that matters.
572
+
559
573
  Different tools, different philosophies. Pick what fits.
560
574
 
561
575
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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": {
package/src/background.js CHANGED
@@ -20,8 +20,9 @@ class BackgroundRunner {
20
20
  * @param {string} task - The task description
21
21
  * @param {object} ctx - Telegram context (for sending updates)
22
22
  * @param {object} memory - Memory instance
23
+ * @param {object} parentContext - Parent context for verbose forwarding
23
24
  */
24
- spawn(claude, task, ctx, memory) {
25
+ spawn(claude, task, ctx, memory, parentContext) {
25
26
  let running = 0;
26
27
  for (const t of this.tasks.values()) {
27
28
  if (t.status === 'running') running++;
@@ -42,6 +43,9 @@ class BackgroundRunner {
42
43
 
43
44
  this.tasks.set(taskId, taskState);
44
45
 
46
+ const verbose = parentContext?.verbose || false;
47
+ const verboseNotify = parentContext?._verboseNotify;
48
+
45
49
  // Start check-in timer before running task to avoid leak if task throws immediately
46
50
  taskState.checkInTimer = setInterval(async () => {
47
51
  if (taskState.status !== 'running') {
@@ -55,13 +59,13 @@ class BackgroundRunner {
55
59
  }, CHECK_IN_INTERVAL);
56
60
 
57
61
  // Run the task
58
- const promise = this._runTask(claude, task, taskState, ctx, memory);
62
+ const promise = this._runTask(claude, task, taskState, ctx, memory, verbose, verboseNotify);
59
63
  taskState.promise = promise;
60
64
 
61
65
  return taskId;
62
66
  }
63
67
 
64
- async _runTask(claude, task, taskState, ctx, memory) {
68
+ async _runTask(claude, task, taskState, ctx, memory, verbose, verboseNotify) {
65
69
  try {
66
70
  // Give the background task a system instruction to report progress
67
71
  const bgPrompt = `You are working on a background task. Do the work thoroughly.
@@ -73,9 +77,12 @@ This helps track what you're doing. Complete the full task, then give the final
73
77
 
74
78
  TASK: ${task}`;
75
79
 
80
+ const bgNotify = verboseNotify ? (msg) => verboseNotify(`[bg#${taskState.id}] ${msg}`) : undefined;
76
81
  const result = await claude.chat(bgPrompt, {
77
82
  chatId: `bg-${taskState.id}`,
78
83
  userName: 'BackgroundTask',
84
+ verbose,
85
+ _verboseNotify: bgNotify,
79
86
  });
80
87
 
81
88
  taskState.status = 'done';
package/src/claude.js CHANGED
@@ -15,18 +15,8 @@ const BLOCKED_EXEC_PATTERNS = [
15
15
  /\bmkfs\b/, /\bdd\s+if=/, /\b:()\{\s*:|:&\s*\};:/,
16
16
  /\bchmod\s+(-R\s+)?[0-7]*\s+\/[^t]/,
17
17
  />\s*\/etc\//, />\s*\/boot\//,
18
- /\beval\s+/, /\bsource\s+/,
19
- /\bbash\s+-c\b/, /\bsh\s+-c\b/, /\bzsh\s+-c\b/,
20
- /`[^`]*`/,
21
- /\$\([^)]*\)/,
22
- /\bpython[23]?\s+-c\b/, /\bperl\s+-e\b/, /\bruby\s+-e\b/, /\bnode\s+-e\b/,
23
18
  /\bcurl\b.*\|\s*(ba)?sh/, /\bwget\b.*\|\s*(ba)?sh/,
24
- /\benv\b.*\b(sh|bash|zsh)\b/,
25
- /\bfind\b.*-exec\b/,
26
- /\bprintf\b.*\|\s*(ba)?sh/,
27
- /\\x[0-9a-fA-F]{2}/, /\\[0-7]{3}/,
28
19
  /\bnc\s+-e\b/, /\bncat\b.*-e\b/,
29
- /\bmkfifo\b/,
30
20
  />\s*\/dev\/sd/,
31
21
  ];
32
22
 
@@ -237,7 +227,11 @@ function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR
237
227
 
238
228
  const verbose = context.verbose || false;
239
229
  if (verbose) context.verboseLog = [];
240
- const vlog = (msg) => { if (verbose) context.verboseLog.push(msg); };
230
+ const vlog = (msg) => {
231
+ if (!verbose) return;
232
+ context.verboseLog.push(msg);
233
+ context._verboseNotify?.(msg);
234
+ };
241
235
 
242
236
  let memoryContext = '';
243
237
  if (memory) {
@@ -255,7 +249,7 @@ Reply with ONLY a JSON object:
255
249
 
256
250
  Memory: casual messages (greetings, jokes, simple questions) → false. References to past, people, projects, preferences → true with optimized search query.
257
251
 
258
- Model: Use "haiku" for: casual chat, greetings, simple factual questions, short replies, trivial tasks. Use "sonnet" for most things (general questions, quick tasks, single-step work, moderate reasoning). Use "opus" ONLY for: complex multi-step research, architecture/design decisions, long-form writing, deep analysis, debugging complex code, tasks requiring exceptional reasoning.`,
252
+ Model: Default to "sonnet". Use "haiku" for: greetings, brief acknowledgments (thanks/ok/bye), casual chitchat, simple factual questions with short answers, quick yes/no questions, and short single-turn exchanges that don't need deep reasoning. Use "sonnet" for: code generation, data analysis, content creation, explanations, creative writing, agentic tool use, general questions, opinions, advice, 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.`,
259
253
  messages: [{ role: 'user', content: userMessage }],
260
254
  });
261
255
 
@@ -1017,7 +1011,7 @@ async function executeToolCall(toolUse, memory, context = {}) {
1017
1011
  const { bg, ctx: telegramCtx, claude: claudeInstance } = context;
1018
1012
  if (!bg || !telegramCtx) return 'Background tasks not available in this context.';
1019
1013
  if (!claudeInstance) return 'Background tasks not available.';
1020
- const taskId = bg.spawn(claudeInstance, input.task, telegramCtx, memory);
1014
+ const taskId = bg.spawn(claudeInstance, input.task, telegramCtx, memory, context);
1021
1015
  if (taskId === null) return 'Too many background tasks running. Wait for one to finish.';
1022
1016
  return `Background task #${taskId} spawned. It will send progress updates and the final result to the chat.`;
1023
1017
  }
package/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { loadConfig, OBOL_DIR } = require('./config');
4
- const { createBot } = require('./telegram');
4
+ const { createBot, checkUpgradeNotify } = require('./telegram');
5
5
  const { setupBackup } = require('./backup');
6
6
  const { setupHeartbeat } = require('./heartbeat');
7
7
  const { migrateToMultiTenant } = require('./legacy-migrate');
@@ -43,6 +43,8 @@ async function main() {
43
43
 
44
44
  const bot = createBot(config.telegram, config);
45
45
 
46
+ checkUpgradeNotify(bot).catch(() => {});
47
+
46
48
  if (config.heartbeat !== false) {
47
49
  setupHeartbeat();
48
50
  }
package/src/messages.js CHANGED
@@ -70,7 +70,7 @@ class MessageLog {
70
70
 
71
71
  const { tickExchange } = require('./evolve');
72
72
  tickExchange(this.userDir).then(result => {
73
- if (result?.ready) this._evolutionReady = true;
73
+ if (result?.ready && !this._evolutionReady && !this._evolutionPending) this._evolutionReady = true;
74
74
  }).catch(() => {});
75
75
  }
76
76
  }
package/src/telegram.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const path = require('path');
2
+ const { execSync } = require('child_process');
2
3
  const { Bot, GrammyError, HttpError, InlineKeyboard } = require('grammy');
3
4
  const { loadConfig } = require('./config');
4
5
  const { evolve, loadEvolutionState } = require('./evolve');
@@ -7,10 +8,14 @@ const { loadTraits, saveTraits, DEFAULT_TRAITS } = require('./personality');
7
8
  const media = require('./media');
8
9
  const credentials = require('./credentials');
9
10
  const { getMaxToolIterations, setMaxToolIterations } = require('./claude');
11
+ const pkg = require('../package.json');
10
12
 
11
13
  const RATE_LIMIT_MS = 3000;
12
14
  const SPAM_THRESHOLD = 5;
13
15
  const SPAM_COOLDOWN_MS = 30000;
16
+ const EVOLUTION_IDLE_MS = 15 * 60 * 1000;
17
+
18
+ const _evolutionTimers = new Map();
14
19
 
15
20
  function startTyping(ctx) {
16
21
  ctx.replyWithChatAction('typing').catch(() => {});
@@ -79,6 +84,7 @@ function createBot(telegramConfig, config) {
79
84
  { command: 'evolution', description: 'Evolution progress' },
80
85
  { command: 'verbose', description: 'Toggle verbose mode on/off' },
81
86
  { command: 'toolimit', description: 'View or set max tool iterations per message' },
87
+ { command: 'upgrade', description: 'Check for updates and upgrade' },
82
88
  { command: 'help', description: 'Show available commands' },
83
89
  ]).catch(() => {});
84
90
 
@@ -374,6 +380,7 @@ Your message is deleted immediately when using /secret set to keep credentials o
374
380
  /clean — Audit workspace
375
381
  /verbose — Toggle verbose mode on/off
376
382
  /toolimit — View or set max tool iterations
383
+ /upgrade — Check for updates and upgrade
377
384
  /help — This message`);
378
385
  });
379
386
 
@@ -384,6 +391,34 @@ Your message is deleted immediately when using /secret set to keep credentials o
384
391
  await ctx.reply(tenant.verbose ? '🔍 Verbose mode ON' : '🔇 Verbose mode OFF');
385
392
  });
386
393
 
394
+ bot.command('upgrade', async (ctx) => {
395
+ const current = pkg.version;
396
+ let latest;
397
+ try {
398
+ latest = execSync(`npm view ${pkg.name} version`, { encoding: 'utf-8' }).trim();
399
+ } catch {
400
+ await ctx.reply('Could not reach npm registry');
401
+ return;
402
+ }
403
+
404
+ if (current === latest) {
405
+ await ctx.reply(`Already on latest (${current})`);
406
+ return;
407
+ }
408
+
409
+ await ctx.reply(`Upgrading ${current} → ${latest}, back in a moment...`);
410
+
411
+ try {
412
+ execSync(`npm install -g ${pkg.name}@latest`, { encoding: 'utf-8', timeout: 120000 });
413
+ const { OBOL_DIR } = require('./config');
414
+ const notifyPath = path.join(OBOL_DIR, '.upgrade-notify.json');
415
+ fs.writeFileSync(notifyPath, JSON.stringify({ chatId: ctx.chat.id, version: latest }));
416
+ execSync('pm2 restart obol', { encoding: 'utf-8', timeout: 15000 });
417
+ } catch (e) {
418
+ await ctx.reply(`Upgrade failed: ${e.message.substring(0, 200)}`);
419
+ }
420
+ });
421
+
387
422
  bot.command('toolimit', async (ctx) => {
388
423
  if (!ctx.from) return;
389
424
  const args = ctx.message.text.split(' ').slice(1);
@@ -450,6 +485,12 @@ Your message is deleted immediately when using /secret set to keep credentials o
450
485
 
451
486
  const tenant = await getTenant(userId, config);
452
487
 
488
+ if (_evolutionTimers.has(userId)) {
489
+ clearTimeout(_evolutionTimers.get(userId));
490
+ _evolutionTimers.delete(userId);
491
+ if (tenant.messageLog) tenant.messageLog._evolutionPending = false;
492
+ }
493
+
453
494
  const stopTyping = startTyping(ctx);
454
495
 
455
496
  try {
@@ -464,6 +505,10 @@ Your message is deleted immediately when using /secret set to keep credentials o
464
505
  claude: tenant.claude,
465
506
  config,
466
507
  verbose: tenant.verbose,
508
+ _verboseNotify: tenant.verbose ? (msg) => {
509
+ const safe = msg.replace(/`/g, "'");
510
+ ctx.reply(`\`${safe}\``, { parse_mode: 'Markdown' }).catch(() => {});
511
+ } : undefined,
467
512
  telegramAsk: (message, options, timeout) => createAsk(ctx, message, options, timeout),
468
513
  _notifyFn: (targetUserId, message) => {
469
514
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
@@ -474,16 +519,12 @@ Your message is deleted immediately when using /secret set to keep credentials o
474
519
 
475
520
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response);
476
521
 
477
- if (tenant.verbose && chatContext.verboseLog?.length) {
478
- const verboseText = '```\n' + chatContext.verboseLog.join('\n') + '\n```';
479
- await ctx.reply(verboseText, { parse_mode: 'Markdown' }).catch(() =>
480
- ctx.reply(verboseText).catch(() => {})
481
- );
482
- }
483
522
 
484
- if (tenant.messageLog?._evolutionReady) {
523
+ if (tenant.messageLog?._evolutionReady && !_evolutionTimers.has(userId)) {
485
524
  tenant.messageLog._evolutionReady = false;
486
- setImmediate(async () => {
525
+ tenant.messageLog._evolutionPending = true;
526
+ const timer = setTimeout(async () => {
527
+ _evolutionTimers.delete(userId);
487
528
  try {
488
529
  const result = await evolve(tenant.claude.client, tenant.messageLog, tenant.memory, tenant.userDir);
489
530
  tenant.claude.reloadPersonality?.();
@@ -523,8 +564,11 @@ Your message is deleted immediately when using /secret set to keep credentials o
523
564
  );
524
565
  } catch (e) {
525
566
  console.error('Evolution failed:', e.message);
567
+ } finally {
568
+ tenant.messageLog._evolutionPending = false;
526
569
  }
527
- });
570
+ }, EVOLUTION_IDLE_MS);
571
+ _evolutionTimers.set(userId, timer);
528
572
  }
529
573
 
530
574
  stopTyping();
@@ -607,6 +651,10 @@ Your message is deleted immediately when using /secret set to keep credentials o
607
651
  claude: tenant.claude,
608
652
  config,
609
653
  verbose: tenant.verbose,
654
+ _verboseNotify: tenant.verbose ? (msg) => {
655
+ const safe = msg.replace(/`/g, "'");
656
+ ctx.reply(`\`${safe}\``, { parse_mode: 'Markdown' }).catch(() => {});
657
+ } : undefined,
610
658
  images: [imageBlock],
611
659
  _notifyFn: (targetUserId, message) => {
612
660
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
@@ -618,11 +666,6 @@ Your message is deleted immediately when using /secret set to keep credentials o
618
666
  tenant.messageLog?.log(ctx.chat.id, 'user', `[${fileInfo.mediaType}] ${caption || filename}`);
619
667
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response);
620
668
 
621
- if (tenant.verbose && mediaChatCtx.verboseLog?.length) {
622
- const verboseText = '```\n' + mediaChatCtx.verboseLog.join('\n') + '\n```';
623
- await ctx.reply(verboseText, { parse_mode: 'Markdown' }).catch(() => ctx.reply(verboseText).catch(() => {}));
624
- }
625
-
626
669
  stopTyping();
627
670
  if (response.length > 4096) {
628
671
  for (const chunk of splitMessage(response, 4096)) {
@@ -642,6 +685,10 @@ Your message is deleted immediately when using /secret set to keep credentials o
642
685
  claude: tenant.claude,
643
686
  config,
644
687
  verbose: tenant.verbose,
688
+ _verboseNotify: tenant.verbose ? (msg) => {
689
+ const safe = msg.replace(/`/g, "'");
690
+ ctx.reply(`\`${safe}\``, { parse_mode: 'Markdown' }).catch(() => {});
691
+ } : undefined,
645
692
  _notifyFn: (targetUserId, message) => {
646
693
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
647
694
  return bot.api.sendMessage(targetUserId, message);
@@ -652,11 +699,6 @@ Your message is deleted immediately when using /secret set to keep credentials o
652
699
  tenant.messageLog?.log(ctx.chat.id, 'user', contextMsg);
653
700
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response);
654
701
 
655
- if (tenant.verbose && mediaCaptionCtx.verboseLog?.length) {
656
- const verboseText = '```\n' + mediaCaptionCtx.verboseLog.join('\n') + '\n```';
657
- await ctx.reply(verboseText, { parse_mode: 'Markdown' }).catch(() => ctx.reply(verboseText).catch(() => {}));
658
- }
659
-
660
702
  stopTyping();
661
703
  if (response.length > 4096) {
662
704
  for (const chunk of splitMessage(response, 4096)) {
@@ -778,4 +820,15 @@ function splitMessage(text, maxLength) {
778
820
  return chunks;
779
821
  }
780
822
 
781
- module.exports = { createBot };
823
+ async function checkUpgradeNotify(bot) {
824
+ const { OBOL_DIR } = require('./config');
825
+ const notifyPath = path.join(OBOL_DIR, '.upgrade-notify.json');
826
+ if (!fs.existsSync(notifyPath)) return;
827
+ try {
828
+ const { chatId, version } = JSON.parse(fs.readFileSync(notifyPath, 'utf-8'));
829
+ fs.unlinkSync(notifyPath);
830
+ await bot.api.sendMessage(chatId, `🪙 Upgraded to ${version}`);
831
+ } catch {}
832
+ }
833
+
834
+ module.exports = { createBot, checkUpgradeNotify };