obol-ai 0.1.5 → 0.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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
@@ -47,6 +47,8 @@ function createAnthropicClient(anthropicConfig, { useOAuth = true } = {}) {
47
47
  throw new Error('No Anthropic credentials configured. Run: obol config');
48
48
  }
49
49
 
50
+ let _refreshPromise = null;
51
+
50
52
  async function ensureFreshToken(anthropicConfig) {
51
53
  if (!anthropicConfig.oauth?.accessToken) return;
52
54
  if (!isExpired(anthropicConfig.oauth)) return;
@@ -60,27 +62,56 @@ async function ensureFreshToken(anthropicConfig) {
60
62
  throw err;
61
63
  }
62
64
 
63
- try {
64
- const tokens = await refreshTokens(anthropicConfig.oauth.refreshToken);
65
- anthropicConfig.oauth.accessToken = tokens.accessToken;
66
- anthropicConfig.oauth.refreshToken = tokens.refreshToken;
67
- anthropicConfig.oauth.expires = tokens.expires;
68
- delete anthropicConfig._oauthFailed;
69
-
70
- const config = loadConfig({ resolve: false });
71
- if (config) {
72
- config.anthropic.oauth = anthropicConfig.oauth;
73
- saveConfig(config);
74
- }
75
- } catch (e) {
76
- if (anthropicConfig.apiKey) {
77
- console.warn('[oauth] Token refresh failed, falling back to API key:', e.message);
78
- anthropicConfig._oauthFailed = true;
79
- } else {
80
- const err = new Error(`OAuth token expired and refresh failed. Re-authenticate with: obol config → Anthropic → OAuth. (${e.message})`);
81
- err.isOAuthExpiry = true;
82
- throw err;
65
+ if (_refreshPromise) {
66
+ try {
67
+ await _refreshPromise;
68
+ } catch {}
69
+ if (!isExpired(anthropicConfig.oauth)) return;
70
+ if (anthropicConfig._oauthFailed) return;
71
+ }
72
+
73
+ _refreshPromise = (async () => {
74
+ try {
75
+ const tokens = await refreshTokens(anthropicConfig.oauth.refreshToken);
76
+ console.log('[oauth] Refresh succeeded, new refresh token:', !!tokens.refreshToken);
77
+ anthropicConfig.oauth.accessToken = tokens.accessToken;
78
+ if (tokens.refreshToken) anthropicConfig.oauth.refreshToken = tokens.refreshToken;
79
+ anthropicConfig.oauth.expires = tokens.expires;
80
+ delete anthropicConfig._oauthFailed;
81
+
82
+ const config = loadConfig({ resolve: false });
83
+ if (config) {
84
+ config.anthropic.oauth = anthropicConfig.oauth;
85
+ saveConfig(config);
86
+ }
87
+ } catch (e) {
88
+ console.warn('[oauth] Refresh failed, checking disk for updated tokens:', e.message);
89
+ const diskConfig = loadConfig({ resolve: false });
90
+ if (diskConfig?.anthropic?.oauth?.accessToken &&
91
+ diskConfig.anthropic.oauth.accessToken !== anthropicConfig.oauth.accessToken &&
92
+ !isExpired(diskConfig.anthropic.oauth)) {
93
+ anthropicConfig.oauth.accessToken = diskConfig.anthropic.oauth.accessToken;
94
+ anthropicConfig.oauth.refreshToken = diskConfig.anthropic.oauth.refreshToken;
95
+ anthropicConfig.oauth.expires = diskConfig.anthropic.oauth.expires;
96
+ delete anthropicConfig._oauthFailed;
97
+ return;
98
+ }
99
+
100
+ if (anthropicConfig.apiKey) {
101
+ console.warn('[oauth] Token refresh failed, falling back to API key:', e.message);
102
+ anthropicConfig._oauthFailed = true;
103
+ } else {
104
+ const err = new Error(`OAuth token expired and refresh failed: ${e.message}`);
105
+ err.isOAuthExpiry = true;
106
+ throw err;
107
+ }
83
108
  }
109
+ })();
110
+
111
+ try {
112
+ await _refreshPromise;
113
+ } finally {
114
+ _refreshPromise = null;
84
115
  }
85
116
  }
86
117
 
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 { generatePKCE, buildAuthorizationUrl, exchangeCodeForTokens } = require('../oauth');
3
4
  const { spawnSync } = require('child_process');
4
5
  const fs = require('fs');
5
6
 
@@ -8,8 +9,9 @@ const SECTIONS = [
8
9
  name: 'Anthropic',
9
10
  fields: [
10
11
  { key: 'anthropic.apiKey', label: 'API Key', secret: true },
11
- { key: 'anthropic.oauth.accessToken', label: 'OAuth Access Token', secret: true },
12
- { key: 'anthropic.oauth.refreshToken', label: 'OAuth Refresh Token', secret: true },
12
+ { key: '_oauth_flow', label: 'Set up / reset OAuth', custom: 'oauth' },
13
+ { key: 'anthropic.oauth.accessToken', label: 'OAuth Access Token (manual)', secret: true },
14
+ { key: 'anthropic.oauth.refreshToken', label: 'OAuth Refresh Token (manual)', secret: true },
13
15
  ],
14
16
  },
15
17
  {
@@ -266,6 +268,59 @@ async function manageUsers(cfg) {
266
268
  }
267
269
  }
268
270
 
271
+ async function runOAuthFlow(cfg) {
272
+ console.log('\n Starting OAuth flow with Anthropic...\n');
273
+
274
+ const { verifier, challenge } = await generatePKCE();
275
+ const authUrl = buildAuthorizationUrl(challenge, verifier);
276
+
277
+ console.log(' 1. Open this URL in your browser:\n');
278
+ console.log(` ${authUrl}\n`);
279
+ console.log(' 2. Authorize the app, then copy the FULL redirect URL from your browser.\n');
280
+ console.log(' It will look like: https://console.anthropic.com/oauth/code/callback?code=XXXXX#STATE\n');
281
+
282
+ const { callbackInput } = await inquirer.prompt([{
283
+ type: 'input',
284
+ name: 'callbackInput',
285
+ message: 'Paste the full callback URL or just the code:',
286
+ validate: (v) => v.trim().length > 0 ? true : 'Required',
287
+ }]);
288
+
289
+ try {
290
+ const input = callbackInput.trim();
291
+ let code, state;
292
+
293
+ if (input.includes('code=')) {
294
+ const url = new URL(input);
295
+ code = url.searchParams.get('code');
296
+ state = url.hash?.replace('#', '') || verifier;
297
+ } else if (input.includes('#')) {
298
+ [code, state] = input.split('#');
299
+ } else {
300
+ code = input;
301
+ state = verifier;
302
+ }
303
+
304
+ if (!code) {
305
+ console.log(' No code found\n');
306
+ return;
307
+ }
308
+
309
+ console.log(' Exchanging code for tokens...');
310
+ const tokens = await exchangeCodeForTokens(code, state, verifier);
311
+
312
+ setNestedValue(cfg, 'anthropic.oauth.accessToken', tokens.accessToken);
313
+ setNestedValue(cfg, 'anthropic.oauth.refreshToken', tokens.refreshToken);
314
+ setNestedValue(cfg, 'anthropic.oauth.expires', tokens.expires);
315
+ saveConfig(cfg);
316
+
317
+ console.log(' OAuth configured with access + refresh token');
318
+ console.log(` Token expires: ${new Date(tokens.expires).toISOString()}\n`);
319
+ } catch (e) {
320
+ console.log(` OAuth flow failed: ${e.message}\n`);
321
+ }
322
+ }
323
+
269
324
  async function config() {
270
325
  const cfg = loadConfig({ resolve: false });
271
326
  if (!cfg) {
@@ -301,6 +356,20 @@ async function config() {
301
356
 
302
357
  const fields = sec.fields;
303
358
  const fieldChoices = fields.map(f => {
359
+ if (f.custom) {
360
+ const hasOAuth = !!getNestedValue(cfg, 'anthropic.oauth.accessToken');
361
+ const hasRefresh = !!getNestedValue(cfg, 'anthropic.oauth.refreshToken');
362
+ const expires = getNestedValue(cfg, 'anthropic.oauth.expires');
363
+ const expired = expires && Date.now() >= expires;
364
+ let status = '';
365
+ if (hasOAuth && hasRefresh && !expired) status = ' ✅';
366
+ else if (hasOAuth && expired) status = ' ⚠️ expired';
367
+ else if (hasOAuth && !hasRefresh) status = ' ⚠️ no refresh token';
368
+ return {
369
+ name: `${f.label}${status}`,
370
+ value: f,
371
+ };
372
+ }
304
373
  const val = getNestedValue(cfg, f.key);
305
374
  return {
306
375
  name: `${f.label}: ${formatValue(val, f.secret)}`,
@@ -317,6 +386,11 @@ async function config() {
317
386
 
318
387
  if (!field) continue;
319
388
 
389
+ if (field.custom === 'oauth') {
390
+ await runOAuthFlow(cfg);
391
+ continue;
392
+ }
393
+
320
394
  const currentVal = getNestedValue(cfg, field.key);
321
395
 
322
396
  if (field.type === 'boolean') {
package/src/cli/init.js CHANGED
@@ -389,15 +389,9 @@ async function setupAnthropicOAuth() {
389
389
  const input = callbackInput.trim();
390
390
 
391
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
- };
392
+ console.log(' That\'s a raw token, not a callback URL.');
393
+ console.log(' Paste the full URL from your browser after authorizing.\n');
394
+ return await setupAnthropicOAuth();
401
395
  }
402
396
 
403
397
  let code, state;
package/src/oauth.js CHANGED
@@ -82,10 +82,11 @@ async function refreshTokens(refreshToken) {
82
82
  }
83
83
 
84
84
  const data = await res.json();
85
+ console.log(`[oauth] Refresh response: expires_in=${data.expires_in}, has_refresh=${!!data.refresh_token}`);
85
86
  return {
86
87
  accessToken: data.access_token,
87
- refreshToken: data.refresh_token,
88
- expires: Date.now() + data.expires_in * 1000 - REFRESH_BUFFER_MS,
88
+ refreshToken: data.refresh_token || null,
89
+ expires: Date.now() + (data.expires_in || 3600) * 1000 - REFRESH_BUFFER_MS,
89
90
  };
90
91
  }
91
92
 
package/src/telegram.js CHANGED
@@ -392,7 +392,8 @@ function createBot(telegramConfig, config) {
392
392
  stopTyping();
393
393
  console.error('Message handling error:', e.message);
394
394
  if (e.isOAuthExpiry) {
395
- await ctx.reply('OAuth token expired. Run `obol config` → Anthropic → re-authenticate OAuth.').catch(() => {});
395
+ console.error('[oauth] Full error:', e.stack || e.message);
396
+ await ctx.reply(`OAuth error: ${e.message}\n\nRun \`obol config\` → Anthropic → re-authenticate OAuth.`).catch(() => {});
396
397
  } else if (e.status === 401 || e.message?.includes('401')) {
397
398
  await ctx.reply('API key invalid or expired. Run `obol config` to update.').catch(() => {});
398
399
  } else if (e.status === 429 || e.message?.includes('rate')) {