obol-ai 0.1.0

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.
@@ -0,0 +1,377 @@
1
+ const { Bot, GrammyError, HttpError } = require('grammy');
2
+ const {
3
+ isFirstRun, markFirstRunComplete, FIRST_RUN_SYSTEM,
4
+ parseSetupResponse, cleanResponse, writePersonalityFromSetup,
5
+ } = require('./first-run');
6
+ const { loadConfig } = require('./config');
7
+ const { isPostSetupDone, runPostSetup } = require('./post-setup');
8
+ const { BackgroundRunner } = require('./background');
9
+ const { shouldEvolve, evolve } = require('./evolve');
10
+
11
+
12
+ function createBot(telegramConfig, claude, memory, messageLog) {
13
+ const bot = new Bot(telegramConfig.token);
14
+ const allowedUsers = new Set(telegramConfig.allowedUsers || []);
15
+ const firstRunHistory = []; // Separate history for onboarding conversation
16
+ const bg = new BackgroundRunner();
17
+
18
+ // Auth middleware
19
+ bot.use(async (ctx, next) => {
20
+ if (allowedUsers.size > 0 && !allowedUsers.has(ctx.from?.id)) {
21
+ return; // Silently ignore unauthorized users
22
+ }
23
+ await next();
24
+ });
25
+
26
+ // Set bot commands menu
27
+ bot.api.setMyCommands([
28
+ { command: 'new', description: 'Start a fresh conversation' },
29
+ { command: 'tasks', description: 'Show running background tasks' },
30
+ { command: 'status', description: 'Bot status and uptime' },
31
+ { command: 'backup', description: 'Trigger GitHub backup now' },
32
+ { command: 'clean', description: 'Audit and fix workspace' },
33
+ ]).catch(() => {}); // Best effort
34
+
35
+ // Handle /start
36
+ bot.command('start', async (ctx) => {
37
+ await ctx.reply('🪙 OBOL is ready. Just send me a message.');
38
+ });
39
+
40
+ // Handle /memory commands
41
+ bot.command('memory', async (ctx) => {
42
+ if (!memory) return ctx.reply('Memory not configured.');
43
+ const args = ctx.message.text.split(' ').slice(1);
44
+ const sub = args[0];
45
+
46
+ if (sub === 'search' && args[1]) {
47
+ const results = await memory.search(args.slice(1).join(' '));
48
+ if (results.length === 0) return ctx.reply('No memories found.');
49
+ const text = results.map((m, i) =>
50
+ `${i + 1}. [${m.category}] ${m.content.substring(0, 100)}`
51
+ ).join('\n');
52
+ return ctx.reply(`🔍 Found ${results.length}:\n\n${text}`);
53
+ }
54
+
55
+ if (sub === 'stats') {
56
+ const stats = await memory.stats();
57
+ return ctx.reply(`📊 ${stats.total} memories\n\n${stats.breakdown}`);
58
+ }
59
+
60
+ return ctx.reply('Usage: /memory search <query> | /memory stats');
61
+ });
62
+
63
+ // Handle /new — fresh conversation
64
+ bot.command('new', async (ctx) => {
65
+ claude.clearHistory(ctx.chat.id);
66
+ await ctx.reply('🪙 Fresh start. What\'s up?');
67
+ });
68
+
69
+ // Handle /status
70
+ bot.command('status', async (ctx) => {
71
+ const uptime = process.uptime();
72
+ const mem = (process.memoryUsage().rss / 1024 / 1024).toFixed(0);
73
+ const h = Math.floor(uptime / 3600);
74
+ const m = Math.floor((uptime % 3600) / 60);
75
+ const running = bg.getStatus();
76
+
77
+ let text = `🪙 OBOL Status\n\n`;
78
+ text += `⏱️ Uptime: ${h}h ${m}m\n`;
79
+ text += `💾 Memory: ${mem}MB\n`;
80
+ text += `⚡ Tasks: ${running.length} running\n`;
81
+
82
+ if (memory) {
83
+ const stats = await memory.stats().catch(() => null);
84
+ if (stats) text += `🧠 Memories: ${stats.total}`;
85
+ }
86
+
87
+ await ctx.reply(text);
88
+ });
89
+
90
+ // Handle /backup
91
+ bot.command('backup', async (ctx) => {
92
+ try {
93
+ const config = loadConfig();
94
+ if (!config?.github) return ctx.reply('GitHub backup not configured.');
95
+ const { runBackup } = require('./backup');
96
+ await ctx.reply('📦 Running backup...');
97
+ await runBackup(config.github);
98
+ await ctx.reply('✅ Backup complete');
99
+ } catch (e) {
100
+ await ctx.reply(`⚠️ Backup failed: ${e.message}`);
101
+ }
102
+ });
103
+
104
+ // Handle /forget
105
+ bot.command('forget', async (ctx) => {
106
+ if (!memory) return ctx.reply('Memory not configured.');
107
+ const id = ctx.message.text.split(' ')[1];
108
+ if (!id) return ctx.reply('Usage: /forget <memory-id>');
109
+ try {
110
+ await memory.forget(id);
111
+ await ctx.reply(`🗑️ Forgotten: ${id}`);
112
+ } catch (e) {
113
+ await ctx.reply(`⚠️ ${e.message}`);
114
+ }
115
+ });
116
+
117
+ // Handle /recent
118
+ bot.command('recent', async (ctx) => {
119
+ if (!memory) return ctx.reply('Memory not configured.');
120
+ const results = await memory.recent({ limit: 10 });
121
+ if (results.length === 0) return ctx.reply('No memories yet.');
122
+ const text = results.map((m, i) => {
123
+ const time = new Date(m.created_at).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
124
+ return `${i + 1}. [${time}] [${m.category}] ${m.content.substring(0, 80)}`;
125
+ }).join('\n');
126
+ await ctx.reply(`🕐 Recent memories:\n\n${text}`);
127
+ });
128
+
129
+ // Handle /today
130
+ bot.command('today', async (ctx) => {
131
+ if (!memory) return ctx.reply('Memory not configured.');
132
+ const results = await memory.byDate('today', { limit: 20 });
133
+ if (results.length === 0) return ctx.reply('Nothing stored today yet.');
134
+ const text = results.map((m, i) => {
135
+ const time = new Date(m.created_at).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
136
+ return `${i + 1}. [${time}] [${m.category}] ${m.content.substring(0, 80)}`;
137
+ }).join('\n');
138
+ await ctx.reply(`📅 Today (${results.length} memories):\n\n${text}`);
139
+ });
140
+
141
+ // Handle /clean — audit and fix workspace
142
+ bot.command('clean', async (ctx) => {
143
+ const { cleanWorkspace } = require('./clean');
144
+ await ctx.replyWithChatAction('typing');
145
+ try {
146
+ const result = await cleanWorkspace();
147
+ if (result.issues.length === 0) {
148
+ await ctx.reply('✨ Workspace is clean. Nothing out of place.');
149
+ } else {
150
+ const text = `🧹 Found ${result.issues.length} issue(s):\n\n` +
151
+ result.issues.map(i => `${i.action === 'deleted' ? '🗑️' : '📦'} ${i.path} → ${i.action}`).join('\n') +
152
+ (result.errors.length > 0 ? `\n\n⚠️ ${result.errors.length} error(s):\n${result.errors.join('\n')}` : '');
153
+ await ctx.reply(text);
154
+ }
155
+ } catch (e) {
156
+ await ctx.reply(`⚠️ Clean failed: ${e.message}`);
157
+ }
158
+ });
159
+
160
+ // Handle /tasks — show running background tasks
161
+ bot.command('tasks', async (ctx) => {
162
+ const running = bg.getStatus();
163
+ if (running.length === 0) {
164
+ return ctx.reply('No background tasks running.');
165
+ }
166
+ const text = running.map(t =>
167
+ `⏳ #${t.id}: ${t.task}... (${t.elapsed})`
168
+ ).join('\n');
169
+ return ctx.reply(`Running tasks:\n\n${text}`);
170
+ });
171
+
172
+ // Handle all text messages
173
+ bot.on('message:text', async (ctx) => {
174
+ const userMessage = ctx.message.text;
175
+ const userId = ctx.from.id;
176
+ const userName = ctx.from.first_name || 'User';
177
+
178
+ try {
179
+ // Show typing indicator
180
+ await ctx.replyWithChatAction('typing');
181
+
182
+ // Log user message
183
+ messageLog?.log(ctx.chat.id, 'user', userMessage);
184
+
185
+ let response;
186
+
187
+ // First-run onboarding — OBOL learns about the user through conversation
188
+ if (isFirstRun()) {
189
+ firstRunHistory.push({ role: 'user', content: userMessage });
190
+
191
+ const msg = await claude.client.messages.create({
192
+ model: 'claude-sonnet-4-20250514',
193
+ max_tokens: 4096,
194
+ system: FIRST_RUN_SYSTEM,
195
+ messages: firstRunHistory,
196
+ });
197
+
198
+ const fullText = msg.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
199
+ firstRunHistory.push({ role: 'assistant', content: fullText });
200
+
201
+ // Check if OBOL has gathered enough info
202
+ const setup = parseSetupResponse(fullText);
203
+ if (setup?.ready) {
204
+ const config = loadConfig();
205
+ writePersonalityFromSetup(setup, config?.bot?.name);
206
+ markFirstRunComplete();
207
+ // Reload personality in claude instance
208
+ claude.reloadPersonality?.();
209
+
210
+ // Run post-setup tasks (pass, swap, firewall)
211
+ if (!isPostSetupDone()) {
212
+ const config = loadConfig({ resolve: false });
213
+ await runPostSetup(config, async (msg) => {
214
+ await ctx.reply(msg).catch(() => {});
215
+ });
216
+ }
217
+ }
218
+
219
+ response = cleanResponse(fullText);
220
+ } else {
221
+ // Normal operation
222
+ response = await claude.chat(userMessage, {
223
+ userId,
224
+ userName,
225
+ chatId: ctx.chat.id,
226
+ bg,
227
+ ctx,
228
+ });
229
+ }
230
+
231
+ // Log assistant response
232
+ messageLog?.log(ctx.chat.id, 'assistant', response);
233
+
234
+ // Check if it's time for soul evolution
235
+ if (messageLog && await shouldEvolve().catch(() => false)) {
236
+ // Run evolution in background — don't block the response
237
+ setImmediate(async () => {
238
+ try {
239
+ const result = await evolve(claude.client, messageLog, memory);
240
+ claude.reloadPersonality?.();
241
+ let msg = `🪙 Evolution #${result.evolutionNumber} complete.`;
242
+
243
+ if (result.scriptsFixed) {
244
+ msg += '\n🔧 Fixed a test regression automatically.';
245
+ } else if (result.scriptsRolledBack) {
246
+ msg += '\n⚠️ Rolled back a script refactor — tests couldn\'t be fixed.';
247
+ }
248
+
249
+ if (result.upgrades && result.upgrades.length > 0) {
250
+ msg += '\n\n🆕 **New capabilities:**';
251
+ for (const u of result.upgrades) {
252
+ msg += `\n• **${u.name}** — ${u.description}`;
253
+ if (u.command) msg += ` → \`${u.command}\``;
254
+ }
255
+ }
256
+
257
+ if (result.deployedApps && result.deployedApps.length > 0) {
258
+ msg += '\n\n🚀 **Deployed:**';
259
+ for (const app of result.deployedApps) {
260
+ if (app.url) {
261
+ msg += `\n• ${app.name} → ${app.url}`;
262
+ } else if (app.error) {
263
+ msg += `\n• ${app.name} — deploy failed: ${app.error.substring(0, 100)}`;
264
+ }
265
+ }
266
+ }
267
+
268
+ if (result.changelog) {
269
+ msg += `\n\n_${result.changelog}_`;
270
+ }
271
+
272
+ await ctx.reply(msg, { parse_mode: 'Markdown' }).catch(() =>
273
+ ctx.reply(msg).catch(() => {})
274
+ );
275
+ } catch (e) {
276
+ console.error('Evolution failed:', e.message);
277
+ }
278
+ });
279
+ }
280
+
281
+ // Send response (split if too long)
282
+ if (response.length > 4096) {
283
+ const chunks = splitMessage(response, 4096);
284
+ for (const chunk of chunks) {
285
+ await ctx.reply(chunk, { parse_mode: 'Markdown' }).catch(() =>
286
+ ctx.reply(chunk) // Fallback without markdown if parsing fails
287
+ );
288
+ }
289
+ } else {
290
+ await ctx.reply(response, { parse_mode: 'Markdown' }).catch(() =>
291
+ ctx.reply(response)
292
+ );
293
+ }
294
+ } catch (e) {
295
+ console.error('Message handling error:', e.message);
296
+ await ctx.reply('⚠️ Something went wrong. Check logs with `obol logs`.');
297
+ }
298
+ });
299
+
300
+ // Handle photos/documents
301
+ bot.on('message:photo', async (ctx) => {
302
+ await ctx.reply('📷 Image support coming soon.');
303
+ });
304
+
305
+ // Global error handler — catch everything, never crash
306
+ bot.catch((err) => {
307
+ const ctx = err.ctx;
308
+ const e = err.error;
309
+ console.error(`[bot.catch] Error while handling update ${ctx?.update?.update_id}:`);
310
+
311
+ if (e instanceof GrammyError) {
312
+ console.error(` Grammy error: ${e.description}`);
313
+ } else if (e instanceof HttpError) {
314
+ console.error(` HTTP error: ${e.message}`);
315
+ } else {
316
+ console.error(` Unknown error:`, e?.message || e);
317
+ }
318
+
319
+ // Try to notify the user, but don't let that fail either
320
+ ctx?.reply?.('⚠️ Something went wrong. I\'m still alive though.').catch(() => {});
321
+ });
322
+
323
+ // Wrap bot.start with auto-restart on polling failures
324
+ const originalStart = bot.start.bind(bot);
325
+ bot.start = async function startWithResilience(opts = {}) {
326
+ const MAX_RETRIES = 10;
327
+ const BASE_DELAY = 1000;
328
+ let retries = 0;
329
+
330
+ const attempt = async () => {
331
+ try {
332
+ retries = 0; // Reset on successful start
333
+ await originalStart({
334
+ ...opts,
335
+ onStart: (info) => {
336
+ console.log(` Bot: @${info.username}`);
337
+ opts.onStart?.(info);
338
+ },
339
+ });
340
+ } catch (e) {
341
+ retries++;
342
+ if (retries > MAX_RETRIES) {
343
+ console.error(`💀 Polling failed ${MAX_RETRIES} times. Giving up.`);
344
+ process.exit(1);
345
+ }
346
+ const delay = Math.min(BASE_DELAY * Math.pow(2, retries - 1), 60000);
347
+ console.error(`⚠️ Polling error (attempt ${retries}/${MAX_RETRIES}): ${e.message}`);
348
+ console.error(` Retrying in ${delay / 1000}s...`);
349
+ await new Promise(r => setTimeout(r, delay));
350
+ return attempt();
351
+ }
352
+ };
353
+
354
+ return attempt();
355
+ };
356
+
357
+ return bot;
358
+ }
359
+
360
+ function splitMessage(text, maxLength) {
361
+ const chunks = [];
362
+ let remaining = text;
363
+ while (remaining.length > 0) {
364
+ if (remaining.length <= maxLength) {
365
+ chunks.push(remaining);
366
+ break;
367
+ }
368
+ // Find last newline before limit
369
+ let splitAt = remaining.lastIndexOf('\n', maxLength);
370
+ if (splitAt === -1 || splitAt < maxLength / 2) splitAt = maxLength;
371
+ chunks.push(remaining.substring(0, splitAt));
372
+ remaining = remaining.substring(splitAt).trimStart();
373
+ }
374
+ return chunks;
375
+ }
376
+
377
+ module.exports = { createBot };
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Shared test utilities for OBOL.
4
+ * Used by both core tests and Opus-generated script tests.
5
+ *
6
+ * Usage:
7
+ * const { test, run, runFail, report } = require('obol/src/test-utils');
8
+ *
9
+ * test('should do X', () => {
10
+ * const out = run('my-script.js', '--flag value');
11
+ * if (!out.includes('expected')) throw new Error('missing expected output');
12
+ * });
13
+ *
14
+ * report();
15
+ */
16
+
17
+ const { execSync } = require('child_process');
18
+ const path = require('path');
19
+
20
+ let passed = 0;
21
+ let failed = 0;
22
+ let suiteName = '';
23
+
24
+ /**
25
+ * Set the suite name (printed as header)
26
+ */
27
+ function suite(name) {
28
+ suiteName = name;
29
+ console.log(`\n${name}`);
30
+ }
31
+
32
+ /**
33
+ * Run a single test case
34
+ */
35
+ function test(name, fn) {
36
+ try {
37
+ fn();
38
+ passed++;
39
+ console.log(` ✅ ${name}`);
40
+ } catch (e) {
41
+ failed++;
42
+ console.error(` ❌ ${name}: ${e.message}`);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Execute a script and return stdout. Throws on non-zero exit.
48
+ * @param {string} scriptPath - Absolute path or relative to cwd
49
+ * @param {string} args - CLI arguments
50
+ * @param {object} opts - { env, timeout, cwd }
51
+ */
52
+ function run(scriptPath, args = '', opts = {}) {
53
+ const ext = path.extname(scriptPath);
54
+ const cmd = ext === '.sh' ? 'bash' : 'node';
55
+ const timeout = opts.timeout || 30000;
56
+ return execSync(`${cmd} "${scriptPath}" ${args}`, {
57
+ encoding: 'utf-8',
58
+ timeout,
59
+ cwd: opts.cwd || process.cwd(),
60
+ env: { ...process.env, ...opts.env },
61
+ stdio: ['pipe', 'pipe', 'pipe'],
62
+ }).trim();
63
+ }
64
+
65
+ /**
66
+ * Execute a script expecting failure (non-zero exit).
67
+ * Returns true if it failed, false if it succeeded (which is unexpected).
68
+ */
69
+ function runFail(scriptPath, args = '', opts = {}) {
70
+ try {
71
+ run(scriptPath, args, opts);
72
+ return false; // Should have failed
73
+ } catch {
74
+ return true; // Expected failure
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Assert helper — throws if condition is false
80
+ */
81
+ function assert(condition, message) {
82
+ if (!condition) throw new Error(message || 'assertion failed');
83
+ }
84
+
85
+ /**
86
+ * Assert two values are equal
87
+ */
88
+ function assertEqual(actual, expected, message) {
89
+ if (actual !== expected) {
90
+ throw new Error(message || `expected "${expected}", got "${actual}"`);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Assert string includes substring
96
+ */
97
+ function assertIncludes(str, substr, message) {
98
+ if (!str.includes(substr)) {
99
+ throw new Error(message || `expected "${str}" to include "${substr}"`);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Print results and exit with appropriate code
105
+ */
106
+ function report() {
107
+ console.log(`\n${passed} passed, ${failed} failed`);
108
+ if (failed > 0) process.exit(1);
109
+ }
110
+
111
+ module.exports = { suite, test, run, runFail, assert, assertEqual, assertIncludes, report };