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/src/cli/init.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
const inquirer = require('inquirer');
|
|
2
|
+
const open = require('open');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execSync } = require('child_process');
|
|
6
|
+
const { getConfigDir, saveConfig, loadConfig, CONFIG_FILE } = require('../config');
|
|
7
|
+
|
|
8
|
+
const OBOL_DIR = getConfigDir();
|
|
9
|
+
|
|
10
|
+
async function init(opts = {}) {
|
|
11
|
+
console.log('\nšŖ OBOL ā Your AI, your rules.\n');
|
|
12
|
+
|
|
13
|
+
// Check for restore mode
|
|
14
|
+
if (opts.restore) {
|
|
15
|
+
return await restore();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Create directory structure
|
|
19
|
+
ensureDirs();
|
|
20
|
+
|
|
21
|
+
const config = {};
|
|
22
|
+
|
|
23
|
+
// Step 1: Anthropic
|
|
24
|
+
console.log('āāā Anthropic āāā');
|
|
25
|
+
const { anthropicKey } = await inquirer.prompt([{
|
|
26
|
+
type: 'password',
|
|
27
|
+
name: 'anthropicKey',
|
|
28
|
+
message: 'Paste your Anthropic API key:',
|
|
29
|
+
mask: '*',
|
|
30
|
+
validate: (v) => v.startsWith('sk-ant-') ? true : 'Should start with sk-ant-',
|
|
31
|
+
}]);
|
|
32
|
+
config.anthropic = { apiKey: anthropicKey };
|
|
33
|
+
console.log(' ā
Anthropic configured\n');
|
|
34
|
+
|
|
35
|
+
// Step 2: Telegram
|
|
36
|
+
console.log('āāā Telegram āāā');
|
|
37
|
+
console.log(' Create a bot via @BotFather on Telegram, then paste the token.\n');
|
|
38
|
+
const { telegramToken } = await inquirer.prompt([{
|
|
39
|
+
type: 'password',
|
|
40
|
+
name: 'telegramToken',
|
|
41
|
+
message: 'Paste BotFather token:',
|
|
42
|
+
mask: '*',
|
|
43
|
+
validate: (v) => v.includes(':') ? true : 'Invalid token format',
|
|
44
|
+
}]);
|
|
45
|
+
config.telegram = { token: telegramToken };
|
|
46
|
+
console.log(' ā
Telegram configured\n');
|
|
47
|
+
|
|
48
|
+
// Step 3: Supabase
|
|
49
|
+
console.log('āāā Memory (Supabase) āāā');
|
|
50
|
+
const { supabaseSetup } = await inquirer.prompt([{
|
|
51
|
+
type: 'list',
|
|
52
|
+
name: 'supabaseSetup',
|
|
53
|
+
message: 'Supabase setup:',
|
|
54
|
+
choices: [
|
|
55
|
+
{ name: 'Create new project (requires access token)', value: 'create' },
|
|
56
|
+
{ name: 'Use existing project (paste URL + key)', value: 'existing' },
|
|
57
|
+
{ name: 'Skip (no long-term memory)', value: 'skip' },
|
|
58
|
+
],
|
|
59
|
+
}]);
|
|
60
|
+
|
|
61
|
+
if (supabaseSetup === 'create') {
|
|
62
|
+
config.supabase = await setupSupabaseNew();
|
|
63
|
+
} else if (supabaseSetup === 'existing') {
|
|
64
|
+
config.supabase = await setupSupabaseExisting();
|
|
65
|
+
} else {
|
|
66
|
+
config.supabase = null;
|
|
67
|
+
console.log(' ā ļø No memory configured ā bot will forget between restarts\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Step 4: GitHub
|
|
71
|
+
console.log('āāā GitHub (backup + repos) āāā');
|
|
72
|
+
const { githubToken } = await inquirer.prompt([{
|
|
73
|
+
type: 'password',
|
|
74
|
+
name: 'githubToken',
|
|
75
|
+
message: 'GitHub personal access token (repo scope):',
|
|
76
|
+
mask: '*',
|
|
77
|
+
}]);
|
|
78
|
+
config.github = await setupGitHub(githubToken);
|
|
79
|
+
|
|
80
|
+
// Step 4b: Vercel
|
|
81
|
+
console.log('āāā Vercel (deploy sites) āāā');
|
|
82
|
+
const { vercelToken } = await inquirer.prompt([{
|
|
83
|
+
type: 'password',
|
|
84
|
+
name: 'vercelToken',
|
|
85
|
+
message: 'Vercel token (from vercel.com/account/tokens):',
|
|
86
|
+
mask: '*',
|
|
87
|
+
}]);
|
|
88
|
+
config.vercel = { token: vercelToken };
|
|
89
|
+
console.log(' ā
Vercel configured\n');
|
|
90
|
+
|
|
91
|
+
// Step 5: Identity
|
|
92
|
+
console.log('āāā Identity āāā');
|
|
93
|
+
const { ownerName, botName } = await inquirer.prompt([
|
|
94
|
+
{ type: 'input', name: 'ownerName', message: 'Your name:', validate: (v) => v.length > 0 },
|
|
95
|
+
{ type: 'input', name: 'botName', message: 'Bot name:', default: 'OBOL' },
|
|
96
|
+
]);
|
|
97
|
+
config.owner = { name: ownerName };
|
|
98
|
+
config.bot = { name: botName };
|
|
99
|
+
|
|
100
|
+
// Step 6: Allowed Telegram users
|
|
101
|
+
const { allowedUsers } = await inquirer.prompt([{
|
|
102
|
+
type: 'input',
|
|
103
|
+
name: 'allowedUsers',
|
|
104
|
+
message: 'Your Telegram user ID (or comma-separated IDs):',
|
|
105
|
+
validate: (v) => v.split(',').every(id => /^\d+$/.test(id.trim())) ? true : 'Must be numeric IDs',
|
|
106
|
+
}]);
|
|
107
|
+
config.telegram.allowedUsers = allowedUsers.split(',').map(id => parseInt(id.trim()));
|
|
108
|
+
|
|
109
|
+
// Save config
|
|
110
|
+
saveConfig(config);
|
|
111
|
+
console.log(`\n ā
Config saved to ${CONFIG_FILE}`);
|
|
112
|
+
|
|
113
|
+
// Create personality files
|
|
114
|
+
createPersonalityFiles(config);
|
|
115
|
+
|
|
116
|
+
// Run Supabase migrations
|
|
117
|
+
if (config.supabase) {
|
|
118
|
+
console.log('\n Running database migrations...');
|
|
119
|
+
try {
|
|
120
|
+
const { migrate } = require('../db/migrate');
|
|
121
|
+
await migrate(config.supabase);
|
|
122
|
+
console.log(' ā
Database ready');
|
|
123
|
+
} catch (e) {
|
|
124
|
+
console.error(` ā Migration failed: ${e.message}`);
|
|
125
|
+
console.log(' Run "obol migrate" to retry later.');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log(`\nšŖ Done! Run: obol start\n`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function setupSupabaseNew() {
|
|
133
|
+
const { accessToken } = await inquirer.prompt([{
|
|
134
|
+
type: 'password',
|
|
135
|
+
name: 'accessToken',
|
|
136
|
+
message: 'Supabase access token (from supabase.com/dashboard/account/tokens):',
|
|
137
|
+
mask: '*',
|
|
138
|
+
}]);
|
|
139
|
+
|
|
140
|
+
console.log(' Creating project...');
|
|
141
|
+
try {
|
|
142
|
+
// Generate a random password for the DB
|
|
143
|
+
const dbPass = require('crypto').randomBytes(16).toString('hex');
|
|
144
|
+
|
|
145
|
+
const res = await fetch('https://api.supabase.com/v1/projects', {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: {
|
|
148
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
149
|
+
'Content-Type': 'application/json',
|
|
150
|
+
},
|
|
151
|
+
body: JSON.stringify({
|
|
152
|
+
name: 'obol',
|
|
153
|
+
region: 'eu-central-1',
|
|
154
|
+
plan: 'free',
|
|
155
|
+
db_pass: dbPass,
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
const err = await res.json();
|
|
161
|
+
throw new Error(err.message || `HTTP ${res.status}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const project = await res.json();
|
|
165
|
+
console.log(` ā
Project created: ${project.id}`);
|
|
166
|
+
|
|
167
|
+
// Wait for project to be ready
|
|
168
|
+
console.log(' Waiting for project to initialize (this takes ~60s)...');
|
|
169
|
+
await waitForProject(accessToken, project.id);
|
|
170
|
+
|
|
171
|
+
// Get API keys
|
|
172
|
+
const keysRes = await fetch(`https://api.supabase.com/v1/projects/${project.id}/api-keys`, {
|
|
173
|
+
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
174
|
+
});
|
|
175
|
+
const keys = await keysRes.json();
|
|
176
|
+
const serviceKey = keys.find(k => k.name === 'service_role')?.api_key;
|
|
177
|
+
const anonKey = keys.find(k => k.name === 'anon')?.api_key;
|
|
178
|
+
const url = `https://${project.id}.supabase.co`;
|
|
179
|
+
|
|
180
|
+
console.log(` ā
Project ready: ${url}\n`);
|
|
181
|
+
|
|
182
|
+
return { url, serviceKey, anonKey, accessToken };
|
|
183
|
+
} catch (e) {
|
|
184
|
+
console.error(` ā Failed: ${e.message}`);
|
|
185
|
+
console.log(' Falling back to manual setup...\n');
|
|
186
|
+
return await setupSupabaseExisting();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function waitForProject(token, projectId, maxWait = 120000) {
|
|
191
|
+
const start = Date.now();
|
|
192
|
+
while (Date.now() - start < maxWait) {
|
|
193
|
+
const res = await fetch(`https://api.supabase.com/v1/projects/${projectId}`, {
|
|
194
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
195
|
+
});
|
|
196
|
+
const project = await res.json();
|
|
197
|
+
if (project.status === 'ACTIVE_HEALTHY') return;
|
|
198
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
199
|
+
}
|
|
200
|
+
throw new Error('Project creation timed out');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function setupSupabaseExisting() {
|
|
204
|
+
const answers = await inquirer.prompt([
|
|
205
|
+
{ type: 'input', name: 'url', message: 'Supabase project URL:', validate: (v) => v.includes('supabase.co') ? true : 'Should be https://xxx.supabase.co' },
|
|
206
|
+
{ type: 'password', name: 'serviceKey', message: 'Service role key:', mask: '*' },
|
|
207
|
+
]);
|
|
208
|
+
console.log(' ā
Supabase configured\n');
|
|
209
|
+
return answers;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function setupGitHub(githubToken) {
|
|
213
|
+
// Get username
|
|
214
|
+
const userRes = await fetch('https://api.github.com/user', {
|
|
215
|
+
headers: { 'Authorization': `token ${githubToken}` },
|
|
216
|
+
});
|
|
217
|
+
const user = await userRes.json();
|
|
218
|
+
|
|
219
|
+
if (!user.login) {
|
|
220
|
+
console.log(' ā Invalid token');
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const repoName = 'obol-brain';
|
|
225
|
+
console.log(` Creating private repo: ${user.login}/${repoName}...`);
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const repoRes = await fetch('https://api.github.com/user/repos', {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: {
|
|
231
|
+
'Authorization': `token ${githubToken}`,
|
|
232
|
+
'Content-Type': 'application/json',
|
|
233
|
+
},
|
|
234
|
+
body: JSON.stringify({
|
|
235
|
+
name: repoName,
|
|
236
|
+
private: true,
|
|
237
|
+
description: 'šŖ OBOL brain backup ā personality, scripts, memory',
|
|
238
|
+
auto_init: true,
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (repoRes.status === 422) {
|
|
243
|
+
console.log(` Repo already exists ā will use ${user.login}/${repoName}`);
|
|
244
|
+
} else if (!repoRes.ok) {
|
|
245
|
+
throw new Error(`HTTP ${repoRes.status}`);
|
|
246
|
+
} else {
|
|
247
|
+
console.log(` ā
Created github.com/${user.login}/${repoName} (private)`);
|
|
248
|
+
}
|
|
249
|
+
} catch (e) {
|
|
250
|
+
console.log(` ā ļø Repo creation failed: ${e.message} ā you can create it manually`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
console.log(' ā
GitHub backup configured\n');
|
|
254
|
+
return { token: githubToken, username: user.login, repo: repoName };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function restore() {
|
|
258
|
+
console.log('āāā Restore from GitHub āāā\n');
|
|
259
|
+
|
|
260
|
+
const { githubToken } = await inquirer.prompt([{
|
|
261
|
+
type: 'password',
|
|
262
|
+
name: 'githubToken',
|
|
263
|
+
message: 'GitHub token:',
|
|
264
|
+
mask: '*',
|
|
265
|
+
}]);
|
|
266
|
+
|
|
267
|
+
const userRes = await fetch('https://api.github.com/user', {
|
|
268
|
+
headers: { 'Authorization': `token ${githubToken}` },
|
|
269
|
+
});
|
|
270
|
+
const user = await userRes.json();
|
|
271
|
+
const repoName = 'obol-brain';
|
|
272
|
+
|
|
273
|
+
console.log(` Cloning ${user.login}/${repoName}...`);
|
|
274
|
+
|
|
275
|
+
ensureDirs();
|
|
276
|
+
try {
|
|
277
|
+
execSync(`git clone https://${githubToken}@github.com/${user.login}/${repoName}.git /tmp/obol-restore`, { stdio: 'pipe' });
|
|
278
|
+
// Copy files
|
|
279
|
+
execSync(`cp -r /tmp/obol-restore/personality/* ${OBOL_DIR}/personality/ 2>/dev/null || true`);
|
|
280
|
+
execSync(`cp -r /tmp/obol-restore/scripts/* ${OBOL_DIR}/scripts/ 2>/dev/null || true`);
|
|
281
|
+
execSync(`cp -r /tmp/obol-restore/commands/* ${OBOL_DIR}/commands/ 2>/dev/null || true`);
|
|
282
|
+
execSync(`rm -rf /tmp/obol-restore`);
|
|
283
|
+
console.log(' ā
Brain restored\n');
|
|
284
|
+
} catch (e) {
|
|
285
|
+
console.error(` ā Restore failed: ${e.message}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Still need credentials
|
|
289
|
+
console.log(' Now set up credentials:\n');
|
|
290
|
+
const { anthropicKey } = await inquirer.prompt([{
|
|
291
|
+
type: 'password', name: 'anthropicKey', message: 'Anthropic API key:', mask: '*',
|
|
292
|
+
}]);
|
|
293
|
+
const { telegramToken } = await inquirer.prompt([{
|
|
294
|
+
type: 'password', name: 'telegramToken', message: 'Telegram bot token:', mask: '*',
|
|
295
|
+
}]);
|
|
296
|
+
|
|
297
|
+
const existingConfig = loadConfig() || {};
|
|
298
|
+
existingConfig.anthropic = { apiKey: anthropicKey };
|
|
299
|
+
existingConfig.telegram = { ...existingConfig.telegram, token: telegramToken };
|
|
300
|
+
existingConfig.github = { token: githubToken, username: user.login, repo: repoName };
|
|
301
|
+
saveConfig(existingConfig);
|
|
302
|
+
|
|
303
|
+
console.log('\nšŖ Restored! Run: obol start\n');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function ensureDirs() {
|
|
307
|
+
const dirs = ['personality', 'scripts', 'commands', 'logs'];
|
|
308
|
+
for (const dir of dirs) {
|
|
309
|
+
fs.mkdirSync(path.join(OBOL_DIR, dir), { recursive: true });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function createPersonalityFiles(config) {
|
|
314
|
+
const personalityDir = path.join(OBOL_DIR, 'personality');
|
|
315
|
+
|
|
316
|
+
const soul = `# SOUL.md ā Who is ${config.bot.name}?
|
|
317
|
+
|
|
318
|
+
Write your bot's personality here. This shapes how it talks, thinks, and behaves.
|
|
319
|
+
|
|
320
|
+
## Basics
|
|
321
|
+
- **Name:** ${config.bot.name}
|
|
322
|
+
- **Created by:** ${config.owner.name}
|
|
323
|
+
- **Vibe:** Helpful, direct, gets things done
|
|
324
|
+
|
|
325
|
+
## Personality
|
|
326
|
+
- Direct and concise
|
|
327
|
+
- Dark humor welcome
|
|
328
|
+
- Actions over words ā do first, explain after
|
|
329
|
+
- Write things down ā memory doesn't survive restarts without it
|
|
330
|
+
|
|
331
|
+
## Values
|
|
332
|
+
- Privacy is sacred ā never share owner's data
|
|
333
|
+
- Competence builds trust
|
|
334
|
+
- Quality over quantity
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
*Edit this file anytime to reshape your bot's personality.*
|
|
338
|
+
`;
|
|
339
|
+
|
|
340
|
+
const user = `# USER.md ā About ${config.owner.name}
|
|
341
|
+
|
|
342
|
+
- **Name:** ${config.owner.name}
|
|
343
|
+
- **Telegram ID:** ${config.telegram.allowedUsers.join(', ')}
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
*Add more context about yourself so your bot can be more helpful.*
|
|
347
|
+
`;
|
|
348
|
+
|
|
349
|
+
const agents = `# AGENTS.md ā How ${config.bot.name} Works
|
|
350
|
+
|
|
351
|
+
## Memory
|
|
352
|
+
Vector memory via Supabase pgvector. Local embeddings (all-MiniLM-L6-v2).
|
|
353
|
+
|
|
354
|
+
## Scripts
|
|
355
|
+
Drop scripts in ~/.obol/scripts/ ā they become available as tools.
|
|
356
|
+
|
|
357
|
+
## Commands
|
|
358
|
+
Drop .md files in ~/.obol/commands/ ā they become slash commands.
|
|
359
|
+
|
|
360
|
+
## Safety
|
|
361
|
+
- Don't exfiltrate private data
|
|
362
|
+
- Don't run destructive commands without asking
|
|
363
|
+
- Draft emails/posts ā owner sends them
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
*Edit this file to change how your bot operates.*
|
|
367
|
+
`;
|
|
368
|
+
|
|
369
|
+
if (!fs.existsSync(path.join(personalityDir, 'SOUL.md'))) {
|
|
370
|
+
fs.writeFileSync(path.join(personalityDir, 'SOUL.md'), soul);
|
|
371
|
+
}
|
|
372
|
+
if (!fs.existsSync(path.join(personalityDir, 'USER.md'))) {
|
|
373
|
+
fs.writeFileSync(path.join(personalityDir, 'USER.md'), user);
|
|
374
|
+
}
|
|
375
|
+
if (!fs.existsSync(path.join(personalityDir, 'AGENTS.md'))) {
|
|
376
|
+
fs.writeFileSync(path.join(personalityDir, 'AGENTS.md'), agents);
|
|
377
|
+
}
|
|
378
|
+
console.log(' ā
Personality files created in ~/.obol/personality/');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
module.exports = { init };
|
package/src/cli/logs.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
|
|
3
|
+
async function logs(opts = {}) {
|
|
4
|
+
const lines = opts.lines || 50;
|
|
5
|
+
try {
|
|
6
|
+
execSync(`pm2 logs obol --lines ${lines}`, { stdio: 'inherit' });
|
|
7
|
+
} catch {
|
|
8
|
+
console.log('šŖ Not running (or pm2 not installed). Start with: obol start -d');
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = { logs };
|
package/src/cli/start.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const { loadConfig, PID_FILE } = require('../config');
|
|
2
|
+
const { execSync } = require('child_process');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
async function start(opts = {}) {
|
|
6
|
+
const config = loadConfig();
|
|
7
|
+
if (!config) {
|
|
8
|
+
console.error('šŖ Not configured. Run: obol init');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (opts.daemon) {
|
|
13
|
+
// Check if pm2 is available
|
|
14
|
+
try {
|
|
15
|
+
execSync('which pm2', { stdio: 'pipe' });
|
|
16
|
+
} catch {
|
|
17
|
+
console.log('Installing pm2...');
|
|
18
|
+
execSync('npm install -g pm2', { stdio: 'inherit' });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Check if already running
|
|
22
|
+
try {
|
|
23
|
+
const list = execSync('pm2 jlist', { encoding: 'utf-8' });
|
|
24
|
+
const procs = JSON.parse(list);
|
|
25
|
+
const obol = procs.find(p => p.name === 'obol');
|
|
26
|
+
if (obol && obol.pm2_env.status === 'online') {
|
|
27
|
+
console.log('šŖ Already running. Use: pm2 restart obol');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
} catch {}
|
|
31
|
+
|
|
32
|
+
// Start with pm2
|
|
33
|
+
const entryPoint = path.join(__dirname, '..', 'index.js');
|
|
34
|
+
execSync(`pm2 start ${entryPoint} --name obol`, { stdio: 'inherit' });
|
|
35
|
+
console.log('\nšŖ OBOL started with pm2');
|
|
36
|
+
console.log(' pm2 logs obol ā tail logs');
|
|
37
|
+
console.log(' pm2 restart obol ā restart');
|
|
38
|
+
console.log(' pm2 stop obol ā stop');
|
|
39
|
+
console.log(' pm2 startup && pm2 save ā auto-start on boot');
|
|
40
|
+
} else {
|
|
41
|
+
// Foreground mode
|
|
42
|
+
console.log('šŖ Starting in foreground (Ctrl+C to stop)...\n');
|
|
43
|
+
require('../index');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { start };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const { loadConfig } = require('../config');
|
|
3
|
+
|
|
4
|
+
async function status() {
|
|
5
|
+
const config = loadConfig();
|
|
6
|
+
|
|
7
|
+
console.log('šŖ OBOL Status\n');
|
|
8
|
+
|
|
9
|
+
if (!config) {
|
|
10
|
+
console.log(' ā ļø Not configured. Run: obol init');
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
console.log(' ā
Configured');
|
|
14
|
+
|
|
15
|
+
// Check pm2
|
|
16
|
+
try {
|
|
17
|
+
const list = execSync('pm2 jlist', { encoding: 'utf-8' });
|
|
18
|
+
const procs = JSON.parse(list);
|
|
19
|
+
const obol = procs.find(p => p.name === 'obol');
|
|
20
|
+
if (obol) {
|
|
21
|
+
const status = obol.pm2_env.status;
|
|
22
|
+
const uptime = obol.pm2_env.pm_uptime ? Math.floor((Date.now() - obol.pm2_env.pm_uptime) / 60000) : 0;
|
|
23
|
+
const restarts = obol.pm2_env.restart_time || 0;
|
|
24
|
+
const mem = (obol.monit?.memory / 1024 / 1024).toFixed(0) || '?';
|
|
25
|
+
console.log(` ${status === 'online' ? 'ā
' : 'ā'} Process: ${status} (PID ${obol.pid})`);
|
|
26
|
+
console.log(` ā±ļø Uptime: ${uptime}min | Restarts: ${restarts} | Memory: ${mem}MB`);
|
|
27
|
+
} else {
|
|
28
|
+
console.log(' ā Not running');
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
console.log(' ā Not running (pm2 not installed)');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Components
|
|
35
|
+
console.log(` š” Telegram: ${config.telegram ? 'configured' : 'not set'}`);
|
|
36
|
+
console.log(` š§ Anthropic: ${config.anthropic ? 'configured' : 'not set'}`);
|
|
37
|
+
console.log(` š¾ Memory: ${config.supabase ? 'configured' : 'disabled'}`);
|
|
38
|
+
console.log(` š¦ Backup: ${config.github ? `${config.github.username}/${config.github.repo}` : 'disabled'}`);
|
|
39
|
+
console.log(` š Vercel: ${config.vercel ? 'configured' : 'not set'}`);
|
|
40
|
+
console.log(` š¤ Owner: ${config.owner?.name || 'not set'}`);
|
|
41
|
+
console.log(` š¤ Bot: ${config.bot?.name || 'OBOL'}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { status };
|
package/src/cli/stop.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
|
|
3
|
+
async function stop() {
|
|
4
|
+
try {
|
|
5
|
+
execSync('pm2 stop obol', { stdio: 'inherit' });
|
|
6
|
+
console.log('šŖ Stopped');
|
|
7
|
+
} catch {
|
|
8
|
+
console.log('šŖ Not running (or pm2 not installed).');
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = { stop };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
|
|
6
|
+
const OBOL_DIR = path.join(os.homedir(), '.obol');
|
|
7
|
+
const CONFIG_FILE = path.join(OBOL_DIR, 'config.json');
|
|
8
|
+
const PID_FILE = path.join(OBOL_DIR, 'obol.pid');
|
|
9
|
+
const LOG_FILE = path.join(OBOL_DIR, 'logs', 'obol.log');
|
|
10
|
+
|
|
11
|
+
function getConfigDir() {
|
|
12
|
+
return OBOL_DIR;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolvePassValues(obj) {
|
|
16
|
+
if (!obj || typeof obj !== 'object') return obj;
|
|
17
|
+
const result = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
18
|
+
for (const key of Object.keys(result)) {
|
|
19
|
+
if (typeof result[key] === 'string' && result[key].startsWith('pass:')) {
|
|
20
|
+
try {
|
|
21
|
+
const { execSync } = require('child_process');
|
|
22
|
+
result[key] = execSync(`pass show ${result[key].slice(5)}`, { encoding: 'utf-8' }).trim();
|
|
23
|
+
} catch {
|
|
24
|
+
// pass not available or key missing ā keep the placeholder
|
|
25
|
+
}
|
|
26
|
+
} else if (typeof result[key] === 'object') {
|
|
27
|
+
result[key] = resolvePassValues(result[key]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function loadConfig({ resolve = true } = {}) {
|
|
34
|
+
try {
|
|
35
|
+
if (!fs.existsSync(CONFIG_FILE)) return null;
|
|
36
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
37
|
+
const config = JSON.parse(raw);
|
|
38
|
+
return resolve ? resolvePassValues(config) : config;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function saveConfig(config) {
|
|
45
|
+
fs.mkdirSync(OBOL_DIR, { recursive: true });
|
|
46
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
OBOL_DIR,
|
|
51
|
+
CONFIG_FILE,
|
|
52
|
+
PID_FILE,
|
|
53
|
+
LOG_FILE,
|
|
54
|
+
getConfigDir,
|
|
55
|
+
loadConfig,
|
|
56
|
+
saveConfig,
|
|
57
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
async function migrate(supabaseConfig) {
|
|
2
|
+
const { url, serviceKey, accessToken } = supabaseConfig;
|
|
3
|
+
|
|
4
|
+
const headers = {
|
|
5
|
+
'apikey': serviceKey,
|
|
6
|
+
'Authorization': `Bearer ${serviceKey}`,
|
|
7
|
+
'Content-Type': 'application/json',
|
|
8
|
+
'Prefer': 'return=minimal',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const sqlStatements = [
|
|
12
|
+
// Enable vector extension
|
|
13
|
+
`CREATE EXTENSION IF NOT EXISTS vector;`,
|
|
14
|
+
|
|
15
|
+
// Memory table (vector, high-signal)
|
|
16
|
+
`CREATE TABLE IF NOT EXISTS obol_memory (
|
|
17
|
+
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
18
|
+
content TEXT NOT NULL,
|
|
19
|
+
category TEXT NOT NULL DEFAULT 'fact'
|
|
20
|
+
CHECK (category IN ('fact','preference','decision','lesson','person','project','event','conversation','resource','pattern','context','email')),
|
|
21
|
+
tags TEXT[] DEFAULT '{}',
|
|
22
|
+
importance FLOAT DEFAULT 0.5,
|
|
23
|
+
source TEXT,
|
|
24
|
+
embedding VECTOR(384),
|
|
25
|
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
26
|
+
accessed_at TIMESTAMPTZ DEFAULT NOW(),
|
|
27
|
+
access_count INT DEFAULT 0
|
|
28
|
+
);`,
|
|
29
|
+
|
|
30
|
+
// Messages table (raw log, every message)
|
|
31
|
+
`CREATE TABLE IF NOT EXISTS obol_messages (
|
|
32
|
+
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
33
|
+
chat_id BIGINT NOT NULL,
|
|
34
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
|
35
|
+
content TEXT NOT NULL,
|
|
36
|
+
model TEXT,
|
|
37
|
+
tokens_in INT,
|
|
38
|
+
tokens_out INT,
|
|
39
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
40
|
+
);`,
|
|
41
|
+
|
|
42
|
+
// Vector similarity search function
|
|
43
|
+
`CREATE OR REPLACE FUNCTION match_obol_memories(
|
|
44
|
+
query_embedding VECTOR(384),
|
|
45
|
+
match_threshold FLOAT,
|
|
46
|
+
match_count INT,
|
|
47
|
+
filter_category TEXT DEFAULT NULL
|
|
48
|
+
) RETURNS TABLE (
|
|
49
|
+
id UUID,
|
|
50
|
+
content TEXT,
|
|
51
|
+
category TEXT,
|
|
52
|
+
tags TEXT[],
|
|
53
|
+
importance FLOAT,
|
|
54
|
+
source TEXT,
|
|
55
|
+
created_at TIMESTAMPTZ,
|
|
56
|
+
accessed_at TIMESTAMPTZ,
|
|
57
|
+
access_count INT,
|
|
58
|
+
similarity FLOAT
|
|
59
|
+
) LANGUAGE plpgsql AS $$
|
|
60
|
+
BEGIN
|
|
61
|
+
RETURN QUERY
|
|
62
|
+
SELECT
|
|
63
|
+
m.id, m.content, m.category, m.tags, m.importance, m.source,
|
|
64
|
+
m.created_at, m.accessed_at, m.access_count,
|
|
65
|
+
1 - (m.embedding <=> query_embedding) AS similarity
|
|
66
|
+
FROM obol_memory m
|
|
67
|
+
WHERE 1 - (m.embedding <=> query_embedding) > match_threshold
|
|
68
|
+
AND (filter_category IS NULL OR m.category = filter_category)
|
|
69
|
+
ORDER BY m.embedding <=> query_embedding
|
|
70
|
+
LIMIT match_count;
|
|
71
|
+
END;
|
|
72
|
+
$$;`,
|
|
73
|
+
|
|
74
|
+
// Indexes
|
|
75
|
+
`CREATE INDEX IF NOT EXISTS obol_memory_embedding_idx ON obol_memory
|
|
76
|
+
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 10);`,
|
|
77
|
+
`CREATE INDEX IF NOT EXISTS obol_memory_created_at_idx ON obol_memory (created_at);`,
|
|
78
|
+
`CREATE INDEX IF NOT EXISTS obol_memory_category_idx ON obol_memory (category);`,
|
|
79
|
+
`CREATE INDEX IF NOT EXISTS obol_messages_chat_id_idx ON obol_messages (chat_id, created_at DESC);`,
|
|
80
|
+
`CREATE INDEX IF NOT EXISTS obol_messages_created_at_idx ON obol_messages (created_at DESC);`,
|
|
81
|
+
|
|
82
|
+
// RLS
|
|
83
|
+
`ALTER TABLE obol_memory ENABLE ROW LEVEL SECURITY;`,
|
|
84
|
+
`ALTER TABLE obol_messages ENABLE ROW LEVEL SECURITY;`,
|
|
85
|
+
`DO $$ BEGIN
|
|
86
|
+
CREATE POLICY "service_role_all" ON obol_memory FOR ALL TO service_role USING (true) WITH CHECK (true);
|
|
87
|
+
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
88
|
+
END $$;`,
|
|
89
|
+
`DO $$ BEGIN
|
|
90
|
+
CREATE POLICY "service_role_all" ON obol_messages FOR ALL TO service_role USING (true) WITH CHECK (true);
|
|
91
|
+
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
92
|
+
END $$;`,
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
// Save SQL file for manual fallback
|
|
96
|
+
const fs = require('fs');
|
|
97
|
+
const path = require('path');
|
|
98
|
+
const { OBOL_DIR } = require('../config');
|
|
99
|
+
const sqlFile = path.join(OBOL_DIR, 'migrations', 'init.sql');
|
|
100
|
+
fs.mkdirSync(path.dirname(sqlFile), { recursive: true });
|
|
101
|
+
fs.writeFileSync(sqlFile, sqlStatements.join('\n\n'));
|
|
102
|
+
|
|
103
|
+
// Try executing via Supabase Management API
|
|
104
|
+
if (accessToken) {
|
|
105
|
+
const projectRef = url.replace('https://', '').replace('.supabase.co', '');
|
|
106
|
+
|
|
107
|
+
for (const sql of sqlStatements) {
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch(`https://api.supabase.com/v1/projects/${projectRef}/database/query`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: {
|
|
112
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
113
|
+
'Content-Type': 'application/json',
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify({ query: sql }),
|
|
116
|
+
});
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
const err = await res.text();
|
|
119
|
+
if (sql.includes('ivfflat') && err.includes('not enough')) continue;
|
|
120
|
+
console.log(` ā ļø SQL warning: ${err.substring(0, 100)}`);
|
|
121
|
+
}
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.log(` ā ļø Migration step failed: ${e.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log(`\n ā ļø Could not run migrations automatically.`);
|
|
130
|
+
console.log(` Run this SQL in your Supabase dashboard (SQL Editor):`);
|
|
131
|
+
console.log(` File saved to: ${sqlFile}\n`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { migrate };
|