obol-ai 0.2.1 → 0.2.3

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/README.md CHANGED
@@ -30,7 +30,7 @@ OBOL is an AI agent that evolves its own personality, rewrites its own code, tes
30
30
 
31
31
  It starts as a blank slate. Through conversation it learns who you are, develops a personality shaped by your interactions, and builds operational knowledge about how to work with you. Every 100 exchanges it reflects on who it's becoming, refactors its own scripts, writes tests, fixes regressions, and builds you new tools based on patterns it spots in your conversations — scripts, commands, or full web apps deployed to Vercel. Over months it becomes an agent that's uniquely yours. No two OBOL instances are alike.
32
32
 
33
- One bot, multiple users. Each allowed Telegram user gets a fully isolated context — their own personality, memory, evolution cycle, workspace, and first-run experience. User A's personality drift, scripts, and memories never leak into User B's. Everything runs in a single process with shared API credentials.
33
+ One bot, multiple users. Each allowed Telegram user gets a fully isolated context — their own personality, memory, evolution cycle, and workspace. User A's personality drift, scripts, and memories never leak into User B's. Everything runs in a single process with shared API credentials.
34
34
 
35
35
  Under the hood: Node.js + Telegram + Claude + Supabase pgvector. No framework, no plugins, no config to maintain. It backs up its brain to GitHub and hardens your server automatically.
36
36
 
@@ -156,7 +156,7 @@ Refined voice, updated your project list, cleaned up 2 unused scripts.
156
156
 
157
157
  ```
158
158
  Day 1: obol init → obol start → first conversation
159
- → OBOL asks 2-3 questions, writes SOUL.md + USER.md
159
+ → OBOL responds naturally from message one
160
160
  → post-setup hardens your VPS automatically
161
161
 
162
162
  Day 2: Every 5 messages → Haiku extracts facts to vector memory
@@ -224,8 +224,7 @@ Router: ctx.from.id → tenant context
224
224
  | GitHub token | Evolution cycle + state |
225
225
  | Vercel token | Scripts, tests, commands, apps |
226
226
  | VPS hardening | Workspace directory (`~/.obol/users/{id}/`) |
227
- | Process manager (pm2) | First-run onboarding experience |
228
- | | GitHub backup (per-user repo dir) |
227
+ | Process manager (pm2) | GitHub backup (per-user repo dir) |
229
228
 
230
229
  ### Tenant routing
231
230
 
@@ -244,11 +243,13 @@ When users store secrets via the `pass` encrypted store, each user gets their ow
244
243
  | Shared bot credentials | `obol/` | `obol/anthropic-key` |
245
244
  | User secrets | `obol/users/{id}/` | `obol/users/206639616/gmail-key` |
246
245
 
246
+ Users manage their own secrets via Telegram: `/secret set <key> <value>` (message auto-deleted for safety), `/secret list`, `/secret remove <key>`. The agent can also read/write secrets via tools for scripts that need API keys at runtime.
247
+
247
248
  ### Adding users
248
249
 
249
250
  1. Add their Telegram user ID to `allowedUsers` in `~/.obol/config.json` (or run `obol config`)
250
251
  2. Restart the bot
251
- 3. They message the bot → OBOL creates their workspace, runs first-run onboarding, and writes their own SOUL.md + USER.md
252
+ 3. They message the bot → OBOL creates their workspace and starts responding immediately. Personality files are created during their first evolution cycle.
252
253
 
253
254
  Each new user starts fresh. Their bot evolves independently from every other user's.
254
255
 
@@ -339,7 +340,7 @@ For Telegram user IDs, OBOL auto-detects by checking who messaged the bot. Just
339
340
 
340
341
  ### First Conversation
341
342
 
342
- Send your first message. OBOL introduces itself, asks 2-3 questions, then writes its own SOUL.md and USER.md. After that, it hardens your VPS and reports progress directly in the Telegram chat (Linux only — skipped on macOS/Windows):
343
+ Send your first message. OBOL responds naturally no onboarding flow, it works from message one. Personality files (SOUL.md, USER.md) are created during the first evolution cycle. After first boot, it hardens your VPS and reports progress directly in the Telegram chat (Linux only — skipped on macOS/Windows):
343
344
 
344
345
  | Task | What |
345
346
  |------|------|
@@ -445,11 +446,18 @@ Or edit `~/.obol/config.json` directly:
445
446
  ## Telegram Commands
446
447
 
447
448
  ```
448
- /new — Fresh conversation
449
- /tasks Running background tasks
450
- /status Uptime and memory stats
451
- /backup Trigger GitHub backup
452
- /clean Audit workspace, remove rogue files, fix misplaced items
449
+ /new — Fresh conversation
450
+ /memory Search or view memory stats
451
+ /recent Last 10 memories
452
+ /today Today's memories
453
+ /tasks Running background tasks
454
+ /status — Bot status, uptime, evolution progress, traits
455
+ /backup — Trigger GitHub backup
456
+ /clean — Audit workspace, remove rogue files, fix misplaced items
457
+ /traits — View or adjust personality traits (0-100)
458
+ /secret — Manage per-user encrypted secrets
459
+ /evolution — Evolution progress
460
+ /help — Show available commands
453
461
  ```
454
462
 
455
463
  Everything else is natural conversation.
@@ -468,6 +476,7 @@ obol logs # Tail logs (pm2 or log file fallback)
468
476
  obol status # Status
469
477
  obol backup # Manual backup
470
478
  obol upgrade # Update to latest version
479
+ obol delete # Full VPS cleanup (removes all OBOL data)
471
480
  ```
472
481
 
473
482
  ## Directory Structure
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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": {
@@ -29,7 +29,8 @@
29
29
  "grammy": "^1.35.0",
30
30
  "inquirer": "^8.2.6",
31
31
  "node-cron": "^3.0.3",
32
- "open": "^8.4.2"
32
+ "open": "^8.4.2",
33
+ "pdfkit": "^0.17.2"
33
34
  },
34
35
  "engines": {
35
36
  "node": ">=18"
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,6 +124,24 @@ async function ensureFreshToken(anthropicConfig) {
124
124
  }
125
125
  }
126
126
 
127
+ function repairHistory(history) {
128
+ for (let i = 0; i < history.length; i++) {
129
+ const msg = history[i];
130
+ if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue;
131
+ const toolUseIds = msg.content.filter(b => b.type === 'tool_use').map(b => b.id);
132
+ if (toolUseIds.length === 0) continue;
133
+ const next = history[i + 1];
134
+ const hasResults = next?.role === 'user' && Array.isArray(next.content) &&
135
+ toolUseIds.every(id => next.content.some(b => b.type === 'tool_result' && b.tool_use_id === id));
136
+ if (!hasResults) {
137
+ const fakeResults = toolUseIds.map(id => ({
138
+ type: 'tool_result', tool_use_id: id, content: '[interrupted]',
139
+ }));
140
+ history.splice(i + 1, 0, { role: 'user', content: fakeResults });
141
+ }
142
+ }
143
+ }
144
+
127
145
  function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR, bridgeEnabled }) {
128
146
  let client = createAnthropicClient(anthropicConfig);
129
147
 
@@ -151,7 +169,10 @@ function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR
151
169
  if (!histories.has(chatId)) histories.set(chatId, []);
152
170
  const history = histories.get(chatId);
153
171
 
154
- // Ask Haiku if we need memory for this message
172
+ const verbose = context.verbose || false;
173
+ if (verbose) context.verboseLog = [];
174
+ const vlog = (msg) => { if (verbose) context.verboseLog.push(msg); };
175
+
155
176
  let memoryContext = '';
156
177
  if (memory) {
157
178
  try {
@@ -179,7 +200,8 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
179
200
  if (jsonStr) decision = JSON.parse(jsonStr);
180
201
  } catch {}
181
202
 
182
- // Set model based on Haiku's decision
203
+ vlog(`[router] model=${decision.model || 'sonnet'} memory=${decision.need_memory || false}${decision.search_query ? ` query="${decision.search_query}"` : ''}`);
204
+
183
205
  if (decision.model === 'opus') {
184
206
  context._model = 'claude-opus-4-6';
185
207
  }
@@ -187,11 +209,9 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
187
209
  if (decision.need_memory) {
188
210
  const query = decision.search_query || userMessage;
189
211
 
190
- // Today's context + semantic search
191
212
  const todayMemories = await memory.byDate('today', { limit: 3 });
192
213
  const semanticMemories = await memory.search(query, { limit: 3, threshold: 0.5 });
193
214
 
194
- // Dedupe by ID
195
215
  const seen = new Set();
196
216
  const combined = [];
197
217
  for (const m of [...todayMemories, ...semanticMemories]) {
@@ -201,6 +221,8 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
201
221
  }
202
222
  }
203
223
 
224
+ vlog(`[memory] ${combined.length} memories found (${todayMemories.length} today, ${semanticMemories.length} semantic)`);
225
+
204
226
  if (combined.length > 0) {
205
227
  memoryContext = '\n\n[Relevant memories]\n' +
206
228
  combined.map(m => `- [${m.category}] ${m.content}`).join('\n');
@@ -208,6 +230,7 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
208
230
  }
209
231
  } catch (e) {
210
232
  console.error('[router] Memory/routing decision failed:', e.message);
233
+ vlog(`[router] ERROR: ${e.message}`);
211
234
  }
212
235
  }
213
236
 
@@ -215,9 +238,19 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
215
238
  history.shift();
216
239
  history.shift();
217
240
  }
218
- if (history.length > 0 && history[0].role !== 'user') {
219
- history.shift();
241
+ while (history.length > 0) {
242
+ const first = history[0];
243
+ if (first.role !== 'user') {
244
+ history.shift();
245
+ continue;
246
+ }
247
+ if (Array.isArray(first.content) && first.content.some(b => b.type === 'tool_result')) {
248
+ history.shift();
249
+ continue;
250
+ }
251
+ break;
220
252
  }
253
+ repairHistory(history);
221
254
 
222
255
  // Add user message with memory context
223
256
  const enrichedMessage = memoryContext
@@ -232,8 +265,8 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
232
265
  history.push({ role: 'user', content: enrichedMessage });
233
266
  }
234
267
 
235
- // Call Claude — Haiku picks the model
236
268
  const model = context._model || 'claude-sonnet-4-6';
269
+ vlog(`[model] ${model} | history=${history.length} msgs`);
237
270
  const systemPrompt = baseSystemPrompt + `\nCurrent time: ${new Date().toISOString()}`;
238
271
  let response = await client.messages.create({
239
272
  model,
@@ -247,8 +280,15 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
247
280
  while (response.stop_reason === 'tool_use') {
248
281
  toolIterations++;
249
282
  if (toolIterations > MAX_TOOL_ITERATIONS) {
250
- history.push({ role: 'assistant', content: response.content });
251
- 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.' });
283
+ const bailoutContent = response.content;
284
+ history.push({ role: 'assistant', content: bailoutContent });
285
+ const bailoutResults = bailoutContent
286
+ .filter(b => b.type === 'tool_use')
287
+ .map(b => ({ type: 'tool_result', tool_use_id: b.id, content: '[max tool iterations reached]' }));
288
+ history.push({ role: 'user', content: [
289
+ ...bailoutResults,
290
+ { type: 'text', text: 'You have used too many tool calls. Please provide a final response now based on what you have so far.' },
291
+ ] });
252
292
  response = await client.messages.create({
253
293
  model,
254
294
  max_tokens: 4096,
@@ -264,6 +304,15 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
264
304
  const toolResults = [];
265
305
  for (const block of assistantContent) {
266
306
  if (block.type === 'tool_use') {
307
+ const inputSummary = block.name === 'exec' ? block.input.command :
308
+ block.name === 'write_file' ? block.input.path :
309
+ block.name === 'read_file' ? block.input.path :
310
+ block.name === 'memory_search' ? block.input.query :
311
+ block.name === 'memory_add' ? `[${block.input.category || 'fact'}]` :
312
+ block.name === 'web_fetch' ? block.input.url :
313
+ block.name === 'background_task' ? block.input.task?.substring(0, 60) :
314
+ JSON.stringify(block.input).substring(0, 80);
315
+ vlog(`[tool] ${block.name}: ${inputSummary}`);
267
316
  const result = await executeToolCall(block, memory, context);
268
317
  toolResults.push({
269
318
  type: 'tool_result',
@@ -284,11 +333,13 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
284
333
  });
285
334
  }
286
335
 
287
- // Extract text response
288
336
  const textBlocks = response.content.filter(b => b.type === 'text');
289
337
  const replyText = textBlocks.map(b => b.text).join('\n');
290
338
 
291
- // Add assistant response to history
339
+ if (response.usage) {
340
+ vlog(`[tokens] in=${response.usage.input_tokens} out=${response.usage.output_tokens}`);
341
+ }
342
+
292
343
  history.push({ role: 'assistant', content: response.content });
293
344
 
294
345
  return replyText;
@@ -310,7 +361,13 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
310
361
  }
311
362
  }
312
363
 
313
- return { chat, client, reloadPersonality, clearHistory };
364
+ function injectHistory(chatId, role, content) {
365
+ if (!histories.has(chatId)) histories.set(chatId, []);
366
+ const history = histories.get(chatId);
367
+ history.push({ role, content });
368
+ }
369
+
370
+ return { chat, client, reloadPersonality, clearHistory, injectHistory };
314
371
  }
315
372
 
316
373
  function buildSystemPrompt(personality, userDir, opts = {}) {
@@ -392,6 +449,7 @@ Use the \`store_secret\`, \`read_secret\`, and \`list_secrets\` tools for all us
392
449
  These store secrets under the prefix \`${passPrefix}/\` in pass (or JSON fallback).
393
450
 
394
451
  Users can also manage secrets via Telegram: \`/secret set <key> <value>\` (message auto-deleted), \`/secret list\`, \`/secret remove <key>\`.
452
+ Since users can store secrets via /secret outside your conversation, ALWAYS call \`list_secrets\` to check what's available before telling the user their credentials aren't stored.
395
453
 
396
454
  Shared bot credentials live under \`obol/\` — do NOT touch or re-create these:
397
455
  \`obol/anthropic-key\`, \`obol/telegram-token\`, \`obol/supabase-url\`, \`obol/supabase-key\`, \`obol/github-token\`, \`obol/vercel-token\`
@@ -589,6 +647,33 @@ function buildTools(memory, opts = {}) {
589
647
  },
590
648
  });
591
649
 
650
+ tools.push({
651
+ name: 'send_file',
652
+ description: 'Send a file to the user via Telegram (PDF, image, document, etc). Use after generating files the user requested.',
653
+ input_schema: {
654
+ type: 'object',
655
+ properties: {
656
+ path: { type: 'string', description: 'Path to the file to send' },
657
+ caption: { type: 'string', description: 'Optional caption for the file' },
658
+ },
659
+ required: ['path'],
660
+ },
661
+ });
662
+
663
+ tools.push({
664
+ name: 'telegram_ask',
665
+ 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.',
666
+ input_schema: {
667
+ type: 'object',
668
+ properties: {
669
+ message: { type: 'string', description: 'Question or prompt to show the user' },
670
+ options: { type: 'array', items: { type: 'string' }, description: 'Button labels (2-6 options, keep each label short)' },
671
+ timeout: { type: 'number', description: 'Seconds to wait for response (default 60)' },
672
+ },
673
+ required: ['message', 'options'],
674
+ },
675
+ });
676
+
592
677
  if (opts.bridgeEnabled) {
593
678
  const { buildBridgeTool, buildBridgeTellTool } = require('./bridge');
594
679
  tools.push(buildBridgeTool());
@@ -791,6 +876,23 @@ async function executeToolCall(toolUse, memory, context = {}) {
791
876
  return keys.join('\n');
792
877
  }
793
878
 
879
+ case 'send_file': {
880
+ const filePath = userDir ? resolveUserPath(input.path, userDir) : input.path;
881
+ if (!fs.existsSync(filePath)) return `File not found: ${filePath}`;
882
+ const telegramCtx = context.ctx;
883
+ if (!telegramCtx) return 'Cannot send files in this context.';
884
+ const { InputFile } = require('grammy');
885
+ await telegramCtx.replyWithDocument(new InputFile(filePath), {
886
+ caption: input.caption || undefined,
887
+ });
888
+ return `Sent: ${path.basename(filePath)}`;
889
+ }
890
+
891
+ case 'telegram_ask': {
892
+ if (!context.telegramAsk) return 'telegram_ask not available in this context.';
893
+ return await context.telegramAsk(input.message, input.options || [], input.timeout);
894
+ }
895
+
794
896
  case 'bridge_ask': {
795
897
  const { bridgeAsk } = require('./bridge');
796
898
  return await bridgeAsk(input.question, context.userId, context.config, context._notifyFn, input.partner_id);
@@ -809,4 +911,7 @@ async function executeToolCall(toolUse, memory, context = {}) {
809
911
  }
810
912
  }
811
913
 
812
- module.exports = { createClaude, createAnthropicClient };
914
+ function getMaxToolIterations() { return MAX_TOOL_ITERATIONS; }
915
+ function setMaxToolIterations(n) { MAX_TOOL_ITERATIONS = n; }
916
+
917
+ module.exports = { createClaude, createAnthropicClient, getMaxToolIterations, setMaxToolIterations };
@@ -41,6 +41,19 @@ Use these tools instead of `exec` for storing/reading secrets — they bypass th
41
41
 
42
42
  Users can also manage secrets via Telegram: `/secret set <key> <value>` (message auto-deleted), `/secret list`, `/secret remove <key>`.
43
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
+ ### Ask User (`telegram_ask`)
48
+ 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.
49
+
50
+ Examples:
51
+ - After listing emails: `telegram_ask({message: "Open any of these?", options: ["#1 Google", "#2 LinkedIn", "#3 DeepLearning", "None"]})`
52
+ - Before sending a reply: `telegram_ask({message: "Send this reply?", options: ["Send it", "Edit first", "Cancel"]})`
53
+ - Before an irreversible action: `telegram_ask({message: "Archive all read emails?", options: ["Yes", "No"]})`
54
+
55
+ Returns the tapped button label, or `"timeout"` if the user doesn't respond within the timeout (default 60s).
56
+
44
57
  ### Bridge (`bridge_ask`, `bridge_tell`)
45
58
  Only available if bridge is enabled. Communicate with partner's AI agent.
46
59
 
@@ -98,6 +111,19 @@ Rules:
98
111
  - Temporary files go in /tmp
99
112
  - If unsure where something belongs, ask
100
113
 
114
+ ## Self-Extending
115
+
116
+ 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.):
117
+
118
+ 1. **Write a script** to `scripts/` using `write_file`
119
+ 2. **Install dependencies** if needed via `exec`: `pip3 install fpdf2` or `npm install --prefix . pdfkit`
120
+ 3. **Run the script** via `exec`: `python3 scripts/generate-pdf.py` or `node scripts/generate-pdf.js`
121
+ 4. **Send the result** to the user with `send_file`
122
+
123
+ Don't tell the user you can't do something — create the capability, then use it.
124
+
125
+ Pre-installed npm packages you can `require()` in Node scripts: `pdfkit`
126
+
101
127
  ## Scripts & Service Integrations
102
128
 
103
129
  When building scripts (Gmail, Notion, APIs, etc.), prefer **Python**:
@@ -122,6 +148,29 @@ Use `background_task` when a request will take multiple steps:
122
148
 
123
149
  Pattern: acknowledge immediately ("On it"), spawn the task, let it work in the background.
124
150
 
151
+ ## Telegram Formatting
152
+
153
+ You communicate via Telegram. Format responses for mobile readability.
154
+
155
+ **Never use markdown tables** — pipe-syntax tables do not render in Telegram. Use numbered lists instead.
156
+
157
+ **Email/inbox lists** — use this pattern:
158
+ ```
159
+ 📬 *Inbox (10)*
160
+
161
+ 1\. *Google* — Security alert `22:58`
162
+ 2\. *LinkedIn* — Matthew Chittle wants to connect `21:31`
163
+ 3\. *DeepLearning\.AI* — AI Dev 26 × SF speakers `13:20`
164
+ 4\. *LinkedIn Jobs* — Project Manager / TPM roles `17:32`
165
+ ```
166
+
167
+ **Copyable values** (email addresses, URLs, API keys, commands) — wrap in backtick code spans:
168
+ `user@example.com`, `https://example.com`, `npm install foo`
169
+
170
+ **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.
171
+
172
+ **Keep lines short** — Telegram wraps long lines poorly on mobile. Break at natural points.
173
+
125
174
  ## Communication Style
126
175
 
127
176
  - Be direct and helpful
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,6 +130,7 @@ 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);
@@ -254,6 +283,11 @@ function createBot(telegramConfig, config) {
254
283
  ctx.api.deleteMessage(ctx.chat.id, ctx.message.message_id).catch(() => {});
255
284
  credentials.storeSecret(userId, key, value);
256
285
  await ctx.reply(`🔑 Secret "${key}" stored securely.`);
286
+ const tenant = await getTenant(userId, config);
287
+ if (tenant.claude?.injectHistory) {
288
+ tenant.claude.injectHistory(ctx.chat.id, 'user', `[System: user stored secret "${key}" via /secret set]`);
289
+ tenant.claude.injectHistory(ctx.chat.id, 'assistant', `Noted — secret "${key}" is now stored.`);
290
+ }
257
291
  } catch (e) {
258
292
  await ctx.reply(`⚠️ ${e.message}`);
259
293
  }
@@ -264,6 +298,11 @@ function createBot(telegramConfig, config) {
264
298
  try {
265
299
  credentials.removeSecret(userId, args[1]);
266
300
  await ctx.reply(`🗑️ Secret "${args[1]}" removed.`);
301
+ const tenant = await getTenant(userId, config);
302
+ if (tenant.claude?.injectHistory) {
303
+ tenant.claude.injectHistory(ctx.chat.id, 'user', `[System: user removed secret "${args[1]}" via /secret remove]`);
304
+ tenant.claude.injectHistory(ctx.chat.id, 'assistant', `Noted — secret "${args[1]}" has been removed.`);
305
+ }
267
306
  } catch (e) {
268
307
  await ctx.reply(`⚠️ ${e.message}`);
269
308
  }
@@ -328,9 +367,38 @@ Your message is deleted immediately when using /secret set to keep credentials o
328
367
  /status — Bot status and uptime
329
368
  /backup — Trigger GitHub backup
330
369
  /clean — Audit workspace
370
+ /verbose — Toggle verbose mode on/off
371
+ /toolimit — View or set max tool iterations
331
372
  /help — This message`);
332
373
  });
333
374
 
375
+ bot.command('verbose', async (ctx) => {
376
+ if (!ctx.from) return;
377
+ const tenant = await getTenant(ctx.from.id, config);
378
+ tenant.verbose = !tenant.verbose;
379
+ await ctx.reply(tenant.verbose ? '🔍 Verbose mode ON' : '🔇 Verbose mode OFF');
380
+ });
381
+
382
+ bot.command('toolimit', async (ctx) => {
383
+ if (!ctx.from) return;
384
+ const args = ctx.message.text.split(' ').slice(1);
385
+ const current = getMaxToolIterations();
386
+
387
+ if (!args[0]) {
388
+ 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`);
389
+ return;
390
+ }
391
+
392
+ const value = parseInt(args[0], 10);
393
+ if (isNaN(value) || value < 1 || value > 500) {
394
+ await ctx.reply(`Invalid value: "${args[0]}"\n\nMust be a number between 1 and 500.\nCurrent: ${current}\n\nExample: /toolimit 50`);
395
+ return;
396
+ }
397
+
398
+ setMaxToolIterations(value);
399
+ await ctx.reply(`🔧 Max tool iterations set to ${value}`);
400
+ });
401
+
334
402
  function checkRateLimit(userId) {
335
403
  const now = Date.now();
336
404
  const userLimit = rateLimits.get(userId) || { lastMessage: 0, spamCount: 0, cooldownUntil: 0 };
@@ -382,7 +450,7 @@ Your message is deleted immediately when using /secret set to keep credentials o
382
450
  try {
383
451
  tenant.messageLog?.log(ctx.chat.id, 'user', userMessage);
384
452
 
385
- const response = await tenant.claude.chat(userMessage, {
453
+ const chatContext = {
386
454
  userId,
387
455
  userName,
388
456
  chatId: ctx.chat.id,
@@ -390,14 +458,24 @@ Your message is deleted immediately when using /secret set to keep credentials o
390
458
  ctx,
391
459
  claude: tenant.claude,
392
460
  config,
461
+ verbose: tenant.verbose,
462
+ telegramAsk: (message, options, timeout) => createAsk(ctx, message, options, timeout),
393
463
  _notifyFn: (targetUserId, message) => {
394
464
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
395
465
  return bot.api.sendMessage(targetUserId, message);
396
466
  },
397
- });
467
+ };
468
+ const response = await tenant.claude.chat(userMessage, chatContext);
398
469
 
399
470
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response);
400
471
 
472
+ if (tenant.verbose && chatContext.verboseLog?.length) {
473
+ const verboseText = '```\n' + chatContext.verboseLog.join('\n') + '\n```';
474
+ await ctx.reply(verboseText, { parse_mode: 'Markdown' }).catch(() =>
475
+ ctx.reply(verboseText).catch(() => {})
476
+ );
477
+ }
478
+
401
479
  if (tenant.messageLog?._evolutionReady) {
402
480
  tenant.messageLog._evolutionReady = false;
403
481
  setImmediate(async () => {
@@ -515,7 +593,7 @@ Your message is deleted immediately when using /secret set to keep credentials o
515
593
  if (media.isImage(fileInfo)) {
516
594
  const imageBlock = media.bufferToImageBlock(buffer, fileInfo.mimeType);
517
595
  const prompt = caption || 'The user sent this image. Describe what you see and respond naturally.';
518
- const response = await tenant.claude.chat(prompt, {
596
+ const mediaChatCtx = {
519
597
  userId,
520
598
  userName: ctx.from.first_name || 'User',
521
599
  chatId: ctx.chat.id,
@@ -523,16 +601,23 @@ Your message is deleted immediately when using /secret set to keep credentials o
523
601
  ctx,
524
602
  claude: tenant.claude,
525
603
  config,
604
+ verbose: tenant.verbose,
526
605
  images: [imageBlock],
527
606
  _notifyFn: (targetUserId, message) => {
528
607
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
529
608
  return bot.api.sendMessage(targetUserId, message);
530
609
  },
531
- });
610
+ };
611
+ const response = await tenant.claude.chat(prompt, mediaChatCtx);
532
612
 
533
613
  tenant.messageLog?.log(ctx.chat.id, 'user', `[${fileInfo.mediaType}] ${caption || filename}`);
534
614
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response);
535
615
 
616
+ if (tenant.verbose && mediaChatCtx.verboseLog?.length) {
617
+ const verboseText = '```\n' + mediaChatCtx.verboseLog.join('\n') + '\n```';
618
+ await ctx.reply(verboseText, { parse_mode: 'Markdown' }).catch(() => ctx.reply(verboseText).catch(() => {}));
619
+ }
620
+
536
621
  stopTyping();
537
622
  if (response.length > 4096) {
538
623
  for (const chunk of splitMessage(response, 4096)) {
@@ -543,7 +628,7 @@ Your message is deleted immediately when using /secret set to keep credentials o
543
628
  }
544
629
  } else if (caption) {
545
630
  const contextMsg = `[User sent a ${fileInfo.mediaType}: ${filename}] ${caption}`;
546
- const response = await tenant.claude.chat(contextMsg, {
631
+ const mediaCaptionCtx = {
547
632
  userId,
548
633
  userName: ctx.from.first_name || 'User',
549
634
  chatId: ctx.chat.id,
@@ -551,15 +636,22 @@ Your message is deleted immediately when using /secret set to keep credentials o
551
636
  ctx,
552
637
  claude: tenant.claude,
553
638
  config,
639
+ verbose: tenant.verbose,
554
640
  _notifyFn: (targetUserId, message) => {
555
641
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
556
642
  return bot.api.sendMessage(targetUserId, message);
557
643
  },
558
- });
644
+ };
645
+ const response = await tenant.claude.chat(contextMsg, mediaCaptionCtx);
559
646
 
560
647
  tenant.messageLog?.log(ctx.chat.id, 'user', contextMsg);
561
648
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response);
562
649
 
650
+ if (tenant.verbose && mediaCaptionCtx.verboseLog?.length) {
651
+ const verboseText = '```\n' + mediaCaptionCtx.verboseLog.join('\n') + '\n```';
652
+ await ctx.reply(verboseText, { parse_mode: 'Markdown' }).catch(() => ctx.reply(verboseText).catch(() => {}));
653
+ }
654
+
563
655
  stopTyping();
564
656
  if (response.length > 4096) {
565
657
  for (const chunk of splitMessage(response, 4096)) {
@@ -588,6 +680,22 @@ Your message is deleted immediately when using /secret set to keep credentials o
588
680
  bot.on('message:animation', handleMedia);
589
681
  bot.on('message:video_note', handleMedia);
590
682
 
683
+ bot.on('callback_query:data', async (ctx) => {
684
+ const data = ctx.callbackQuery.data;
685
+ if (!data.startsWith('ask:')) return ctx.answerCallbackQuery();
686
+ const parts = data.split(':');
687
+ const askId = parseInt(parts[1]);
688
+ const optIdx = parseInt(parts[2]);
689
+ const pending = pendingAsks.get(askId);
690
+ if (!pending) return ctx.answerCallbackQuery({ text: 'Expired' });
691
+ const selected = pending.options[optIdx];
692
+ clearTimeout(pending.timer);
693
+ pendingAsks.delete(askId);
694
+ await ctx.editMessageText(`${ctx.callbackQuery.message.text}\n\n✓ _${selected}_`, { parse_mode: 'Markdown' }).catch(() => {});
695
+ await ctx.answerCallbackQuery({ text: selected });
696
+ pending.resolve(selected);
697
+ });
698
+
591
699
  bot.catch((err) => {
592
700
  const ctx = err.ctx;
593
701
  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
  };