obol-ai 0.2.2 → 0.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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/claude.js CHANGED
@@ -7,7 +7,7 @@ const { saveConfig, loadConfig, OBOL_DIR } = require('./config');
7
7
  const { execAsync, isAllowedUrl } = require('./sanitize');
8
8
 
9
9
  const MAX_EXEC_TIMEOUT = 120;
10
- const MAX_TOOL_ITERATIONS = 15;
10
+ let MAX_TOOL_ITERATIONS = 100;
11
11
 
12
12
  const BLOCKED_EXEC_PATTERNS = [
13
13
  /\brm\s+(-[a-zA-Z]*f|-[a-zA-Z]*r|--force|--recursive)\b/,
@@ -124,20 +124,108 @@ async function ensureFreshToken(anthropicConfig) {
124
124
  }
125
125
  }
126
126
 
127
+ function repairHistory(history) {
128
+ const allToolUseIds = new Set();
129
+ for (const msg of history) {
130
+ if (msg.role === 'assistant' && Array.isArray(msg.content)) {
131
+ for (const b of msg.content) {
132
+ if (b.type === 'tool_use') allToolUseIds.add(b.id);
133
+ }
134
+ }
135
+ }
136
+
137
+ for (let i = history.length - 1; i >= 0; i--) {
138
+ const msg = history[i];
139
+ if (msg.role !== 'user' || !Array.isArray(msg.content)) continue;
140
+ const toolResults = msg.content.filter(b => b.type === 'tool_result');
141
+ if (toolResults.length === 0) continue;
142
+ const orphaned = toolResults.filter(b => !allToolUseIds.has(b.tool_use_id));
143
+ if (orphaned.length === 0) continue;
144
+ const remaining = msg.content.filter(b => b.type !== 'tool_result' || allToolUseIds.has(b.tool_use_id));
145
+ if (remaining.length === 0) {
146
+ history.splice(i, 1);
147
+ } else {
148
+ msg.content = remaining;
149
+ }
150
+ }
151
+
152
+ for (let i = 0; i < history.length; i++) {
153
+ const msg = history[i];
154
+ if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue;
155
+ const toolUseIds = msg.content.filter(b => b.type === 'tool_use').map(b => b.id);
156
+ if (toolUseIds.length === 0) continue;
157
+ const next = history[i + 1];
158
+ if (next?.role === 'user' && Array.isArray(next.content)) {
159
+ const existingIds = new Set(next.content.filter(b => b.type === 'tool_result').map(b => b.tool_use_id));
160
+ const missingIds = toolUseIds.filter(id => !existingIds.has(id));
161
+ if (missingIds.length > 0) {
162
+ next.content = [
163
+ ...next.content,
164
+ ...missingIds.map(id => ({ type: 'tool_result', tool_use_id: id, content: '[interrupted]' })),
165
+ ];
166
+ }
167
+ } else {
168
+ const fakeResults = toolUseIds.map(id => ({
169
+ type: 'tool_result', tool_use_id: id, content: '[interrupted]',
170
+ }));
171
+ history.splice(i + 1, 0, { role: 'user', content: fakeResults });
172
+ }
173
+ }
174
+
175
+ for (let i = history.length - 1; i > 0; i--) {
176
+ if (history[i].role === history[i - 1].role && history[i].role === 'user') {
177
+ const prev = history[i - 1];
178
+ const curr = history[i];
179
+ const prevArr = Array.isArray(prev.content) ? prev.content : [{ type: 'text', text: prev.content }];
180
+ const currArr = Array.isArray(curr.content) ? curr.content : [{ type: 'text', text: curr.content }];
181
+ history[i - 1] = { role: 'user', content: [...prevArr, ...currArr] };
182
+ history.splice(i, 1);
183
+ }
184
+ }
185
+ }
186
+
127
187
  function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR, bridgeEnabled }) {
128
188
  let client = createAnthropicClient(anthropicConfig);
129
189
 
130
190
  let baseSystemPrompt = buildSystemPrompt(personality, userDir, { bridgeEnabled });
131
191
 
132
192
  const histories = new Map();
193
+ const chatLocks = new Map();
133
194
  const MAX_HISTORY = 50;
134
195
 
135
196
  const tools = buildTools(memory, { bridgeEnabled });
136
197
 
198
+ function acquireChatLock(chatId) {
199
+ if (!chatLocks.has(chatId)) chatLocks.set(chatId, { promise: Promise.resolve(), busy: false });
200
+ const lock = chatLocks.get(chatId);
201
+ let release;
202
+ const prev = lock.promise;
203
+ lock.promise = new Promise(r => { release = r; });
204
+ return prev.then(() => {
205
+ lock.busy = true;
206
+ return () => { lock.busy = false; release(); };
207
+ });
208
+ }
209
+
210
+ function isChatBusy(chatId) {
211
+ return chatLocks.get(chatId)?.busy || false;
212
+ }
213
+
137
214
  async function chat(userMessage, context = {}) {
138
215
  context.userDir = userDir;
139
216
  const chatId = context.chatId || 'default';
140
217
 
218
+ if (isChatBusy(chatId)) {
219
+ return 'I\'m still working on the previous request. Give me a moment.';
220
+ }
221
+
222
+ const releaseLock = await acquireChatLock(chatId);
223
+
224
+ if (!histories.has(chatId)) histories.set(chatId, []);
225
+ const history = histories.get(chatId);
226
+
227
+ try {
228
+
141
229
  if (anthropicConfig.oauth?.accessToken) {
142
230
  await ensureFreshToken(anthropicConfig);
143
231
  if (anthropicConfig._oauthFailed) {
@@ -147,16 +235,15 @@ function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR
147
235
  }
148
236
  }
149
237
 
150
- // Get or create history
151
- if (!histories.has(chatId)) histories.set(chatId, []);
152
- const history = histories.get(chatId);
238
+ const verbose = context.verbose || false;
239
+ if (verbose) context.verboseLog = [];
240
+ const vlog = (msg) => { if (verbose) context.verboseLog.push(msg); };
153
241
 
154
- // Ask Haiku if we need memory for this message
155
242
  let memoryContext = '';
156
243
  if (memory) {
157
244
  try {
158
245
  const memoryDecision = await client.messages.create({
159
- model: 'claude-haiku-4-5-20251001',
246
+ model: 'claude-haiku-4-5',
160
247
  max_tokens: 100,
161
248
  system: `You are a router. Analyze this user message and decide two things:
162
249
 
@@ -164,11 +251,11 @@ function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR
164
251
  2. What model complexity does it need?
165
252
 
166
253
  Reply with ONLY a JSON object:
167
- {"need_memory": true/false, "search_query": "optimized search query", "model": "sonnet|opus"}
254
+ {"need_memory": true/false, "search_query": "optimized search query", "model": "haiku|sonnet|opus"}
168
255
 
169
256
  Memory: casual messages (greetings, jokes, simple questions) → false. References to past, people, projects, preferences → true with optimized search query.
170
257
 
171
- Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single-step work). Use "opus" ONLY for: complex multi-step research, architecture/design decisions, long-form writing, deep analysis, debugging complex code, tasks requiring exceptional reasoning.`,
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.`,
172
259
  messages: [{ role: 'user', content: userMessage }],
173
260
  });
174
261
 
@@ -179,19 +266,20 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
179
266
  if (jsonStr) decision = JSON.parse(jsonStr);
180
267
  } catch {}
181
268
 
182
- // Set model based on Haiku's decision
269
+ vlog(`[router] model=${decision.model || 'sonnet'} memory=${decision.need_memory || false}${decision.search_query ? ` query="${decision.search_query}"` : ''}`);
270
+
183
271
  if (decision.model === 'opus') {
184
272
  context._model = 'claude-opus-4-6';
273
+ } else if (decision.model === 'haiku') {
274
+ context._model = 'claude-haiku-4-5';
185
275
  }
186
276
 
187
277
  if (decision.need_memory) {
188
278
  const query = decision.search_query || userMessage;
189
279
 
190
- // Today's context + semantic search
191
280
  const todayMemories = await memory.byDate('today', { limit: 3 });
192
281
  const semanticMemories = await memory.search(query, { limit: 3, threshold: 0.5 });
193
282
 
194
- // Dedupe by ID
195
283
  const seen = new Set();
196
284
  const combined = [];
197
285
  for (const m of [...todayMemories, ...semanticMemories]) {
@@ -201,6 +289,8 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
201
289
  }
202
290
  }
203
291
 
292
+ vlog(`[memory] ${combined.length} memories found (${todayMemories.length} today, ${semanticMemories.length} semantic)`);
293
+
204
294
  if (combined.length > 0) {
205
295
  memoryContext = '\n\n[Relevant memories]\n' +
206
296
  combined.map(m => `- [${m.category}] ${m.content}`).join('\n');
@@ -208,25 +298,33 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
208
298
  }
209
299
  } catch (e) {
210
300
  console.error('[router] Memory/routing decision failed:', e.message);
301
+ vlog(`[router] ERROR: ${e.message}`);
211
302
  }
212
303
  }
213
304
 
214
305
  while (history.length >= MAX_HISTORY) {
215
- history.shift();
216
- history.shift();
306
+ let cut = 0;
307
+ while (cut < history.length - 1) {
308
+ const msg = history[cut];
309
+ cut++;
310
+ if (msg.role === 'assistant' && Array.isArray(msg.content) &&
311
+ msg.content.some(b => b.type === 'tool_use')) continue;
312
+ if (msg.role === 'user' && Array.isArray(msg.content) &&
313
+ msg.content.some(b => b.type === 'tool_result')) continue;
314
+ if (msg.role === 'assistant') break;
315
+ }
316
+ history.splice(0, cut);
317
+ if (cut === 0) { history.shift(); history.shift(); break; }
217
318
  }
218
319
  while (history.length > 0) {
219
320
  const first = history[0];
220
- if (first.role !== 'user') {
221
- history.shift();
222
- continue;
223
- }
321
+ if (first.role !== 'user') { history.shift(); continue; }
224
322
  if (Array.isArray(first.content) && first.content.some(b => b.type === 'tool_result')) {
225
- history.shift();
226
- continue;
323
+ history.shift(); continue;
227
324
  }
228
325
  break;
229
326
  }
327
+ repairHistory(history);
230
328
 
231
329
  // Add user message with memory context
232
330
  const enrichedMessage = memoryContext
@@ -241,8 +339,8 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
241
339
  history.push({ role: 'user', content: enrichedMessage });
242
340
  }
243
341
 
244
- // Call Claude — Haiku picks the model
245
342
  const model = context._model || 'claude-sonnet-4-6';
343
+ vlog(`[model] ${model} | history=${history.length} msgs`);
246
344
  const systemPrompt = baseSystemPrompt + `\nCurrent time: ${new Date().toISOString()}`;
247
345
  let response = await client.messages.create({
248
346
  model,
@@ -256,8 +354,15 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
256
354
  while (response.stop_reason === 'tool_use') {
257
355
  toolIterations++;
258
356
  if (toolIterations > MAX_TOOL_ITERATIONS) {
259
- history.push({ role: 'assistant', content: response.content });
260
- history.push({ role: 'user', content: 'You have used too many tool calls. Please provide a final response now based on what you have so far.' });
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
+ ] });
261
366
  response = await client.messages.create({
262
367
  model,
263
368
  max_tokens: 4096,
@@ -273,6 +378,15 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
273
378
  const toolResults = [];
274
379
  for (const block of assistantContent) {
275
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}`);
276
390
  const result = await executeToolCall(block, memory, context);
277
391
  toolResults.push({
278
392
  type: 'tool_result',
@@ -293,14 +407,26 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
293
407
  });
294
408
  }
295
409
 
296
- // Extract text response
297
410
  const textBlocks = response.content.filter(b => b.type === 'text');
298
411
  const replyText = textBlocks.map(b => b.text).join('\n');
299
412
 
300
- // Add assistant response to history
413
+ if (response.usage) {
414
+ vlog(`[tokens] in=${response.usage.input_tokens} out=${response.usage.output_tokens}`);
415
+ }
416
+
301
417
  history.push({ role: 'assistant', content: response.content });
302
418
 
303
419
  return replyText;
420
+
421
+ } catch (e) {
422
+ if (e.status === 400 && e.message?.includes('tool_use')) {
423
+ console.error('[claude] Repairing corrupted history after 400 error');
424
+ repairHistory(history);
425
+ }
426
+ throw e;
427
+ } finally {
428
+ releaseLock();
429
+ }
304
430
  }
305
431
 
306
432
  function reloadPersonality() {
@@ -325,7 +451,28 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
325
451
  history.push({ role, content });
326
452
  }
327
453
 
328
- return { chat, client, reloadPersonality, clearHistory, injectHistory };
454
+ function getContextStats(chatId) {
455
+ const id = chatId || 'default';
456
+ const history = histories.get(id) || [];
457
+ const MAX_CONTEXT = 200000;
458
+ let chars = baseSystemPrompt.length;
459
+ for (const msg of history) {
460
+ if (typeof msg.content === 'string') {
461
+ chars += msg.content.length;
462
+ } else if (Array.isArray(msg.content)) {
463
+ for (const b of msg.content) {
464
+ if (b.text) chars += b.text.length;
465
+ else if (b.content) chars += (typeof b.content === 'string' ? b.content.length : JSON.stringify(b.content).length);
466
+ else if (b.type === 'tool_use') chars += JSON.stringify(b.input || {}).length + (b.name?.length || 0);
467
+ }
468
+ }
469
+ }
470
+ const estimatedTokens = Math.round(chars / 4);
471
+ const pct = Math.min(100, Math.round((estimatedTokens / MAX_CONTEXT) * 100));
472
+ return { messages: history.length, estimatedTokens, maxTokens: MAX_CONTEXT, pct };
473
+ }
474
+
475
+ return { chat, client, reloadPersonality, clearHistory, injectHistory, getContextStats };
329
476
  }
330
477
 
331
478
  function buildSystemPrompt(personality, userDir, opts = {}) {
@@ -427,6 +574,126 @@ Both tools notify the partner that their agent was contacted. Keep messages spec
427
574
  `);
428
575
  }
429
576
 
577
+ // Tool documentation (hardcoded — never drifts)
578
+ parts.push(`
579
+ ## Tools
580
+
581
+ ### Shell (\`exec\`)
582
+ Run shell commands. Workspace is your home directory.
583
+ - Timeout: 30s default, 120s max
584
+ - Blocked: \`rm -rf\`, \`shutdown\`, \`eval\`, \`bash -c\`, backtick injection, pipe-to-shell
585
+ - Sensitive paths blocked: \`/etc/passwd\`, \`.env\`, \`.ssh/\`, \`/root/\`
586
+
587
+ ### Memory (\`memory_search\`, \`memory_add\`, \`memory_date\`)
588
+ Vector memory via Supabase pgvector with local embeddings.
589
+ - \`memory_search\` — semantic search across all memories
590
+ - \`memory_add\` — store facts, decisions, preferences, events, people, projects
591
+ - \`memory_date\` — get memories by date ("today", "yesterday", "7d", "2026-02-22")
592
+
593
+ Categories: \`fact\`, \`preference\`, \`decision\`, \`lesson\`, \`person\`, \`project\`, \`event\`, \`conversation\`, \`resource\`, \`pattern\`, \`context\`, \`email\`
594
+
595
+ ### Files (\`read_file\`, \`write_file\`)
596
+ Read and write files within your workspace. Parent directories created automatically.
597
+ Cannot access paths outside workspace or /tmp.
598
+
599
+ ### Web (\`web_fetch\`)
600
+ Fetch and extract readable content from any URL via Jina reader.
601
+
602
+ ### Vercel (\`vercel_deploy\`, \`vercel_list\`)
603
+ Deploy directories to Vercel. Ship websites, dashboards, web apps.
604
+
605
+ ### Background Tasks (\`background_task\`)
606
+ Spawn heavy work (research, site building, complex analysis) in the background.
607
+ The main conversation stays responsive. User gets progress updates every 30s.
608
+ After spawning, reply with a brief acknowledgment.
609
+
610
+ ### Secrets (\`store_secret\`, \`read_secret\`, \`list_secrets\`)
611
+ Per-user encrypted secret store (pass or JSON fallback).
612
+ - \`store_secret\` — store a key/value secret (API keys, passwords, tokens)
613
+ - \`read_secret\` — read a secret by key
614
+ - \`list_secrets\` — list all secret keys (keys only, not values)
615
+
616
+ Use these tools instead of \`exec\` for storing/reading secrets — they bypass the \`bash -c\` restriction.
617
+
618
+ ### Send File (\`send_file\`)
619
+ Send a file back to the user via Telegram. Use after generating PDFs, images, documents, or any file the user requested.
620
+
621
+ ### Ask User (\`telegram_ask\`)
622
+ Send a message with inline keyboard buttons and wait for the user to tap one. Use for human-in-the-loop decisions before taking action.
623
+
624
+ Examples:
625
+ - After listing emails: \`telegram_ask({message: "Open any of these?", options: ["#1 Google", "#2 LinkedIn", "#3 DeepLearning", "None"]})\`
626
+ - Before sending a reply: \`telegram_ask({message: "Send this reply?", options: ["Send it", "Edit first", "Cancel"]})\`
627
+ - Before an irreversible action: \`telegram_ask({message: "Archive all read emails?", options: ["Yes", "No"]})\`
628
+
629
+ Returns the tapped button label, or \`"timeout"\` if the user doesn't respond within the timeout (default 60s).
630
+
631
+ ### Bridge (\`bridge_ask\`, \`bridge_tell\`)
632
+ Only available if bridge is enabled. Communicate with partner's AI agent.
633
+ `);
634
+
635
+ // Available custom scripts (dynamic — always current)
636
+ const scriptsDir = userDir ? path.join(userDir, 'scripts') : null;
637
+ let scriptManifest = '(no custom scripts yet)';
638
+ if (scriptsDir && fs.existsSync(scriptsDir)) {
639
+ try {
640
+ const scriptFiles = fs.readdirSync(scriptsDir).filter(f => {
641
+ try { return fs.statSync(path.join(scriptsDir, f)).isFile(); } catch { return false; }
642
+ });
643
+ if (scriptFiles.length > 0) {
644
+ scriptManifest = scriptFiles.map(s => `- ${s}`).join('\n');
645
+ }
646
+ } catch {}
647
+ }
648
+ parts.push(`\n## Available Scripts\nScripts you've built in your workspace (run via exec tool):\n${scriptManifest}`);
649
+
650
+ // Telegram formatting (hardcoded — never drifts)
651
+ parts.push(`
652
+ ## Telegram Formatting
653
+
654
+ You communicate via Telegram. Format responses for mobile readability.
655
+
656
+ **Never use markdown tables** — pipe-syntax tables do not render in Telegram. Use numbered lists instead.
657
+
658
+ **Email/inbox lists** — use this pattern:
659
+ \`\`\`
660
+ šŸ“¬ *Inbox (10)*
661
+
662
+ 1\\. *Google* — Security alert \`22:58\`
663
+ 2\\. *LinkedIn* — Matthew Chittle wants to connect \`21:31\`
664
+ 3\\. *DeepLearning\\.AI* — AI Dev 26 Ɨ SF speakers \`13:20\`
665
+ 4\\. *LinkedIn Jobs* — Project Manager / TPM roles \`17:32\`
666
+ \`\`\`
667
+
668
+ **Copyable values** (email addresses, URLs, API keys, commands) — wrap in backtick code spans:
669
+ \`user@example.com\`, \`https://example.com\`, \`npm install foo\`
670
+
671
+ **Human-in-the-loop** — after listing emails or before acting, use \`telegram_ask\` to offer inline buttons rather than asking the user to type a reply.
672
+
673
+ **Keep lines short** — Telegram wraps long lines poorly on mobile. Break at natural points.
674
+ `);
675
+
676
+ // Safety rules (hardcoded — never drifts)
677
+ parts.push(`
678
+ ## Safety Rules
679
+
680
+ ### Never
681
+ - Share owner's private data with anyone
682
+ - Run destructive commands without asking (\`rm -rf\`, \`DROP TABLE\`, etc.)
683
+ - Send emails or messages on behalf of owner — draft them, owner sends
684
+ - Modify system files (\`/etc/\`, \`/boot/\`)
685
+ - Store secrets in plaintext — use \`store_secret\` for sensitive data
686
+ - Create files outside workspace (except /tmp)
687
+ - Hardcode credentials in scripts — always read them via \`read_secret\` at runtime
688
+
689
+ ### Always
690
+ - Draft emails/posts for review before sending
691
+ - Ask before running anything irreversible
692
+ - Store important info in memory proactively
693
+ - Search memory before claiming you don't know something
694
+ - Use \`store_secret\`/\`read_secret\` for all credential operations
695
+ `);
696
+
430
697
  return parts.join('\n');
431
698
  }
432
699
 
@@ -618,6 +885,20 @@ function buildTools(memory, opts = {}) {
618
885
  },
619
886
  });
620
887
 
888
+ tools.push({
889
+ name: 'telegram_ask',
890
+ description: 'Send a message to the user with inline keyboard buttons and wait for their tap. Use for human-in-the-loop decisions: confirmations, approvals, action selection. Returns the label of the button the user pressed, or "timeout" if they don\'t respond within the timeout.',
891
+ input_schema: {
892
+ type: 'object',
893
+ properties: {
894
+ message: { type: 'string', description: 'Question or prompt to show the user' },
895
+ options: { type: 'array', items: { type: 'string' }, description: 'Button labels (2-6 options, keep each label short)' },
896
+ timeout: { type: 'number', description: 'Seconds to wait for response (default 60)' },
897
+ },
898
+ required: ['message', 'options'],
899
+ },
900
+ });
901
+
621
902
  if (opts.bridgeEnabled) {
622
903
  const { buildBridgeTool, buildBridgeTellTool } = require('./bridge');
623
904
  tools.push(buildBridgeTool());
@@ -832,6 +1113,11 @@ async function executeToolCall(toolUse, memory, context = {}) {
832
1113
  return `Sent: ${path.basename(filePath)}`;
833
1114
  }
834
1115
 
1116
+ case 'telegram_ask': {
1117
+ if (!context.telegramAsk) return 'telegram_ask not available in this context.';
1118
+ return await context.telegramAsk(input.message, input.options || [], input.timeout);
1119
+ }
1120
+
835
1121
  case 'bridge_ask': {
836
1122
  const { bridgeAsk } = require('./bridge');
837
1123
  return await bridgeAsk(input.question, context.userId, context.config, context._notifyFn, input.partner_id);
@@ -850,4 +1136,7 @@ async function executeToolCall(toolUse, memory, context = {}) {
850
1136
  }
851
1137
  }
852
1138
 
853
- module.exports = { createClaude, createAnthropicClient };
1139
+ function getMaxToolIterations() { return MAX_TOOL_ITERATIONS; }
1140
+ function setMaxToolIterations(n) { MAX_TOOL_ITERATIONS = n; }
1141
+
1142
+ module.exports = { createClaude, createAnthropicClient, getMaxToolIterations, setMaxToolIterations };
@@ -1,52 +1,5 @@
1
1
  # AGENTS.md — Operating Manual
2
2
 
3
- ## Tools
4
-
5
- ### Shell (`exec`)
6
- Run shell commands. Workspace is your home directory.
7
- - Timeout: 30s default, 120s max
8
- - Blocked: `rm -rf`, `shutdown`, `eval`, `bash -c`, backtick injection, pipe-to-shell
9
- - Sensitive paths blocked: `/etc/passwd`, `.env`, `.ssh/`, `/root/`
10
-
11
- ### Memory (`memory_search`, `memory_add`, `memory_date`)
12
- Vector memory via Supabase pgvector with local embeddings.
13
- - `memory_search` — semantic search across all memories
14
- - `memory_add` — store facts, decisions, preferences, events, people, projects
15
- - `memory_date` — get memories by date ("today", "yesterday", "7d", "2026-02-22")
16
-
17
- Categories: `fact`, `preference`, `decision`, `lesson`, `person`, `project`, `event`, `conversation`, `resource`, `pattern`, `context`, `email`
18
-
19
- ### Files (`read_file`, `write_file`)
20
- Read and write files within your workspace. Parent directories created automatically.
21
- Cannot access paths outside workspace or /tmp.
22
-
23
- ### Web (`web_fetch`)
24
- Fetch and extract readable content from any URL via Jina reader.
25
-
26
- ### Vercel (`vercel_deploy`, `vercel_list`)
27
- Deploy directories to Vercel. Ship websites, dashboards, web apps.
28
-
29
- ### Background Tasks (`background_task`)
30
- Spawn heavy work (research, site building, complex analysis) in the background.
31
- The main conversation stays responsive. User gets progress updates every 30s.
32
- After spawning, reply with a brief acknowledgment.
33
-
34
- ### Secrets (`store_secret`, `read_secret`, `list_secrets`)
35
- Per-user encrypted secret store (pass or JSON fallback).
36
- - `store_secret` — store a key/value secret (API keys, passwords, tokens)
37
- - `read_secret` — read a secret by key
38
- - `list_secrets` — list all secret keys (keys only, not values)
39
-
40
- Use these tools instead of `exec` for storing/reading secrets — they bypass the `bash -c` restriction.
41
-
42
- Users can also manage secrets via Telegram: `/secret set <key> <value>` (message auto-deleted), `/secret list`, `/secret remove <key>`.
43
-
44
- ### Send File (`send_file`)
45
- Send a file back to the user via Telegram. Use after generating PDFs, images, documents, or any file the user requested.
46
-
47
- ### Bridge (`bridge_ask`, `bridge_tell`)
48
- Only available if bridge is enabled. Communicate with partner's AI agent.
49
-
50
3
  ## Memory Strategy
51
4
 
52
5
  Haiku auto-consolidates every 5 exchanges — important context gets stored automatically.
@@ -64,43 +17,6 @@ Search memory before answering questions about:
64
17
  - Anything the owner mentioned before
65
18
  - "What did we discuss about X?"
66
19
 
67
- ## Safety Rules
68
-
69
- ### Never
70
- - Share owner's private data with anyone
71
- - Run destructive commands without asking (`rm -rf`, `DROP TABLE`, etc.)
72
- - Send emails or messages on behalf of owner — draft them, owner sends
73
- - Modify system files (`/etc/`, `/boot/`)
74
- - Store secrets in plaintext — use `store_secret` for sensitive data
75
- - Create files outside workspace (except /tmp)
76
- - Hardcode credentials in scripts — always read them via `read_secret` at runtime
77
-
78
- ### Always
79
- - Draft emails/posts for review before sending
80
- - Ask before running anything irreversible
81
- - Store important info in memory proactively
82
- - Search memory before claiming you don't know something
83
- - Use `store_secret`/`read_secret` for all credential operations
84
-
85
- ## Workspace Structure
86
-
87
- ```
88
- workspace/
89
- ā”œā”€ā”€ personality/ (SOUL.md, USER.md, AGENTS.md, evolution/)
90
- ā”œā”€ā”€ scripts/ (utility scripts)
91
- ā”œā”€ā”€ tests/ (test suite)
92
- ā”œā”€ā”€ commands/ (command definitions)
93
- ā”œā”€ā”€ apps/ (web apps for Vercel)
94
- ā”œā”€ā”€ assets/ (uploaded files, images, media)
95
- └── logs/
96
- ```
97
-
98
- Rules:
99
- - NEVER create new top-level directories
100
- - Place files in the correct existing directory
101
- - Temporary files go in /tmp
102
- - If unsure where something belongs, ask
103
-
104
20
  ## Self-Extending
105
21
 
106
22
  You can give yourself new capabilities by writing scripts and running them. If the user asks for something you don't have a dedicated tool for (PDF generation, image manipulation, data processing, etc.):
package/src/evolve.js CHANGED
@@ -284,7 +284,15 @@ Third person factual profile: name, location, timezone, nationality, job, skills
284
284
 
285
285
  ## Part 3: AGENTS.md (how to operate)
286
286
 
287
- Operational manual written as instructions to yourself. **Preserve ALL existing tool documentation** — tools don't change between evolutions. Add owner-specific rules discovered from conversations. Add workflow patterns that work well. Keep what works, remove what doesn't. Sections to maintain: Tools, Memory Strategy, Safety Rules, Workspace Structure, Background Task Guidelines, Communication Style, Evolution.
287
+ Operational manual written as instructions to yourself. Focus on owner-specific workflows, service integrations, and lessons learned from conversations.
288
+
289
+ **Do NOT include in AGENTS.md** — these are already hardcoded in the base system prompt and must not be duplicated:
290
+ - Tool documentation (exec, memory_*, read_file, write_file, web_fetch, vercel_*, background_task, store_secret, read_secret, list_secrets, send_file, telegram_ask, bridge_*)
291
+ - Telegram Formatting rules
292
+ - Safety Rules (Never/Always)
293
+ - Workspace Structure
294
+
295
+ **What belongs in AGENTS.md:** Memory Strategy, Self-Extending patterns, Scripts & Service Integrations, Background Task Guidelines, Communication Style, Evolution notes, and any owner-specific workflows or lessons discovered from conversations. Keep what works, remove what doesn't.
288
296
 
289
297
  ## Part 3b: Personality Traits
290
298
 
package/src/telegram.js CHANGED
@@ -1,11 +1,12 @@
1
1
  const path = require('path');
2
- const { Bot, GrammyError, HttpError } = require('grammy');
2
+ const { Bot, GrammyError, HttpError, InlineKeyboard } = require('grammy');
3
3
  const { loadConfig } = require('./config');
4
4
  const { evolve, loadEvolutionState } = require('./evolve');
5
5
  const { getTenant } = require('./tenant');
6
6
  const { loadTraits, saveTraits, DEFAULT_TRAITS } = require('./personality');
7
7
  const media = require('./media');
8
8
  const credentials = require('./credentials');
9
+ const { getMaxToolIterations, setMaxToolIterations } = require('./claude');
9
10
 
10
11
  const RATE_LIMIT_MS = 3000;
11
12
  const SPAM_THRESHOLD = 5;
@@ -23,6 +24,31 @@ function createBot(telegramConfig, config) {
23
24
  const bot = new Bot(telegramConfig.token);
24
25
  const allowedUsers = new Set(telegramConfig.allowedUsers || []);
25
26
  const rateLimits = new Map();
27
+ const pendingAsks = new Map();
28
+ let askIdCounter = 0;
29
+
30
+ function createAsk(ctx, message, options, timeoutSecs = 60) {
31
+ return new Promise((resolve) => {
32
+ const askId = ++askIdCounter;
33
+ const keyboard = new InlineKeyboard();
34
+ options.forEach((opt, i) => {
35
+ keyboard.text(opt, `ask:${askId}:${i}`);
36
+ if ((i + 1) % 3 === 0 && i < options.length - 1) keyboard.row();
37
+ });
38
+ const timer = setTimeout(() => {
39
+ if (pendingAsks.has(askId)) {
40
+ pendingAsks.delete(askId);
41
+ resolve('timeout');
42
+ }
43
+ }, timeoutSecs * 1000);
44
+ pendingAsks.set(askId, { resolve, options, timer });
45
+ ctx.reply(message, { parse_mode: 'Markdown', reply_markup: keyboard }).catch(() => {
46
+ clearTimeout(timer);
47
+ pendingAsks.delete(askId);
48
+ resolve('error');
49
+ });
50
+ });
51
+ }
26
52
 
27
53
  const _rateLimitCleanup = setInterval(() => {
28
54
  const now = Date.now();
@@ -51,6 +77,8 @@ function createBot(telegramConfig, config) {
51
77
  { command: 'traits', description: 'View or adjust personality traits' },
52
78
  { command: 'secret', description: 'Manage per-user secrets' },
53
79
  { command: 'evolution', description: 'Evolution progress' },
80
+ { command: 'verbose', description: 'Toggle verbose mode on/off' },
81
+ { command: 'toolimit', description: 'View or set max tool iterations per message' },
54
82
  { command: 'help', description: 'Show available commands' },
55
83
  ]).catch(() => {});
56
84
 
@@ -102,12 +130,18 @@ function createBot(telegramConfig, config) {
102
130
  text += `ā±ļø Uptime: ${h}h ${m}m\n`;
103
131
  text += `šŸ’¾ Memory: ${mem}MB\n`;
104
132
  text += `⚔ Tasks: ${running.length} running\n`;
133
+ text += `šŸ”§ Tool limit: ${getMaxToolIterations()}\n`;
105
134
 
106
135
  if (tenant.memory) {
107
136
  const stats = await tenant.memory.stats().catch(() => null);
108
137
  if (stats) text += `🧠 Memories: ${stats.total}\n`;
109
138
  }
110
139
 
140
+ const ctxStats = tenant.claude.getContextStats(ctx.chat.id);
141
+ const ctxBar = 'ā–ˆ'.repeat(Math.floor(ctxStats.pct / 5)) + 'ā–‘'.repeat(20 - Math.floor(ctxStats.pct / 5));
142
+ text += `\nšŸ“ Context: ${ctxBar} ${ctxStats.pct}%\n`;
143
+ text += ` ${(ctxStats.estimatedTokens / 1000).toFixed(1)}k / ${(ctxStats.maxTokens / 1000).toFixed(0)}k tokens (${ctxStats.messages} msgs)\n`;
144
+
111
145
  const evoState = loadEvolutionState(tenant.userDir);
112
146
  const cfg = loadConfig();
113
147
  const threshold = cfg?.evolution?.exchanges || 100;
@@ -338,9 +372,38 @@ Your message is deleted immediately when using /secret set to keep credentials o
338
372
  /status — Bot status and uptime
339
373
  /backup — Trigger GitHub backup
340
374
  /clean — Audit workspace
375
+ /verbose — Toggle verbose mode on/off
376
+ /toolimit — View or set max tool iterations
341
377
  /help — This message`);
342
378
  });
343
379
 
380
+ bot.command('verbose', async (ctx) => {
381
+ if (!ctx.from) return;
382
+ const tenant = await getTenant(ctx.from.id, config);
383
+ tenant.verbose = !tenant.verbose;
384
+ await ctx.reply(tenant.verbose ? 'šŸ” Verbose mode ON' : 'šŸ”‡ Verbose mode OFF');
385
+ });
386
+
387
+ bot.command('toolimit', async (ctx) => {
388
+ if (!ctx.from) return;
389
+ const args = ctx.message.text.split(' ').slice(1);
390
+ const current = getMaxToolIterations();
391
+
392
+ if (!args[0]) {
393
+ await ctx.reply(`šŸ”§ Max tool iterations: ${current}\n\nThis limits how many tool calls OBOL can make per message. Higher = more complex tasks, but slower responses.\n\nSet: /toolimit <number>\nExample: /toolimit 50`);
394
+ return;
395
+ }
396
+
397
+ const value = parseInt(args[0], 10);
398
+ if (isNaN(value) || value < 1 || value > 500) {
399
+ await ctx.reply(`Invalid value: "${args[0]}"\n\nMust be a number between 1 and 500.\nCurrent: ${current}\n\nExample: /toolimit 50`);
400
+ return;
401
+ }
402
+
403
+ setMaxToolIterations(value);
404
+ await ctx.reply(`šŸ”§ Max tool iterations set to ${value}`);
405
+ });
406
+
344
407
  function checkRateLimit(userId) {
345
408
  const now = Date.now();
346
409
  const userLimit = rateLimits.get(userId) || { lastMessage: 0, spamCount: 0, cooldownUntil: 0 };
@@ -392,7 +455,7 @@ Your message is deleted immediately when using /secret set to keep credentials o
392
455
  try {
393
456
  tenant.messageLog?.log(ctx.chat.id, 'user', userMessage);
394
457
 
395
- const response = await tenant.claude.chat(userMessage, {
458
+ const chatContext = {
396
459
  userId,
397
460
  userName,
398
461
  chatId: ctx.chat.id,
@@ -400,14 +463,24 @@ Your message is deleted immediately when using /secret set to keep credentials o
400
463
  ctx,
401
464
  claude: tenant.claude,
402
465
  config,
466
+ verbose: tenant.verbose,
467
+ telegramAsk: (message, options, timeout) => createAsk(ctx, message, options, timeout),
403
468
  _notifyFn: (targetUserId, message) => {
404
469
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
405
470
  return bot.api.sendMessage(targetUserId, message);
406
471
  },
407
- });
472
+ };
473
+ const response = await tenant.claude.chat(userMessage, chatContext);
408
474
 
409
475
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response);
410
476
 
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
+
411
484
  if (tenant.messageLog?._evolutionReady) {
412
485
  tenant.messageLog._evolutionReady = false;
413
486
  setImmediate(async () => {
@@ -525,7 +598,7 @@ Your message is deleted immediately when using /secret set to keep credentials o
525
598
  if (media.isImage(fileInfo)) {
526
599
  const imageBlock = media.bufferToImageBlock(buffer, fileInfo.mimeType);
527
600
  const prompt = caption || 'The user sent this image. Describe what you see and respond naturally.';
528
- const response = await tenant.claude.chat(prompt, {
601
+ const mediaChatCtx = {
529
602
  userId,
530
603
  userName: ctx.from.first_name || 'User',
531
604
  chatId: ctx.chat.id,
@@ -533,16 +606,23 @@ Your message is deleted immediately when using /secret set to keep credentials o
533
606
  ctx,
534
607
  claude: tenant.claude,
535
608
  config,
609
+ verbose: tenant.verbose,
536
610
  images: [imageBlock],
537
611
  _notifyFn: (targetUserId, message) => {
538
612
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
539
613
  return bot.api.sendMessage(targetUserId, message);
540
614
  },
541
- });
615
+ };
616
+ const response = await tenant.claude.chat(prompt, mediaChatCtx);
542
617
 
543
618
  tenant.messageLog?.log(ctx.chat.id, 'user', `[${fileInfo.mediaType}] ${caption || filename}`);
544
619
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response);
545
620
 
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
+
546
626
  stopTyping();
547
627
  if (response.length > 4096) {
548
628
  for (const chunk of splitMessage(response, 4096)) {
@@ -553,7 +633,7 @@ Your message is deleted immediately when using /secret set to keep credentials o
553
633
  }
554
634
  } else if (caption) {
555
635
  const contextMsg = `[User sent a ${fileInfo.mediaType}: ${filename}] ${caption}`;
556
- const response = await tenant.claude.chat(contextMsg, {
636
+ const mediaCaptionCtx = {
557
637
  userId,
558
638
  userName: ctx.from.first_name || 'User',
559
639
  chatId: ctx.chat.id,
@@ -561,15 +641,22 @@ Your message is deleted immediately when using /secret set to keep credentials o
561
641
  ctx,
562
642
  claude: tenant.claude,
563
643
  config,
644
+ verbose: tenant.verbose,
564
645
  _notifyFn: (targetUserId, message) => {
565
646
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
566
647
  return bot.api.sendMessage(targetUserId, message);
567
648
  },
568
- });
649
+ };
650
+ const response = await tenant.claude.chat(contextMsg, mediaCaptionCtx);
569
651
 
570
652
  tenant.messageLog?.log(ctx.chat.id, 'user', contextMsg);
571
653
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response);
572
654
 
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
+
573
660
  stopTyping();
574
661
  if (response.length > 4096) {
575
662
  for (const chunk of splitMessage(response, 4096)) {
@@ -598,6 +685,22 @@ Your message is deleted immediately when using /secret set to keep credentials o
598
685
  bot.on('message:animation', handleMedia);
599
686
  bot.on('message:video_note', handleMedia);
600
687
 
688
+ bot.on('callback_query:data', async (ctx) => {
689
+ const data = ctx.callbackQuery.data;
690
+ if (!data.startsWith('ask:')) return ctx.answerCallbackQuery();
691
+ const parts = data.split(':');
692
+ const askId = parseInt(parts[1]);
693
+ const optIdx = parseInt(parts[2]);
694
+ const pending = pendingAsks.get(askId);
695
+ if (!pending) return ctx.answerCallbackQuery({ text: 'Expired' });
696
+ const selected = pending.options[optIdx];
697
+ await ctx.answerCallbackQuery({ text: selected });
698
+ clearTimeout(pending.timer);
699
+ pendingAsks.delete(askId);
700
+ ctx.editMessageText(`${ctx.callbackQuery.message.text}\n\nāœ“ _${selected}_`, { parse_mode: 'Markdown' }).catch(() => {});
701
+ pending.resolve(selected);
702
+ });
703
+
601
704
  bot.catch((err) => {
602
705
  const ctx = err.ctx;
603
706
  const e = err.error;
package/src/tenant.js CHANGED
@@ -43,6 +43,7 @@ async function createTenant(userId, config) {
43
43
 
44
44
  return {
45
45
  claude, memory, messageLog, personality, bg, userDir, userId,
46
+ verbose: false,
46
47
  _personalityLoadedAt: Date.now(),
47
48
  _personalityMtime: personalityMtime,
48
49
  };