obol-ai 0.2.39 → 0.3.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/heartbeat.js CHANGED
@@ -1,6 +1,149 @@
1
1
  const cron = require('node-cron');
2
2
  const { createScheduler } = require('./scheduler');
3
3
  const { getTenant } = require('./tenant');
4
+ const { shouldEvolveNow, evolve } = require('./evolve');
5
+ const { ensureUserDir } = require('./config');
6
+ const { runAnalysis } = require('./analysis');
7
+ const { runCuriosity } = require('./curiosity');
8
+ const { runCuriosityDispatch } = require('./curiosity-dispatch');
9
+ const { createSelfMemory } = require('./memory-self');
10
+
11
+
12
+ const ANALYSIS_HOURS = new Set([4, 7, 10, 13, 16, 19, 22]);
13
+ const CURIOSITY_HOURS = new Set([1, 7, 13, 19]);
14
+
15
+ const _evolutionRunning = new Set();
16
+ let _curiosityRunning = false;
17
+
18
+ function getLocalHour(timezone) {
19
+ const parts = new Intl.DateTimeFormat('en-US', {
20
+ timeZone: timezone,
21
+ hour: '2-digit',
22
+ minute: '2-digit',
23
+ hour12: false,
24
+ }).formatToParts(new Date());
25
+ return {
26
+ hour: parseInt(parts.find(p => p.type === 'hour').value),
27
+ minute: parseInt(parts.find(p => p.type === 'minute').value),
28
+ };
29
+ }
30
+
31
+ async function runEvolutionForUser(bot, config, userId) {
32
+ if (_evolutionRunning.has(userId)) return;
33
+
34
+ const timezone = config.timezone || 'UTC';
35
+ const userDir = ensureUserDir(userId);
36
+
37
+ if (!shouldEvolveNow(userDir, timezone)) return;
38
+
39
+ _evolutionRunning.add(userId);
40
+ console.log(`[evolution] Starting nightly evolution for user ${userId}`);
41
+
42
+ try {
43
+ const tenant = await getTenant(userId, config);
44
+ const selfMemory = config.supabase ? await createSelfMemory(config.supabase, 0).catch(() => null) : null;
45
+ const result = await evolve(tenant.claude.client, tenant.messageLog, tenant.memory, tenant.userDir, config.supabase, selfMemory);
46
+ tenant.claude.reloadPersonality?.();
47
+
48
+ let msg = `šŸŖ™ Evolution #${result.evolutionNumber} complete.`;
49
+ if (result.scriptsFixed) msg += '\nšŸ”§ Fixed a test regression automatically.';
50
+ else if (result.scriptsRolledBack) msg += '\nāš ļø Rolled back a script refactor — tests couldn\'t be fixed.';
51
+
52
+ if (result.upgrades?.length > 0) {
53
+ msg += '\n\nšŸ†• <b>New capabilities:</b>';
54
+ for (const u of result.upgrades) {
55
+ msg += `\n• <b>${u.name}</b> — ${u.description}`;
56
+ if (u.command) msg += ` → <code>${u.command}</code>`;
57
+ }
58
+ }
59
+
60
+ if (result.deployedApps?.length > 0) {
61
+ msg += '\n\nšŸš€ <b>Deployed:</b>';
62
+ for (const app of result.deployedApps) {
63
+ msg += app.url
64
+ ? `\n• ${app.name} → ${app.url}`
65
+ : `\n• ${app.name} — deploy failed: ${(app.error || '').substring(0, 100)}`;
66
+ }
67
+ }
68
+
69
+ if (result.changelog) msg += `\n\n<i>${result.changelog}</i>`;
70
+
71
+ await bot.api.sendMessage(userId, msg, { parse_mode: 'HTML' }).catch(() => {});
72
+ console.log(`[evolution] Completed evolution #${result.evolutionNumber} for user ${userId}`);
73
+ } catch (e) {
74
+ console.error(`[evolution] Failed for user ${userId}:`, e.message);
75
+ } finally {
76
+ _evolutionRunning.delete(userId);
77
+ }
78
+ }
79
+
80
+ async function runCuriosityOnce(config, allowedUsers) {
81
+ if (!config.supabase) return;
82
+ if (_curiosityRunning) {
83
+ console.log('[curiosity] Skipping — previous cycle still running');
84
+ return;
85
+ }
86
+ _curiosityRunning = true;
87
+ try {
88
+ const selfMemory = await createSelfMemory(config.supabase, 0);
89
+ const firstTenant = await getTenant(allowedUsers[0], config);
90
+ const client = firstTenant.claude.client;
91
+
92
+ const contexts = await Promise.all(allowedUsers.map(async (userId) => {
93
+ try {
94
+ const tenant = await getTenant(userId, config);
95
+ const parts = [];
96
+ if (tenant.personality?.user) parts.push(tenant.personality.user);
97
+ if (tenant.patterns) {
98
+ const fmt = await tenant.patterns.format().catch(() => null);
99
+ if (fmt) parts.push(fmt);
100
+ }
101
+ if (tenant.memory) {
102
+ const recent = await tenant.memory.recent({ limit: 3 }).catch(() => []);
103
+ if (recent.length) parts.push(recent.map(m => `- ${m.content}`).join('\n'));
104
+ }
105
+ if (tenant.scheduler) {
106
+ const events = await tenant.scheduler.list({ status: 'pending', limit: 3 }).catch(() => []);
107
+ if (events.length) parts.push(events.map(e => `- ${e.title}`).join('\n'));
108
+ }
109
+ return parts.join('\n');
110
+ } catch {
111
+ return null;
112
+ }
113
+ }));
114
+
115
+ const peopleContext = contexts.filter(Boolean).join('\n\n---\n\n');
116
+ await runCuriosity(client, selfMemory, 0, { peopleContext });
117
+
118
+ const userDispatchData = await Promise.all(allowedUsers.map(async (userId) => {
119
+ try {
120
+ const tenant = await getTenant(userId, config);
121
+ const patterns = tenant.patterns ? await tenant.patterns.format().catch(() => null) : null;
122
+ const events = tenant.scheduler
123
+ ? await tenant.scheduler.list({ status: 'pending', limit: 5 }).catch(() => [])
124
+ : [];
125
+ const userProfile = tenant.personality?.user || null;
126
+ return { userId, chatId: userId, timezone: config.timezone || 'UTC', patterns, events, scheduler: tenant.scheduler, userProfile };
127
+ } catch { return null; }
128
+ }));
129
+ await runCuriosityDispatch(client, selfMemory, userDispatchData.filter(Boolean));
130
+ } catch (e) {
131
+ console.error('[curiosity] Failed:', e.message);
132
+ } finally {
133
+ _curiosityRunning = false;
134
+ }
135
+ }
136
+
137
+ async function runAnalysisForUser(bot, config, userId) {
138
+ const timezone = config.timezone || 'UTC';
139
+ try {
140
+ const tenant = await getTenant(userId, config);
141
+ if (!tenant.messageLog || !tenant.scheduler || !tenant.patterns) return;
142
+ await runAnalysis(tenant.claude.client, tenant.messageLog, tenant.scheduler, tenant.patterns, tenant.memory, userId, userId, timezone);
143
+ } catch (e) {
144
+ console.error(`[analysis] Failed for user ${userId}:`, e.message);
145
+ }
146
+ }
4
147
 
5
148
  function makeFakeCtx(bot, chatId) {
6
149
  return {
@@ -52,6 +195,46 @@ function setupHeartbeat(bot, config) {
52
195
  }
53
196
  });
54
197
 
198
+ const allowedUsers = config?.telegram?.allowedUsers || [];
199
+ if (allowedUsers.length > 0) {
200
+ cron.schedule('* * * * *', async () => {
201
+ const timezone = config.timezone || 'UTC';
202
+ const { hour, minute } = getLocalHour(timezone);
203
+ if (hour !== 3 || minute !== 0) return;
204
+
205
+ for (const userId of allowedUsers) {
206
+ runEvolutionForUser(bot, config, userId).catch(e =>
207
+ console.error(`[evolution] Unhandled error for user ${userId}:`, e.message)
208
+ );
209
+ }
210
+ });
211
+ console.log(` āœ… Evolution cron running (daily 3am ${config.timezone || 'UTC'})`);
212
+
213
+ cron.schedule('* * * * *', async () => {
214
+ const timezone = config.timezone || 'UTC';
215
+ const { hour, minute } = getLocalHour(timezone);
216
+ if (!ANALYSIS_HOURS.has(hour) || minute !== 0) return;
217
+
218
+ for (const userId of allowedUsers) {
219
+ runAnalysisForUser(bot, config, userId).catch(e =>
220
+ console.error(`[analysis] Unhandled error for user ${userId}:`, e.message)
221
+ );
222
+ }
223
+ });
224
+ console.log(` āœ… Analysis cron running (every 3h ${config.timezone || 'UTC'})`);
225
+
226
+ cron.schedule('* * * * *', async () => {
227
+ const timezone = config.timezone || 'UTC';
228
+ const { hour, minute } = getLocalHour(timezone);
229
+ if (!CURIOSITY_HOURS.has(hour) || minute !== 0) return;
230
+
231
+ runCuriosityOnce(config, allowedUsers).catch(e =>
232
+ console.error('[curiosity] Unhandled error:', e.message)
233
+ );
234
+ });
235
+ console.log(` āœ… Curiosity cron running (every 6h ${config.timezone || 'UTC'})`);
236
+ }
237
+
55
238
  console.log(' āœ… Heartbeat running (every 1min)');
56
239
  }
57
240
 
@@ -69,17 +252,54 @@ async function sendReminderMessage(bot, event) {
69
252
  );
70
253
  }
71
254
 
255
+ async function buildProactiveContext(tenant, timezone, query) {
256
+ const parts = [];
257
+
258
+ const localTime = new Date().toLocaleString('en-US', {
259
+ timeZone: timezone,
260
+ weekday: 'long',
261
+ hour: '2-digit',
262
+ minute: '2-digit',
263
+ hour12: true,
264
+ });
265
+ parts.push(`Local time: ${localTime} (${timezone})`);
266
+
267
+ if (tenant.patterns) {
268
+ const formatted = await tenant.patterns.format().catch(() => null);
269
+ if (formatted) parts.push(`\nUser patterns:\n${formatted}`);
270
+ }
271
+
272
+ if (tenant.memory) {
273
+ const memories = query
274
+ ? await tenant.memory.search(query, { limit: 5 }).catch(() => [])
275
+ : await tenant.memory.recent({ limit: 5 }).catch(() => []);
276
+ if (memories.length > 0) {
277
+ parts.push(`\nRecent memory:\n${memories.map(m => `- ${m.content}`).join('\n')}`);
278
+ }
279
+ }
280
+
281
+ return parts.join('\n');
282
+ }
283
+
72
284
  async function runAgenticEvent(bot, config, event) {
73
285
  const tenant = await getTenant(event.user_id, config);
286
+ const timezone = event.timezone || config.timezone || 'UTC';
287
+
288
+ const query = event.description || event.instructions;
289
+ const context = await buildProactiveContext(tenant, timezone, query).catch(() => '');
290
+ const instructions = context
291
+ ? `[Context]\n${context}\n\n---\n\n${event.instructions}`
292
+ : event.instructions;
293
+
74
294
  const fakeCtx = makeFakeCtx(bot, event.chat_id);
75
295
 
76
296
  const taskId = tenant.bg.spawn(
77
297
  tenant.claude,
78
- event.instructions,
298
+ instructions,
79
299
  fakeCtx,
80
300
  tenant.memory,
81
301
  null,
82
- {},
302
+ { silent: true },
83
303
  {
84
304
  userId: event.user_id,
85
305
  chatId: event.chat_id,
package/src/index.js CHANGED
@@ -1,9 +1,12 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
1
3
  const { loadConfig } = require('./config');
2
4
  const { createBot, checkUpgradeNotify } = require('./telegram');
3
5
  const { setupBackup } = require('./backup');
4
6
  const { setupHeartbeat } = require('./heartbeat');
5
7
  const { migrateToMultiTenant } = require('./legacy-migrate');
6
8
  const { isPostSetupDone, runPostSetup } = require('./post-setup');
9
+ const { restoreIfMissing, PERSONALITY_DIR } = require('./soul');
7
10
 
8
11
  async function main() {
9
12
  const config = loadConfig();
@@ -24,8 +27,16 @@ async function main() {
24
27
  } catch (e) {
25
28
  console.error(` Database migration failed: ${e.message}`);
26
29
  }
30
+
31
+ try {
32
+ await restoreIfMissing(config.supabase);
33
+ } catch (e) {
34
+ console.error(` Soul restore failed: ${e.message}`);
35
+ }
27
36
  }
28
37
 
38
+ fs.mkdirSync(PERSONALITY_DIR, { recursive: true });
39
+
29
40
  if (!isPostSetupDone()) {
30
41
  runPostSetup(loadConfig({ resolve: false }), console.log).catch(e =>
31
42
  console.error('Post-setup error:', e.message)
@@ -0,0 +1,123 @@
1
+ const { getEmbedding } = require('./memory');
2
+
3
+ const VALID_CATEGORIES = new Set(['research', 'interest', 'self', 'pattern']);
4
+
5
+ async function createSelfMemory(supabaseConfig, userId) {
6
+ const { url, serviceKey } = supabaseConfig;
7
+
8
+ const headers = {
9
+ 'apikey': serviceKey,
10
+ 'Authorization': `Bearer ${serviceKey}`,
11
+ 'Content-Type': 'application/json',
12
+ 'Prefer': 'return=representation',
13
+ };
14
+
15
+ async function add(content, opts = {}) {
16
+ const category = VALID_CATEGORIES.has(opts.category) ? opts.category : 'research';
17
+ const importance = opts.importance || 0.5;
18
+ const source = opts.source || null;
19
+ const tags = opts.tags || [];
20
+
21
+ const embedding = await getEmbedding(content);
22
+
23
+ const res = await fetch(`${url}/rest/v1/obol_self_memory`, {
24
+ method: 'POST',
25
+ headers,
26
+ body: JSON.stringify({ content, category, importance, source, tags, embedding, user_id: userId }),
27
+ });
28
+ const data = await res.json();
29
+ if (!res.ok) throw new Error(JSON.stringify(data));
30
+ return data[0];
31
+ }
32
+
33
+ async function search(query, opts = {}) {
34
+ const embedding = await getEmbedding(query);
35
+ const limit = opts.limit || 10;
36
+ const threshold = opts.threshold || 0.3;
37
+ const category = opts.category || null;
38
+
39
+ const res = await fetch(`${url}/rest/v1/rpc/match_obol_self_memories`, {
40
+ method: 'POST',
41
+ headers,
42
+ body: JSON.stringify({
43
+ query_embedding: embedding,
44
+ match_threshold: threshold,
45
+ match_count: limit,
46
+ filter_category: category,
47
+ filter_user_id: userId,
48
+ }),
49
+ });
50
+ const data = await res.json();
51
+ if (!res.ok) throw new Error(JSON.stringify(data));
52
+
53
+ if (data.length > 0) {
54
+ const ids = data.map(m => m.id);
55
+ await fetch(`${url}/rest/v1/rpc/increment_self_memory_access`, {
56
+ method: 'POST',
57
+ headers,
58
+ body: JSON.stringify({ memory_ids: ids }),
59
+ }).catch(() => {});
60
+ }
61
+
62
+ return data;
63
+ }
64
+
65
+ async function recent(opts = {}) {
66
+ const limit = opts.limit || 10;
67
+ let fetchUrl = `${url}/rest/v1/obol_self_memory?select=id,content,category,tags,importance,source,created_at&order=created_at.desc&limit=${limit}&user_id=eq.${userId}`;
68
+ if (opts.category) fetchUrl += `&category=eq.${opts.category}`;
69
+
70
+ const res = await fetch(fetchUrl, { headers });
71
+ if (!res.ok) throw new Error(`Self memory recent failed: HTTP ${res.status}`);
72
+ return await res.json();
73
+ }
74
+
75
+ async function query(opts = {}) {
76
+ const limit = opts.limit || 20;
77
+ const parts = [`user_id=eq.${userId}`];
78
+ if (opts.category) parts.push(`category=eq.${opts.category}`);
79
+ if (opts.source) parts.push(`source=eq.${opts.source}`);
80
+ if (opts.minImportance) parts.push(`importance=gte.${opts.minImportance}`);
81
+ if (opts.tags?.length) parts.push(`tags=ov.{${opts.tags.join(',')}}`);
82
+
83
+ const res = await fetch(
84
+ `${url}/rest/v1/obol_self_memory?select=id,content,category,tags,importance,source,created_at&${parts.join('&')}&order=created_at.desc&limit=${limit}`,
85
+ { headers }
86
+ );
87
+ const data = await res.json();
88
+ if (!res.ok) throw new Error(JSON.stringify(data));
89
+ return data;
90
+ }
91
+
92
+ async function update(id, opts = {}) {
93
+ const patch = {};
94
+ if (opts.content !== undefined) {
95
+ patch.content = opts.content;
96
+ patch.embedding = await getEmbedding(opts.content);
97
+ }
98
+ if (opts.category !== undefined && VALID_CATEGORIES.has(opts.category)) patch.category = opts.category;
99
+ if (opts.importance !== undefined) patch.importance = opts.importance;
100
+ if (opts.tags !== undefined) patch.tags = opts.tags;
101
+ if (opts.source !== undefined) patch.source = opts.source;
102
+
103
+ const res = await fetch(`${url}/rest/v1/obol_self_memory?id=eq.${id}`, {
104
+ method: 'PATCH',
105
+ headers,
106
+ body: JSON.stringify(patch),
107
+ });
108
+ const data = await res.json();
109
+ if (!res.ok) throw new Error(JSON.stringify(data));
110
+ return data[0];
111
+ }
112
+
113
+ async function forget(id) {
114
+ await fetch(`${url}/rest/v1/obol_self_memory?id=eq.${id}`, {
115
+ method: 'DELETE',
116
+ headers: { ...headers, 'Prefer': 'return=minimal' },
117
+ });
118
+ }
119
+
120
+ return { add, search, recent, query, update, forget };
121
+ }
122
+
123
+ module.exports = { createSelfMemory };
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 };