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 +20 -11
- package/package.json +3 -2
- package/src/claude.js +119 -14
- package/src/defaults/AGENTS.md +49 -0
- package/src/telegram.js +115 -7
- package/src/tenant.js +1 -0
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,
|
|
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
|
|
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) |
|
|
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
|
|
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
|
|
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
|
|
449
|
-
/
|
|
450
|
-
/
|
|
451
|
-
/
|
|
452
|
-
/
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
history
|
|
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
|
-
|
|
251
|
-
history.push({ role: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
914
|
+
function getMaxToolIterations() { return MAX_TOOL_ITERATIONS; }
|
|
915
|
+
function setMaxToolIterations(n) { MAX_TOOL_ITERATIONS = n; }
|
|
916
|
+
|
|
917
|
+
module.exports = { createClaude, createAnthropicClient, getMaxToolIterations, setMaxToolIterations };
|
package/src/defaults/AGENTS.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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;
|