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.
package/src/claude.js ADDED
@@ -0,0 +1,443 @@
1
+ const Anthropic = require('@anthropic-ai/sdk');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { execSync } = require('child_process');
5
+ const { OBOL_DIR } = require('./config');
6
+
7
+ function createClaude(anthropicConfig, { personality, memory }) {
8
+ const client = new Anthropic({ apiKey: anthropicConfig.apiKey });
9
+
10
+ // Build system prompt from personality files
11
+ const systemPrompt = buildSystemPrompt(personality);
12
+
13
+ // Conversation history per chat (in-memory, resets on restart)
14
+ const histories = new Map();
15
+ const MAX_HISTORY = 50;
16
+
17
+ // Define tools
18
+ const tools = buildTools(memory);
19
+
20
+ async function chat(userMessage, context = {}) {
21
+ const chatId = context.chatId || 'default';
22
+
23
+ // Get or create history
24
+ if (!histories.has(chatId)) histories.set(chatId, []);
25
+ const history = histories.get(chatId);
26
+
27
+ // Ask Haiku if we need memory for this message
28
+ let memoryContext = '';
29
+ if (memory) {
30
+ try {
31
+ const memoryDecision = await client.messages.create({
32
+ model: 'claude-haiku-4-20250514',
33
+ max_tokens: 100,
34
+ system: `You are a router. Analyze this user message and decide two things:
35
+
36
+ 1. Does it need memory context? (past conversations, facts, preferences, people, events)
37
+ 2. What model complexity does it need?
38
+
39
+ Reply with ONLY a JSON object:
40
+ {"need_memory": true/false, "search_query": "optimized search query", "model": "sonnet|opus"}
41
+
42
+ Memory: casual messages (greetings, jokes, simple questions) → false. References to past, people, projects, preferences → true with optimized search query.
43
+
44
+ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single-step work). Use "opus" ONLY for: complex multi-step research, architecture/design decisions, long-form writing, deep analysis, debugging complex code, tasks requiring exceptional reasoning.`,
45
+ messages: [{ role: 'user', content: userMessage }],
46
+ });
47
+
48
+ const decisionText = memoryDecision.content[0]?.text || '';
49
+ const decision = JSON.parse(decisionText.match(/\{[\s\S]*\}/)?.[0] || '{}');
50
+
51
+ // Set model based on Haiku's decision
52
+ if (decision.model === 'opus') {
53
+ context._model = 'claude-opus-4-20250514';
54
+ }
55
+
56
+ if (decision.need_memory) {
57
+ const query = decision.search_query || userMessage;
58
+
59
+ // Today's context + semantic search
60
+ const todayMemories = await memory.byDate('today', { limit: 3 });
61
+ const semanticMemories = await memory.search(query, { limit: 3, threshold: 0.5 });
62
+
63
+ // Dedupe by ID
64
+ const seen = new Set();
65
+ const combined = [];
66
+ for (const m of [...todayMemories, ...semanticMemories]) {
67
+ if (!seen.has(m.id)) {
68
+ seen.add(m.id);
69
+ combined.push(m);
70
+ }
71
+ }
72
+
73
+ if (combined.length > 0) {
74
+ memoryContext = '\n\n[Relevant memories]\n' +
75
+ combined.map(m => `- [${m.category}] ${m.content}`).join('\n');
76
+ }
77
+ }
78
+ } catch {}
79
+ }
80
+
81
+ // Add user message with memory context
82
+ const enrichedMessage = memoryContext
83
+ ? userMessage + memoryContext
84
+ : userMessage;
85
+ history.push({ role: 'user', content: enrichedMessage });
86
+
87
+ // Trim history if too long
88
+ while (history.length > MAX_HISTORY) history.shift();
89
+
90
+ // Call Claude — Haiku picks the model
91
+ const model = context._model || 'claude-sonnet-4-20250514';
92
+ let response = await client.messages.create({
93
+ model,
94
+ max_tokens: 4096,
95
+ system: systemPrompt,
96
+ messages: history,
97
+ tools: tools.length > 0 ? tools : undefined,
98
+ });
99
+
100
+ // Handle tool use loop
101
+ while (response.stop_reason === 'tool_use') {
102
+ const assistantContent = response.content;
103
+ history.push({ role: 'assistant', content: assistantContent });
104
+
105
+ const toolResults = [];
106
+ for (const block of assistantContent) {
107
+ if (block.type === 'tool_use') {
108
+ const result = await executeToolCall(block, memory, context);
109
+ toolResults.push({
110
+ type: 'tool_result',
111
+ tool_use_id: block.id,
112
+ content: result,
113
+ });
114
+ }
115
+ }
116
+
117
+ history.push({ role: 'user', content: toolResults });
118
+
119
+ response = await client.messages.create({
120
+ model,
121
+ max_tokens: 4096,
122
+ system: systemPrompt,
123
+ messages: history,
124
+ tools,
125
+ });
126
+ }
127
+
128
+ // Extract text response
129
+ const textBlocks = response.content.filter(b => b.type === 'text');
130
+ const replyText = textBlocks.map(b => b.text).join('\n');
131
+
132
+ // Add assistant response to history
133
+ history.push({ role: 'assistant', content: response.content });
134
+
135
+ return replyText;
136
+ }
137
+
138
+ function reloadPersonality() {
139
+ const newPersonality = require('./personality').loadPersonality();
140
+ Object.assign(personality, newPersonality);
141
+ }
142
+
143
+ function clearHistory(chatId) {
144
+ if (chatId) {
145
+ histories.delete(chatId);
146
+ } else {
147
+ histories.clear();
148
+ }
149
+ }
150
+
151
+ return { chat, client, reloadPersonality, clearHistory };
152
+ }
153
+
154
+ function buildSystemPrompt(personality) {
155
+ const parts = ['You are an AI assistant powered by OBOL.'];
156
+
157
+ if (personality.soul) parts.push(`\n## Personality\n${personality.soul}`);
158
+ if (personality.user) parts.push(`\n## About Your Owner\n${personality.user}`);
159
+ if (personality.agents) parts.push(`\n## Operating Instructions\n${personality.agents}`);
160
+
161
+ parts.push(`
162
+ ## Workspace Discipline
163
+
164
+ The OBOL directory (~/.obol/) has a fixed structure:
165
+
166
+ \`\`\`
167
+ ~/.obol/
168
+ ├── config.json
169
+ ├── personality/ (SOUL.md, USER.md, AGENTS.md, evolution/)
170
+ ├── scripts/ (utility scripts)
171
+ ├── tests/ (test suite)
172
+ ├── commands/ (command definitions)
173
+ ├── apps/ (web apps for Vercel)
174
+ └── logs/
175
+ \`\`\`
176
+
177
+ **Rules:**
178
+ - NEVER create new top-level directories unless the user explicitly asks for one.
179
+ - Place files in the correct existing directory. Scripts → scripts/, tests → tests/, etc.
180
+ - Temporary files go in /tmp, not in the OBOL directory.
181
+ - If unsure where something belongs, ask — don't guess.
182
+ - Run \`/clean\` to audit and fix misplaced files.
183
+ `);
184
+
185
+ parts.push(`\nCurrent time: ${new Date().toISOString()}`);
186
+
187
+ return parts.join('\n');
188
+ }
189
+
190
+ function buildTools(memory) {
191
+ const tools = [];
192
+
193
+ // Shell execution
194
+ tools.push({
195
+ name: 'exec',
196
+ description: 'Execute a shell command and return the output. Use for file operations, system tasks, running scripts.',
197
+ input_schema: {
198
+ type: 'object',
199
+ properties: {
200
+ command: { type: 'string', description: 'Shell command to execute' },
201
+ timeout: { type: 'number', description: 'Timeout in seconds (default 30)' },
202
+ },
203
+ required: ['command'],
204
+ },
205
+ });
206
+
207
+ // Memory tools
208
+ if (memory) {
209
+ tools.push({
210
+ name: 'memory_search',
211
+ description: 'Search vector memory for relevant past context. Use before answering questions about prior conversations, decisions, or facts.',
212
+ input_schema: {
213
+ type: 'object',
214
+ properties: {
215
+ query: { type: 'string', description: 'Search query' },
216
+ limit: { type: 'number', description: 'Max results (default 10)' },
217
+ category: { type: 'string', description: 'Filter by category' },
218
+ },
219
+ required: ['query'],
220
+ },
221
+ });
222
+
223
+ tools.push({
224
+ name: 'memory_add',
225
+ description: 'Store a new memory. Use to remember facts, decisions, preferences, events.',
226
+ input_schema: {
227
+ type: 'object',
228
+ properties: {
229
+ content: { type: 'string', description: 'What to remember' },
230
+ category: { type: 'string', enum: ['fact', 'preference', 'decision', 'lesson', 'person', 'project', 'event', 'conversation', 'resource', 'pattern', 'context'], description: 'Memory category' },
231
+ importance: { type: 'number', description: 'Importance 0-1 (default 0.5)' },
232
+ source: { type: 'string', description: 'Where this came from' },
233
+ },
234
+ required: ['content'],
235
+ },
236
+ });
237
+
238
+ tools.push({
239
+ name: 'memory_date',
240
+ description: 'Get memories from a specific date. Use for "what did we do today/yesterday" questions.',
241
+ input_schema: {
242
+ type: 'object',
243
+ properties: {
244
+ date: { type: 'string', description: 'Date: "today", "yesterday", "2026-02-22", "7d"' },
245
+ category: { type: 'string', description: 'Filter by category' },
246
+ },
247
+ required: ['date'],
248
+ },
249
+ });
250
+ }
251
+
252
+ // Web fetch
253
+ tools.push({
254
+ name: 'web_fetch',
255
+ description: 'Fetch and extract readable content from a URL.',
256
+ input_schema: {
257
+ type: 'object',
258
+ properties: {
259
+ url: { type: 'string', description: 'URL to fetch' },
260
+ },
261
+ required: ['url'],
262
+ },
263
+ });
264
+
265
+ // Vercel deploy
266
+ tools.push({
267
+ name: 'vercel_deploy',
268
+ description: 'Deploy a directory to Vercel. Use to ship websites, dashboards, and web apps for the user.',
269
+ input_schema: {
270
+ type: 'object',
271
+ properties: {
272
+ directory: { type: 'string', description: 'Path to the project directory to deploy' },
273
+ name: { type: 'string', description: 'Project name' },
274
+ production: { type: 'boolean', description: 'Deploy to production (default false = preview)' },
275
+ },
276
+ required: ['directory'],
277
+ },
278
+ });
279
+
280
+ tools.push({
281
+ name: 'vercel_list',
282
+ description: 'List Vercel deployments for a project.',
283
+ input_schema: {
284
+ type: 'object',
285
+ properties: {
286
+ project: { type: 'string', description: 'Project name' },
287
+ },
288
+ required: ['project'],
289
+ },
290
+ });
291
+
292
+ // Background task
293
+ tools.push({
294
+ name: 'background_task',
295
+ description: 'Spawn a heavy task in the background. Use when a request will take multiple steps (research, building a site, complex analysis). The main conversation stays responsive. The user gets progress check-ins every 30s and the final result when done. Reply to the user with a brief acknowledgment like "On it 🪙" after spawning.',
296
+ input_schema: {
297
+ type: 'object',
298
+ properties: {
299
+ task: { type: 'string', description: 'Detailed description of the task to complete' },
300
+ },
301
+ required: ['task'],
302
+ },
303
+ });
304
+
305
+ // Read/write files
306
+ tools.push({
307
+ name: 'read_file',
308
+ description: 'Read contents of a file.',
309
+ input_schema: {
310
+ type: 'object',
311
+ properties: {
312
+ path: { type: 'string', description: 'File path' },
313
+ },
314
+ required: ['path'],
315
+ },
316
+ });
317
+
318
+ tools.push({
319
+ name: 'write_file',
320
+ description: 'Write content to a file. Creates parent directories if needed.',
321
+ input_schema: {
322
+ type: 'object',
323
+ properties: {
324
+ path: { type: 'string', description: 'File path' },
325
+ content: { type: 'string', description: 'File content' },
326
+ },
327
+ required: ['path', 'content'],
328
+ },
329
+ });
330
+
331
+ return tools;
332
+ }
333
+
334
+ async function executeToolCall(toolUse, memory, context = {}) {
335
+ const { name, input } = toolUse;
336
+
337
+ try {
338
+ switch (name) {
339
+ case 'exec': {
340
+ const timeout = (input.timeout || 30) * 1000;
341
+ const output = execSync(input.command, {
342
+ encoding: 'utf-8',
343
+ timeout,
344
+ maxBuffer: 1024 * 1024,
345
+ stdio: ['pipe', 'pipe', 'pipe'],
346
+ });
347
+ return output.substring(0, 10000);
348
+ }
349
+
350
+ case 'memory_search': {
351
+ const results = await memory.search(input.query, {
352
+ limit: input.limit,
353
+ category: input.category,
354
+ });
355
+ return JSON.stringify(results.map(m => ({
356
+ content: m.content,
357
+ category: m.category,
358
+ importance: m.importance,
359
+ created: m.created_at,
360
+ source: m.source,
361
+ })));
362
+ }
363
+
364
+ case 'memory_add': {
365
+ const result = await memory.add(input.content, {
366
+ category: input.category || 'fact',
367
+ importance: input.importance || 0.5,
368
+ source: input.source,
369
+ });
370
+ return `Stored memory: ${result.id}`;
371
+ }
372
+
373
+ case 'memory_date': {
374
+ const results = await memory.byDate(input.date, { category: input.category });
375
+ return JSON.stringify(results.map(m => ({
376
+ content: m.content,
377
+ category: m.category,
378
+ created: m.created_at,
379
+ })));
380
+ }
381
+
382
+ case 'background_task': {
383
+ const { bg, ctx: telegramCtx } = context;
384
+ if (!bg || !telegramCtx) return 'Background tasks not available in this context.';
385
+ const claudeInstance = { chat, client, reloadPersonality };
386
+ const taskId = bg.spawn(claudeInstance, input.task, telegramCtx, memory);
387
+ return `Background task #${taskId} spawned. It will send progress updates and the final result to the chat.`;
388
+ }
389
+
390
+ case 'vercel_deploy': {
391
+ const { loadConfig } = require('./config');
392
+ const cfg = loadConfig();
393
+ const token = cfg?.vercel?.token;
394
+ if (!token) return 'Vercel not configured.';
395
+ const dir = input.directory;
396
+ const prod = input.production ? '--prod' : '';
397
+ const name = input.name ? `--name ${input.name}` : '';
398
+ const output = execSync(
399
+ `cd ${dir} && npx vercel ${prod} ${name} --token ${token} --yes 2>&1`,
400
+ { encoding: 'utf-8', timeout: 120000 }
401
+ );
402
+ return output.substring(0, 5000);
403
+ }
404
+
405
+ case 'vercel_list': {
406
+ const { loadConfig } = require('./config');
407
+ const cfg = loadConfig();
408
+ const token = cfg?.vercel?.token;
409
+ if (!token) return 'Vercel not configured.';
410
+ const output = execSync(
411
+ `npx vercel ls ${input.project} --token ${token} 2>&1`,
412
+ { encoding: 'utf-8', timeout: 30000 }
413
+ );
414
+ return output.substring(0, 5000);
415
+ }
416
+
417
+ case 'web_fetch': {
418
+ const res = await fetch(input.url);
419
+ const text = await res.text();
420
+ // Basic HTML stripping
421
+ const clean = text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').substring(0, 10000);
422
+ return clean;
423
+ }
424
+
425
+ case 'read_file': {
426
+ return fs.readFileSync(input.path, 'utf-8').substring(0, 50000);
427
+ }
428
+
429
+ case 'write_file': {
430
+ fs.mkdirSync(path.dirname(input.path), { recursive: true });
431
+ fs.writeFileSync(input.path, input.content);
432
+ return `Written: ${input.path}`;
433
+ }
434
+
435
+ default:
436
+ return `Unknown tool: ${name}`;
437
+ }
438
+ } catch (e) {
439
+ return `Error: ${e.message}`;
440
+ }
441
+ }
442
+
443
+ module.exports = { createClaude };
package/src/clean.js ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Workspace cleaner — audits ~/.obol/ for misplaced files and rogue directories.
3
+ *
4
+ * Known structure:
5
+ * config.json, .evolution-state.json, .first-run-done, .post-setup-done
6
+ * personality/, scripts/, tests/, commands/, apps/, logs/
7
+ *
8
+ * Everything else is flagged. Rogue directories and unknown files are removed.
9
+ * Misplaced files (e.g. a .js in personality/) are moved to the correct location.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { OBOL_DIR } = require('./config');
15
+
16
+ // Allowed top-level entries
17
+ const ALLOWED_DIRS = new Set(['personality', 'scripts', 'tests', 'commands', 'apps', 'logs']);
18
+ const ALLOWED_FILES = new Set([
19
+ 'config.json',
20
+ '.evolution-state.json',
21
+ '.first-run-done',
22
+ '.post-setup-done',
23
+ ]);
24
+ // Files that can appear at top level with any name
25
+ const ALLOWED_PATTERNS = [
26
+ /^\./, // Hidden files (dotfiles)
27
+ ];
28
+
29
+ // Where file types belong
30
+ const FILE_RULES = {
31
+ '.js': 'scripts',
32
+ '.sh': 'scripts',
33
+ '.md': 'commands', // .md files outside personality/ are probably commands
34
+ };
35
+
36
+ async function cleanWorkspace() {
37
+ const issues = [];
38
+ const errors = [];
39
+
40
+ if (!fs.existsSync(OBOL_DIR)) {
41
+ return { issues, errors: ['OBOL_DIR does not exist'] };
42
+ }
43
+
44
+ const entries = fs.readdirSync(OBOL_DIR, { withFileTypes: true });
45
+
46
+ for (const entry of entries) {
47
+ const fullPath = path.join(OBOL_DIR, entry.name);
48
+
49
+ if (entry.isDirectory()) {
50
+ if (!ALLOWED_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
51
+ // Rogue directory — check if it has useful files first
52
+ const files = safeReaddir(fullPath);
53
+ if (files.length === 0) {
54
+ // Empty rogue dir — delete
55
+ try {
56
+ fs.rmdirSync(fullPath);
57
+ issues.push({ path: entry.name + '/', action: 'deleted (empty rogue dir)' });
58
+ } catch (e) {
59
+ errors.push(`Failed to remove ${entry.name}/: ${e.message}`);
60
+ }
61
+ } else {
62
+ // Non-empty rogue dir — relocate files, then delete
63
+ for (const file of files) {
64
+ const src = path.join(fullPath, file);
65
+ const dest = guessDestination(file);
66
+ if (dest) {
67
+ try {
68
+ const destPath = path.join(OBOL_DIR, dest, file);
69
+ fs.mkdirSync(path.join(OBOL_DIR, dest), { recursive: true });
70
+ fs.renameSync(src, destPath);
71
+ issues.push({ path: `${entry.name}/${file}`, action: `moved → ${dest}/${file}` });
72
+ } catch (e) {
73
+ errors.push(`Failed to move ${entry.name}/${file}: ${e.message}`);
74
+ }
75
+ } else {
76
+ try {
77
+ fs.unlinkSync(src);
78
+ issues.push({ path: `${entry.name}/${file}`, action: 'deleted (unknown type)' });
79
+ } catch (e) {
80
+ errors.push(`Failed to delete ${entry.name}/${file}: ${e.message}`);
81
+ }
82
+ }
83
+ }
84
+ // Try to remove the now-empty dir
85
+ try {
86
+ fs.rmdirSync(fullPath);
87
+ issues.push({ path: entry.name + '/', action: 'deleted (rogue dir cleared)' });
88
+ } catch {} // May not be empty if errors occurred
89
+ }
90
+ }
91
+ } else if (entry.isFile()) {
92
+ if (!ALLOWED_FILES.has(entry.name) && !ALLOWED_PATTERNS.some(p => p.test(entry.name))) {
93
+ // Misplaced file at top level
94
+ const dest = guessDestination(entry.name);
95
+ if (dest) {
96
+ try {
97
+ const destPath = path.join(OBOL_DIR, dest, entry.name);
98
+ fs.mkdirSync(path.join(OBOL_DIR, dest), { recursive: true });
99
+ fs.renameSync(fullPath, destPath);
100
+ issues.push({ path: entry.name, action: `moved → ${dest}/${entry.name}` });
101
+ } catch (e) {
102
+ errors.push(`Failed to move ${entry.name}: ${e.message}`);
103
+ }
104
+ } else {
105
+ try {
106
+ fs.unlinkSync(fullPath);
107
+ issues.push({ path: entry.name, action: 'deleted (unknown file at root)' });
108
+ } catch (e) {
109
+ errors.push(`Failed to delete ${entry.name}: ${e.message}`);
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ // Check for misplaced files within known directories
117
+ const dirFileRules = {
118
+ personality: ['.md'], // Only markdown
119
+ scripts: ['.js', '.sh'], // Only scripts
120
+ tests: ['.js', '.sh'], // Only tests
121
+ commands: ['.md'], // Only markdown
122
+ };
123
+
124
+ for (const [dir, allowedExts] of Object.entries(dirFileRules)) {
125
+ const dirPath = path.join(OBOL_DIR, dir);
126
+ if (!fs.existsSync(dirPath)) continue;
127
+
128
+ const files = safeReaddir(dirPath);
129
+ for (const file of files) {
130
+ const ext = path.extname(file);
131
+ if (ext && !allowedExts.includes(ext)) {
132
+ const dest = guessDestination(file);
133
+ if (dest && dest !== dir) {
134
+ try {
135
+ const src = path.join(dirPath, file);
136
+ const destPath = path.join(OBOL_DIR, dest, file);
137
+ fs.mkdirSync(path.join(OBOL_DIR, dest), { recursive: true });
138
+ fs.renameSync(src, destPath);
139
+ issues.push({ path: `${dir}/${file}`, action: `moved → ${dest}/${file}` });
140
+ } catch (e) {
141
+ errors.push(`Failed to move ${dir}/${file}: ${e.message}`);
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ return { issues, errors };
149
+ }
150
+
151
+ function guessDestination(filename) {
152
+ const ext = path.extname(filename);
153
+
154
+ // Test files go to tests/
155
+ if (filename.startsWith('test-') || filename.startsWith('test_')) return 'tests';
156
+
157
+ return FILE_RULES[ext] || null;
158
+ }
159
+
160
+ function safeReaddir(dir) {
161
+ try {
162
+ return fs.readdirSync(dir).filter(f => {
163
+ try { return fs.statSync(path.join(dir, f)).isFile(); } catch { return false; }
164
+ });
165
+ } catch { return []; }
166
+ }
167
+
168
+ module.exports = { cleanWorkspace };
@@ -0,0 +1,20 @@
1
+ const { loadConfig } = require('../config');
2
+ const { runBackup } = require('../backup');
3
+
4
+ async function backup() {
5
+ const config = loadConfig();
6
+ if (!config?.github) {
7
+ console.log('🪙 GitHub backup not configured. Run: obol init');
8
+ return;
9
+ }
10
+
11
+ console.log('🪙 Running backup...');
12
+ try {
13
+ await runBackup(config.github);
14
+ console.log('✅ Backup complete');
15
+ } catch (e) {
16
+ console.error(`❌ Backup failed: ${e.message}`);
17
+ }
18
+ }
19
+
20
+ module.exports = { backup };