obol-ai 0.2.5 → 0.2.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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,7 +22,7 @@
22
22
  "author": "Jo Vinkenroye <jestersimpps@gmail.com>",
23
23
  "license": "MIT",
24
24
  "dependencies": {
25
- "@anthropic-ai/sdk": "^0.39.0",
25
+ "@anthropic-ai/sdk": "^0.78.0",
26
26
  "@supabase/supabase-js": "^2.49.1",
27
27
  "@xenova/transformers": "^2.17.2",
28
28
  "commander": "^13.1.0",
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
 
@@ -342,81 +336,47 @@ Model: Use "haiku" for: casual chat, greetings, simple factual questions, short
342
336
  const model = context._model || 'claude-sonnet-4-6';
343
337
  vlog(`[model] ${model} | history=${history.length} msgs`);
344
338
  const systemPrompt = baseSystemPrompt + `\nCurrent time: ${new Date().toISOString()}`;
345
- let response = await client.messages.create({
339
+ const runnableTools = buildRunnableTools(tools, memory, context, vlog);
340
+
341
+ const runner = client.beta.messages.toolRunner({
346
342
  model,
347
343
  max_tokens: 4096,
348
344
  system: systemPrompt,
349
- messages: history,
350
- tools: tools.length > 0 ? tools : undefined,
345
+ messages: [...history],
346
+ tools: runnableTools.length > 0 ? runnableTools : undefined,
347
+ max_iterations: MAX_TOOL_ITERATIONS,
351
348
  });
352
349
 
353
- let toolIterations = 0;
354
- while (response.stop_reason === 'tool_use') {
355
- toolIterations++;
356
- if (toolIterations > MAX_TOOL_ITERATIONS) {
357
- const bailoutContent = response.content;
358
- history.push({ role: 'assistant', content: bailoutContent });
359
- const bailoutResults = bailoutContent
360
- .filter(b => b.type === 'tool_use')
361
- .map(b => ({ type: 'tool_result', tool_use_id: b.id, content: '[max tool iterations reached]' }));
362
- history.push({ role: 'user', content: [
363
- ...bailoutResults,
364
- { type: 'text', text: 'You have used too many tool calls. Please provide a final response now based on what you have so far.' },
365
- ] });
366
- response = await client.messages.create({
367
- model,
368
- max_tokens: 4096,
369
- system: systemPrompt,
370
- messages: history,
371
- });
372
- break;
373
- }
374
-
375
- const assistantContent = response.content;
376
- history.push({ role: 'assistant', content: assistantContent });
377
-
378
- const toolResults = [];
379
- for (const block of assistantContent) {
380
- if (block.type === 'tool_use') {
381
- const inputSummary = block.name === 'exec' ? block.input.command :
382
- block.name === 'write_file' ? block.input.path :
383
- block.name === 'read_file' ? block.input.path :
384
- block.name === 'memory_search' ? block.input.query :
385
- block.name === 'memory_add' ? `[${block.input.category || 'fact'}]` :
386
- block.name === 'web_fetch' ? block.input.url :
387
- block.name === 'background_task' ? block.input.task?.substring(0, 60) :
388
- JSON.stringify(block.input).substring(0, 80);
389
- vlog(`[tool] ${block.name}: ${inputSummary}`);
390
- const result = await executeToolCall(block, memory, context);
391
- toolResults.push({
392
- type: 'tool_result',
393
- tool_use_id: block.id,
394
- content: result,
395
- });
396
- }
350
+ let finalMessage;
351
+ for await (const message of runner) {
352
+ finalMessage = message;
353
+ if (message.usage) {
354
+ vlog(`[tokens] in=${message.usage.input_tokens} out=${message.usage.output_tokens}`);
397
355
  }
398
-
399
- history.push({ role: 'user', content: toolResults });
400
-
401
- response = await client.messages.create({
402
- model,
403
- max_tokens: 4096,
404
- system: systemPrompt,
405
- messages: history,
406
- tools,
407
- });
408
356
  }
409
357
 
410
- const textBlocks = response.content.filter(b => b.type === 'text');
411
- const replyText = textBlocks.map(b => b.text).join('\n');
412
-
413
- if (response.usage) {
414
- vlog(`[tokens] in=${response.usage.input_tokens} out=${response.usage.output_tokens}`);
358
+ const runnerMessages = runner.params.messages;
359
+ const newMessages = runnerMessages.slice(history.length);
360
+ for (const msg of newMessages) {
361
+ history.push(msg);
415
362
  }
416
363
 
417
- history.push({ role: 'assistant', content: response.content });
364
+ if (finalMessage.stop_reason === 'tool_use') {
365
+ const bailoutResults = finalMessage.content
366
+ .filter(b => b.type === 'tool_use')
367
+ .map(b => ({ type: 'tool_result', tool_use_id: b.id, content: '[max tool iterations reached]' }));
368
+ history.push({ role: 'user', content: [
369
+ ...bailoutResults,
370
+ { type: 'text', text: 'You have used too many tool calls. Please provide a final response now based on what you have so far.' },
371
+ ] });
372
+ const bailoutResponse = await client.messages.create({
373
+ model, max_tokens: 4096, system: systemPrompt, messages: history,
374
+ });
375
+ history.push({ role: 'assistant', content: bailoutResponse.content });
376
+ return bailoutResponse.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
377
+ }
418
378
 
419
- return replyText;
379
+ return finalMessage.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
420
380
 
421
381
  } catch (e) {
422
382
  if (e.status === 400 && e.message?.includes('tool_use')) {
@@ -908,6 +868,24 @@ function buildTools(memory, opts = {}) {
908
868
  return tools;
909
869
  }
910
870
 
871
+ function buildRunnableTools(tools, memory, context, vlog) {
872
+ return tools.map(tool => ({
873
+ ...tool,
874
+ run: async (input) => {
875
+ const inputSummary = tool.name === 'exec' ? input.command :
876
+ tool.name === 'write_file' ? input.path :
877
+ tool.name === 'read_file' ? input.path :
878
+ tool.name === 'memory_search' ? input.query :
879
+ tool.name === 'memory_add' ? `[${input.category || 'fact'}]` :
880
+ tool.name === 'web_fetch' ? input.url :
881
+ tool.name === 'background_task' ? input.task?.substring(0, 60) :
882
+ JSON.stringify(input).substring(0, 80);
883
+ vlog(`[tool] ${tool.name}: ${inputSummary}`);
884
+ return await executeToolCall({ name: tool.name, input }, memory, context);
885
+ },
886
+ }));
887
+ }
888
+
911
889
  function resolveUserPath(inputPath, userDir) {
912
890
  if (!userDir) throw new Error('userDir is required for path resolution');
913
891
  const resolved = path.isAbsolute(inputPath)
@@ -1017,7 +995,7 @@ async function executeToolCall(toolUse, memory, context = {}) {
1017
995
  const { bg, ctx: telegramCtx, claude: claudeInstance } = context;
1018
996
  if (!bg || !telegramCtx) return 'Background tasks not available in this context.';
1019
997
  if (!claudeInstance) return 'Background tasks not available.';
1020
- const taskId = bg.spawn(claudeInstance, input.task, telegramCtx, memory);
998
+ const taskId = bg.spawn(claudeInstance, input.task, telegramCtx, memory, context);
1021
999
  if (taskId === null) return 'Too many background tasks running. Wait for one to finish.';
1022
1000
  return `Background task #${taskId} spawned. It will send progress updates and the final result to the chat.`;
1023
1001
  }
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
@@ -13,6 +13,9 @@ const pkg = require('../package.json');
13
13
  const RATE_LIMIT_MS = 3000;
14
14
  const SPAM_THRESHOLD = 5;
15
15
  const SPAM_COOLDOWN_MS = 30000;
16
+ const EVOLUTION_IDLE_MS = 15 * 60 * 1000;
17
+
18
+ const _evolutionTimers = new Map();
16
19
 
17
20
  function startTyping(ctx) {
18
21
  ctx.replyWithChatAction('typing').catch(() => {});
@@ -482,6 +485,12 @@ Your message is deleted immediately when using /secret set to keep credentials o
482
485
 
483
486
  const tenant = await getTenant(userId, config);
484
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
+
485
494
  const stopTyping = startTyping(ctx);
486
495
 
487
496
  try {
@@ -496,6 +505,10 @@ Your message is deleted immediately when using /secret set to keep credentials o
496
505
  claude: tenant.claude,
497
506
  config,
498
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,
499
512
  telegramAsk: (message, options, timeout) => createAsk(ctx, message, options, timeout),
500
513
  _notifyFn: (targetUserId, message) => {
501
514
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
@@ -506,16 +519,12 @@ Your message is deleted immediately when using /secret set to keep credentials o
506
519
 
507
520
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response);
508
521
 
509
- if (tenant.verbose && chatContext.verboseLog?.length) {
510
- const verboseText = '```\n' + chatContext.verboseLog.join('\n') + '\n```';
511
- await ctx.reply(verboseText, { parse_mode: 'Markdown' }).catch(() =>
512
- ctx.reply(verboseText).catch(() => {})
513
- );
514
- }
515
522
 
516
- if (tenant.messageLog?._evolutionReady) {
523
+ if (tenant.messageLog?._evolutionReady && !_evolutionTimers.has(userId)) {
517
524
  tenant.messageLog._evolutionReady = false;
518
- setImmediate(async () => {
525
+ tenant.messageLog._evolutionPending = true;
526
+ const timer = setTimeout(async () => {
527
+ _evolutionTimers.delete(userId);
519
528
  try {
520
529
  const result = await evolve(tenant.claude.client, tenant.messageLog, tenant.memory, tenant.userDir);
521
530
  tenant.claude.reloadPersonality?.();
@@ -555,8 +564,11 @@ Your message is deleted immediately when using /secret set to keep credentials o
555
564
  );
556
565
  } catch (e) {
557
566
  console.error('Evolution failed:', e.message);
567
+ } finally {
568
+ tenant.messageLog._evolutionPending = false;
558
569
  }
559
- });
570
+ }, EVOLUTION_IDLE_MS);
571
+ _evolutionTimers.set(userId, timer);
560
572
  }
561
573
 
562
574
  stopTyping();
@@ -639,6 +651,10 @@ Your message is deleted immediately when using /secret set to keep credentials o
639
651
  claude: tenant.claude,
640
652
  config,
641
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,
642
658
  images: [imageBlock],
643
659
  _notifyFn: (targetUserId, message) => {
644
660
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
@@ -650,11 +666,6 @@ Your message is deleted immediately when using /secret set to keep credentials o
650
666
  tenant.messageLog?.log(ctx.chat.id, 'user', `[${fileInfo.mediaType}] ${caption || filename}`);
651
667
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response);
652
668
 
653
- if (tenant.verbose && mediaChatCtx.verboseLog?.length) {
654
- const verboseText = '```\n' + mediaChatCtx.verboseLog.join('\n') + '\n```';
655
- await ctx.reply(verboseText, { parse_mode: 'Markdown' }).catch(() => ctx.reply(verboseText).catch(() => {}));
656
- }
657
-
658
669
  stopTyping();
659
670
  if (response.length > 4096) {
660
671
  for (const chunk of splitMessage(response, 4096)) {
@@ -674,6 +685,10 @@ Your message is deleted immediately when using /secret set to keep credentials o
674
685
  claude: tenant.claude,
675
686
  config,
676
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,
677
692
  _notifyFn: (targetUserId, message) => {
678
693
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
679
694
  return bot.api.sendMessage(targetUserId, message);
@@ -684,11 +699,6 @@ Your message is deleted immediately when using /secret set to keep credentials o
684
699
  tenant.messageLog?.log(ctx.chat.id, 'user', contextMsg);
685
700
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response);
686
701
 
687
- if (tenant.verbose && mediaCaptionCtx.verboseLog?.length) {
688
- const verboseText = '```\n' + mediaCaptionCtx.verboseLog.join('\n') + '\n```';
689
- await ctx.reply(verboseText, { parse_mode: 'Markdown' }).catch(() => ctx.reply(verboseText).catch(() => {}));
690
- }
691
-
692
702
  stopTyping();
693
703
  if (response.length > 4096) {
694
704
  for (const chunk of splitMessage(response, 4096)) {