obol-ai 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,10 +41,10 @@ Named after the AI in [The Last Instruction](https://latentpress.com) — a mach
41
41
  ```bash
42
42
  npm install -g obol-ai
43
43
  obol init
44
- obol start
44
+ obol start -d
45
45
  ```
46
46
 
47
- The init wizard walks you through everything — credentials are validated inline, and your Telegram ID is auto-detected. OBOL handles the rest.
47
+ The init wizard walks you through everything — credentials are validated inline, and your Telegram ID is auto-detected. `obol start -d` runs as a background daemon via pm2 (auto-installs pm2 if missing).
48
48
 
49
49
  ## How It Works
50
50
 
@@ -339,7 +339,7 @@ For Telegram user IDs, OBOL auto-detects by checking who messaged the bot. Just
339
339
 
340
340
  ### First Conversation
341
341
 
342
- Send your first message. OBOL introduces itself, asks 2-3 questions, then writes its own SOUL.md and USER.md. After that, it silently hardens your VPS (Linux only — skipped on macOS/Windows):
342
+ Send your first message. OBOL introduces itself, asks 2-3 questions, then writes its own SOUL.md and USER.md. After that, it hardens your VPS and reports progress directly in the Telegram chat (Linux only — skipped on macOS/Windows):
343
343
 
344
344
  | Task | What |
345
345
  |------|------|
@@ -354,6 +354,69 @@ Send your first message. OBOL introduces itself, asks 2-3 questions, then writes
354
354
 
355
355
  > ⚠️ After first run, SSH moves to port 2222: `ssh -p 2222 root@YOUR_IP`
356
356
 
357
+ ## Running the Bot
358
+
359
+ ### Foreground (testing)
360
+
361
+ ```bash
362
+ obol start
363
+ ```
364
+
365
+ Logs print to stdout. Ctrl+C to stop.
366
+
367
+ ### Daemon (production)
368
+
369
+ ```bash
370
+ obol start -d
371
+ ```
372
+
373
+ This uses pm2 under the hood (auto-installs if needed). The bot auto-restarts on crash and survives reboots.
374
+
375
+ ```bash
376
+ obol status # check if running + uptime + memory
377
+ obol logs # tail logs
378
+ obol stop # stop the daemon
379
+
380
+ # pm2 commands also work directly
381
+ pm2 logs obol # tail logs
382
+ pm2 restart obol # restart
383
+ pm2 monit # live dashboard
384
+ ```
385
+
386
+ To survive server reboots:
387
+
388
+ ```bash
389
+ pm2 startup
390
+ pm2 save
391
+ ```
392
+
393
+ ### Authentication
394
+
395
+ OBOL supports two Anthropic auth methods:
396
+
397
+ | Method | How | Fallback |
398
+ |--------|-----|----------|
399
+ | **API Key** | `sk-ant-...` from console.anthropic.com | — |
400
+ | **Claude Max OAuth** | Browser sign-in during `obol init` | Auto-refreshes tokens; falls back to API key if refresh fails |
401
+
402
+ You can configure both during init. If OAuth tokens expire and refresh fails, OBOL silently falls back to the API key.
403
+
404
+ ### Secret Storage (pass)
405
+
406
+ On Linux, OBOL auto-encrypts all credentials on first boot:
407
+
408
+ 1. Installs GPG + `pass`
409
+ 2. Migrates plaintext secrets from `config.json` into the encrypted store
410
+ 3. Config values become references like `pass:obol/anthropic-key`
411
+
412
+ If a pass key is missing at runtime, the value resolves to `null` and OBOL falls back gracefully (skips OAuth, uses API key, etc). You'll see a one-time error in logs.
413
+
414
+ ```bash
415
+ pass ls # list stored secrets
416
+ pass show obol/anthropic-key # reveal a secret
417
+ pass insert obol/my-secret # add a new secret
418
+ ```
419
+
357
420
  ## Resilience
358
421
 
359
422
  OBOL is designed to stay alive without babysitting:
@@ -404,6 +467,7 @@ obol stop # Stop (pm2 or PID fallback)
404
467
  obol logs # Tail logs (pm2 or log file fallback)
405
468
  obol status # Status
406
469
  obol backup # Manual backup
470
+ obol upgrade # Update to latest version
407
471
  ```
408
472
 
409
473
  ## Directory Structure
package/bin/obol.js CHANGED
@@ -70,4 +70,12 @@ program
70
70
  await backup();
71
71
  });
72
72
 
73
+ program
74
+ .command('upgrade')
75
+ .description('Update to the latest version')
76
+ .action(async () => {
77
+ const { upgrade } = require('../src/cli/upgrade');
78
+ await upgrade();
79
+ });
80
+
73
81
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Self-evolving AI assistant that learns, remembers, and acts on its own. Persistent vector memory, self-rewriting personality, proactive heartbeats.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/claude.js CHANGED
@@ -31,7 +31,7 @@ const SENSITIVE_READ_PATHS = [
31
31
  ];
32
32
 
33
33
  function createAnthropicClient(anthropicConfig, { useOAuth = true } = {}) {
34
- if (useOAuth && anthropicConfig.oauth) {
34
+ if (useOAuth && anthropicConfig.oauth?.accessToken) {
35
35
  return new Anthropic({
36
36
  apiKey: null,
37
37
  authToken: anthropicConfig.oauth.accessToken,
@@ -48,8 +48,17 @@ function createAnthropicClient(anthropicConfig, { useOAuth = true } = {}) {
48
48
  }
49
49
 
50
50
  async function ensureFreshToken(anthropicConfig) {
51
- if (!anthropicConfig.oauth) return;
51
+ if (!anthropicConfig.oauth?.accessToken) return;
52
52
  if (!isExpired(anthropicConfig.oauth)) return;
53
+ if (!anthropicConfig.oauth.refreshToken) {
54
+ if (anthropicConfig.apiKey) {
55
+ anthropicConfig._oauthFailed = true;
56
+ return;
57
+ }
58
+ const err = new Error('OAuth token expired and no refresh token available. Re-authenticate with: obol config → Anthropic → OAuth');
59
+ err.isOAuthExpiry = true;
60
+ throw err;
61
+ }
53
62
 
54
63
  try {
55
64
  const tokens = await refreshTokens(anthropicConfig.oauth.refreshToken);
@@ -77,7 +86,7 @@ async function ensureFreshToken(anthropicConfig) {
77
86
 
78
87
  function createClaude(anthropicConfig, { personality, memory, userDir, bridgeEnabled }) {
79
88
  let client = createAnthropicClient(anthropicConfig);
80
- const useOAuth = !!anthropicConfig.oauth;
89
+ const useOAuth = !!anthropicConfig.oauth?.accessToken;
81
90
 
82
91
  const baseSystemPrompt = buildSystemPrompt(personality, userDir, { bridgeEnabled });
83
92
 
@@ -282,9 +291,14 @@ ${workDir}/
282
291
 
283
292
  ## Secrets (pass)
284
293
 
285
- When storing secrets with \`pass\`, ALWAYS use the prefix \`${passPrefix}/\`.
294
+ When storing NEW user secrets with \`pass\`, use the prefix \`${passPrefix}/\`.
286
295
  Example: \`pass insert ${passPrefix}/gmail-key\`
287
- Shared bot credentials (Anthropic, Telegram, Supabase) live under \`obol/\` — do NOT touch those.
296
+
297
+ Shared bot credentials live under \`obol/\` — do NOT touch or re-create these:
298
+ \`obol/anthropic-key\`, \`obol/telegram-token\`, \`obol/supabase-url\`, \`obol/supabase-key\`, \`obol/github-token\`, \`obol/vercel-token\`
299
+
300
+ To check if a secret exists: \`pass show obol/github-token\`
301
+ To list all secrets: \`pass ls\`
288
302
  `);
289
303
 
290
304
  if (opts.bridgeEnabled) {
@@ -509,13 +523,19 @@ async function executeToolCall(toolUse, memory, context = {}) {
509
523
  }
510
524
  }
511
525
  const timeout = Math.min(input.timeout || 30, MAX_EXEC_TIMEOUT) * 1000;
526
+ const realHome = process.env.HOME || '/root';
512
527
  const output = execSync(input.command, {
513
528
  encoding: 'utf-8',
514
529
  timeout,
515
530
  maxBuffer: 1024 * 1024,
516
531
  stdio: ['pipe', 'pipe', 'pipe'],
517
532
  cwd: userDir || undefined,
518
- env: userDir ? { ...process.env, HOME: userDir } : process.env,
533
+ env: userDir ? {
534
+ ...process.env,
535
+ HOME: userDir,
536
+ GNUPGHOME: process.env.GNUPGHOME || `${realHome}/.gnupg`,
537
+ PASSWORD_STORE_DIR: process.env.PASSWORD_STORE_DIR || `${realHome}/.password-store`,
538
+ } : process.env,
519
539
  });
520
540
  const truncated = output.substring(0, 10000);
521
541
  return output.length > 10000 ? truncated + '\n...(truncated)' : truncated;
@@ -584,10 +604,14 @@ async function executeToolCall(toolUse, memory, context = {}) {
584
604
  case 'vercel_list': {
585
605
  const token = context.config?.vercel?.token;
586
606
  if (!token) return 'Vercel not configured.';
587
- const output = execSync(
588
- `npx vercel ls ${input.project} 2>&1`,
589
- { encoding: 'utf-8', timeout: 30000, env: { ...process.env, VERCEL_TOKEN: token } }
590
- );
607
+ const listArgs = ['vercel', 'ls'];
608
+ if (input.project) {
609
+ const safeProject = input.project.replace(/[^a-zA-Z0-9_\-./]/g, '');
610
+ if (safeProject) listArgs.push(safeProject);
611
+ }
612
+ const output = execFileSync('npx', listArgs, {
613
+ encoding: 'utf-8', timeout: 30000, env: { ...process.env, VERCEL_TOKEN: token },
614
+ });
591
615
  return output.substring(0, 5000);
592
616
  }
593
617
 
package/src/cli/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const inquirer = require('inquirer');
2
2
  const { loadConfig, saveConfig, CONFIG_FILE, ensureUserDir, getUserDir } = require('../config');
3
+ const { spawnSync } = require('child_process');
3
4
  const fs = require('fs');
4
5
 
5
6
  const SECTIONS = [
@@ -98,10 +99,29 @@ function maskSecret(value) {
98
99
  function formatValue(value, secret) {
99
100
  if (value === undefined || value === null) return '(not set)';
100
101
  if (Array.isArray(value)) return value.join(', ');
102
+ if (typeof value === 'string' && value.startsWith('pass:')) {
103
+ if (!secret) return value;
104
+ const passKey = value.slice(5);
105
+ try {
106
+ const { execSync } = require('child_process');
107
+ const resolved = execSync(`pass show ${passKey}`, { encoding: 'utf-8' }).trim();
108
+ return maskSecret(resolved);
109
+ } catch {
110
+ return '(pass key missing)';
111
+ }
112
+ }
101
113
  if (secret) return maskSecret(value);
102
114
  return String(value);
103
115
  }
104
116
 
117
+ function updatePassSecret(passKey, newValue) {
118
+ const result = spawnSync('pass', ['insert', '-f', '-m', passKey], {
119
+ input: newValue,
120
+ stdio: ['pipe', 'pipe', 'pipe'],
121
+ });
122
+ return result.status === 0;
123
+ }
124
+
105
125
  async function detectTelegramUsers(token) {
106
126
  if (!token) return null;
107
127
  const res = await fetch(`https://api.telegram.org/bot${token}/getUpdates?limit=50`);
@@ -318,6 +338,7 @@ async function config() {
318
338
  setNestedValue(cfg, field.key, parseInt(newVal.trim()));
319
339
  } else {
320
340
  const promptType = field.secret ? 'password' : 'input';
341
+ const isPassRef = typeof currentVal === 'string' && currentVal.startsWith('pass:');
321
342
  const opts = {
322
343
  type: promptType,
323
344
  name: 'newVal',
@@ -325,14 +346,23 @@ async function config() {
325
346
  };
326
347
  if (field.secret) {
327
348
  opts.mask = '*';
328
- if (currentVal) {
329
- console.log(` Current: ${maskSecret(currentVal)}`);
330
- }
349
+ console.log(` Current: ${formatValue(currentVal, true)}`);
331
350
  } else {
332
351
  opts.default = currentVal != null ? String(currentVal) : '';
333
352
  }
334
353
  const { newVal } = await inquirer.prompt([opts]);
335
- setNestedValue(cfg, field.key, newVal);
354
+
355
+ if (isPassRef) {
356
+ const passKey = currentVal.slice(5);
357
+ if (updatePassSecret(passKey, newVal)) {
358
+ console.log(` ✅ Updated in pass store (${passKey})`);
359
+ } else {
360
+ console.log(` ⚠️ Failed to update pass store, saving to config directly`);
361
+ setNestedValue(cfg, field.key, newVal);
362
+ }
363
+ } else {
364
+ setNestedValue(cfg, field.key, newVal);
365
+ }
336
366
  }
337
367
 
338
368
  saveConfig(cfg);
package/src/cli/init.js CHANGED
@@ -357,6 +357,7 @@ async function init(opts = {}) {
357
357
  obol start -d Start as background daemon
358
358
  obol config Edit configuration later
359
359
  obol status Check bot status
360
+ obol upgrade Update to latest version
360
361
 
361
362
  Config: ${CONFIG_FILE}
362
363
  `);
@@ -385,9 +386,22 @@ async function setupAnthropicOAuth() {
385
386
  validate: (v) => v.trim().length > 0 ? true : 'Required',
386
387
  }]);
387
388
 
388
- let code, state;
389
389
  const input = callbackInput.trim();
390
390
 
391
+ if (input.includes('sk-ant-oat')) {
392
+ console.log(' ✅ OAuth token detected — using directly');
393
+ console.log(' ⚠️ No refresh token — you\'ll need to re-auth when it expires\n');
394
+ return {
395
+ oauth: {
396
+ accessToken: input,
397
+ refreshToken: null,
398
+ expires: Date.now() + 60 * 60 * 1000,
399
+ },
400
+ };
401
+ }
402
+
403
+ let code, state;
404
+
391
405
  if (input.includes('code=')) {
392
406
  const url = new URL(input);
393
407
  code = url.searchParams.get('code');
@@ -0,0 +1,71 @@
1
+ const { execSync } = require('child_process');
2
+ const pkg = require('../../package.json');
3
+
4
+ /** @returns {string|null} */
5
+ function getLatestVersion() {
6
+ try {
7
+ return execSync(`npm view ${pkg.name} version`, { encoding: 'utf-8' }).trim();
8
+ } catch {
9
+ return null;
10
+ }
11
+ }
12
+
13
+ /** @returns {boolean} */
14
+ function isBotRunning() {
15
+ try {
16
+ const list = execSync('pm2 jlist', { encoding: 'utf-8' });
17
+ const procs = JSON.parse(list);
18
+ const obol = procs.find(p => p.name === 'obol');
19
+ return obol?.pm2_env?.status === 'online';
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ async function upgrade() {
26
+ const current = pkg.version;
27
+ console.log(`🪙 Current version: ${current}`);
28
+
29
+ const latest = getLatestVersion();
30
+ if (!latest) {
31
+ console.error(' ❌ Could not reach npm registry');
32
+ process.exit(1);
33
+ }
34
+
35
+ if (current === latest) {
36
+ console.log(` ✅ Already on latest (${latest})`);
37
+ return;
38
+ }
39
+
40
+ console.log(` ⬆ New version available: ${latest}\n`);
41
+
42
+ const wasRunning = isBotRunning();
43
+
44
+ if (wasRunning) {
45
+ console.log(' Stopping bot...');
46
+ try {
47
+ execSync('pm2 stop obol', { stdio: 'pipe' });
48
+ } catch {}
49
+ }
50
+
51
+ console.log(' Installing update...');
52
+ try {
53
+ execSync(`npm install -g ${pkg.name}@latest`, { stdio: 'inherit' });
54
+ } catch (e) {
55
+ console.error(`\n ❌ Update failed: ${e.message}`);
56
+ if (wasRunning) {
57
+ console.log(' Restarting bot...');
58
+ execSync('pm2 start obol', { stdio: 'pipe' });
59
+ }
60
+ process.exit(1);
61
+ }
62
+
63
+ if (wasRunning) {
64
+ console.log('\n Restarting bot...');
65
+ execSync('pm2 start obol', { stdio: 'pipe' });
66
+ }
67
+
68
+ console.log(`\n🪙 Upgraded to ${latest}`);
69
+ }
70
+
71
+ module.exports = { upgrade };
package/src/config.js CHANGED
@@ -23,7 +23,9 @@ function resolvePassValues(obj) {
23
23
  const { execSync } = require('child_process');
24
24
  result[key] = execSync(`pass show ${passKey}`, { encoding: 'utf-8' }).trim();
25
25
  } catch (e) {
26
- console.warn(`[config] Failed to resolve pass:${passKey} — ${e.message?.includes('not found') ? 'key not found' : 'pass not installed or unavailable'}`);
26
+ const reason = e.message?.includes('not found') ? 'key not found' : 'pass not installed or unavailable';
27
+ console.error(`[config] Failed to resolve ${passKey} — ${reason}`);
28
+ result[key] = null;
27
29
  }
28
30
  } else if (typeof result[key] === 'object') {
29
31
  result[key] = resolvePassValues(result[key]);