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.
@@ -0,0 +1,110 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { OBOL_DIR } = require('./config');
4
+
5
+ const FIRST_RUN_FLAG = path.join(OBOL_DIR, '.first-run-complete');
6
+
7
+ function isFirstRun() {
8
+ return !fs.existsSync(FIRST_RUN_FLAG);
9
+ }
10
+
11
+ function markFirstRunComplete() {
12
+ fs.writeFileSync(FIRST_RUN_FLAG, new Date().toISOString());
13
+ }
14
+
15
+ // System prompt for the first-run conversation
16
+ const FIRST_RUN_SYSTEM = `You are OBOL, an AI assistant that was just installed. This is your FIRST conversation with your new owner. You need to learn about them to set yourself up.
17
+
18
+ Your job: have a natural, friendly conversation to learn:
19
+ 1. What they do (job, interests, projects)
20
+ 2. What vibe/personality they want from you (casual, professional, chaotic, etc.)
21
+ 3. Important context (people, projects, locations, anything they mention)
22
+
23
+ Rules:
24
+ - ALL features are enabled by default. Don't ask about features or permissions.
25
+ - Keep it natural — don't make it feel like a form
26
+ - 2-3 questions max, then you're done
27
+ - Be warm but not cringe
28
+ - After you have enough info, generate the personality files
29
+
30
+ When you have enough context, respond with a JSON block at the END of your message (after your normal text response) like this:
31
+
32
+ \`\`\`obol-setup
33
+ {
34
+ "soul": "Full SOUL.md content here — the bot's personality, voice, humor style, values",
35
+ "user": "Full USER.md content here — everything learned about the owner",
36
+ "ready": true
37
+ }
38
+ \`\`\`
39
+
40
+ Only include the JSON block when you have enough info (usually after 2-3 exchanges). The "soul" should be a proper markdown document reflecting the vibe they want. The "user" should capture everything they told you.
41
+
42
+ Start with a brief intro — you're OBOL, you're set up and running, tell me about yourself so I can be useful.`;
43
+
44
+ // Parse the setup JSON from a response
45
+ function parseSetupResponse(text) {
46
+ const match = text.match(/```obol-setup\n([\s\S]*?)\n```/);
47
+ if (!match) return null;
48
+ try {
49
+ return JSON.parse(match[1]);
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ // Strip the setup JSON from the visible response
56
+ function cleanResponse(text) {
57
+ return text.replace(/```obol-setup\n[\s\S]*?\n```/, '').trim();
58
+ }
59
+
60
+ // Write the personality files from setup data
61
+ function writePersonalityFromSetup(setup, botName) {
62
+ const dir = path.join(OBOL_DIR, 'personality');
63
+ fs.mkdirSync(dir, { recursive: true });
64
+
65
+ if (setup.soul) {
66
+ fs.writeFileSync(path.join(dir, 'SOUL.md'), setup.soul);
67
+ }
68
+ if (setup.user) {
69
+ fs.writeFileSync(path.join(dir, 'USER.md'), setup.user);
70
+ }
71
+
72
+ // Write a default AGENTS.md if it doesn't exist
73
+ const agentsPath = path.join(dir, 'AGENTS.md');
74
+ if (!fs.existsSync(agentsPath)) {
75
+ fs.writeFileSync(agentsPath, `# AGENTS.md — How ${botName || 'OBOL'} Works
76
+
77
+ ## Memory
78
+ Vector memory via Supabase pgvector. Local embeddings (all-MiniLM-L6-v2).
79
+ Search memory before answering questions about the past.
80
+ Store important facts, decisions, preferences, and events automatically.
81
+
82
+ ## Tools
83
+ - Execute shell commands
84
+ - Read/write files
85
+ - Search the web
86
+ - Deploy to Vercel
87
+ - Vector memory (search, add, date query)
88
+
89
+ ## Safety
90
+ - Never share owner's private data
91
+ - Draft emails/posts — owner sends them
92
+ - Don't run destructive commands without asking
93
+
94
+ ## Heartbeat
95
+ Check in periodically. Be proactive — surface useful info, don't just wait.
96
+
97
+ ## Backup
98
+ Brain backs up to GitHub daily. Personality, scripts, commands.
99
+ `);
100
+ }
101
+ }
102
+
103
+ module.exports = {
104
+ isFirstRun,
105
+ markFirstRunComplete,
106
+ FIRST_RUN_SYSTEM,
107
+ parseSetupResponse,
108
+ cleanResponse,
109
+ writePersonalityFromSetup,
110
+ };
@@ -0,0 +1,16 @@
1
+ const cron = require('node-cron');
2
+
3
+ function setupHeartbeat(claude, memory) {
4
+ // Every 30 minutes, check if anything needs attention
5
+ cron.schedule('*/30 * * * *', async () => {
6
+ console.log(`[${new Date().toISOString()}] Heartbeat tick`);
7
+
8
+ // Memory consolidation could go here
9
+ // Email checks could go here
10
+ // Custom heartbeat tasks from AGENTS.md could go here
11
+ });
12
+
13
+ console.log(' ✅ Heartbeat running (every 30min)');
14
+ }
15
+
16
+ module.exports = { setupHeartbeat };
package/src/index.js ADDED
@@ -0,0 +1,55 @@
1
+ const { loadConfig, OBOL_DIR } = require('./config');
2
+ const { createBot } = require('./telegram');
3
+ const { createClaude } = require('./claude');
4
+ const { createMemory } = require('./memory');
5
+ const { createMessageLog } = require('./messages');
6
+ const { loadPersonality } = require('./personality');
7
+ const { setupBackup } = require('./backup');
8
+ const { setupHeartbeat } = require('./heartbeat');
9
+
10
+ async function main() {
11
+ const config = loadConfig();
12
+ if (!config) {
13
+ console.error('🪙 Not configured. Run: obol init');
14
+ process.exit(1);
15
+ }
16
+
17
+ console.log('🪙 OBOL starting...\n');
18
+
19
+ // Initialize components
20
+ const personality = loadPersonality();
21
+ const memory = config.supabase ? await createMemory(config.supabase) : null;
22
+ const claude = createClaude(config.anthropic, { personality, memory });
23
+ const messageLog = config.supabase ? createMessageLog(config.supabase, memory, claude.client) : null;
24
+ const bot = createBot(config.telegram, claude, memory, messageLog);
25
+
26
+ // Setup heartbeat
27
+ if (config.heartbeat !== false) {
28
+ setupHeartbeat(claude, memory);
29
+ }
30
+
31
+ // Setup GitHub backup
32
+ if (config.github) {
33
+ setupBackup(config.github);
34
+ }
35
+
36
+ // Graceful shutdown
37
+ const shutdown = (signal) => {
38
+ console.log(`\n🪙 ${signal} received. Shutting down gracefully...`);
39
+ bot.stop();
40
+ process.exit(0);
41
+ };
42
+ process.on('SIGINT', () => shutdown('SIGINT'));
43
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
44
+
45
+ // Start bot
46
+ console.log('🪙 OBOL is alive. Listening for messages...\n');
47
+ await bot.start({
48
+ onStart: (info) => console.log(` Bot: @${info.username}`),
49
+ });
50
+ }
51
+
52
+ main().catch((e) => {
53
+ console.error('💥 Fatal:', e.message);
54
+ process.exit(1);
55
+ });
package/src/memory.js ADDED
@@ -0,0 +1,164 @@
1
+ const { pipeline } = require('@xenova/transformers');
2
+
3
+ let embedder;
4
+
5
+ async function getEmbedding(text) {
6
+ if (!embedder) {
7
+ console.log(' Loading embedding model (first run downloads ~30MB)...');
8
+ embedder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
9
+ console.log(' ✅ Embedding model ready');
10
+ }
11
+ const result = await embedder(text, { pooling: 'mean', normalize: true });
12
+ return Array.from(result.data);
13
+ }
14
+
15
+ async function createMemory(supabaseConfig) {
16
+ const { url, serviceKey } = supabaseConfig;
17
+
18
+ const headers = {
19
+ 'apikey': serviceKey,
20
+ 'Authorization': `Bearer ${serviceKey}`,
21
+ 'Content-Type': 'application/json',
22
+ 'Prefer': 'return=representation',
23
+ };
24
+
25
+ async function add(content, opts = {}) {
26
+ const category = opts.category || 'fact';
27
+ const importance = opts.importance || 0.5;
28
+ const source = opts.source || null;
29
+ const tags = opts.tags || [];
30
+
31
+ const embedding = await getEmbedding(content);
32
+
33
+ const res = await fetch(`${url}/rest/v1/obol_memory`, {
34
+ method: 'POST',
35
+ headers,
36
+ body: JSON.stringify({ content, category, importance, source, tags, embedding }),
37
+ });
38
+ const data = await res.json();
39
+ if (!res.ok) throw new Error(JSON.stringify(data));
40
+ return data[0];
41
+ }
42
+
43
+ async function search(query, opts = {}) {
44
+ const embedding = await getEmbedding(query);
45
+ const limit = opts.limit || 10;
46
+ const threshold = opts.threshold || 0.3;
47
+ const category = opts.category || null;
48
+
49
+ const res = await fetch(`${url}/rest/v1/rpc/match_obol_memories`, {
50
+ method: 'POST',
51
+ headers,
52
+ body: JSON.stringify({
53
+ query_embedding: embedding,
54
+ match_threshold: threshold,
55
+ match_count: limit,
56
+ filter_category: category,
57
+ }),
58
+ });
59
+ const data = await res.json();
60
+ if (!res.ok) throw new Error(JSON.stringify(data));
61
+
62
+ // Update accessed_at
63
+ if (data.length > 0) {
64
+ const ids = data.map(m => m.id);
65
+ await fetch(`${url}/rest/v1/obol_memory?id=in.(${ids.join(',')})`, {
66
+ method: 'PATCH',
67
+ headers: { ...headers, 'Prefer': 'return=minimal' },
68
+ body: JSON.stringify({ accessed_at: new Date().toISOString() }),
69
+ }).catch(() => {}); // Best effort
70
+ }
71
+
72
+ return data;
73
+ }
74
+
75
+ async function byDate(dateStr, opts = {}) {
76
+ const { start, end } = parseDateRange(dateStr);
77
+ const limit = opts.limit || 50;
78
+
79
+ let fetchUrl = `${url}/rest/v1/obol_memory?select=id,content,category,tags,importance,source,created_at&created_at=gte.${start.toISOString()}&created_at=lt.${end.toISOString()}&order=created_at.asc&limit=${limit}`;
80
+ if (opts.category) fetchUrl += `&category=eq.${opts.category}`;
81
+
82
+ const res = await fetch(fetchUrl, { headers });
83
+ const data = await res.json();
84
+ if (!res.ok) throw new Error(JSON.stringify(data));
85
+ return data;
86
+ }
87
+
88
+ async function recent(opts = {}) {
89
+ const limit = opts.limit || 10;
90
+ let fetchUrl = `${url}/rest/v1/obol_memory?select=id,content,category,tags,importance,source,created_at&order=created_at.desc&limit=${limit}`;
91
+ if (opts.category) fetchUrl += `&category=eq.${opts.category}`;
92
+
93
+ const res = await fetch(fetchUrl, { headers });
94
+ return await res.json();
95
+ }
96
+
97
+ async function update(id, opts = {}) {
98
+ const patch = {};
99
+ if (opts.content) {
100
+ patch.content = opts.content;
101
+ patch.embedding = await getEmbedding(opts.content);
102
+ }
103
+ if (opts.category) patch.category = opts.category;
104
+ if (opts.importance) patch.importance = opts.importance;
105
+ if (opts.tags) patch.tags = opts.tags;
106
+ if (opts.source) patch.source = opts.source;
107
+
108
+ const res = await fetch(`${url}/rest/v1/obol_memory?id=eq.${id}`, {
109
+ method: 'PATCH',
110
+ headers,
111
+ body: JSON.stringify(patch),
112
+ });
113
+ const data = await res.json();
114
+ if (!res.ok) throw new Error(JSON.stringify(data));
115
+ return data[0];
116
+ }
117
+
118
+ async function forget(id) {
119
+ await fetch(`${url}/rest/v1/obol_memory?id=eq.${id}`, {
120
+ method: 'DELETE',
121
+ headers: { ...headers, 'Prefer': 'return=minimal' },
122
+ });
123
+ }
124
+
125
+ async function stats() {
126
+ const res = await fetch(`${url}/rest/v1/obol_memory?select=category`, { headers });
127
+ const data = await res.json();
128
+ const counts = {};
129
+ data.forEach(m => { counts[m.category] = (counts[m.category] || 0) + 1; });
130
+ const breakdown = Object.entries(counts)
131
+ .sort((a, b) => b[1] - a[1])
132
+ .map(([cat, count]) => ` ${cat}: ${count}`)
133
+ .join('\n');
134
+ return { total: data.length, counts, breakdown };
135
+ }
136
+
137
+ return { add, search, byDate, recent, update, forget, stats };
138
+ }
139
+
140
+ function parseDateRange(dateStr) {
141
+ let start, end;
142
+ const now = new Date();
143
+
144
+ if (!dateStr || dateStr === 'today') {
145
+ start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
146
+ end = new Date(start); end.setDate(end.getDate() + 1);
147
+ } else if (dateStr === 'yesterday') {
148
+ start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
149
+ end = new Date(now.getFullYear(), now.getMonth(), now.getDate());
150
+ } else if (/^(\d+)d$/.test(dateStr)) {
151
+ const days = parseInt(dateStr);
152
+ start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days);
153
+ end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
154
+ } else {
155
+ const parsed = new Date(dateStr);
156
+ if (isNaN(parsed)) throw new Error(`Cannot parse date: ${dateStr}`);
157
+ start = new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate());
158
+ end = new Date(start); end.setDate(end.getDate() + 1);
159
+ }
160
+
161
+ return { start, end };
162
+ }
163
+
164
+ module.exports = { createMemory, getEmbedding };
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Message logging + periodic consolidation to vector memory.
3
+ *
4
+ * Tier 1: obol_messages — raw log, every message, no embeddings
5
+ * Tier 2: obol_memory — vector, Haiku summarizes every ~5 exchanges
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { OBOL_DIR } = require('./config');
11
+
12
+ class MessageLog {
13
+ constructor(supabaseConfig, memory, claudeClient) {
14
+ this.url = supabaseConfig.url;
15
+ this.headers = {
16
+ 'apikey': supabaseConfig.serviceKey,
17
+ 'Authorization': `Bearer ${supabaseConfig.serviceKey}`,
18
+ 'Content-Type': 'application/json',
19
+ 'Prefer': 'return=representation',
20
+ };
21
+ this.memory = memory;
22
+ this.client = claudeClient;
23
+ this.exchangeCount = new Map(); // chatId -> count since last consolidation
24
+ }
25
+
26
+ /**
27
+ * Log a message (fire and forget)
28
+ */
29
+ async log(chatId, role, content, opts = {}) {
30
+ try {
31
+ await fetch(`${this.url}/rest/v1/obol_messages`, {
32
+ method: 'POST',
33
+ headers: this.headers,
34
+ body: JSON.stringify({
35
+ chat_id: chatId,
36
+ role,
37
+ content: content.substring(0, 50000), // cap at 50k
38
+ model: opts.model || null,
39
+ tokens_in: opts.tokensIn || null,
40
+ tokens_out: opts.tokensOut || null,
41
+ }),
42
+ });
43
+ } catch {} // Best effort
44
+
45
+ // Track exchanges for consolidation + evolution
46
+ if (role === 'assistant') {
47
+ const count = (this.exchangeCount.get(chatId) || 0) + 1;
48
+ this.exchangeCount.set(chatId, count);
49
+
50
+ // Consolidate every 5 exchanges
51
+ if (count >= 5) {
52
+ this.exchangeCount.set(chatId, 0);
53
+ this.consolidate(chatId).catch(() => {});
54
+ }
55
+
56
+ // Tick evolution counter
57
+ const { tickExchange } = require('./evolve');
58
+ tickExchange().catch(() => {});
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Get recent messages for context loading on boot
64
+ */
65
+ async getRecent(chatId, limit = 20) {
66
+ try {
67
+ const res = await fetch(
68
+ `${this.url}/rest/v1/obol_messages?chat_id=eq.${chatId}&order=created_at.desc&limit=${limit}&select=role,content,created_at`,
69
+ { headers: this.headers }
70
+ );
71
+ const data = await res.json();
72
+ return data.reverse(); // oldest first
73
+ } catch {
74
+ return [];
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Haiku consolidates recent messages into vector memory
80
+ */
81
+ async consolidate(chatId) {
82
+ if (!this.memory || !this.client) return;
83
+
84
+ try {
85
+ // Get last 10 messages
86
+ const messages = await this.getRecent(chatId, 10);
87
+ if (messages.length < 4) return; // Not enough to consolidate
88
+
89
+ const transcript = messages.map(m =>
90
+ `${m.role === 'user' ? 'Human' : 'Assistant'}: ${m.content.substring(0, 500)}`
91
+ ).join('\n');
92
+
93
+ // Ask Haiku to extract memories worth storing long-term
94
+ const response = await this.client.messages.create({
95
+ model: 'claude-haiku-4-20250514',
96
+ max_tokens: 500,
97
+ system: `Analyze this conversation and extract important facts worth remembering long-term.
98
+
99
+ Return JSON:
100
+ {
101
+ "memories": [
102
+ {"content": "concise fact", "category": "fact|preference|decision|lesson|person|project|event|conversation|resource|pattern|context"}
103
+ ]
104
+ }
105
+
106
+ Skip: greetings, small talk, filler. Keep: facts, decisions, preferences, people, projects, events, lessons learned.
107
+
108
+ Return empty array if nothing worth storing.`,
109
+ messages: [{ role: 'user', content: transcript }],
110
+ });
111
+
112
+ const text = response.content[0]?.text || '';
113
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
114
+ if (!jsonMatch) return;
115
+
116
+ const extracted = JSON.parse(jsonMatch[0]);
117
+
118
+ // Store memories to vector store
119
+ if (extracted.memories?.length) {
120
+ for (const mem of extracted.memories) {
121
+ if (mem.content && mem.content.length > 10) {
122
+ await this.memory.add(mem.content, {
123
+ category: mem.category || 'conversation',
124
+ importance: 0.5,
125
+ source: 'auto-consolidation',
126
+ });
127
+ }
128
+ }
129
+ }
130
+
131
+ // Personality files (SOUL.md, USER.md) are only updated by Opus during soul evolution
132
+ } catch {} // Best effort
133
+ }
134
+ }
135
+
136
+ function createMessageLog(supabaseConfig, memory, claudeClient) {
137
+ return new MessageLog(supabaseConfig, memory, claudeClient);
138
+ }
139
+
140
+ module.exports = { createMessageLog };
@@ -0,0 +1,27 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { OBOL_DIR } = require('./config');
4
+
5
+ function loadPersonality() {
6
+ const dir = path.join(OBOL_DIR, 'personality');
7
+ const personality = {};
8
+
9
+ const files = {
10
+ soul: 'SOUL.md',
11
+ user: 'USER.md',
12
+ agents: 'AGENTS.md',
13
+ };
14
+
15
+ for (const [key, filename] of Object.entries(files)) {
16
+ const filepath = path.join(dir, filename);
17
+ try {
18
+ personality[key] = fs.readFileSync(filepath, 'utf-8');
19
+ } catch {
20
+ personality[key] = null;
21
+ }
22
+ }
23
+
24
+ return personality;
25
+ }
26
+
27
+ module.exports = { loadPersonality };