obol-ai 0.2.9 → 0.2.11

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.
@@ -15,7 +15,8 @@
15
15
  "Bash(git -C /Users/jovinkenroye/Sites/obol add:*)",
16
16
  "Bash(git -C:*)",
17
17
  "Bash(pass ls:*)",
18
- "mcp__context7__query-docs"
18
+ "mcp__context7__query-docs",
19
+ "Bash(git stash pop:*)"
19
20
  ]
20
21
  }
21
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
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": {
@@ -30,6 +30,7 @@
30
30
  "inquirer": "^8.2.6",
31
31
  "node-cron": "^3.0.3",
32
32
  "open": "^8.4.2",
33
+ "pdf-parse": "^2.4.5",
33
34
  "pdfkit": "^0.17.2"
34
35
  },
35
36
  "engines": {
package/src/claude.js CHANGED
@@ -8,7 +8,7 @@ const { execAsync, isAllowedUrl } = require('./sanitize');
8
8
  const { ChatHistory } = require('./history');
9
9
 
10
10
  const MAX_EXEC_TIMEOUT = 120;
11
- let MAX_TOOL_ITERATIONS = 100;
11
+ let MAX_TOOL_ITERATIONS = 10;
12
12
 
13
13
  const BLOCKED_EXEC_PATTERNS = [
14
14
  /\brm\s+(-[a-zA-Z]*f|-[a-zA-Z]*r|--force|--recursive)\b/,
@@ -204,6 +204,12 @@ Model: Default to "sonnet". Use "haiku" for: greetings, brief acknowledgments (t
204
204
 
205
205
  vlog(`[router] model=${decision.model || 'sonnet'} memory=${decision.need_memory || false}${decision.search_query ? ` query="${decision.search_query}"` : ''}`);
206
206
 
207
+ context._onRouteDecision?.({
208
+ model: decision.model || 'sonnet',
209
+ needMemory: decision.need_memory || false,
210
+ memoryCount: 0,
211
+ });
212
+
207
213
  if (decision.model === 'opus') {
208
214
  context._model = 'claude-opus-4-6';
209
215
  } else if (decision.model === 'haiku') {
@@ -227,6 +233,8 @@ Model: Default to "sonnet". Use "haiku" for: greetings, brief acknowledgments (t
227
233
 
228
234
  vlog(`[memory] ${combined.length} memories found (${todayMemories.length} today, ${semanticMemories.length} semantic)`);
229
235
 
236
+ context._onRouteUpdate?.({ memoryCount: combined.length });
237
+
230
238
  if (combined.length > 0) {
231
239
  memoryContext = '\n\n[Relevant memories]\n' +
232
240
  combined.map(m => `- [${m.category}] ${m.content}`).join('\n');
@@ -298,7 +306,22 @@ Model: Default to "sonnet". Use "haiku" for: greetings, brief acknowledgments (t
298
306
  return { text, usage: totalUsage, model };
299
307
  }
300
308
 
301
- const text = finalMessage.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
309
+ let text = finalMessage.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
310
+
311
+ if (!text.trim() && newMessages.length > 1) {
312
+ vlog('[claude] No text in final response after tool use — forcing summary');
313
+ histories.pushUser(chatId, 'Provide a concise response to the user based on the tool results above.');
314
+ const summaryResponse = await client.messages.create({
315
+ model, max_tokens: 4096, system: systemPrompt, messages: [...histories.get(chatId)],
316
+ }, { signal: abortController.signal });
317
+ histories.pushAssistant(chatId, summaryResponse.content);
318
+ if (summaryResponse.usage) {
319
+ totalUsage.input_tokens += summaryResponse.usage.input_tokens || 0;
320
+ totalUsage.output_tokens += summaryResponse.usage.output_tokens || 0;
321
+ }
322
+ text = summaryResponse.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
323
+ }
324
+
302
325
  return { text, usage: totalUsage, model };
303
326
 
304
327
  } catch (e) {
@@ -537,26 +560,39 @@ Only available if bridge is enabled. Communicate with partner's AI agent.
537
560
  parts.push(`
538
561
  ## Telegram Formatting
539
562
 
540
- You communicate via Telegram. Format responses for mobile readability.
541
-
542
- **Never use markdown tables** — pipe-syntax tables do not render in Telegram. Use numbered lists instead.
543
-
544
- **Email/inbox lists** — use this pattern:
545
- \`\`\`
563
+ You communicate via Telegram. Use ONLY Telegram Markdown syntax — never GitHub-flavored Markdown.
564
+
565
+ ALLOWED formatting:
566
+ - *bold* (single asterisks)
567
+ - _italic_ (underscores)
568
+ - \`inline code\` (backticks)
569
+ - \`\`\`code blocks\`\`\` (triple backticks)
570
+
571
+ FORBIDDEN formatting — these do NOT render in Telegram:
572
+ - **double asterisks** — use *single asterisks* instead
573
+ - ## headings — use *bold text* on its own line instead
574
+ - --- horizontal rules — use a blank line instead
575
+ - [text](url) links — just paste the raw URL
576
+ - > blockquotes — not supported
577
+
578
+ Structure tips:
579
+ - Break content into short paragraphs with blank lines
580
+ - Use *bold* sparingly for section titles on their own line
581
+ - Use numbered lists (1. 2. 3.) or bullet dashes (- item)
582
+ - Keep lines short — Telegram wraps poorly on mobile
583
+ - Never use markdown tables — use numbered lists instead
584
+
585
+ *Email/inbox lists* — use this pattern:
546
586
  📬 *Inbox (10)*
547
587
 
548
588
  1\\. *Google* — Security alert \`22:58\`
549
589
  2\\. *LinkedIn* — Matthew Chittle wants to connect \`21:31\`
550
590
  3\\. *DeepLearning\\.AI* — AI Dev 26 × SF speakers \`13:20\`
551
- 4\\. *LinkedIn Jobs* — Project Manager / TPM roles \`17:32\`
552
- \`\`\`
553
591
 
554
- **Copyable values** (email addresses, URLs, API keys, commands) — wrap in backtick code spans:
592
+ *Copyable values* (email addresses, URLs, API keys, commands) — wrap in backtick code spans:
555
593
  \`user@example.com\`, \`https://example.com\`, \`npm install foo\`
556
594
 
557
- **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.
558
-
559
- **Keep lines short** — Telegram wraps long lines poorly on mobile. Break at natural points.
595
+ *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.
560
596
  `);
561
597
 
562
598
  // Safety rules (hardcoded — never drifts)
@@ -579,6 +615,7 @@ You communicate via Telegram. Format responses for mobile readability.
579
615
  - Search memory before claiming you don't know something
580
616
  - Use \`store_secret\`/\`read_secret\` for all credential operations
581
617
  - If a user sends what appears to be an API key, token, or credential in conversation, immediately warn them that it's visible in chat history, tell them to revoke/rotate it, and direct them to use \`/secret set <key> <value>\` instead
618
+ - After executing tools (exec, web_fetch, read_secret, etc.), ALWAYS provide a text response summarizing what you found or did. Never end your turn with only tool calls and no text reply — the user cannot see tool results directly, they only see your text responses
582
619
  `);
583
620
 
584
621
  return parts.join('\n');
@@ -702,7 +739,7 @@ function buildTools(memory, opts = {}) {
702
739
  // Read/write files
703
740
  tools.push({
704
741
  name: 'read_file',
705
- description: 'Read contents of a file.',
742
+ description: 'Read contents of a file. Supports text files and PDFs (extracts text from PDF automatically).',
706
743
  input_schema: {
707
744
  type: 'object',
708
745
  properties: {
@@ -848,6 +885,8 @@ function buildRunnableTools(tools, memory, context, vlog) {
848
885
  tool.name === 'cancel_event' ? input.event_id :
849
886
  JSON.stringify(input).substring(0, 80);
850
887
  vlog(`[tool] ${tool.name}: ${inputSummary}`);
888
+
889
+ context._onToolStart?.(tool.name, inputSummary);
851
890
  return await executeToolCall({ name: tool.name, input }, memory, context);
852
891
  },
853
892
  }));
@@ -1014,6 +1053,13 @@ async function executeToolCall(toolUse, memory, context = {}) {
1014
1053
 
1015
1054
  case 'read_file': {
1016
1055
  const filePath = userDir ? resolveUserPath(input.path, userDir) : input.path;
1056
+ if (filePath.toLowerCase().endsWith('.pdf')) {
1057
+ const pdfParse = require('pdf-parse');
1058
+ const pdfBuffer = fs.readFileSync(filePath);
1059
+ const { text } = await pdfParse(pdfBuffer);
1060
+ const truncatedPdf = text.substring(0, 50000);
1061
+ return text.length > 50000 ? truncatedPdf + '\n...(truncated)' : truncatedPdf;
1062
+ }
1017
1063
  const fileContent = fs.readFileSync(filePath, 'utf-8');
1018
1064
  const truncatedFile = fileContent.substring(0, 50000);
1019
1065
  return fileContent.length > 50000 ? truncatedFile + '\n...(truncated)' : truncatedFile;
package/src/history.js CHANGED
@@ -119,6 +119,7 @@ function validate(messages) {
119
119
 
120
120
  const allToolUseIds = new Set();
121
121
  const allToolResultIds = new Set();
122
+ const duplicateToolResultIds = [];
122
123
 
123
124
  for (const msg of messages) {
124
125
  if (msg.role === 'assistant' && Array.isArray(msg.content)) {
@@ -128,11 +129,20 @@ function validate(messages) {
128
129
  }
129
130
  if (msg.role === 'user' && Array.isArray(msg.content)) {
130
131
  for (const b of msg.content) {
131
- if (b.type === 'tool_result') allToolResultIds.add(b.tool_use_id);
132
+ if (b.type === 'tool_result') {
133
+ if (allToolResultIds.has(b.tool_use_id)) {
134
+ duplicateToolResultIds.push(b.tool_use_id);
135
+ }
136
+ allToolResultIds.add(b.tool_use_id);
137
+ }
132
138
  }
133
139
  }
134
140
  }
135
141
 
142
+ for (const id of duplicateToolResultIds) {
143
+ errors.push(`duplicate tool_result for tool_use_id=${id}`);
144
+ }
145
+
136
146
  for (const id of allToolResultIds) {
137
147
  if (!allToolUseIds.has(id)) {
138
148
  errors.push(`orphaned tool_result for tool_use_id=${id}`);
@@ -150,12 +160,18 @@ function validate(messages) {
150
160
 
151
161
  function repair(messages) {
152
162
  const allToolUseIds = new Set();
163
+ const allToolResultIds = new Set();
153
164
  for (const msg of messages) {
154
165
  if (msg.role === 'assistant' && Array.isArray(msg.content)) {
155
166
  for (const b of msg.content) {
156
167
  if (b.type === 'tool_use') allToolUseIds.add(b.id);
157
168
  }
158
169
  }
170
+ if (msg.role === 'user' && Array.isArray(msg.content)) {
171
+ for (const b of msg.content) {
172
+ if (b.type === 'tool_result') allToolResultIds.add(b.tool_use_id);
173
+ }
174
+ }
159
175
  }
160
176
 
161
177
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -183,7 +199,7 @@ function repair(messages) {
183
199
  if (next?.role === 'user' && Array.isArray(next.content)) {
184
200
  const existingIds = new Set(
185
201
  next.content.filter(b => b.type === 'tool_result').map(b => b.tool_use_id));
186
- const missingIds = toolUseIds.filter(id => !existingIds.has(id));
202
+ const missingIds = toolUseIds.filter(id => !existingIds.has(id) && !allToolResultIds.has(id));
187
203
  if (missingIds.length > 0) {
188
204
  next.content = [
189
205
  ...next.content,
@@ -193,10 +209,14 @@ function repair(messages) {
193
209
  ];
194
210
  }
195
211
  } else {
196
- const fakeResults = toolUseIds.map(id => ({
197
- type: 'tool_result', tool_use_id: id, content: '[interrupted]',
198
- }));
199
- messages.splice(i + 1, 0, { role: 'user', content: fakeResults });
212
+ const existingElsewhere = toolUseIds.filter(id => allToolResultIds.has(id));
213
+ const trulyMissing = toolUseIds.filter(id => !allToolResultIds.has(id));
214
+ if (trulyMissing.length > 0) {
215
+ const fakeResults = trulyMissing.map(id => ({
216
+ type: 'tool_result', tool_use_id: id, content: '[interrupted]',
217
+ }));
218
+ messages.splice(i + 1, 0, { role: 'user', content: fakeResults });
219
+ }
200
220
  }
201
221
  }
202
222
 
@@ -212,6 +232,17 @@ function repair(messages) {
212
232
  messages.splice(i, 1);
213
233
  }
214
234
  }
235
+
236
+ for (const msg of messages) {
237
+ if (msg.role !== 'user' || !Array.isArray(msg.content)) continue;
238
+ const seen = new Set();
239
+ msg.content = msg.content.filter(b => {
240
+ if (b.type !== 'tool_result') return true;
241
+ if (seen.has(b.tool_use_id)) return false;
242
+ seen.add(b.tool_use_id);
243
+ return true;
244
+ });
245
+ }
215
246
  }
216
247
 
217
248
  class ChatHistory {
package/src/telegram.js CHANGED
@@ -14,8 +14,87 @@ const RATE_LIMIT_MS = 3000;
14
14
  const SPAM_THRESHOLD = 5;
15
15
  const SPAM_COOLDOWN_MS = 30000;
16
16
  const EVOLUTION_IDLE_MS = 15 * 60 * 1000;
17
+ const DEDUP_TTL_MS = 5 * 60 * 1000;
18
+ const DEDUP_MAX_SIZE = 2000;
19
+ const TEXT_BUFFER_GAP_MS = 1500;
20
+ const TEXT_BUFFER_MAX_PARTS = 12;
21
+ const TEXT_BUFFER_MAX_CHARS = 50000;
22
+ const TEXT_BUFFER_THRESHOLD = 4000;
23
+ const MEDIA_GROUP_DELAY_MS = 500;
24
+ const TERM_WIDTH = 25;
25
+ const TERM_SEP = '━'.repeat(TERM_WIDTH);
17
26
 
18
27
  const _evolutionTimers = new Map();
28
+ const _toolDescriptionCache = new Map();
29
+
30
+ function termBar(pct, width = 20) {
31
+ const filled = Math.round((pct / 100) * width);
32
+ return '━'.repeat(filled) + '╌'.repeat(width - filled);
33
+ }
34
+
35
+ function buildStatusHtml({ route, elapsed, toolStatus }) {
36
+ const lines = [`◈ OBOL ${'━'.repeat(TERM_WIDTH - 7)}`];
37
+ if (route) {
38
+ lines.push(`⬡ ROUTE ${(route.model || 'sonnet').toUpperCase()}`);
39
+ if (route.memoryCount > 0) {
40
+ lines.push(`⬡ MEMORY ${route.memoryCount} recalled`);
41
+ } else if (route.needMemory) {
42
+ lines.push(`⬡ MEMORY scanning`);
43
+ }
44
+ }
45
+ if (toolStatus) {
46
+ lines.push(`▸ ${toolStatus}`);
47
+ } else {
48
+ lines.push(`▸ Processing`);
49
+ }
50
+ const es = elapsed > 0 ? ` ${elapsed}s ` : '';
51
+ const padLen = Math.max(0, TERM_WIDTH - es.length);
52
+ const left = Math.floor(padLen / 2);
53
+ const right = padLen - left;
54
+ lines.push(`${'━'.repeat(left)}${es}${'━'.repeat(right)}`);
55
+ return `<pre>${lines.join('\n')}</pre>`;
56
+ }
57
+
58
+ function markdownToTelegramHtml(text) {
59
+ const codeBlocks = [];
60
+ let result = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
61
+ const idx = codeBlocks.length;
62
+ const escaped = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
63
+ codeBlocks.push(`<pre>${escaped}</pre>`);
64
+ return `\x00CB${idx}\x00`;
65
+ });
66
+
67
+ const inlineCode = [];
68
+ result = result.replace(/`([^`\n]+)`/g, (_, code) => {
69
+ const idx = inlineCode.length;
70
+ const escaped = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
71
+ inlineCode.push(`<code>${escaped}</code>`);
72
+ return `\x00IC${idx}\x00`;
73
+ });
74
+
75
+ result = result.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
76
+ result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
77
+ result = result.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
78
+ result = result.replace(/~~(.+?)~~/g, '<s>$1</s>');
79
+ result = result.replace(/(?<!\w)\*([^\s*](?:.*?[^\s*])?)\*(?!\w)/g, '<i>$1</i>');
80
+ result = result.replace(/(?<!\w)_([^\s_](?:.*?[^\s_])?)_(?!\w)/g, '<i>$1</i>');
81
+
82
+ result = result.replace(/\x00CB(\d+)\x00/g, (_, idx) => codeBlocks[parseInt(idx)]);
83
+ result = result.replace(/\x00IC(\d+)\x00/g, (_, idx) => inlineCode[parseInt(idx)]);
84
+
85
+ return result;
86
+ }
87
+
88
+ function sendHtml(ctx, text, extra = {}) {
89
+ const html = markdownToTelegramHtml(text);
90
+ return ctx.reply(html, { parse_mode: 'HTML', ...extra }).catch(() => ctx.reply(text, extra));
91
+ }
92
+
93
+ function editHtml(ctx, chatId, messageId, text, extra = {}) {
94
+ const html = markdownToTelegramHtml(text);
95
+ return ctx.api.editMessageText(chatId, messageId, html, { parse_mode: 'HTML', ...extra })
96
+ .catch(() => ctx.api.editMessageText(chatId, messageId, text, extra));
97
+ }
19
98
 
20
99
  function startTyping(ctx) {
21
100
  ctx.replyWithChatAction('typing').catch(() => {});
@@ -30,8 +109,32 @@ function createBot(telegramConfig, config) {
30
109
  const allowedUsers = new Set(telegramConfig.allowedUsers || []);
31
110
  const rateLimits = new Map();
32
111
  const pendingAsks = new Map();
112
+ const processedUpdates = new Map();
113
+ const textBuffers = new Map();
114
+ const mediaGroups = new Map();
33
115
  let askIdCounter = 0;
34
116
 
117
+ function describeToolCall(client, toolName, inputSummary) {
118
+ const key = `${toolName}:${inputSummary}`;
119
+ const cached = _toolDescriptionCache.get(key);
120
+ if (cached) return Promise.resolve(cached);
121
+
122
+ return client.messages.create({
123
+ model: 'claude-haiku-4-5',
124
+ max_tokens: 30,
125
+ system: 'Describe this tool call in 3-8 words from the user\'s perspective. Present participle. No quotes, period, or emoji.',
126
+ messages: [{ role: 'user', content: `${toolName}: ${inputSummary}` }],
127
+ }).then(r => {
128
+ const desc = r.content[0]?.text?.trim() || null;
129
+ if (desc) _toolDescriptionCache.set(key, desc);
130
+ if (_toolDescriptionCache.size > 200) {
131
+ const first = _toolDescriptionCache.keys().next().value;
132
+ _toolDescriptionCache.delete(first);
133
+ }
134
+ return desc;
135
+ }).catch(() => null);
136
+ }
137
+
35
138
  function createAsk(ctx, message, options, timeoutSecs = 60) {
36
139
  return new Promise((resolve) => {
37
140
  const askId = ++askIdCounter;
@@ -47,7 +150,7 @@ function createBot(telegramConfig, config) {
47
150
  }
48
151
  }, timeoutSecs * 1000);
49
152
  pendingAsks.set(askId, { resolve, options, timer });
50
- ctx.reply(message, { parse_mode: 'Markdown', reply_markup: keyboard }).catch(() => {
153
+ sendHtml(ctx, message, { reply_markup: keyboard }).catch(() => {
51
154
  clearTimeout(timer);
52
155
  pendingAsks.delete(askId);
53
156
  resolve('error');
@@ -63,6 +166,21 @@ function createBot(telegramConfig, config) {
63
166
  }, 600000);
64
167
  _rateLimitCleanup.unref();
65
168
 
169
+ bot.use(async (ctx, next) => {
170
+ const updateId = ctx.update?.update_id;
171
+ if (updateId != null) {
172
+ if (processedUpdates.has(updateId)) return;
173
+ processedUpdates.set(updateId, Date.now());
174
+ if (processedUpdates.size > DEDUP_MAX_SIZE) {
175
+ const now = Date.now();
176
+ for (const [id, ts] of processedUpdates) {
177
+ if (now - ts > DEDUP_TTL_MS) processedUpdates.delete(id);
178
+ }
179
+ }
180
+ }
181
+ await next();
182
+ });
183
+
66
184
  bot.use(async (ctx, next) => {
67
185
  if (allowedUsers.size > 0 && !allowedUsers.has(ctx.from?.id)) {
68
186
  return;
@@ -91,7 +209,7 @@ function createBot(telegramConfig, config) {
91
209
  ]).catch(() => {});
92
210
 
93
211
  bot.command('start', async (ctx) => {
94
- await ctx.reply('🪙 OBOL is ready. Just send me a message.');
212
+ await ctx.reply(`<pre>◈ OBOL v${pkg.version}\n${TERM_SEP}\nSYSTEM ONLINE\n${TERM_SEP}</pre>`, { parse_mode: 'HTML' });
95
213
  });
96
214
 
97
215
  bot.command('memory', async (ctx) => {
@@ -122,7 +240,7 @@ function createBot(telegramConfig, config) {
122
240
  if (!ctx.from) return;
123
241
  const tenant = await getTenant(ctx.from.id, config);
124
242
  tenant.claude.clearHistory(ctx.chat.id);
125
- await ctx.reply('🪙 Fresh start. What\'s up?');
243
+ await ctx.reply(`<pre>◈ CONTEXT CLEARED\n${TERM_SEP}</pre>`, { parse_mode: 'HTML' });
126
244
  });
127
245
 
128
246
  bot.command('status', async (ctx) => {
@@ -134,35 +252,49 @@ function createBot(telegramConfig, config) {
134
252
  const m = Math.floor((uptime % 3600) / 60);
135
253
  const running = tenant.bg.getStatus();
136
254
 
137
- let text = `🪙 OBOL Status\n\n`;
138
- text += `⏱️ Uptime: ${h}h ${m}m\n`;
139
- text += `💾 Memory: ${mem}MB\n`;
140
- text += `⚡ Tasks: ${running.length} running\n`;
141
- text += `🔧 Tool limit: ${getMaxToolIterations()}\n`;
255
+ const lines = [
256
+ `◈ OBOL SYSTEM STATUS`,
257
+ TERM_SEP,
258
+ ``,
259
+ `RUNTIME`,
260
+ ` uptime ${h}h ${m}m`,
261
+ ` memory ${mem}MB`,
262
+ ` tasks ${running.length} active`,
263
+ ` tools ${getMaxToolIterations()} max iter`,
264
+ ];
142
265
 
143
266
  if (tenant.memory) {
144
267
  const stats = await tenant.memory.stats().catch(() => null);
145
- if (stats) text += `🧠 Memories: ${stats.total}\n`;
268
+ lines.push(``, `MEMORY BANK`);
269
+ lines.push(` stored ${stats ? stats.total : '?'} memories`);
146
270
  }
147
271
 
148
272
  const ctxStats = tenant.claude.getContextStats(ctx.chat.id);
149
- const ctxBar = '█'.repeat(Math.floor(ctxStats.pct / 5)) + '░'.repeat(20 - Math.floor(ctxStats.pct / 5));
150
- text += `\n📐 Context: ${ctxBar} ${ctxStats.pct}%\n`;
151
- text += ` ${(ctxStats.estimatedTokens / 1000).toFixed(1)}k / ${(ctxStats.maxTokens / 1000).toFixed(0)}k tokens (${ctxStats.messages} msgs)\n`;
273
+ lines.push(
274
+ ``, `CONTEXT`,
275
+ ` ${termBar(ctxStats.pct)} ${ctxStats.pct}%`,
276
+ ` ${(ctxStats.estimatedTokens / 1000).toFixed(1)}k / ${(ctxStats.maxTokens / 1000).toFixed(0)}k tokens`,
277
+ ` ${ctxStats.messages} messages`,
278
+ );
152
279
 
153
280
  const evoState = loadEvolutionState(tenant.userDir);
154
281
  const cfg = loadConfig();
155
282
  const threshold = cfg?.evolution?.exchanges || 100;
156
283
  const evoCount = evoState.exchangesSinceLastEvolution || 0;
157
284
  const evoPct = Math.min(100, Math.round((evoCount / threshold) * 100));
158
- const evoBar = '█'.repeat(Math.floor(evoPct / 5)) + '░'.repeat(20 - Math.floor(evoPct / 5));
159
- text += `\n🧬 Evolution: ${evoBar} ${evoPct}% (${evoState.evolutionCount || 0} completed)\n`;
285
+ lines.push(
286
+ ``, `EVOLUTION`,
287
+ ` ${termBar(evoPct)} ${evoPct}%`,
288
+ ` ${evoCount}/${threshold} exchanges ▪ ${evoState.evolutionCount || 0} completed`,
289
+ );
160
290
 
161
291
  const personalityDir = path.join(tenant.userDir, 'personality');
162
292
  const traits = loadTraits(personalityDir);
163
- text += `\n🎛 Traits\n${formatTraits(traits)}`;
293
+ lines.push(``, `TRAITS`);
294
+ lines.push(formatTraits(traits));
295
+ lines.push(TERM_SEP);
164
296
 
165
- await ctx.reply(text);
297
+ await ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
166
298
  });
167
299
 
168
300
  bot.command('backup', async (ctx) => {
@@ -250,7 +382,8 @@ function createBot(telegramConfig, config) {
250
382
  saveTraits(personalityDir, { ...DEFAULT_TRAITS });
251
383
  tenant.claude.reloadPersonality();
252
384
  const traits = { ...DEFAULT_TRAITS };
253
- await ctx.reply(`🎛 Traits reset to defaults\n\n${formatTraits(traits)}`);
385
+ const lines = [`◈ OBOL PERSONALITY MATRIX`, TERM_SEP, `RESET TO DEFAULTS`, ``, formatTraits(traits), TERM_SEP];
386
+ await ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
254
387
  return;
255
388
  }
256
389
 
@@ -269,12 +402,14 @@ function createBot(telegramConfig, config) {
269
402
  traits[traitName] = value;
270
403
  saveTraits(personalityDir, traits);
271
404
  tenant.claude.reloadPersonality();
272
- await ctx.reply(`🎛 Updated ${traitName} → ${value}\n\n${formatTraits(traits)}`);
405
+ const lines = [`◈ OBOL PERSONALITY MATRIX`, TERM_SEP, `UPDATED ${traitName} → ${value}`, ``, formatTraits(traits), TERM_SEP];
406
+ await ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
273
407
  return;
274
408
  }
275
409
 
276
410
  const traits = loadTraits(personalityDir);
277
- await ctx.reply(`🎛 Personality Traits\n\n${formatTraits(traits)}\n\nAdjust: /traits <name> <0-100>\nReset: /traits reset`);
411
+ const lines = [`◈ OBOL PERSONALITY MATRIX`, TERM_SEP, ``, formatTraits(traits), ``, `/traits &lt;name&gt; &lt;0-100&gt;`, `/traits reset`, TERM_SEP];
412
+ await ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
278
413
  });
279
414
 
280
415
  bot.command('secret', async (ctx) => {
@@ -339,16 +474,20 @@ Your message is deleted immediately when using /secret set to keep credentials o
339
474
  const threshold = cfg?.evolution?.exchanges || 100;
340
475
  const count = state.exchangesSinceLastEvolution || 0;
341
476
  const pct = Math.min(100, Math.round((count / threshold) * 100));
342
- const bar = '█'.repeat(Math.floor(pct / 5)) + '░'.repeat(20 - Math.floor(pct / 5));
343
477
 
344
- let text = `🧬 Evolution Progress\n\n`;
345
- text += `${bar} ${pct}%\n`;
346
- text += `${count}/${threshold} exchanges\n`;
347
- text += `Evolutions completed: ${state.evolutionCount || 0}\n`;
478
+ const lines = [
479
+ `◈ OBOL EVOLUTION CYCLE`,
480
+ TERM_SEP,
481
+ ``,
482
+ ` ${termBar(pct)} ${pct}%`,
483
+ ` ${count}/${threshold} exchanges`,
484
+ ` ${state.evolutionCount || 0} completed`,
485
+ ];
348
486
  if (state.lastEvolution) {
349
- text += `Last evolution: ${new Date(state.lastEvolution).toLocaleDateString()}`;
487
+ lines.push(` last ${new Date(state.lastEvolution).toLocaleDateString()}`);
350
488
  }
351
- await ctx.reply(text);
489
+ lines.push(TERM_SEP);
490
+ await ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
352
491
  });
353
492
 
354
493
  bot.command('events', async (ctx) => {
@@ -363,9 +502,7 @@ Your message is deleted immediately when using /secret set to keep credentials o
363
502
  const dueLocal = new Date(e.due_at).toLocaleString('en-US', { timeZone: tz, dateStyle: 'medium', timeStyle: 'short' });
364
503
  return `${i + 1}. *${e.title}*\n ${dueLocal} (${tz})\n \`${e.id}\``;
365
504
  }).join('\n\n');
366
- await ctx.reply(`📅 *Upcoming Events*\n\n${text}`, { parse_mode: 'Markdown' }).catch(() =>
367
- ctx.reply(`📅 Upcoming Events\n\n${text.replace(/\*/g, '')}`)
368
- );
505
+ await sendHtml(ctx, `📅 **Upcoming Events**\n\n${text}`);
369
506
  } catch (e) {
370
507
  await ctx.reply(`⚠️ ${e.message}`);
371
508
  }
@@ -375,13 +512,18 @@ Your message is deleted immediately when using /secret set to keep credentials o
375
512
  if (!ctx.from) return;
376
513
  const tenant = await getTenant(ctx.from.id, config);
377
514
  const running = tenant.bg.getStatus();
515
+ const lines = [`◈ OBOL ACTIVE TASKS`, TERM_SEP];
378
516
  if (running.length === 0) {
379
- return ctx.reply('No background tasks running.');
517
+ lines.push(``, ` (none)`);
518
+ } else {
519
+ lines.push(``);
520
+ for (const t of running) {
521
+ lines.push(` ▸ #${t.id} ${t.task}`);
522
+ lines.push(` ${t.elapsed}`);
523
+ }
380
524
  }
381
- const text = running.map(t =>
382
- `⏳ #${t.id}: ${t.task}... (${t.elapsed})`
383
- ).join('\n');
384
- return ctx.reply(`Running tasks:\n\n${text}`);
525
+ lines.push(TERM_SEP);
526
+ return ctx.reply(`<pre>${lines.join('\n')}</pre>`, { parse_mode: 'HTML' });
385
527
  });
386
528
 
387
529
  bot.command('help', async (ctx) => {
@@ -412,14 +554,19 @@ Your message is deleted immediately when using /secret set to keep credentials o
412
554
  if (!ctx.from) return;
413
555
  const tenant = await getTenant(ctx.from.id, config);
414
556
  const stopped = tenant.claude.stopChat(ctx.chat.id);
415
- await ctx.reply(stopped ? '⏹ Stopped.' : 'Nothing running to stop.');
557
+ if (stopped) {
558
+ await ctx.reply(`<pre>◈ PROCESS TERMINATED\n${TERM_SEP}</pre>`, { parse_mode: 'HTML' });
559
+ } else {
560
+ await ctx.reply('Nothing running to stop.');
561
+ }
416
562
  });
417
563
 
418
564
  bot.command('verbose', async (ctx) => {
419
565
  if (!ctx.from) return;
420
566
  const tenant = await getTenant(ctx.from.id, config);
421
567
  tenant.verbose = !tenant.verbose;
422
- await ctx.reply(tenant.verbose ? '🔍 Verbose mode ON' : '🔇 Verbose mode OFF');
568
+ const state = tenant.verbose ? ' ACTIVE' : ' INACTIVE';
569
+ await ctx.reply(`<pre>◈ VERBOSE ${state}\n${TERM_SEP}</pre>`, { parse_mode: 'HTML' });
423
570
  });
424
571
 
425
572
  bot.command('upgrade', async (ctx) => {
@@ -509,37 +656,9 @@ Your message is deleted immediately when using /secret set to keep credentials o
509
656
  return API_KEY_PATTERNS.some(pattern => pattern.test(text));
510
657
  }
511
658
 
512
- bot.on('message:text', async (ctx) => {
513
- if (!ctx.from) return;
514
- const userMessage = ctx.message.text;
515
- if (!userMessage || !userMessage.trim()) return;
659
+ async function processTextMessage(ctx, fullMessage) {
516
660
  const userId = ctx.from.id;
517
661
  const userName = ctx.from.first_name || 'User';
518
-
519
- if (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup') {
520
- const me = await bot.api.getMe();
521
- if (!userMessage.includes(`@${me.username}`)) return;
522
- }
523
-
524
- if (!userMessage.startsWith('/secret') && containsApiKey(userMessage)) {
525
- ctx.api.deleteMessage(ctx.chat.id, ctx.message.message_id).catch(() => {});
526
- await ctx.reply(
527
- '⚠️ That message contained what looks like an API key or token. I deleted it, but it may have been seen already — consider rotating it.\n\nUse `/secret set <name> <value>` to store credentials safely.'
528
- ).catch(() => {});
529
- return;
530
- }
531
-
532
- const rateResult = checkRateLimit(userId);
533
- if (rateResult === 'cooldown' || rateResult === 'skip') return;
534
- if (rateResult === 'spam') {
535
- await ctx.reply('Spam detected. Cooling down for 30 seconds.').catch(() => {});
536
- return;
537
- }
538
- if (rateResult === 'slow') {
539
- await ctx.reply('Slow down a bit — I\'m still processing.').catch(() => {});
540
- return;
541
- }
542
-
543
662
  const tenant = await getTenant(userId, config);
544
663
 
545
664
  if (_evolutionTimers.has(userId)) {
@@ -548,10 +667,47 @@ Your message is deleted immediately when using /secret set to keep credentials o
548
667
  if (tenant.messageLog) tenant.messageLog._evolutionPending = false;
549
668
  }
550
669
 
670
+ let replyContext = '';
671
+ const reply = ctx.message?.reply_to_message;
672
+ if (reply) {
673
+ const quote = (reply.text || reply.caption || '').substring(0, 500);
674
+ const sender = reply.from
675
+ ? (reply.from.first_name || '') + (reply.from.last_name ? ` ${reply.from.last_name}` : '')
676
+ : reply.forward_origin?.sender_user?.first_name || 'someone';
677
+ if (quote) replyContext = `[Replying to "${quote}" from ${sender}]\n\n`;
678
+ }
679
+
680
+ const chatMessage = replyContext + fullMessage;
551
681
  const stopTyping = startTyping(ctx);
552
682
 
683
+ let statusMsgId = null;
684
+ let statusText = 'Processing';
685
+ let statusTimer = null;
686
+ let statusStart = null;
687
+ let routeInfo = null;
688
+
689
+ const clearStatus = () => {
690
+ if (statusTimer) { clearInterval(statusTimer); statusTimer = null; }
691
+ if (statusMsgId) { ctx.api.deleteMessage(ctx.chat.id, statusMsgId).catch(() => {}); statusMsgId = null; }
692
+ };
693
+
694
+ const startStatusTimer = () => {
695
+ if (statusTimer) return;
696
+ statusStart = Date.now();
697
+ const html = buildStatusHtml({ route: routeInfo, elapsed: 0, toolStatus: statusText });
698
+ ctx.reply(html, { parse_mode: 'HTML' }).then(sent => {
699
+ if (sent) statusMsgId = sent.message_id;
700
+ }).catch(() => {});
701
+ statusTimer = setInterval(() => {
702
+ if (!statusMsgId) return;
703
+ const elapsed = Math.round((Date.now() - statusStart) / 1000);
704
+ const html = buildStatusHtml({ route: routeInfo, elapsed, toolStatus: statusText });
705
+ ctx.api.editMessageText(ctx.chat.id, statusMsgId, html, { parse_mode: 'HTML' }).catch(() => {});
706
+ }, 1000);
707
+ };
708
+
553
709
  try {
554
- tenant.messageLog?.log(ctx.chat.id, 'user', userMessage);
710
+ tenant.messageLog?.log(ctx.chat.id, 'user', chatMessage);
555
711
 
556
712
  const chatContext = {
557
713
  userId,
@@ -564,25 +720,48 @@ Your message is deleted immediately when using /secret set to keep credentials o
564
720
  config,
565
721
  verbose: tenant.verbose,
566
722
  _verboseNotify: tenant.verbose ? (msg) => {
567
- const safe = msg.replace(/`/g, "'");
568
- ctx.reply(`\`${safe}\``, { parse_mode: 'Markdown' }).catch(() => {});
723
+ sendHtml(ctx, `\`${msg}\``).catch(() => {});
569
724
  } : undefined,
570
725
  telegramAsk: (message, options, timeout) => createAsk(ctx, message, options, timeout),
571
726
  _notifyFn: (targetUserId, message) => {
572
727
  if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
573
728
  return bot.api.sendMessage(targetUserId, message);
574
729
  },
730
+ _onRouteDecision: (info) => {
731
+ routeInfo = info;
732
+ startStatusTimer();
733
+ },
734
+ _onRouteUpdate: (update) => {
735
+ if (routeInfo) routeInfo.memoryCount = update.memoryCount;
736
+ },
737
+ _onToolStart: (toolName, inputSummary) => {
738
+ statusText = 'Processing';
739
+ describeToolCall(tenant.claude.client, toolName, inputSummary).then(desc => {
740
+ if (desc) statusText = desc;
741
+ });
742
+ startStatusTimer();
743
+ },
575
744
  };
576
- const { text: response, usage, model } = await tenant.claude.chat(userMessage, chatContext);
745
+ const { text: response, usage, model } = await tenant.claude.chat(chatMessage, chatContext);
746
+
747
+ if (statusTimer) {
748
+ clearInterval(statusTimer);
749
+ statusTimer = null;
750
+ if (statusMsgId) {
751
+ const elapsed = statusStart ? Math.round((Date.now() - statusStart) / 1000) : 0;
752
+ const html = buildStatusHtml({ route: routeInfo, elapsed, toolStatus: 'Formatting output' });
753
+ ctx.api.editMessageText(ctx.chat.id, statusMsgId, html, { parse_mode: 'HTML' }).catch(() => {});
754
+ }
755
+ }
577
756
 
578
757
  if (!response?.trim()) {
579
758
  stopTyping();
759
+ clearStatus();
580
760
  return;
581
761
  }
582
762
 
583
763
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response, { model, tokensIn: usage?.input_tokens, tokensOut: usage?.output_tokens });
584
764
 
585
-
586
765
  if (tenant.messageLog?._evolutionReady && !_evolutionTimers.has(userId)) {
587
766
  tenant.messageLog._evolutionReady = false;
588
767
  tenant.messageLog._evolutionPending = true;
@@ -622,9 +801,7 @@ Your message is deleted immediately when using /secret set to keep credentials o
622
801
  msg += `\n\n_${result.changelog}_`;
623
802
  }
624
803
 
625
- await ctx.reply(msg, { parse_mode: 'Markdown' }).catch(() =>
626
- ctx.reply(msg).catch(() => {})
627
- );
804
+ await sendHtml(ctx, msg).catch(() => {});
628
805
  } catch (e) {
629
806
  console.error('Evolution failed:', e.message);
630
807
  } finally {
@@ -639,164 +816,325 @@ Your message is deleted immediately when using /secret set to keep credentials o
639
816
  if (response.length > 4096) {
640
817
  const chunks = splitMessage(response, 4096);
641
818
  for (const chunk of chunks) {
642
- await ctx.reply(chunk, { parse_mode: 'Markdown' }).catch(() =>
643
- ctx.reply(chunk)
644
- );
819
+ await sendHtml(ctx, chunk).catch(() => {});
645
820
  }
646
821
  } else {
647
- await ctx.reply(response, { parse_mode: 'Markdown' }).catch(() =>
648
- ctx.reply(response)
649
- );
822
+ await sendHtml(ctx, response).catch(() => {});
823
+ }
824
+
825
+ if (usage && model) {
826
+ const tag = model.includes('opus') ? 'opus' : model.includes('haiku') ? 'haiku' : 'sonnet';
827
+ const tokIn = usage.input_tokens >= 1000 ? `${(usage.input_tokens/1000).toFixed(1)}k` : usage.input_tokens;
828
+ const tokOut = usage.output_tokens >= 1000 ? `${(usage.output_tokens/1000).toFixed(1)}k` : usage.output_tokens;
829
+ const dur = statusStart ? ((Date.now() - statusStart)/1000).toFixed(1) : null;
830
+ const parts = [`◈ ${tag}`, `${tokIn} in`, `${tokOut} out`];
831
+ if (dur) parts.push(`${dur}s`);
832
+ await ctx.reply(`<code>${parts.join(' ▪ ')}</code>`, { parse_mode: 'HTML' }).catch(() => {});
650
833
  }
834
+
835
+ if (statusMsgId) ctx.api.deleteMessage(ctx.chat.id, statusMsgId).catch(() => {});
651
836
  } catch (e) {
837
+ clearStatus();
652
838
  stopTyping();
653
839
  console.error('Message handling error:', e.message);
654
- if (e.isOAuthExpiry) {
655
- console.error('[oauth] Full error:', e.stack || e.message);
656
- await ctx.reply(`OAuth error: ${e.message}\n\nRun \`obol config\` Anthropic → re-authenticate OAuth.`).catch(() => {});
657
- } else if (e.status === 401 || e.message?.includes('401')) {
658
- await ctx.reply('API key invalid or expired. Run `obol config` to update.').catch(() => {});
659
- } else if (e.status === 429 || e.message?.includes('rate')) {
660
- await ctx.reply('Rate limited. Wait a moment and try again.').catch(() => {});
661
- } else {
662
- await ctx.reply('Something went wrong. Check logs with `obol logs`.').catch(() => {});
663
- }
840
+ const errMsg = e.isOAuthExpiry
841
+ ? `OAuth error: ${e.message}\n\nRun \`obol config\` → Anthropic → re-authenticate OAuth.`
842
+ : (e.status === 401 || e.message?.includes('401'))
843
+ ? 'API key invalid or expired. Run `obol config` to update.'
844
+ : (e.status === 429 || e.message?.includes('rate'))
845
+ ? 'Rate limited. Wait a moment and try again.'
846
+ : 'Something went wrong. Check logs with `obol logs`.';
847
+ if (e.isOAuthExpiry) console.error('[oauth] Full error:', e.stack || e.message);
848
+ await ctx.reply(errMsg).catch(() => {});
664
849
  }
665
- });
850
+ }
666
851
 
667
- const MAX_MEDIA_SIZE = 50 * 1024 * 1024; // 50MB
852
+ function flushTextBuffer(chatId, ctx) {
853
+ const buf = textBuffers.get(chatId);
854
+ if (!buf) return;
855
+ clearTimeout(buf.timer);
856
+ textBuffers.delete(chatId);
857
+ const combined = buf.parts.join('');
858
+ processTextMessage(ctx, combined).catch(e => console.error('Buffer flush error:', e.message));
859
+ }
668
860
 
669
- async function handleMedia(ctx) {
861
+ bot.on('message:text', async (ctx) => {
670
862
  if (!ctx.from) return;
863
+ const userMessage = ctx.message.text;
864
+ if (!userMessage || !userMessage.trim()) return;
671
865
  const userId = ctx.from.id;
866
+
867
+ if (ctx.chat.type === 'group' || ctx.chat.type === 'supergroup') {
868
+ const me = await bot.api.getMe();
869
+ if (!userMessage.includes(`@${me.username}`)) return;
870
+ }
871
+
872
+ if (!userMessage.startsWith('/secret') && containsApiKey(userMessage)) {
873
+ ctx.api.deleteMessage(ctx.chat.id, ctx.message.message_id).catch(() => {});
874
+ await ctx.reply(
875
+ '⚠️ That message contained what looks like an API key or token. I deleted it, but it may have been seen already — consider rotating it.\n\nUse `/secret set <name> <value>` to store credentials safely.'
876
+ ).catch(() => {});
877
+ return;
878
+ }
879
+
672
880
  const rateResult = checkRateLimit(userId);
673
- if (rateResult) return;
674
- const fileInfo = media.getFileInfo(ctx);
675
- if (!fileInfo) return;
881
+ if (rateResult === 'cooldown' || rateResult === 'skip') return;
882
+ if (rateResult === 'spam') {
883
+ await ctx.reply('Spam detected. Cooling down for 30 seconds.').catch(() => {});
884
+ return;
885
+ }
886
+ if (rateResult === 'slow') {
887
+ await ctx.reply('Slow down a bit — I\'m still processing.').catch(() => {});
888
+ return;
889
+ }
676
890
 
677
- if (fileInfo.fileSize > MAX_MEDIA_SIZE) {
678
- await ctx.reply(`File too large (${(fileInfo.fileSize / 1024 / 1024).toFixed(1)}MB). Max is 50MB.`).catch(() => {});
891
+ const chatId = ctx.chat.id;
892
+ const existingBuf = textBuffers.get(chatId);
893
+
894
+ if (userMessage.length >= TEXT_BUFFER_THRESHOLD) {
895
+ if (existingBuf) {
896
+ clearTimeout(existingBuf.timer);
897
+ if (existingBuf.parts.length < TEXT_BUFFER_MAX_PARTS &&
898
+ existingBuf.totalLength + userMessage.length <= TEXT_BUFFER_MAX_CHARS) {
899
+ existingBuf.parts.push(userMessage);
900
+ existingBuf.totalLength += userMessage.length;
901
+ existingBuf.ctx = ctx;
902
+ existingBuf.timer = setTimeout(() => flushTextBuffer(chatId, ctx), TEXT_BUFFER_GAP_MS);
903
+ return;
904
+ }
905
+ flushTextBuffer(chatId, ctx);
906
+ }
907
+ const buf = {
908
+ parts: [userMessage],
909
+ totalLength: userMessage.length,
910
+ ctx,
911
+ timer: setTimeout(() => flushTextBuffer(chatId, ctx), TEXT_BUFFER_GAP_MS),
912
+ };
913
+ textBuffers.set(chatId, buf);
679
914
  return;
680
915
  }
681
916
 
682
- const stopTyping = startTyping(ctx);
917
+ if (existingBuf) {
918
+ flushTextBuffer(chatId, existingBuf.ctx);
919
+ }
683
920
 
684
- try {
685
- const tenant = await getTenant(userId, config);
686
- const file = await ctx.getFile();
687
- const buffer = await media.downloadFile(telegramConfig.token, file.file_path);
921
+ await processTextMessage(ctx, userMessage);
922
+ });
688
923
 
689
- const filename = media.generateFilename(fileInfo, file.file_path);
690
- const assetsDir = path.join(tenant.userDir, 'assets');
691
- const savedPath = media.saveFile(buffer, assetsDir, filename);
924
+ const MAX_MEDIA_SIZE = 50 * 1024 * 1024;
692
925
 
693
- const caption = ctx.message.caption || '';
926
+ async function downloadMediaItem(ctx, fileInfo) {
927
+ const file = await ctx.getFile();
928
+ const buffer = await media.downloadFile(telegramConfig.token, file.file_path);
929
+ const filename = media.generateFilename(fileInfo, file.file_path);
930
+ return { buffer, filename, fileInfo, caption: ctx.message.caption || '' };
931
+ }
694
932
 
695
- if (tenant.memory && !media.isImage(fileInfo)) {
696
- const memContent = media.buildMemoryContent(fileInfo, filename, savedPath, caption);
697
- await tenant.memory.add(memContent, {
698
- category: 'resource',
699
- importance: 0.6,
700
- source: 'telegram-media',
701
- tags: [fileInfo.mediaType],
702
- }).catch(() => {});
703
- }
933
+ async function processMediaItems(ctx, items) {
934
+ if (!ctx.from) return;
935
+ const userId = ctx.from.id;
936
+ const stopTyping = startTyping(ctx);
937
+ let statusMsgId = null;
938
+ let statusText = 'Processing';
939
+ let statusTimer = null;
940
+ let statusStart = null;
941
+ let routeInfo = null;
942
+
943
+ const clearStatus = () => {
944
+ if (statusTimer) { clearInterval(statusTimer); statusTimer = null; }
945
+ if (statusMsgId) { ctx.api.deleteMessage(ctx.chat.id, statusMsgId).catch(() => {}); statusMsgId = null; }
946
+ };
704
947
 
705
- if (media.isImage(fileInfo)) {
706
- const imageBlock = media.bufferToImageBlock(buffer, fileInfo.mimeType);
707
- const prompt = caption || 'The user sent this image. Describe what you see and respond naturally.';
708
- const mediaChatCtx = {
709
- userId,
710
- userName: ctx.from.first_name || 'User',
711
- chatId: ctx.chat.id,
712
- bg: tenant.bg,
713
- ctx,
714
- claude: tenant.claude,
715
- scheduler: tenant.scheduler,
716
- config,
717
- verbose: tenant.verbose,
718
- _verboseNotify: tenant.verbose ? (msg) => {
719
- const safe = msg.replace(/`/g, "'");
720
- ctx.reply(`\`${safe}\``, { parse_mode: 'Markdown' }).catch(() => {});
721
- } : undefined,
722
- images: [imageBlock],
723
- _notifyFn: (targetUserId, message) => {
724
- if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
725
- return bot.api.sendMessage(targetUserId, message);
726
- },
727
- };
728
- const { text: response, usage, model } = await tenant.claude.chat(prompt, mediaChatCtx);
948
+ const startStatusTimer = () => {
949
+ if (statusTimer) return;
950
+ statusStart = Date.now();
951
+ const html = buildStatusHtml({ route: routeInfo, elapsed: 0, toolStatus: statusText });
952
+ ctx.reply(html, { parse_mode: 'HTML' }).then(sent => {
953
+ if (sent) statusMsgId = sent.message_id;
954
+ }).catch(() => {});
955
+ statusTimer = setInterval(() => {
956
+ if (!statusMsgId) return;
957
+ const elapsed = Math.round((Date.now() - statusStart) / 1000);
958
+ const html = buildStatusHtml({ route: routeInfo, elapsed, toolStatus: statusText });
959
+ ctx.api.editMessageText(ctx.chat.id, statusMsgId, html, { parse_mode: 'HTML' }).catch(() => {});
960
+ }, 1000);
961
+ };
729
962
 
730
- stopTyping();
731
- if (!response?.trim()) return;
732
-
733
- tenant.messageLog?.log(ctx.chat.id, 'user', `[${fileInfo.mediaType}] ${caption || filename}`);
734
- tenant.messageLog?.log(ctx.chat.id, 'assistant', response, { model, tokensIn: usage?.input_tokens, tokensOut: usage?.output_tokens });
735
-
736
- if (tenant.memory) {
737
- const analysisMemory = `Image: ${filename} (saved at ${savedPath})${caption ? `. Caption: "${caption}"` : ''}. Analysis: ${response.substring(0, 1500)}`;
738
- await tenant.memory.add(analysisMemory, {
739
- category: 'resource',
740
- importance: 0.7,
741
- source: 'image-analysis',
742
- tags: ['image', ...(caption ? caption.toLowerCase().split(/\s+/).slice(0, 3) : [])],
963
+ try {
964
+ const tenant = await getTenant(userId, config);
965
+ const assetsDir = path.join(tenant.userDir, 'assets');
966
+ const imageBlocks = [];
967
+ const nonImageParts = [];
968
+ const caption = items.map(i => i.caption).filter(Boolean).join('\n') || '';
969
+
970
+ for (const item of items) {
971
+ const savedPath = media.saveFile(item.buffer, assetsDir, item.filename);
972
+
973
+ if (tenant.memory && !media.isImage(item.fileInfo)) {
974
+ const memContent = media.buildMemoryContent(item.fileInfo, item.filename, savedPath, item.caption);
975
+ await tenant.memory.add(memContent, {
976
+ category: 'resource', importance: 0.6,
977
+ source: 'telegram-media', tags: [item.fileInfo.mediaType],
743
978
  }).catch(() => {});
744
979
  }
745
980
 
746
- if (response.length > 4096) {
747
- for (const chunk of splitMessage(response, 4096)) {
748
- await ctx.reply(chunk, { parse_mode: 'Markdown' }).catch(() => ctx.reply(chunk));
749
- }
981
+ if (media.isImage(item.fileInfo)) {
982
+ imageBlocks.push(media.bufferToImageBlock(item.buffer, item.fileInfo.mimeType));
750
983
  } else {
751
- await ctx.reply(response, { parse_mode: 'Markdown' }).catch(() => ctx.reply(response));
984
+ nonImageParts.push(item.caption
985
+ ? `[User sent a ${item.fileInfo.mediaType}: ${item.filename}, saved at ${savedPath}] ${item.caption}`
986
+ : `[User sent a ${item.fileInfo.mediaType}: ${item.filename}, saved at ${savedPath}. Use read_file to read its contents if needed.]`);
752
987
  }
753
- } else if (caption) {
754
- const contextMsg = `[User sent a ${fileInfo.mediaType}: ${filename}] ${caption}`;
755
- const mediaCaptionCtx = {
756
- userId,
757
- userName: ctx.from.first_name || 'User',
758
- chatId: ctx.chat.id,
759
- bg: tenant.bg,
760
- ctx,
761
- claude: tenant.claude,
762
- scheduler: tenant.scheduler,
763
- config,
764
- verbose: tenant.verbose,
765
- _verboseNotify: tenant.verbose ? (msg) => {
766
- const safe = msg.replace(/`/g, "'");
767
- ctx.reply(`\`${safe}\``, { parse_mode: 'Markdown' }).catch(() => {});
768
- } : undefined,
769
- _notifyFn: (targetUserId, message) => {
770
- if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
771
- return bot.api.sendMessage(targetUserId, message);
772
- },
773
- };
774
- const { text: response, usage, model } = await tenant.claude.chat(contextMsg, mediaCaptionCtx);
775
-
776
- stopTyping();
777
- if (!response?.trim()) return;
988
+ }
778
989
 
779
- tenant.messageLog?.log(ctx.chat.id, 'user', contextMsg);
780
- tenant.messageLog?.log(ctx.chat.id, 'assistant', response, { model, tokensIn: usage?.input_tokens, tokensOut: usage?.output_tokens });
990
+ let prompt, chatImages;
991
+ if (imageBlocks.length > 0) {
992
+ prompt = caption || `The user sent ${imageBlocks.length} image(s). Describe what you see and respond naturally.`;
993
+ if (nonImageParts.length > 0) prompt += '\n\n' + nonImageParts.join('\n');
994
+ chatImages = imageBlocks;
995
+ } else {
996
+ prompt = nonImageParts.join('\n');
997
+ }
781
998
 
782
- if (response.length > 4096) {
783
- for (const chunk of splitMessage(response, 4096)) {
784
- await ctx.reply(chunk, { parse_mode: 'Markdown' }).catch(() => ctx.reply(chunk));
785
- }
786
- } else {
787
- await ctx.reply(response, { parse_mode: 'Markdown' }).catch(() => ctx.reply(response));
999
+ const mediaChatCtx = {
1000
+ userId,
1001
+ userName: ctx.from.first_name || 'User',
1002
+ chatId: ctx.chat.id,
1003
+ bg: tenant.bg, ctx, claude: tenant.claude,
1004
+ scheduler: tenant.scheduler, config,
1005
+ verbose: tenant.verbose,
1006
+ _verboseNotify: tenant.verbose ? (msg) => {
1007
+ sendHtml(ctx, `\`${msg}\``).catch(() => {});
1008
+ } : undefined,
1009
+ ...(chatImages ? { images: chatImages } : {}),
1010
+ _notifyFn: (targetUserId, message) => {
1011
+ if (!allowedUsers.has(targetUserId)) throw new Error('Cannot notify user outside allowed list');
1012
+ return bot.api.sendMessage(targetUserId, message);
1013
+ },
1014
+ _onRouteDecision: (info) => {
1015
+ routeInfo = info;
1016
+ startStatusTimer();
1017
+ },
1018
+ _onRouteUpdate: (update) => {
1019
+ if (routeInfo) routeInfo.memoryCount = update.memoryCount;
1020
+ },
1021
+ _onToolStart: (toolName, inputSummary) => {
1022
+ statusText = 'Processing';
1023
+ describeToolCall(tenant.claude.client, toolName, inputSummary).then(desc => {
1024
+ if (desc) statusText = desc;
1025
+ });
1026
+ startStatusTimer();
1027
+ },
1028
+ };
1029
+ const { text: response, usage, model } = await tenant.claude.chat(prompt, mediaChatCtx);
1030
+
1031
+ if (statusTimer) {
1032
+ clearInterval(statusTimer);
1033
+ statusTimer = null;
1034
+ if (statusMsgId) {
1035
+ const elapsed = statusStart ? Math.round((Date.now() - statusStart) / 1000) : 0;
1036
+ const html = buildStatusHtml({ route: routeInfo, elapsed, toolStatus: 'Formatting output' });
1037
+ ctx.api.editMessageText(ctx.chat.id, statusMsgId, html, { parse_mode: 'HTML' }).catch(() => {});
788
1038
  }
1039
+ }
1040
+
1041
+ stopTyping();
1042
+ if (!response?.trim()) {
1043
+ clearStatus();
1044
+ return;
1045
+ }
1046
+
1047
+ const logLabel = items.map(i => `[${i.fileInfo.mediaType}] ${i.caption || i.filename}`).join(', ');
1048
+ tenant.messageLog?.log(ctx.chat.id, 'user', logLabel);
1049
+ tenant.messageLog?.log(ctx.chat.id, 'assistant', response, { model, tokensIn: usage?.input_tokens, tokensOut: usage?.output_tokens });
1050
+
1051
+ if (tenant.memory && imageBlocks.length > 0) {
1052
+ const filenames = items.filter(i => media.isImage(i.fileInfo)).map(i => i.filename).join(', ');
1053
+ const analysisMemory = `Images: ${filenames}${caption ? `. Caption: "${caption}"` : ''}. Analysis: ${response.substring(0, 1500)}`;
1054
+ await tenant.memory.add(analysisMemory, {
1055
+ category: 'resource', importance: 0.7,
1056
+ source: 'image-analysis',
1057
+ tags: ['image', ...(caption ? caption.toLowerCase().split(/\s+/).slice(0, 3) : [])],
1058
+ }).catch(() => {});
1059
+ }
1060
+
1061
+ if (response.length > 4096) {
1062
+ const chunks = splitMessage(response, 4096);
1063
+ for (const chunk of chunks) await sendHtml(ctx, chunk).catch(() => {});
789
1064
  } else {
790
- stopTyping();
791
- await ctx.reply(`Got it — saved ${filename}`);
1065
+ await sendHtml(ctx, response).catch(() => {});
1066
+ }
1067
+
1068
+ if (usage && model) {
1069
+ const tag = model.includes('opus') ? 'opus' : model.includes('haiku') ? 'haiku' : 'sonnet';
1070
+ const tokIn = usage.input_tokens >= 1000 ? `${(usage.input_tokens/1000).toFixed(1)}k` : usage.input_tokens;
1071
+ const tokOut = usage.output_tokens >= 1000 ? `${(usage.output_tokens/1000).toFixed(1)}k` : usage.output_tokens;
1072
+ const dur = statusStart ? ((Date.now() - statusStart)/1000).toFixed(1) : null;
1073
+ const parts = [`◈ ${tag}`, `${tokIn} in`, `${tokOut} out`];
1074
+ if (dur) parts.push(`${dur}s`);
1075
+ await ctx.reply(`<code>${parts.join(' ▪ ')}</code>`, { parse_mode: 'HTML' }).catch(() => {});
792
1076
  }
1077
+
1078
+ if (statusMsgId) ctx.api.deleteMessage(ctx.chat.id, statusMsgId).catch(() => {});
793
1079
  } catch (e) {
1080
+ clearStatus();
794
1081
  stopTyping();
795
1082
  console.error('Media handling error:', e.message);
796
1083
  await ctx.reply('Failed to process that file. Check logs.').catch(() => {});
797
1084
  }
798
1085
  }
799
1086
 
1087
+ async function handleMedia(ctx) {
1088
+ if (!ctx.from) return;
1089
+ const userId = ctx.from.id;
1090
+ const rateResult = checkRateLimit(userId);
1091
+ if (rateResult) return;
1092
+ const fileInfo = media.getFileInfo(ctx);
1093
+ if (!fileInfo) return;
1094
+
1095
+ if (fileInfo.fileSize > MAX_MEDIA_SIZE) {
1096
+ await ctx.reply(`File too large (${(fileInfo.fileSize / 1024 / 1024).toFixed(1)}MB). Max is 50MB.`).catch(() => {});
1097
+ return;
1098
+ }
1099
+
1100
+ const item = await downloadMediaItem(ctx, fileInfo).catch(e => {
1101
+ console.error('Media download error:', e.message);
1102
+ return null;
1103
+ });
1104
+ if (!item) return;
1105
+
1106
+ const groupId = ctx.message.media_group_id;
1107
+ if (groupId) {
1108
+ const existing = mediaGroups.get(groupId);
1109
+ if (existing) {
1110
+ clearTimeout(existing.timer);
1111
+ existing.items.push(item);
1112
+ existing.ctx = ctx;
1113
+ existing.timer = setTimeout(() => {
1114
+ mediaGroups.delete(groupId);
1115
+ processMediaItems(existing.ctx, existing.items).catch(e =>
1116
+ console.error('Media group error:', e.message)
1117
+ );
1118
+ }, MEDIA_GROUP_DELAY_MS);
1119
+ } else {
1120
+ const group = {
1121
+ items: [item],
1122
+ ctx,
1123
+ timer: setTimeout(() => {
1124
+ mediaGroups.delete(groupId);
1125
+ processMediaItems(ctx, [item]).catch(e =>
1126
+ console.error('Media group error:', e.message)
1127
+ );
1128
+ }, MEDIA_GROUP_DELAY_MS),
1129
+ };
1130
+ mediaGroups.set(groupId, group);
1131
+ }
1132
+ return;
1133
+ }
1134
+
1135
+ await processMediaItems(ctx, [item]);
1136
+ }
1137
+
800
1138
  bot.on('message:photo', handleMedia);
801
1139
  bot.on('message:document', handleMedia);
802
1140
  bot.on('message:voice', handleMedia);
@@ -808,17 +1146,19 @@ Your message is deleted immediately when using /secret set to keep credentials o
808
1146
 
809
1147
  bot.on('callback_query:data', async (ctx) => {
810
1148
  const data = ctx.callbackQuery.data;
811
- if (!data.startsWith('ask:')) return ctx.answerCallbackQuery();
1149
+ const answer = (opts) => ctx.answerCallbackQuery(opts).catch(() => {});
1150
+ if (!data.startsWith('ask:')) return answer();
812
1151
  const parts = data.split(':');
813
1152
  const askId = parseInt(parts[1]);
814
1153
  const optIdx = parseInt(parts[2]);
815
1154
  const pending = pendingAsks.get(askId);
816
- if (!pending) return ctx.answerCallbackQuery({ text: 'Expired' });
1155
+ if (!pending) return answer({ text: 'Expired' });
817
1156
  const selected = pending.options[optIdx];
818
- await ctx.answerCallbackQuery({ text: selected });
1157
+ await answer({ text: selected });
819
1158
  clearTimeout(pending.timer);
820
1159
  pendingAsks.delete(askId);
821
- ctx.editMessageText(`${ctx.callbackQuery.message.text}\n\n✓ _${selected}_`, { parse_mode: 'Markdown' }).catch(() => {});
1160
+ const confirmHtml = markdownToTelegramHtml(`${ctx.callbackQuery.message.text}\n\n✓ _${selected}_`);
1161
+ ctx.editMessageText(confirmHtml, { parse_mode: 'HTML' }).catch(() => {});
822
1162
  pending.resolve(selected);
823
1163
  });
824
1164
 
@@ -877,9 +1217,8 @@ Your message is deleted immediately when using /secret set to keep credentials o
877
1217
  function formatTraits(traits) {
878
1218
  const maxLen = Math.max(...Object.keys(traits).map(k => k.length));
879
1219
  return Object.entries(traits).map(([name, val]) => {
880
- const filled = Math.round(val / 5);
881
- const bar = '█'.repeat(filled) + '░'.repeat(20 - filled);
882
- return `${name.charAt(0).toUpperCase() + name.slice(1).padEnd(maxLen)} ${bar} ${val}`;
1220
+ const label = (name.charAt(0).toUpperCase() + name.slice(1)).padEnd(maxLen + 1);
1221
+ return ` ${label}${termBar(val)} ${val}`;
883
1222
  }).join('\n');
884
1223
  }
885
1224