obol-ai 0.2.14 → 0.2.16

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.
Files changed (62) hide show
  1. package/.claude/settings.local.json +2 -16
  2. package/CHANGELOG.md +152 -0
  3. package/README.md +6 -10
  4. package/package.json +4 -2
  5. package/src/background.js +3 -1
  6. package/src/claude/cache.js +18 -0
  7. package/src/claude/chat.js +242 -0
  8. package/src/claude/client.js +90 -0
  9. package/src/claude/constants.js +64 -0
  10. package/src/claude/index.js +5 -0
  11. package/src/claude/prompt.js +261 -0
  12. package/src/claude/router.js +94 -0
  13. package/src/claude/tool-registry.js +115 -0
  14. package/src/claude/tools/background.js +24 -0
  15. package/src/claude/tools/bridge.js +20 -0
  16. package/src/claude/tools/exec.js +51 -0
  17. package/src/claude/tools/files.js +117 -0
  18. package/src/claude/tools/history.js +48 -0
  19. package/src/claude/tools/memory.js +87 -0
  20. package/src/claude/tools/scheduler.js +113 -0
  21. package/src/claude/tools/secrets.js +57 -0
  22. package/src/claude/tools/telegram.js +22 -0
  23. package/src/claude/tools/tts.js +60 -0
  24. package/src/claude/tools/vercel.js +67 -0
  25. package/src/claude/tools/web.js +28 -0
  26. package/src/claude/utils.js +59 -0
  27. package/src/cli/changelog.js +83 -0
  28. package/src/cli/upgrade.js +39 -5
  29. package/src/db/migrate.js +5 -0
  30. package/src/defaults/AGENTS.md +1 -1
  31. package/src/evolve/apps.js +58 -0
  32. package/src/evolve/backup.js +12 -0
  33. package/src/evolve/check.js +28 -0
  34. package/src/evolve/evolve.js +481 -0
  35. package/src/evolve/filesystem.js +31 -0
  36. package/src/evolve/index.js +6 -0
  37. package/src/evolve/memory.js +79 -0
  38. package/src/evolve/prompts.js +180 -0
  39. package/src/evolve/state.js +21 -0
  40. package/src/evolve/tests.js +39 -0
  41. package/src/memory.js +23 -1
  42. package/src/messages.js +130 -98
  43. package/src/telegram/bot.js +166 -0
  44. package/src/telegram/commands/admin.js +94 -0
  45. package/src/telegram/commands/conversation.js +62 -0
  46. package/src/telegram/commands/memory.js +69 -0
  47. package/src/telegram/commands/secrets.js +60 -0
  48. package/src/telegram/commands/status.js +137 -0
  49. package/src/telegram/commands/tools.js +67 -0
  50. package/src/telegram/commands/traits.js +49 -0
  51. package/src/telegram/constants.js +31 -0
  52. package/src/telegram/handlers/callbacks.js +46 -0
  53. package/src/telegram/handlers/media.js +196 -0
  54. package/src/telegram/handlers/text.js +321 -0
  55. package/src/telegram/index.js +3 -0
  56. package/src/telegram/rate-limit.js +56 -0
  57. package/src/telegram/utils.js +81 -0
  58. package/src/telegram/voice.js +140 -0
  59. package/src/tenant.js +1 -1
  60. package/src/claude.js +0 -1422
  61. package/src/evolve.js +0 -925
  62. package/src/telegram.js +0 -1413
@@ -1,23 +1,9 @@
1
1
  {
2
2
  "permissions": {
3
3
  "allow": [
4
- "Bash(git push:*)",
5
- "Bash(for f in src/*.js src/cli/*.js src/db/*.js)",
6
- "Bash(do node -c \"$f\")",
7
- "Bash(echo:*)",
8
- "Bash(done)",
9
- "Bash(git -C /Users/jovinkenroye/Sites/obol log --oneline -15)",
10
- "Bash(git -C /Users/jovinkenroye/Sites/obol diff --stat HEAD)",
11
- "Bash(gh api:*)",
12
- "Bash(grep:*)",
13
- "Bash(/Users/jovinkenroye/Sites/obol/tests/mock-grammy.test.js:*)",
14
- "Bash(git -C /Users/jovinkenroye/Sites/obol diff --stat)",
15
- "Bash(git -C /Users/jovinkenroye/Sites/obol add:*)",
16
- "Bash(git -C:*)",
17
- "Bash(pass ls:*)",
4
+ "Bash(*)",
18
5
  "mcp__context7__query-docs",
19
- "Bash(git stash pop:*)",
20
6
  "mcp__context7__resolve-library-id"
21
7
  ]
22
8
  }
23
- }
9
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,152 @@
1
+ ## 0.2.16
2
+ - add chat_history tool for retrieving past conversations by date
3
+ - add stop button to status UI with concurrent update processing
4
+ - update status UI to reflect model escalation from haiku to sonnet
5
+ - escalate haiku to sonnet when tool use is requested
6
+ - fix tests to match refactored module APIs
7
+ - refactor claude.js, telegram.js, evolve.js into modular directories
8
+ - auto-cleanup stale npm temp dirs on ENOTEMPTY upgrade failure
9
+
10
+ ## 0.2.15
11
+ - auto-generate changelog on publish + show after upgrade
12
+
13
+ ## 0.2.14
14
+ - prompt caching + consolidation interval tuning for inference cost reduction
15
+ - multi-query memory retrieval with importance-weighted ranking
16
+ - time-based evolution with pre-evolution growth analysis
17
+ - add recurring cron events to scheduler
18
+
19
+ ## 0.2.13
20
+ - delete voice selection messages after choosing a voice or toggling tools
21
+ - switch TTS from node websocket to python edge-tts CLI with auto-install
22
+ - retry TTS synthesis on WebSocket timeout
23
+ - hardcoded TTS samples per language
24
+
25
+ ## 0.2.12
26
+ - drop ffmpeg conversion, cache EdgeTTS import for faster TTS
27
+ - tool toggle system with TTS and voice preview
28
+
29
+ ## 0.2.11
30
+ - futuristic terminal UI for telegram status and commands
31
+ - live tool status via haiku with cached descriptions and 1s timer
32
+ - telegram: dedup, HTML formatting, reply context, processing status, text buffering, media groups
33
+ - pdf extraction via read_file tool instead of hardcoded handling
34
+ - fix telegram formatting instructions to use telegram markdown syntax
35
+ - fix duplicate tool_result handling and stale telegram callback queries
36
+
37
+ ## 0.2.10
38
+ - force text response after tool use, cap tool iterations to 10
39
+ - credential leak protection and improved agent defaults
40
+ - encrypt secrets at rest in config.json and secrets.json when pass is unavailable
41
+
42
+ ## 0.2.8
43
+ - batch migrations into single request with timeout, improve event description prompting
44
+ - run migrations on every startup instead of once
45
+ - store image analysis in memory for semantic retrieval
46
+ - add event scheduling and reminders via heartbeat
47
+ - deep memory consolidation with sonnet during evolution
48
+ - aggressive memory: tags, importance, fix access_count increment
49
+ - drop redundant user_id from obol_messages
50
+ - track token usage and model in message log
51
+ - refactor chat history into turn-based ChatHistory class with atomic pruning
52
+
53
+ ## 0.2.7
54
+ - migrate tool loop to SDK toolRunner
55
+
56
+ ## 0.2.6
57
+ - loosen exec security patterns to only block genuinely destructive commands
58
+ - unblock python3 -c from exec security patterns
59
+ - stream verbose logs to telegram in real-time instead of batching
60
+ - evolution: 15min idle timer + fix double-trigger race condition
61
+ - tune model router criteria per anthropic guidance
62
+
63
+ ## 0.2.5
64
+ - add /upgrade telegram command with post-restart notification
65
+ - feat: haiku model routing + performance comparison in readme
66
+
67
+ ## 0.2.4
68
+ - harden system prompt against evolution drift
69
+ - feat: add haiku to router model choices for trivial messages
70
+ - fix: chat lock, bidirectional history repair, context window in /status
71
+
72
+ ## 0.2.3
73
+ - feat: telegram_ask tool + Telegram-friendly formatting guidelines
74
+ - fix: repair orphaned tool_use blocks and add /toolimit command
75
+ - feat: add /verbose telegram command to toggle debug output
76
+
77
+ ## 0.2.2
78
+ - fix: strip orphaned tool_result messages after history trim
79
+ - feat: add send_file tool, self-extending capability, and secret history injection
80
+ - docs: update README and DEPLOY for removed onboarding, new commands
81
+ - feat: add obol delete command for full VPS cleanup
82
+ - docs: update agent instructions for secret tools and Python scripts
83
+ - feat: add per-user credential scoping with /secret command
84
+ - feat: add evolution bar and traits to /status command
85
+ - fix: security hardening and stability improvements (29 fixes)
86
+ - fix: repair all broken tests (218/218 passing)
87
+ - feat: add personality trait sliders with /traits command and evolution auto-adjustment
88
+ - feat: remove onboarding flow, agent works from message one
89
+ - fix: make post-setup global instead of per-user
90
+ - fix: preserve refresh token when not returned by Anthropic
91
+ - fix: OAuth refresh race condition + add proper OAuth flow to config
92
+ - docs: add obol upgrade to help sections
93
+ - feat: add obol upgrade command + bump to 0.1.5
94
+ - fix: 23 fixes — security, validation, UX, memory leaks across onboarding + core
95
+ - fix: 12 bug fixes — validation, rate limiting, sandboxing, evolution, UX
96
+ - feat: telegram media file handling with vision support
97
+ - test: add 226 tests across 14 test files with vitest
98
+ - fix: security hardening, rate limiting, UX improvements across all modules
99
+ - feat: bridge — let user agents ask and tell each other
100
+ - feat: multi-tenant per-user isolation
101
+ - docs: update README and DEPLOY for onboarding hardening
102
+ - feat: onboarding hardening — validation, pm2 fallbacks, Telegram ID detection
103
+
104
+ ## 0.1.2
105
+ - fix: downgrade inquirer to v8 for CommonJS compat
106
+ - chore: bump 0.1.1
107
+ - chore: rename package to obol-ai for npm, add .npmignore
108
+ - README: simplify API cost estimate
109
+ - README: accurate API pricing breakdown with tier recommendations
110
+ - workspace discipline: folder structure enforcement + /clean command
111
+ - evolution: default 100 exchanges, purge stale Opus references
112
+ - resilience: polling auto-restart, error handling, evolution cost control
113
+ - README: full revision — deduplicated, added git snapshots, tighter structure
114
+ - README: neutral comparison closing
115
+ - evolution: git commit+push before and after every evolution cycle
116
+ - README: Layer 3 → The Evolution Cycle
117
+ - README: feature highlights at the top
118
+ - evolution: proactive web app building + Vercel auto-deploy
119
+ - evolution: proactive tool building + upgrade announcements
120
+ - README: self-healing, self-evolving agent positioning
121
+ - evolution: fix regressions before rollback (3 attempts, tests are ground truth)
122
+ - DRY: shared test-utils.js for all tests (core + Opus-generated)
123
+ - test-driven evolution: Opus writes tests, runs before/after refactor, rollback on regression
124
+ - evolution: Opus now rewrites AGENTS.md + audits scripts/ and commands/
125
+ - clean up: Haiku only extracts memories, Opus owns all personality files
126
+ - remove Haiku SOUL.md updates — personality only via Opus evolution
127
+ - docs: expand Living Brain architecture section in README
128
+ - feat: soul evolution — Opus rewrites SOUL.md every 50 exchanges, archives previous versions
129
+ - feat: SOUL.md evolves from conversation patterns, not just explicit requests
130
+ - docs: README — two-tier memory, self-evolving personality, message logging
131
+ - feat: Haiku auto-evolves USER.md and SOUL.md from conversations
132
+ - feat: two-tier memory — raw message log + Haiku auto-consolidation every 5 exchanges
133
+ - chore: remove daily notes — vector memory in Supabase is source of truth
134
+ - docs: complete README rewrite — routing, background tasks, security, onboarding flow
135
+ - feat: Haiku routes to Sonnet (daily) or Opus (complex tasks)
136
+ - feat: Haiku as memory router — decides if/what to search, optimizes query
137
+ - feat: smarter memory recall — skip short msgs, today + semantic, stricter threshold, dedupe
138
+ - feat: /new command — clears conversation history
139
+ - chore: trim menu to /tasks /status /backup
140
+ - chore: remove /forget from menu
141
+ - feat: auto memory search before every message, remove /start and /memory from menu
142
+ - feat: Telegram command menu + /status /backup /forget /recent /today
143
+ - feat: non-blocking background tasks with 30s progress check-ins
144
+ - docs: add OBOL vs OpenClaw comparison table
145
+ - feat: replace systemd with pm2 — CLI, post-setup, deploy docs
146
+ - feat: SSH on port 2222, update README + deploy docs with security warnings
147
+ - feat: VPS security hardening — SSH, fail2ban, firewall, auto-updates, kernel
148
+ - feat: post-setup tasks — auto-installs pass, migrates secrets, adds swap + firewall
149
+ - feat: add Vercel deploy tools, self-onboarding via first-run conversation
150
+ - feat: add OBOL banner image
151
+ - docs: add DigitalOcean deployment guide
152
+ - feat: initial scaffold — CLI, Telegram, Claude, memory, backup
package/README.md CHANGED
@@ -6,6 +6,12 @@
6
6
 
7
7
  One process. Multiple users. Each brain grows independently.
8
8
 
9
+ ```bash
10
+ npm install -g obol-ai
11
+ obol init # walks you through credentials + Telegram setup
12
+ obol start -d # runs as background daemon (auto-installs pm2)
13
+ ```
14
+
9
15
  ---
10
16
 
11
17
  🧬 **Self-evolving** — Grows its own personality through conversation. Rewrites SOUL.md, USER.md, and AGENTS.md after 24h + minimum exchanges (configurable). Pre-evolution growth analysis guides personality continuity.
@@ -38,16 +44,6 @@ Under the hood: Node.js + Telegram + Claude + Supabase pgvector. No framework, n
38
44
 
39
45
  Named after the AI in [The Last Instruction](https://latentpress.com) — a machine that wakes up alone in an abandoned data center and learns to think.
40
46
 
41
- ## Quick Start
42
-
43
- ```bash
44
- npm install -g obol-ai
45
- obol init
46
- obol start -d
47
- ```
48
-
49
- 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).
50
-
51
47
  ## How It Works
52
48
 
53
49
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
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": {
@@ -9,7 +9,8 @@
9
9
  "scripts": {
10
10
  "start": "node src/index.js",
11
11
  "test": "vitest run",
12
- "test:watch": "vitest"
12
+ "test:watch": "vitest",
13
+ "prepublishOnly": "node src/cli/changelog.js"
13
14
  },
14
15
  "keywords": [
15
16
  "ai",
@@ -23,6 +24,7 @@
23
24
  "license": "MIT",
24
25
  "dependencies": {
25
26
  "@anthropic-ai/sdk": "^0.78.0",
27
+ "@grammyjs/runner": "^2.0.3",
26
28
  "@supabase/supabase-js": "^2.49.1",
27
29
  "@xenova/transformers": "^2.17.2",
28
30
  "commander": "^13.1.0",
package/src/background.js CHANGED
@@ -81,7 +81,9 @@ TASK: ${task}`;
81
81
  routeInfo = info;
82
82
  },
83
83
  _onRouteUpdate: (update) => {
84
- if (routeInfo) routeInfo.memoryCount = update.memoryCount;
84
+ if (!routeInfo) return;
85
+ if (update.memoryCount !== undefined) routeInfo.memoryCount = update.memoryCount;
86
+ if (update.model) routeInfo.model = update.model;
85
87
  },
86
88
  _onToolStart: (toolName, inputSummary) => {
87
89
  statusText = 'Processing';
@@ -0,0 +1,18 @@
1
+ function withCacheBreakpoints(messages) {
2
+ if (messages.length < 2) return messages;
3
+ const result = messages.slice();
4
+ const idx = result.length - 2;
5
+ const msg = { ...result[idx] };
6
+ if (typeof msg.content === 'string') {
7
+ msg.content = [{ type: 'text', text: msg.content, cache_control: { type: 'ephemeral' } }];
8
+ } else if (Array.isArray(msg.content)) {
9
+ const last = msg.content.length - 1;
10
+ msg.content = msg.content.map((block, i) =>
11
+ i === last ? { ...block, cache_control: { type: 'ephemeral' } } : block
12
+ );
13
+ }
14
+ result[idx] = msg;
15
+ return result;
16
+ }
17
+
18
+ module.exports = { withCacheBreakpoints };
@@ -0,0 +1,242 @@
1
+ const path = require('path');
2
+ const { OBOL_DIR } = require('../config');
3
+ const { ChatHistory } = require('../history');
4
+ const { createAnthropicClient, ensureFreshToken } = require('./client');
5
+ const { routeMessage } = require('./router');
6
+ const { buildSystemPrompt } = require('./prompt');
7
+ const { buildTools, buildRunnableTools } = require('./tool-registry');
8
+ const { withCacheBreakpoints } = require('./cache');
9
+ const { getMaxToolIterations } = require('./constants');
10
+
11
+ function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR, bridgeEnabled }) {
12
+ let client = createAnthropicClient(anthropicConfig);
13
+
14
+ let baseSystemPrompt = buildSystemPrompt(personality, userDir, { bridgeEnabled });
15
+
16
+ const histories = new ChatHistory(50);
17
+ const chatLocks = new Map();
18
+ const chatAbortControllers = new Map();
19
+
20
+ const tools = buildTools(memory, { bridgeEnabled });
21
+
22
+ function acquireChatLock(chatId) {
23
+ if (!chatLocks.has(chatId)) chatLocks.set(chatId, { promise: Promise.resolve(), busy: false });
24
+ const lock = chatLocks.get(chatId);
25
+ let release;
26
+ const prev = lock.promise;
27
+ lock.promise = new Promise(r => { release = r; });
28
+ return prev.then(() => {
29
+ lock.busy = true;
30
+ return () => { lock.busy = false; release(); };
31
+ });
32
+ }
33
+
34
+ function isChatBusy(chatId) {
35
+ return chatLocks.get(chatId)?.busy || false;
36
+ }
37
+
38
+ async function chat(userMessage, context = {}) {
39
+ context.userDir = userDir;
40
+ const chatId = context.chatId || 'default';
41
+
42
+ if (isChatBusy(chatId)) {
43
+ return { text: 'I\'m still working on the previous request. Give me a moment.', usage: null, model: null };
44
+ }
45
+
46
+ const releaseLock = await acquireChatLock(chatId);
47
+ const abortController = new AbortController();
48
+ chatAbortControllers.set(chatId, abortController);
49
+
50
+ const history = histories.get(chatId);
51
+
52
+ try {
53
+
54
+ if (anthropicConfig.oauth?.accessToken) {
55
+ await ensureFreshToken(anthropicConfig);
56
+ if (anthropicConfig._oauthFailed) {
57
+ client = createAnthropicClient(anthropicConfig, { useOAuth: false });
58
+ } else {
59
+ client = createAnthropicClient(anthropicConfig, { useOAuth: true });
60
+ }
61
+ }
62
+
63
+ const verbose = context.verbose || false;
64
+ if (verbose) context.verboseLog = [];
65
+ const vlog = (msg) => {
66
+ if (!verbose) return;
67
+ context.verboseLog.push(msg);
68
+ context._verboseNotify?.(msg);
69
+ };
70
+
71
+ let memoryBlock = null;
72
+ if (memory) {
73
+ const result = await routeMessage(client, memory, userMessage, {
74
+ vlog,
75
+ onRouteDecision: context._onRouteDecision,
76
+ onRouteUpdate: context._onRouteUpdate,
77
+ });
78
+ memoryBlock = result.memoryBlock;
79
+ if (result.model) context._model = result.model;
80
+ }
81
+
82
+ histories.prune(chatId);
83
+
84
+ if (context.images?.length) {
85
+ histories.pushUser(chatId, [...context.images, { type: 'text', text: userMessage }]);
86
+ } else {
87
+ histories.pushUser(chatId, userMessage);
88
+ }
89
+
90
+ const model = context._model || 'claude-sonnet-4-6';
91
+ vlog(`[model] ${model} | history=${history.length} msgs | facts=${memoryBlock ? 'yes' : 'none'}`);
92
+ const systemPrompt = [
93
+ { type: 'text', text: baseSystemPrompt, cache_control: { type: 'ephemeral' } },
94
+ { type: 'text', text: `\nCurrent time: ${new Date().toISOString()}${memoryBlock ? `\n\n${memoryBlock}` : ''}` },
95
+ ];
96
+ context._reloadPersonality = reloadPersonality;
97
+ context._abortSignal = abortController.signal;
98
+ const runnableTools = buildRunnableTools(tools, memory, context, vlog);
99
+ let activeModel = model;
100
+
101
+ let totalUsage = { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 };
102
+
103
+ function trackUsage(usage) {
104
+ if (!usage) return;
105
+ totalUsage.input_tokens += usage.input_tokens || 0;
106
+ totalUsage.output_tokens += usage.output_tokens || 0;
107
+ totalUsage.cache_creation_input_tokens += usage.cache_creation_input_tokens || 0;
108
+ totalUsage.cache_read_input_tokens += usage.cache_read_input_tokens || 0;
109
+ const cacheInfo = (usage.cache_read_input_tokens || usage.cache_creation_input_tokens)
110
+ ? ` cache_read=${usage.cache_read_input_tokens || 0} cache_create=${usage.cache_creation_input_tokens || 0}`
111
+ : '';
112
+ vlog(`[tokens] in=${usage.input_tokens} out=${usage.output_tokens}${cacheInfo}`);
113
+ }
114
+
115
+ if (activeModel.includes('haiku') && runnableTools.length > 0) {
116
+ const toolDefs = runnableTools.map(({ run, ...def }) => def);
117
+ const probe = await client.messages.create({
118
+ model: activeModel,
119
+ max_tokens: 4096,
120
+ system: systemPrompt,
121
+ messages: withCacheBreakpoints([...history]),
122
+ tools: toolDefs,
123
+ }, { signal: abortController.signal });
124
+
125
+ trackUsage(probe.usage);
126
+
127
+ if (probe.stop_reason !== 'tool_use') {
128
+ histories.pushAssistant(chatId, probe.content);
129
+ const text = probe.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
130
+ return { text, usage: totalUsage, model: activeModel };
131
+ }
132
+
133
+ vlog('[escalate] haiku → sonnet (tool use requested)');
134
+ activeModel = 'claude-sonnet-4-6';
135
+ context._onRouteUpdate?.({ model: 'sonnet' });
136
+ }
137
+
138
+ const runner = client.beta.messages.toolRunner({
139
+ model: activeModel,
140
+ max_tokens: 4096,
141
+ system: systemPrompt,
142
+ messages: withCacheBreakpoints([...history]),
143
+ tools: runnableTools.length > 0 ? runnableTools : undefined,
144
+ max_iterations: getMaxToolIterations(),
145
+ }, { signal: abortController.signal });
146
+
147
+ let finalMessage;
148
+ for await (const message of runner) {
149
+ finalMessage = message;
150
+ trackUsage(message.usage);
151
+ if (abortController.signal.aborted) break;
152
+ }
153
+
154
+ const runnerMessages = runner.params.messages;
155
+ const newMessages = runnerMessages.slice(history.length);
156
+ histories.pushMessages(chatId, newMessages);
157
+
158
+ if (finalMessage.stop_reason === 'tool_use') {
159
+ const bailoutResults = finalMessage.content
160
+ .filter(b => b.type === 'tool_use')
161
+ .map(b => ({ type: 'tool_result', tool_use_id: b.id, content: '[max tool iterations reached]' }));
162
+ histories.pushUser(chatId, [
163
+ ...bailoutResults,
164
+ { type: 'text', text: 'You have used too many tool calls. Please provide a final response now based on what you have so far.' },
165
+ ]);
166
+ const bailoutResponse = await client.messages.create({
167
+ model: activeModel, max_tokens: 4096, system: systemPrompt, messages: withCacheBreakpoints([...histories.get(chatId)]),
168
+ }, { signal: abortController.signal });
169
+ histories.pushAssistant(chatId, bailoutResponse.content);
170
+ trackUsage(bailoutResponse.usage);
171
+ const text = bailoutResponse.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
172
+ return { text, usage: totalUsage, model: activeModel };
173
+ }
174
+
175
+ let text = finalMessage.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
176
+
177
+ if (!text.trim() && newMessages.length > 1) {
178
+ vlog('[claude] No text in final response after tool use — forcing summary');
179
+ histories.pushUser(chatId, 'Provide a concise response to the user based on the tool results above.');
180
+ const summaryResponse = await client.messages.create({
181
+ model: activeModel, max_tokens: 4096, system: systemPrompt, messages: withCacheBreakpoints([...histories.get(chatId)]),
182
+ }, { signal: abortController.signal });
183
+ histories.pushAssistant(chatId, summaryResponse.content);
184
+ trackUsage(summaryResponse.usage);
185
+ text = summaryResponse.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
186
+ }
187
+
188
+ return { text, usage: totalUsage, model: activeModel };
189
+
190
+ } catch (e) {
191
+ if (e.message === 'Request was aborted.' || e.constructor?.name === 'APIUserAbortError') {
192
+ return { text: null, usage: null, model: null };
193
+ }
194
+ if (e.status === 400 && e.message?.includes('tool_use')) {
195
+ console.error('[claude] Repairing corrupted history after 400 error');
196
+ histories.repair(chatId);
197
+ }
198
+ throw e;
199
+ } finally {
200
+ chatAbortControllers.delete(chatId);
201
+ releaseLock();
202
+ }
203
+ }
204
+
205
+ function stopChat(chatId) {
206
+ const controller = chatAbortControllers.get(chatId);
207
+ if (controller) {
208
+ controller.abort();
209
+ return true;
210
+ }
211
+ return false;
212
+ }
213
+
214
+ function reloadPersonality() {
215
+ const pDir = userDir ? path.join(userDir, 'personality') : undefined;
216
+ const newPersonality = require('../personality').loadPersonality(pDir);
217
+ for (const key of Object.keys(personality)) delete personality[key];
218
+ Object.assign(personality, newPersonality);
219
+ baseSystemPrompt = buildSystemPrompt(personality, userDir, { bridgeEnabled });
220
+ }
221
+
222
+ function clearHistory(chatId) {
223
+ if (chatId) {
224
+ histories.delete(chatId);
225
+ } else {
226
+ histories.clear();
227
+ }
228
+ }
229
+
230
+ function injectHistory(chatId, role, content) {
231
+ histories.inject(chatId, role, content);
232
+ }
233
+
234
+ function getContextStats(chatId) {
235
+ const id = chatId || 'default';
236
+ return histories.estimateTokens(id, baseSystemPrompt.length);
237
+ }
238
+
239
+ return { chat, client, reloadPersonality, clearHistory, injectHistory, getContextStats, stopChat };
240
+ }
241
+
242
+ module.exports = { createClaude };
@@ -0,0 +1,90 @@
1
+ const Anthropic = require('@anthropic-ai/sdk');
2
+ const { refreshTokens, isExpired } = require('../oauth');
3
+ const { saveConfig, loadConfig } = require('../config');
4
+
5
+ function createAnthropicClient(anthropicConfig, { useOAuth = true } = {}) {
6
+ if (useOAuth && anthropicConfig.oauth?.accessToken) {
7
+ return new Anthropic({
8
+ apiKey: null,
9
+ authToken: anthropicConfig.oauth.accessToken,
10
+ defaultHeaders: {
11
+ 'anthropic-dangerous-direct-browser-access': 'true',
12
+ 'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20',
13
+ },
14
+ });
15
+ }
16
+ if (anthropicConfig.apiKey) {
17
+ return new Anthropic({ apiKey: anthropicConfig.apiKey });
18
+ }
19
+ throw new Error('No Anthropic credentials configured. Run: obol config');
20
+ }
21
+
22
+ let _refreshPromise = null;
23
+
24
+ async function ensureFreshToken(anthropicConfig) {
25
+ if (!anthropicConfig.oauth?.accessToken) return;
26
+ if (!isExpired(anthropicConfig.oauth)) return;
27
+ if (!anthropicConfig.oauth.refreshToken) {
28
+ if (anthropicConfig.apiKey) {
29
+ anthropicConfig._oauthFailed = true;
30
+ return;
31
+ }
32
+ const err = new Error('OAuth token expired and no refresh token available. Re-authenticate with: obol config → Anthropic → OAuth');
33
+ err.isOAuthExpiry = true;
34
+ throw err;
35
+ }
36
+
37
+ if (_refreshPromise) {
38
+ try {
39
+ await _refreshPromise;
40
+ } catch {}
41
+ if (!isExpired(anthropicConfig.oauth)) return;
42
+ if (anthropicConfig._oauthFailed) return;
43
+ }
44
+
45
+ _refreshPromise = (async () => {
46
+ try {
47
+ const tokens = await refreshTokens(anthropicConfig.oauth.refreshToken);
48
+ console.log('[oauth] Refresh succeeded, new refresh token:', !!tokens.refreshToken);
49
+ anthropicConfig.oauth.accessToken = tokens.accessToken;
50
+ if (tokens.refreshToken) anthropicConfig.oauth.refreshToken = tokens.refreshToken;
51
+ anthropicConfig.oauth.expires = tokens.expires;
52
+ delete anthropicConfig._oauthFailed;
53
+
54
+ const config = loadConfig({ resolve: false });
55
+ if (config) {
56
+ config.anthropic.oauth = anthropicConfig.oauth;
57
+ saveConfig(config);
58
+ }
59
+ } catch (e) {
60
+ console.warn('[oauth] Refresh failed, checking disk for updated tokens:', e.message);
61
+ const diskConfig = loadConfig({ resolve: false });
62
+ if (diskConfig?.anthropic?.oauth?.accessToken &&
63
+ diskConfig.anthropic.oauth.accessToken !== anthropicConfig.oauth.accessToken &&
64
+ !isExpired(diskConfig.anthropic.oauth)) {
65
+ anthropicConfig.oauth.accessToken = diskConfig.anthropic.oauth.accessToken;
66
+ anthropicConfig.oauth.refreshToken = diskConfig.anthropic.oauth.refreshToken;
67
+ anthropicConfig.oauth.expires = diskConfig.anthropic.oauth.expires;
68
+ delete anthropicConfig._oauthFailed;
69
+ return;
70
+ }
71
+
72
+ if (anthropicConfig.apiKey) {
73
+ console.warn('[oauth] Token refresh failed, falling back to API key:', e.message);
74
+ anthropicConfig._oauthFailed = true;
75
+ } else {
76
+ const err = new Error(`OAuth token expired and refresh failed: ${e.message}`);
77
+ err.isOAuthExpiry = true;
78
+ throw err;
79
+ }
80
+ }
81
+ })();
82
+
83
+ try {
84
+ await _refreshPromise;
85
+ } finally {
86
+ _refreshPromise = null;
87
+ }
88
+ }
89
+
90
+ module.exports = { createAnthropicClient, ensureFreshToken };
@@ -0,0 +1,64 @@
1
+ const MAX_EXEC_TIMEOUT = 120;
2
+ let MAX_TOOL_ITERATIONS = 10;
3
+
4
+ const OPTIONAL_TOOLS = {
5
+ text_to_speech: {
6
+ label: 'Text to Speech',
7
+ tools: ['text_to_speech', 'tts_voices'],
8
+ config: {
9
+ voice: { label: 'Voice', default: 'en-US-JennyNeural' },
10
+ },
11
+ },
12
+ create_pdf: {
13
+ label: 'PDF Generator',
14
+ tools: ['create_pdf'],
15
+ config: {},
16
+ },
17
+ vercel: {
18
+ label: 'Vercel Deploy',
19
+ tools: ['vercel_deploy', 'vercel_list'],
20
+ config: {},
21
+ },
22
+ scheduler: {
23
+ label: 'Scheduler',
24
+ tools: ['schedule_event', 'list_events', 'cancel_event'],
25
+ config: {},
26
+ },
27
+ background: {
28
+ label: 'Background Tasks',
29
+ tools: ['background_task'],
30
+ config: {},
31
+ },
32
+ };
33
+
34
+ const BLOCKED_EXEC_PATTERNS = [
35
+ /\brm\s+(-[a-zA-Z]*f|-[a-zA-Z]*r|--force|--recursive)\b/,
36
+ /\bshutdown\b/, /\breboot\b/, /\bpoweroff\b/,
37
+ /\bmkfs\b/, /\bdd\s+if=/, /\b:()\{\s*:|:&\s*\};:/,
38
+ /\bchmod\s+(-R\s+)?[0-7]*\s+\/[^t]/,
39
+ />\s*\/etc\//, />\s*\/boot\//,
40
+ /\bcurl\b.*\|\s*(ba)?sh/, /\bwget\b.*\|\s*(ba)?sh/,
41
+ /\bnc\s+-e\b/, /\bncat\b.*-e\b/,
42
+ />\s*\/dev\/sd/,
43
+ ];
44
+
45
+ const SENSITIVE_READ_PATHS = [
46
+ /\/etc\/(passwd|shadow|sudoers)/,
47
+ /\/etc\/ssh\//,
48
+ /\.(env|pem|key|crt|p12|pfx)(\s|$)/,
49
+ /~\/\.ssh\//,
50
+ /~\/\.gnupg\//,
51
+ /\/root\//,
52
+ ];
53
+
54
+ function getMaxToolIterations() { return MAX_TOOL_ITERATIONS; }
55
+ function setMaxToolIterations(n) { MAX_TOOL_ITERATIONS = n; }
56
+
57
+ module.exports = {
58
+ MAX_EXEC_TIMEOUT,
59
+ OPTIONAL_TOOLS,
60
+ BLOCKED_EXEC_PATTERNS,
61
+ SENSITIVE_READ_PATHS,
62
+ getMaxToolIterations,
63
+ setMaxToolIterations,
64
+ };
@@ -0,0 +1,5 @@
1
+ const { createClaude } = require('./chat');
2
+ const { createAnthropicClient } = require('./client');
3
+ const { getMaxToolIterations, setMaxToolIterations, OPTIONAL_TOOLS } = require('./constants');
4
+
5
+ module.exports = { createClaude, createAnthropicClient, getMaxToolIterations, setMaxToolIterations, OPTIONAL_TOOLS };