obol-ai 0.2.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.2.2",
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": {
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
 
@@ -227,6 +250,7 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
227
250
  }
228
251
  break;
229
252
  }
253
+ repairHistory(history);
230
254
 
231
255
  // Add user message with memory context
232
256
  const enrichedMessage = memoryContext
@@ -241,8 +265,8 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
241
265
  history.push({ role: 'user', content: enrichedMessage });
242
266
  }
243
267
 
244
- // Call Claude — Haiku picks the model
245
268
  const model = context._model || 'claude-sonnet-4-6';
269
+ vlog(`[model] ${model} | history=${history.length} msgs`);
246
270
  const systemPrompt = baseSystemPrompt + `\nCurrent time: ${new Date().toISOString()}`;
247
271
  let response = await client.messages.create({
248
272
  model,
@@ -256,8 +280,15 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
256
280
  while (response.stop_reason === 'tool_use') {
257
281
  toolIterations++;
258
282
  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.' });
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
+ ] });
261
292
  response = await client.messages.create({
262
293
  model,
263
294
  max_tokens: 4096,
@@ -273,6 +304,15 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
273
304
  const toolResults = [];
274
305
  for (const block of assistantContent) {
275
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}`);
276
316
  const result = await executeToolCall(block, memory, context);
277
317
  toolResults.push({
278
318
  type: 'tool_result',
@@ -293,11 +333,13 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
293
333
  });
294
334
  }
295
335
 
296
- // Extract text response
297
336
  const textBlocks = response.content.filter(b => b.type === 'text');
298
337
  const replyText = textBlocks.map(b => b.text).join('\n');
299
338
 
300
- // Add assistant response to history
339
+ if (response.usage) {
340
+ vlog(`[tokens] in=${response.usage.input_tokens} out=${response.usage.output_tokens}`);
341
+ }
342
+
301
343
  history.push({ role: 'assistant', content: response.content });
302
344
 
303
345
  return replyText;
@@ -618,6 +660,20 @@ function buildTools(memory, opts = {}) {
618
660
  },
619
661
  });
620
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
+
621
677
  if (opts.bridgeEnabled) {
622
678
  const { buildBridgeTool, buildBridgeTellTool } = require('./bridge');
623
679
  tools.push(buildBridgeTool());
@@ -832,6 +888,11 @@ async function executeToolCall(toolUse, memory, context = {}) {
832
888
  return `Sent: ${path.basename(filePath)}`;
833
889
  }
834
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
+
835
896
  case 'bridge_ask': {
836
897
  const { bridgeAsk } = require('./bridge');
837
898
  return await bridgeAsk(input.question, context.userId, context.config, context._notifyFn, input.partner_id);
@@ -850,4 +911,7 @@ async function executeToolCall(toolUse, memory, context = {}) {
850
911
  }
851
912
  }
852
913
 
853
- 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 };
@@ -44,6 +44,16 @@ Users can also manage secrets via Telegram: `/secret set <key> <value>` (message
44
44
  ### Send File (`send_file`)
45
45
  Send a file back to the user via Telegram. Use after generating PDFs, images, documents, or any file the user requested.
46
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
+
47
57
  ### Bridge (`bridge_ask`, `bridge_tell`)
48
58
  Only available if bridge is enabled. Communicate with partner's AI agent.
49
59
 
@@ -138,6 +148,29 @@ Use `background_task` when a request will take multiple steps:
138
148
 
139
149
  Pattern: acknowledge immediately ("On it"), spawn the task, let it work in the background.
140
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
+
141
174
  ## Communication Style
142
175
 
143
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);
@@ -338,9 +367,38 @@ Your message is deleted immediately when using /secret set to keep credentials o
338
367
  /status — Bot status and uptime
339
368
  /backup — Trigger GitHub backup
340
369
  /clean — Audit workspace
370
+ /verbose — Toggle verbose mode on/off
371
+ /toolimit — View or set max tool iterations
341
372
  /help — This message`);
342
373
  });
343
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
+
344
402
  function checkRateLimit(userId) {
345
403
  const now = Date.now();
346
404
  const userLimit = rateLimits.get(userId) || { lastMessage: 0, spamCount: 0, cooldownUntil: 0 };
@@ -392,7 +450,7 @@ Your message is deleted immediately when using /secret set to keep credentials o
392
450
  try {
393
451
  tenant.messageLog?.log(ctx.chat.id, 'user', userMessage);
394
452
 
395
- const response = await tenant.claude.chat(userMessage, {
453
+ const chatContext = {
396
454
  userId,
397
455
  userName,
398
456
  chatId: ctx.chat.id,
@@ -400,14 +458,24 @@ Your message is deleted immediately when using /secret set to keep credentials o
400
458
  ctx,
401
459
  claude: tenant.claude,
402
460
  config,
461
+ verbose: tenant.verbose,
462
+ telegramAsk: (message, options, timeout) => createAsk(ctx, message, options, timeout),
403
463
  _notifyFn: (targetUserId, message) => {
404
464
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
405
465
  return bot.api.sendMessage(targetUserId, message);
406
466
  },
407
- });
467
+ };
468
+ const response = await tenant.claude.chat(userMessage, chatContext);
408
469
 
409
470
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response);
410
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
+
411
479
  if (tenant.messageLog?._evolutionReady) {
412
480
  tenant.messageLog._evolutionReady = false;
413
481
  setImmediate(async () => {
@@ -525,7 +593,7 @@ Your message is deleted immediately when using /secret set to keep credentials o
525
593
  if (media.isImage(fileInfo)) {
526
594
  const imageBlock = media.bufferToImageBlock(buffer, fileInfo.mimeType);
527
595
  const prompt = caption || 'The user sent this image. Describe what you see and respond naturally.';
528
- const response = await tenant.claude.chat(prompt, {
596
+ const mediaChatCtx = {
529
597
  userId,
530
598
  userName: ctx.from.first_name || 'User',
531
599
  chatId: ctx.chat.id,
@@ -533,16 +601,23 @@ Your message is deleted immediately when using /secret set to keep credentials o
533
601
  ctx,
534
602
  claude: tenant.claude,
535
603
  config,
604
+ verbose: tenant.verbose,
536
605
  images: [imageBlock],
537
606
  _notifyFn: (targetUserId, message) => {
538
607
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
539
608
  return bot.api.sendMessage(targetUserId, message);
540
609
  },
541
- });
610
+ };
611
+ const response = await tenant.claude.chat(prompt, mediaChatCtx);
542
612
 
543
613
  tenant.messageLog?.log(ctx.chat.id, 'user', `[${fileInfo.mediaType}] ${caption || filename}`);
544
614
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response);
545
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
+
546
621
  stopTyping();
547
622
  if (response.length > 4096) {
548
623
  for (const chunk of splitMessage(response, 4096)) {
@@ -553,7 +628,7 @@ Your message is deleted immediately when using /secret set to keep credentials o
553
628
  }
554
629
  } else if (caption) {
555
630
  const contextMsg = `[User sent a ${fileInfo.mediaType}: ${filename}] ${caption}`;
556
- const response = await tenant.claude.chat(contextMsg, {
631
+ const mediaCaptionCtx = {
557
632
  userId,
558
633
  userName: ctx.from.first_name || 'User',
559
634
  chatId: ctx.chat.id,
@@ -561,15 +636,22 @@ Your message is deleted immediately when using /secret set to keep credentials o
561
636
  ctx,
562
637
  claude: tenant.claude,
563
638
  config,
639
+ verbose: tenant.verbose,
564
640
  _notifyFn: (targetUserId, message) => {
565
641
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
566
642
  return bot.api.sendMessage(targetUserId, message);
567
643
  },
568
- });
644
+ };
645
+ const response = await tenant.claude.chat(contextMsg, mediaCaptionCtx);
569
646
 
570
647
  tenant.messageLog?.log(ctx.chat.id, 'user', contextMsg);
571
648
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response);
572
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
+
573
655
  stopTyping();
574
656
  if (response.length > 4096) {
575
657
  for (const chunk of splitMessage(response, 4096)) {
@@ -598,6 +680,22 @@ Your message is deleted immediately when using /secret set to keep credentials o
598
680
  bot.on('message:animation', handleMedia);
599
681
  bot.on('message:video_note', handleMedia);
600
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
+
601
699
  bot.catch((err) => {
602
700
  const ctx = err.ctx;
603
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
  };