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/README.md +364 -0
- package/bin/obol.js +64 -0
- package/docs/DEPLOY.md +277 -0
- package/docs/obol-banner.png +0 -0
- package/package.json +29 -0
- package/src/background.js +188 -0
- package/src/backup.js +66 -0
- package/src/claude.js +443 -0
- package/src/clean.js +168 -0
- package/src/cli/backup.js +20 -0
- package/src/cli/init.js +381 -0
- package/src/cli/logs.js +12 -0
- package/src/cli/start.js +47 -0
- package/src/cli/status.js +44 -0
- package/src/cli/stop.js +12 -0
- package/src/config.js +57 -0
- package/src/db/migrate.js +134 -0
- package/src/evolve.js +668 -0
- package/src/first-run.js +110 -0
- package/src/heartbeat.js +16 -0
- package/src/index.js +55 -0
- package/src/memory.js +164 -0
- package/src/messages.js +140 -0
- package/src/personality.js +27 -0
- package/src/post-setup.js +410 -0
- package/src/telegram.js +377 -0
- package/src/test-utils.js +111 -0
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "obol-ai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Self-evolving AI assistant that learns, remembers, and acts on its own. Persistent vector memory, self-rewriting personality, proactive heartbeats.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"obol": "./bin/obol.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node src/index.js",
|
|
11
|
+
"test": "node tests/run.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": ["ai", "assistant", "telegram", "anthropic", "claude", "vector-memory"],
|
|
14
|
+
"author": "Jo Vinkenroye <jestersimpps@gmail.com>",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@anthropic-ai/sdk": "^0.39.0",
|
|
18
|
+
"@supabase/supabase-js": "^2.49.1",
|
|
19
|
+
"@xenova/transformers": "^2.17.2",
|
|
20
|
+
"grammy": "^1.35.0",
|
|
21
|
+
"commander": "^13.1.0",
|
|
22
|
+
"inquirer": "^9.2.12",
|
|
23
|
+
"open": "^10.1.0",
|
|
24
|
+
"node-cron": "^3.0.3"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background task runner with progress check-ins.
|
|
3
|
+
*
|
|
4
|
+
* Main conversation stays responsive. Heavy tasks run in background.
|
|
5
|
+
* Claude periodically reports progress back to the user.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const CHECK_IN_INTERVAL = 30000; // 30 seconds
|
|
9
|
+
|
|
10
|
+
class BackgroundRunner {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.tasks = new Map(); // taskId -> { promise, status, progress, chatId }
|
|
13
|
+
this.taskCounter = 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Spawn a background task. Returns immediately.
|
|
18
|
+
* @param {object} claude - Claude client
|
|
19
|
+
* @param {string} task - The task description
|
|
20
|
+
* @param {object} ctx - Telegram context (for sending updates)
|
|
21
|
+
* @param {object} memory - Memory instance
|
|
22
|
+
*/
|
|
23
|
+
spawn(claude, task, ctx, memory) {
|
|
24
|
+
const taskId = ++this.taskCounter;
|
|
25
|
+
const chatId = ctx.chat.id;
|
|
26
|
+
|
|
27
|
+
const taskState = {
|
|
28
|
+
id: taskId,
|
|
29
|
+
task,
|
|
30
|
+
chatId,
|
|
31
|
+
status: 'running',
|
|
32
|
+
progress: [],
|
|
33
|
+
startedAt: Date.now(),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
this.tasks.set(taskId, taskState);
|
|
37
|
+
|
|
38
|
+
// Run the task
|
|
39
|
+
const promise = this._runTask(claude, task, taskState, ctx, memory);
|
|
40
|
+
|
|
41
|
+
taskState.promise = promise;
|
|
42
|
+
|
|
43
|
+
// Start check-in timer
|
|
44
|
+
taskState.checkInTimer = setInterval(async () => {
|
|
45
|
+
if (taskState.status !== 'running') {
|
|
46
|
+
clearInterval(taskState.checkInTimer);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const elapsed = Math.floor((Date.now() - taskState.startedAt) / 1000);
|
|
51
|
+
await this._checkIn(claude, taskState, ctx, elapsed);
|
|
52
|
+
}, CHECK_IN_INTERVAL);
|
|
53
|
+
|
|
54
|
+
return taskId;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async _runTask(claude, task, taskState, ctx, memory) {
|
|
58
|
+
try {
|
|
59
|
+
// Give the background task a system instruction to report progress
|
|
60
|
+
const bgPrompt = `You are working on a background task. Do the work thoroughly.
|
|
61
|
+
|
|
62
|
+
After EVERY tool call, update your internal progress by including a brief status line like:
|
|
63
|
+
"[PROGRESS] Searched 5 dental clinics, now checking reviews..."
|
|
64
|
+
|
|
65
|
+
This helps track what you're doing. Complete the full task, then give the final result.
|
|
66
|
+
|
|
67
|
+
TASK: ${task}`;
|
|
68
|
+
|
|
69
|
+
const result = await claude.chat(bgPrompt, {
|
|
70
|
+
chatId: `bg-${taskState.id}`,
|
|
71
|
+
userName: 'BackgroundTask',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
taskState.status = 'done';
|
|
75
|
+
taskState.result = result;
|
|
76
|
+
clearInterval(taskState.checkInTimer);
|
|
77
|
+
|
|
78
|
+
// Send final result
|
|
79
|
+
const elapsed = Math.floor((Date.now() - taskState.startedAt) / 1000);
|
|
80
|
+
const header = `✅ Done! (${formatDuration(elapsed)})\n\n`;
|
|
81
|
+
|
|
82
|
+
await sendLong(ctx, header + result);
|
|
83
|
+
|
|
84
|
+
// Store to memory
|
|
85
|
+
if (memory) {
|
|
86
|
+
await memory.add(`Background task completed: "${task.substring(0, 100)}". Took ${elapsed}s.`, {
|
|
87
|
+
category: 'context',
|
|
88
|
+
source: 'background-task',
|
|
89
|
+
}).catch(() => {});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Cleanup after 5 min
|
|
93
|
+
setTimeout(() => this.tasks.delete(taskState.id), 300000);
|
|
94
|
+
|
|
95
|
+
} catch (e) {
|
|
96
|
+
taskState.status = 'error';
|
|
97
|
+
taskState.error = e.message;
|
|
98
|
+
clearInterval(taskState.checkInTimer);
|
|
99
|
+
|
|
100
|
+
await ctx.reply(`⚠️ Background task failed: ${e.message}`).catch(() => {});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async _checkIn(claude, taskState, ctx, elapsed) {
|
|
105
|
+
try {
|
|
106
|
+
// Ask Claude for a brief status update based on what it's been doing
|
|
107
|
+
const checkInPrompt = `You have a background task running for ${elapsed}s: "${taskState.task}"
|
|
108
|
+
|
|
109
|
+
Give a ONE LINE progress update (emoji + what's happening). Be specific about what you've found/done so far. Example: "⏳ Found 8 clinics, comparing ratings and prices..."`;
|
|
110
|
+
|
|
111
|
+
// Use a separate quick call — don't interfere with the main task
|
|
112
|
+
const update = await claude.chat(checkInPrompt, {
|
|
113
|
+
chatId: `checkin-${taskState.id}-${elapsed}`,
|
|
114
|
+
userName: 'CheckIn',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (update && update.trim()) {
|
|
118
|
+
await ctx.reply(update.trim()).catch(() => {});
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// Check-in failed — not critical, skip it
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get status of all running tasks
|
|
127
|
+
*/
|
|
128
|
+
getStatus() {
|
|
129
|
+
const running = [];
|
|
130
|
+
for (const [id, task] of this.tasks) {
|
|
131
|
+
if (task.status === 'running') {
|
|
132
|
+
const elapsed = Math.floor((Date.now() - task.startedAt) / 1000);
|
|
133
|
+
running.push({
|
|
134
|
+
id,
|
|
135
|
+
task: task.task.substring(0, 80),
|
|
136
|
+
elapsed: formatDuration(elapsed),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return running;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check if there are running tasks
|
|
145
|
+
*/
|
|
146
|
+
hasRunningTasks() {
|
|
147
|
+
for (const task of this.tasks.values()) {
|
|
148
|
+
if (task.status === 'running') return true;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function formatDuration(seconds) {
|
|
155
|
+
if (seconds < 60) return `${seconds}s`;
|
|
156
|
+
const min = Math.floor(seconds / 60);
|
|
157
|
+
const sec = seconds % 60;
|
|
158
|
+
return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function sendLong(ctx, text) {
|
|
162
|
+
if (text.length <= 4096) {
|
|
163
|
+
await ctx.reply(text, { parse_mode: 'Markdown' }).catch(() =>
|
|
164
|
+
ctx.reply(text)
|
|
165
|
+
);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Split on newlines
|
|
170
|
+
let remaining = text;
|
|
171
|
+
while (remaining.length > 0) {
|
|
172
|
+
if (remaining.length <= 4096) {
|
|
173
|
+
await ctx.reply(remaining, { parse_mode: 'Markdown' }).catch(() =>
|
|
174
|
+
ctx.reply(remaining)
|
|
175
|
+
);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
let splitAt = remaining.lastIndexOf('\n', 4096);
|
|
179
|
+
if (splitAt === -1 || splitAt < 2000) splitAt = 4096;
|
|
180
|
+
const chunk = remaining.substring(0, splitAt);
|
|
181
|
+
await ctx.reply(chunk, { parse_mode: 'Markdown' }).catch(() =>
|
|
182
|
+
ctx.reply(chunk)
|
|
183
|
+
);
|
|
184
|
+
remaining = remaining.substring(splitAt).trimStart();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { BackgroundRunner };
|
package/src/backup.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const cron = require('node-cron');
|
|
2
|
+
const { execSync } = require('child_process');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { OBOL_DIR } = require('./config');
|
|
6
|
+
|
|
7
|
+
function setupBackup(githubConfig) {
|
|
8
|
+
const { token, username, repo } = githubConfig;
|
|
9
|
+
const backupDir = path.join(OBOL_DIR, '.backup-repo');
|
|
10
|
+
|
|
11
|
+
// Daily backup at 3 AM
|
|
12
|
+
cron.schedule('0 3 * * *', async () => {
|
|
13
|
+
try {
|
|
14
|
+
await runBackup(githubConfig);
|
|
15
|
+
console.log(`[${new Date().toISOString()}] Backup complete`);
|
|
16
|
+
} catch (e) {
|
|
17
|
+
console.error(`[${new Date().toISOString()}] Backup failed: ${e.message}`);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
console.log(' ✅ GitHub backup scheduled (daily 3 AM)');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function runBackup(githubConfig, commitMessage) {
|
|
25
|
+
const { token, username, repo } = githubConfig;
|
|
26
|
+
const backupDir = path.join(OBOL_DIR, '.backup-repo');
|
|
27
|
+
const repoUrl = `https://${token}@github.com/${username}/${repo}.git`;
|
|
28
|
+
|
|
29
|
+
// Clone or pull
|
|
30
|
+
if (!fs.existsSync(path.join(backupDir, '.git'))) {
|
|
31
|
+
execSync(`git clone ${repoUrl} ${backupDir}`, { stdio: 'pipe' });
|
|
32
|
+
} else {
|
|
33
|
+
execSync('git pull', { cwd: backupDir, stdio: 'pipe' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Set git identity
|
|
37
|
+
execSync('git config user.name "OBOL"', { cwd: backupDir });
|
|
38
|
+
execSync('git config user.email "obol@backup"', { cwd: backupDir });
|
|
39
|
+
|
|
40
|
+
// Sync files (exclude secrets)
|
|
41
|
+
const syncDirs = ['personality', 'scripts', 'tests', 'commands', 'apps'];
|
|
42
|
+
for (const dir of syncDirs) {
|
|
43
|
+
const src = path.join(OBOL_DIR, dir);
|
|
44
|
+
const dst = path.join(backupDir, dir);
|
|
45
|
+
if (fs.existsSync(src)) {
|
|
46
|
+
execSync(`mkdir -p ${dst} && cp -r ${src}/* ${dst}/ 2>/dev/null || true`, { stdio: 'pipe' });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Commit and push
|
|
51
|
+
execSync('git add -A', { cwd: backupDir, stdio: 'pipe' });
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const status = execSync('git status --porcelain', { cwd: backupDir, encoding: 'utf-8' });
|
|
55
|
+
if (status.trim()) {
|
|
56
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
57
|
+
const msg = commitMessage || `backup: ${date}`;
|
|
58
|
+
execSync(`git commit -m "${msg}"`, { cwd: backupDir, stdio: 'pipe' });
|
|
59
|
+
execSync('git push', { cwd: backupDir, stdio: 'pipe' });
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Nothing to commit
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { setupBackup, runBackup };
|