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 +67 -3
- package/bin/obol.js +8 -0
- package/package.json +1 -1
- package/src/claude.js +34 -10
- package/src/cli/config.js +34 -4
- package/src/cli/init.js +15 -1
- package/src/cli/upgrade.js +71 -0
- package/src/config.js +3 -1
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.
|
|
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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "obol-ai",
|
|
3
|
-
"version": "0.1.
|
|
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\`,
|
|
294
|
+
When storing NEW user secrets with \`pass\`, use the prefix \`${passPrefix}/\`.
|
|
286
295
|
Example: \`pass insert ${passPrefix}/gmail-key\`
|
|
287
|
-
|
|
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 ? {
|
|
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
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]);
|