obol-ai 0.2.39 → 0.3.1

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/messages.js CHANGED
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Tier 1: obol_messages — raw log, every message
5
5
  * Tier 2: obol_memory — curated facts, extracted after every assistant turn
6
+ * Tier 3: obol_events — follow-up intents, detected by batch analysis (see analysis.js)
7
+ * Tier 4: obol_user_patterns — synthesized behavioral patterns, refreshed by batch analysis (see analysis.js)
6
8
  */
7
9
 
8
10
  const Anthropic = require('@anthropic-ai/sdk');
@@ -24,6 +26,7 @@ class MessageLog {
24
26
  this.exchangeCount = new Map();
25
27
  this._lastUserMessage = new Map();
26
28
  this._verboseCallbacks = new Map();
29
+ this._lastActivity = new Map();
27
30
  this._cleanup = setInterval(() => {
28
31
  const now = Date.now();
29
32
  for (const [key] of this.exchangeCount) {
@@ -35,7 +38,6 @@ class MessageLog {
35
38
  }
36
39
  }, 600000);
37
40
  this._cleanup.unref();
38
- this._lastActivity = new Map();
39
41
  }
40
42
 
41
43
  async log(chatId, role, content, opts = {}) {
@@ -71,17 +73,9 @@ class MessageLog {
71
73
  if (lastUser) {
72
74
  this._extractFacts(chatId, lastUser, truncated).catch(() => {});
73
75
  }
74
-
75
- const { checkEvolution } = require('./evolve');
76
- checkEvolution(this.userDir, this).then(result => {
77
- if (result?.ready && !this._evolutionReady && !this._evolutionPending) this._evolutionReady = true;
78
- }).catch(() => {});
79
76
  }
80
77
  }
81
78
 
82
- /**
83
- * Get recent messages for context loading on boot
84
- */
85
79
  async getRecent(chatId, limit = 50) {
86
80
  try {
87
81
  const res = await fetch(
@@ -89,7 +83,7 @@ class MessageLog {
89
83
  { headers: this.headers }
90
84
  );
91
85
  const data = await res.json();
92
- const rows = data.reverse(); // oldest first
86
+ const rows = data.reverse();
93
87
  const firstUserIdx = rows.findIndex(r => r.role === 'user');
94
88
  return firstUserIdx > 0 ? rows.slice(firstUserIdx) : rows;
95
89
  } catch {
@@ -97,9 +91,20 @@ class MessageLog {
97
91
  }
98
92
  }
99
93
 
100
- /**
101
- * Get messages by date range for history retrieval
102
- */
94
+ async getSince(chatId, since, limit = 500) {
95
+ try {
96
+ const res = await fetch(
97
+ `${this.url}/rest/v1/obol_messages?chat_id=eq.${chatId}&created_at=gte.${since.toISOString()}&order=created_at.asc&limit=${limit}&select=role,content,created_at`,
98
+ { headers: this.headers }
99
+ );
100
+ const data = await res.json();
101
+ if (!res.ok) return [];
102
+ return data;
103
+ } catch {
104
+ return [];
105
+ }
106
+ }
107
+
103
108
  async getByDate(chatId, dateStr, opts = {}) {
104
109
  const { start, end } = parseDateRange(dateStr);
105
110
  const limit = opts.limit || 50;
@@ -165,13 +170,12 @@ class MessageLog {
165
170
  const response = await client.messages.create({
166
171
  model: 'claude-haiku-4-5-20251001',
167
172
  max_tokens: 1024,
168
- system: `Extract 0-5 atomic facts from this exchange worth remembering long-term.
173
+ system: `Extract facts from this exchange.
169
174
 
175
+ FACTS (0-5 atomic facts worth remembering long-term):
170
176
  Store: personal details, preferences, decisions, projects, plans, people mentioned, technical details, events, deadlines, emotional context, resources.
171
- Skip: greetings, acknowledgments, content-free exchanges. Use facts=[] if nothing worth storing.
172
-
173
- Importance: 0.3 minor, 0.5 useful, 0.7 important, 0.9 critical.
174
- Keep each fact atomic — one idea per entry.`,
177
+ Skip: greetings, acknowledgments, content-free exchanges.
178
+ Importance: 0.3 minor, 0.5 useful, 0.7 important, 0.9 critical.`,
175
179
  tools: extractTool,
176
180
  tool_choice: { type: 'tool', name: 'save_memory' },
177
181
  messages: [{ role: 'user', content: `Human: ${userMsg.substring(0, 2000)}\nAssistant: ${assistantMsg.substring(0, 2000)}` }],
@@ -181,38 +185,31 @@ Keep each fact atomic — one idea per entry.`,
181
185
  if (!toolUse) return;
182
186
 
183
187
  const facts = toolUse.input?.facts;
184
- if (!Array.isArray(facts)) return;
185
-
186
- if (facts.length === 0) {
187
- vlog?.('[extract] 0 facts (trivial exchange)');
188
- return;
189
- }
190
188
 
191
- const validCategories = new Set(['fact','preference','decision','lesson','person','project','event','conversation','resource','pattern','context','email']);
192
- let stored = 0;
193
- let duped = 0;
194
-
195
- for (const fact of facts.slice(0, 5)) {
196
- if (!fact.content || fact.content.length <= 10) continue;
197
- try {
198
- const existing = await this.memory.search(fact.content, { limit: 1, threshold: 0.92 });
199
- if (existing.length > 0) { duped++; continue; }
200
- } catch {}
201
- const category = validCategories.has(fact.category) ? fact.category : 'fact';
202
- const importance = typeof fact.importance === 'number' ? Math.min(1, Math.max(0, fact.importance)) : 0.5;
203
- const tags = Array.isArray(fact.tags) ? fact.tags.slice(0, 5) : [];
204
- await this.memory.add(fact.content, {
205
- category,
206
- tags,
207
- importance,
208
- source: 'turn-extraction',
209
- });
210
- stored++;
211
- vlog?.(`[extract] +[${category}] ${fact.content}`);
212
- }
189
+ if (Array.isArray(facts) && facts.length > 0) {
190
+ const validCategories = new Set(['fact','preference','decision','lesson','person','project','event','conversation','resource','pattern','context','email']);
191
+ let stored = 0;
192
+ let duped = 0;
193
+
194
+ for (const fact of facts.slice(0, 5)) {
195
+ if (!fact.content || fact.content.length <= 10) continue;
196
+ try {
197
+ const existing = await this.memory.search(fact.content, { limit: 1, threshold: 0.92 });
198
+ if (existing.length > 0) { duped++; continue; }
199
+ } catch {}
200
+ const category = validCategories.has(fact.category) ? fact.category : 'fact';
201
+ const importance = typeof fact.importance === 'number' ? Math.min(1, Math.max(0, fact.importance)) : 0.5;
202
+ const tags = Array.isArray(fact.tags) ? fact.tags.slice(0, 5) : [];
203
+ await this.memory.add(fact.content, { category, tags, importance, source: 'turn-extraction' });
204
+ stored++;
205
+ vlog?.(`[extract] +[${category}] ${fact.content}`);
206
+ }
213
207
 
214
- if (stored > 0 || duped > 0) {
215
- vlog?.(`[extract] ${stored} stored, ${duped} duped, ${facts.length} extracted`);
208
+ if (stored > 0 || duped > 0) {
209
+ vlog?.(`[extract] ${stored} stored, ${duped} duped, ${facts.length} extracted`);
210
+ }
211
+ } else {
212
+ vlog?.('[extract] 0 facts (trivial exchange)');
216
213
  }
217
214
  } catch (e) {
218
215
  console.error('[extract] Failed:', e.message);
@@ -0,0 +1,111 @@
1
+ const VALID_DIMENSIONS = new Set(['timing', 'mood', 'humor', 'engagement', 'communication', 'topics']);
2
+
3
+ async function createPatterns(supabaseConfig, userId) {
4
+ const { url, serviceKey } = supabaseConfig;
5
+
6
+ const headers = {
7
+ 'apikey': serviceKey,
8
+ 'Authorization': `Bearer ${serviceKey}`,
9
+ 'Content-Type': 'application/json',
10
+ 'Prefer': 'return=representation',
11
+ };
12
+
13
+ async function upsert(key, dimension, summary, data = {}, confidence = 0.5) {
14
+ if (!VALID_DIMENSIONS.has(dimension)) throw new Error(`Invalid dimension: ${dimension}`);
15
+
16
+ const res = await fetch(`${url}/rest/v1/obol_user_patterns`, {
17
+ method: 'POST',
18
+ headers: { ...headers, 'Prefer': 'resolution=merge-duplicates,return=representation' },
19
+ body: JSON.stringify({
20
+ user_id: userId,
21
+ key,
22
+ dimension,
23
+ summary,
24
+ data,
25
+ confidence,
26
+ updated_at: new Date().toISOString(),
27
+ observation_count: 1,
28
+ }),
29
+ });
30
+ const result = await res.json();
31
+ if (!res.ok) throw new Error(JSON.stringify(result));
32
+ return result[0];
33
+ }
34
+
35
+ async function incrementObservation(key, dimension, summary, data = {}, confidence = 0.5) {
36
+ const existing = await get(key);
37
+ const count = (existing?.observation_count || 0) + 1;
38
+
39
+ const res = await fetch(`${url}/rest/v1/obol_user_patterns`, {
40
+ method: 'POST',
41
+ headers: { ...headers, 'Prefer': 'resolution=merge-duplicates,return=representation' },
42
+ body: JSON.stringify({
43
+ user_id: userId,
44
+ key,
45
+ dimension,
46
+ summary,
47
+ data,
48
+ confidence,
49
+ observation_count: count,
50
+ updated_at: new Date().toISOString(),
51
+ }),
52
+ });
53
+ const result = await res.json();
54
+ if (!res.ok) throw new Error(JSON.stringify(result));
55
+ return result[0];
56
+ }
57
+
58
+ async function get(key) {
59
+ const res = await fetch(
60
+ `${url}/rest/v1/obol_user_patterns?user_id=eq.${userId}&key=eq.${encodeURIComponent(key)}&limit=1`,
61
+ { headers }
62
+ );
63
+ const data = await res.json();
64
+ if (!res.ok) throw new Error(JSON.stringify(data));
65
+ return data[0] || null;
66
+ }
67
+
68
+ async function getAll() {
69
+ const res = await fetch(
70
+ `${url}/rest/v1/obol_user_patterns?user_id=eq.${userId}&order=dimension.asc,updated_at.desc`,
71
+ { headers }
72
+ );
73
+ const data = await res.json();
74
+ if (!res.ok) throw new Error(JSON.stringify(data));
75
+ return data;
76
+ }
77
+
78
+ async function getByDimension(dimension) {
79
+ const res = await fetch(
80
+ `${url}/rest/v1/obol_user_patterns?user_id=eq.${userId}&dimension=eq.${dimension}&order=updated_at.desc`,
81
+ { headers }
82
+ );
83
+ const data = await res.json();
84
+ if (!res.ok) throw new Error(JSON.stringify(data));
85
+ return data;
86
+ }
87
+
88
+ async function remove(key) {
89
+ await fetch(
90
+ `${url}/rest/v1/obol_user_patterns?user_id=eq.${userId}&key=eq.${encodeURIComponent(key)}`,
91
+ { method: 'DELETE', headers: { ...headers, 'Prefer': 'return=minimal' } }
92
+ );
93
+ }
94
+
95
+ async function format() {
96
+ const all = await getAll();
97
+ if (!all.length) return null;
98
+ const byDimension = {};
99
+ for (const p of all) {
100
+ if (!byDimension[p.dimension]) byDimension[p.dimension] = [];
101
+ byDimension[p.dimension].push(p.summary);
102
+ }
103
+ return Object.entries(byDimension)
104
+ .map(([dim, summaries]) => `[${dim}]\n${summaries.map(s => `- ${s}`).join('\n')}`)
105
+ .join('\n\n');
106
+ }
107
+
108
+ return { upsert, incrementObservation, get, getAll, getByDimension, remove, format };
109
+ }
110
+
111
+ module.exports = { createPatterns };
@@ -4,17 +4,16 @@ const { OBOL_DIR } = require('./config');
4
4
 
5
5
  const DEFAULT_TRAITS = require('./defaults/traits.json');
6
6
 
7
- function loadPersonality(dir) {
8
- dir = dir || path.join(OBOL_DIR, 'personality');
7
+ function loadPersonality(sharedDir, userDir) {
8
+ sharedDir = sharedDir || path.join(OBOL_DIR, 'personality');
9
+ userDir = userDir || sharedDir;
9
10
  const personality = {};
10
11
 
11
- const files = {
12
- soul: 'SOUL.md',
13
- user: 'USER.md',
14
- agents: 'AGENTS.md',
15
- };
16
-
17
- for (const [key, filename] of Object.entries(files)) {
12
+ for (const [key, filename, dir] of [
13
+ ['soul', 'SOUL.md', sharedDir],
14
+ ['agents', 'AGENTS.md', userDir],
15
+ ['user', 'USER.md', userDir],
16
+ ]) {
18
17
  const filepath = path.join(dir, filename);
19
18
  try {
20
19
  personality[key] = fs.readFileSync(filepath, 'utf-8');
@@ -23,7 +22,7 @@ function loadPersonality(dir) {
23
22
  }
24
23
  }
25
24
 
26
- personality.traits = loadTraits(dir);
25
+ personality.traits = loadTraits(userDir);
27
26
 
28
27
  return personality;
29
28
  }
package/src/soul.js ADDED
@@ -0,0 +1,53 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { OBOL_DIR } = require('./config');
4
+
5
+ const PERSONALITY_DIR = path.join(OBOL_DIR, 'personality');
6
+
7
+ function makeHeaders(supabaseConfig) {
8
+ return {
9
+ 'apikey': supabaseConfig.serviceKey,
10
+ 'Authorization': `Bearer ${supabaseConfig.serviceKey}`,
11
+ 'Content-Type': 'application/json',
12
+ 'Prefer': 'return=minimal',
13
+ };
14
+ }
15
+
16
+ async function backup(supabaseConfig, key, content) {
17
+ if (!supabaseConfig?.url || !supabaseConfig?.serviceKey) return;
18
+ await fetch(`${supabaseConfig.url}/rest/v1/obol_soul`, {
19
+ method: 'POST',
20
+ headers: { ...makeHeaders(supabaseConfig), 'Prefer': 'resolution=merge-duplicates,return=minimal' },
21
+ body: JSON.stringify({ id: key, content, updated_at: new Date().toISOString() }),
22
+ });
23
+ }
24
+
25
+ async function restore(supabaseConfig, key) {
26
+ if (!supabaseConfig?.url || !supabaseConfig?.serviceKey) return null;
27
+ const res = await fetch(`${supabaseConfig.url}/rest/v1/obol_soul?id=eq.${key}&select=content`, {
28
+ headers: makeHeaders(supabaseConfig),
29
+ });
30
+ if (!res.ok) return null;
31
+ const rows = await res.json();
32
+ return rows?.[0]?.content || null;
33
+ }
34
+
35
+ async function restoreIfMissing(supabaseConfig) {
36
+ if (!supabaseConfig?.url || !supabaseConfig?.serviceKey) return;
37
+ fs.mkdirSync(PERSONALITY_DIR, { recursive: true });
38
+
39
+ const soulPath = path.join(PERSONALITY_DIR, 'SOUL.md');
40
+ if (!fs.existsSync(soulPath)) {
41
+ try {
42
+ const content = await restore(supabaseConfig, 'soul');
43
+ if (content) {
44
+ fs.writeFileSync(soulPath, content);
45
+ console.log(' [soul] Restored SOUL.md from Supabase');
46
+ }
47
+ } catch (e) {
48
+ console.error(` [soul] Failed to restore SOUL.md: ${e.message}`);
49
+ }
50
+ }
51
+ }
52
+
53
+ module.exports = { backup, restore, restoreIfMissing, PERSONALITY_DIR };
@@ -3,7 +3,6 @@ const { TERM_WIDTH } = require('../status');
3
3
  const RATE_LIMIT_MS = 3000;
4
4
  const SPAM_THRESHOLD = 5;
5
5
  const SPAM_COOLDOWN_MS = 30000;
6
- const EVOLUTION_IDLE_MS = 15 * 60 * 1000;
7
6
  const DEDUP_TTL_MS = 5 * 60 * 1000;
8
7
  const DEDUP_MAX_SIZE = 2000;
9
8
  const TEXT_BUFFER_GAP_MS = 1500;
@@ -18,7 +17,6 @@ module.exports = {
18
17
  RATE_LIMIT_MS,
19
18
  SPAM_THRESHOLD,
20
19
  SPAM_COOLDOWN_MS,
21
- EVOLUTION_IDLE_MS,
22
20
  DEDUP_TTL_MS,
23
21
  DEDUP_MAX_SIZE,
24
22
  TEXT_BUFFER_GAP_MS,
@@ -1,11 +1,9 @@
1
1
  const { InlineKeyboard } = require('grammy');
2
2
  const { getTenant } = require('../../tenant');
3
- const { evolve, loadEvolutionState } = require('../../evolve');
4
3
  const { buildStatusHtml, describeToolCall } = require('../../status');
5
4
  const { sendHtml, startTyping, splitMessage } = require('../utils');
6
- const { EVOLUTION_IDLE_MS, TEXT_BUFFER_GAP_MS, TEXT_BUFFER_MAX_PARTS, TEXT_BUFFER_MAX_CHARS, TEXT_BUFFER_THRESHOLD } = require('../constants');
5
+ const { TEXT_BUFFER_GAP_MS, TEXT_BUFFER_MAX_PARTS, TEXT_BUFFER_MAX_CHARS, TEXT_BUFFER_THRESHOLD } = require('../constants');
7
6
 
8
- const _evolutionTimers = new Map();
9
7
  const textBuffers = new Map();
10
8
  const VERBOSE_FLUSH_MS = 2000;
11
9
 
@@ -141,12 +139,6 @@ async function processTextMessage(ctx, fullMessage, { config, allowedUsers, bot,
141
139
  const userId = ctx.from.id;
142
140
  const tenant = await getTenant(userId, config);
143
141
 
144
- if (_evolutionTimers.has(userId)) {
145
- clearTimeout(_evolutionTimers.get(userId));
146
- _evolutionTimers.delete(userId);
147
- if (tenant.messageLog) tenant.messageLog._evolutionPending = false;
148
- }
149
-
150
142
  let replyContext = '';
151
143
  const reply = ctx.message?.reply_to_message;
152
144
  if (reply) {
@@ -214,55 +206,6 @@ async function processTextMessage(ctx, fullMessage, { config, allowedUsers, bot,
214
206
 
215
207
  tenant.messageLog?.log(ctx.chat.id, 'assistant', response, { model, tokensIn: usage?.input_tokens, tokensOut: usage?.output_tokens });
216
208
 
217
- if (tenant.messageLog?._evolutionReady && !_evolutionTimers.has(userId)) {
218
- tenant.messageLog._evolutionReady = false;
219
- tenant.messageLog._evolutionPending = true;
220
- const timer = setTimeout(async () => {
221
- _evolutionTimers.delete(userId);
222
- try {
223
- const result = await evolve(tenant.claude.client, tenant.messageLog, tenant.memory, tenant.userDir);
224
- tenant.claude.reloadPersonality?.();
225
- let msg = `🪙 Evolution #${result.evolutionNumber} complete.`;
226
-
227
- if (result.scriptsFixed) {
228
- msg += '\n🔧 Fixed a test regression automatically.';
229
- } else if (result.scriptsRolledBack) {
230
- msg += '\n⚠️ Rolled back a script refactor — tests couldn\'t be fixed.';
231
- }
232
-
233
- if (result.upgrades && result.upgrades.length > 0) {
234
- msg += '\n\n🆕 **New capabilities:**';
235
- for (const u of result.upgrades) {
236
- msg += `\n• **${u.name}** — ${u.description}`;
237
- if (u.command) msg += ` → \`${u.command}\``;
238
- }
239
- }
240
-
241
- if (result.deployedApps && result.deployedApps.length > 0) {
242
- msg += '\n\n🚀 **Deployed:**';
243
- for (const app of result.deployedApps) {
244
- if (app.url) {
245
- msg += `\n• ${app.name} → ${app.url}`;
246
- } else if (app.error) {
247
- msg += `\n• ${app.name} — deploy failed: ${app.error.substring(0, 100)}`;
248
- }
249
- }
250
- }
251
-
252
- if (result.changelog) {
253
- msg += `\n\n_${result.changelog}_`;
254
- }
255
-
256
- await sendHtml(ctx, msg).catch(() => {});
257
- } catch (e) {
258
- console.error('Evolution failed:', e.message);
259
- } finally {
260
- tenant.messageLog._evolutionPending = false;
261
- }
262
- }, EVOLUTION_IDLE_MS);
263
- _evolutionTimers.set(userId, timer);
264
- }
265
-
266
209
  stopTyping();
267
210
 
268
211
  if (response.length > 4096) {
package/src/tenant.js CHANGED
@@ -1,6 +1,9 @@
1
1
  const { ensureUserDir } = require('./config');
2
+ const { PERSONALITY_DIR } = require('./soul');
2
3
  const { loadPersonality } = require('./personality');
3
4
  const { createMemory } = require('./memory');
5
+ const { createSelfMemory } = require('./memory-self');
6
+ const { createPatterns } = require('./patterns');
4
7
  const { createClaude } = require('./claude');
5
8
  const { createMessageLog } = require('./messages');
6
9
  const { BackgroundRunner } = require('./background');
@@ -30,13 +33,14 @@ _tenantCleanup.unref();
30
33
 
31
34
  async function createTenant(userId, config) {
32
35
  const userDir = ensureUserDir(userId);
33
- const personalityDir = path.join(userDir, 'personality');
34
- const personality = loadPersonality(personalityDir);
36
+ const personality = loadPersonality(PERSONALITY_DIR, path.join(userDir, 'personality'));
35
37
  const memory = config.supabase ? await createMemory(config.supabase, userId) : null;
38
+ const selfMemory = config.supabase ? await createSelfMemory(config.supabase, userId) : null;
39
+ const patterns = config.supabase ? await createPatterns(config.supabase, userId) : null;
36
40
  const bridgeEnabled = isBridgeEnabled(config) && (config.telegram?.allowedUsers?.length || 0) >= 2;
37
- const claude = createClaude(config.anthropic, { personality, memory, userDir, bridgeEnabled, botName: config.bot?.name });
38
- const messageLog = config.supabase ? createMessageLog(config.supabase, memory, config.anthropic, userId, userDir) : null;
41
+ const claude = createClaude(config.anthropic, { personality, memory, selfMemory, userDir, bridgeEnabled, botName: config.bot?.name });
39
42
  const scheduler = config.supabase ? createScheduler(config.supabase, userId) : null;
43
+ const messageLog = config.supabase ? createMessageLog(config.supabase, memory, config.anthropic, userId, userDir) : null;
40
44
  const toolPrefsApi = config.supabase ? createToolPrefs(config.supabase, userId) : null;
41
45
  const bg = new BackgroundRunner();
42
46
 
@@ -60,7 +64,7 @@ async function createTenant(userId, config) {
60
64
  } catch {}
61
65
 
62
66
  return {
63
- claude, memory, messageLog, personality, scheduler, bg, userDir, userId,
67
+ claude, memory, selfMemory, patterns, messageLog, personality, scheduler, bg, userDir, userId,
64
68
  toolPrefs,
65
69
  toolPrefsApi,
66
70
  async reloadToolPrefs() {
@@ -78,9 +82,8 @@ async function getTenant(userId, config) {
78
82
  if (tenants.has(userId)) {
79
83
  const tenant = tenants.get(userId);
80
84
  if (Date.now() - (tenant._personalityLoadedAt || 0) > PERSONALITY_CACHE_TTL) {
81
- const personalityDir = path.join(tenant.userDir, 'personality');
82
85
  try {
83
- const soulPath = path.join(personalityDir, 'SOUL.md');
86
+ const soulPath = path.join(PERSONALITY_DIR, 'SOUL.md');
84
87
  const mtime = fs.statSync(soulPath).mtimeMs;
85
88
  if (mtime > (tenant._personalityMtime || 0)) {
86
89
  tenant.claude.reloadPersonality();