obol-ai 0.2.7 → 0.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Self-evolving AI assistant that learns, remembers, and acts on its own. Persistent vector memory, self-rewriting personality, proactive heartbeats.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/background.js CHANGED
@@ -78,7 +78,7 @@ This helps track what you're doing. Complete the full task, then give the final
78
78
  TASK: ${task}`;
79
79
 
80
80
  const bgNotify = verboseNotify ? (msg) => verboseNotify(`[bg#${taskState.id}] ${msg}`) : undefined;
81
- const result = await claude.chat(bgPrompt, {
81
+ const { text: result } = await claude.chat(bgPrompt, {
82
82
  chatId: `bg-${taskState.id}`,
83
83
  userName: 'BackgroundTask',
84
84
  verbose,
@@ -125,7 +125,7 @@ Give a ONE LINE progress update (emoji + what's happening). Be specific about wh
125
125
 
126
126
  // Use a separate quick call — don't interfere with the main task
127
127
  const checkInChatId = `checkin-${taskState.id}`;
128
- const update = await claude.chat(checkInPrompt, {
128
+ const { text: update } = await claude.chat(checkInPrompt, {
129
129
  chatId: checkInChatId,
130
130
  userName: 'CheckIn',
131
131
  });
@@ -177,6 +177,7 @@ function formatDuration(seconds) {
177
177
  }
178
178
 
179
179
  async function sendLong(ctx, text) {
180
+ if (!text?.trim()) return;
180
181
  if (text.length <= 4096) {
181
182
  await ctx.reply(text, { parse_mode: 'Markdown' }).catch(() =>
182
183
  ctx.reply(text)
package/src/claude.js CHANGED
@@ -5,6 +5,7 @@ const { execSync, execFileSync } = require('child_process');
5
5
  const { refreshTokens, isExpired, isOAuthToken } = require('./oauth');
6
6
  const { saveConfig, loadConfig, OBOL_DIR } = require('./config');
7
7
  const { execAsync, isAllowedUrl } = require('./sanitize');
8
+ const { ChatHistory } = require('./history');
8
9
 
9
10
  const MAX_EXEC_TIMEOUT = 120;
10
11
  let MAX_TOOL_ITERATIONS = 100;
@@ -114,74 +115,14 @@ async function ensureFreshToken(anthropicConfig) {
114
115
  }
115
116
  }
116
117
 
117
- function repairHistory(history) {
118
- const allToolUseIds = new Set();
119
- for (const msg of history) {
120
- if (msg.role === 'assistant' && Array.isArray(msg.content)) {
121
- for (const b of msg.content) {
122
- if (b.type === 'tool_use') allToolUseIds.add(b.id);
123
- }
124
- }
125
- }
126
-
127
- for (let i = history.length - 1; i >= 0; i--) {
128
- const msg = history[i];
129
- if (msg.role !== 'user' || !Array.isArray(msg.content)) continue;
130
- const toolResults = msg.content.filter(b => b.type === 'tool_result');
131
- if (toolResults.length === 0) continue;
132
- const orphaned = toolResults.filter(b => !allToolUseIds.has(b.tool_use_id));
133
- if (orphaned.length === 0) continue;
134
- const remaining = msg.content.filter(b => b.type !== 'tool_result' || allToolUseIds.has(b.tool_use_id));
135
- if (remaining.length === 0) {
136
- history.splice(i, 1);
137
- } else {
138
- msg.content = remaining;
139
- }
140
- }
141
-
142
- for (let i = 0; i < history.length; i++) {
143
- const msg = history[i];
144
- if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue;
145
- const toolUseIds = msg.content.filter(b => b.type === 'tool_use').map(b => b.id);
146
- if (toolUseIds.length === 0) continue;
147
- const next = history[i + 1];
148
- if (next?.role === 'user' && Array.isArray(next.content)) {
149
- const existingIds = new Set(next.content.filter(b => b.type === 'tool_result').map(b => b.tool_use_id));
150
- const missingIds = toolUseIds.filter(id => !existingIds.has(id));
151
- if (missingIds.length > 0) {
152
- next.content = [
153
- ...next.content,
154
- ...missingIds.map(id => ({ type: 'tool_result', tool_use_id: id, content: '[interrupted]' })),
155
- ];
156
- }
157
- } else {
158
- const fakeResults = toolUseIds.map(id => ({
159
- type: 'tool_result', tool_use_id: id, content: '[interrupted]',
160
- }));
161
- history.splice(i + 1, 0, { role: 'user', content: fakeResults });
162
- }
163
- }
164
-
165
- for (let i = history.length - 1; i > 0; i--) {
166
- if (history[i].role === history[i - 1].role && history[i].role === 'user') {
167
- const prev = history[i - 1];
168
- const curr = history[i];
169
- const prevArr = Array.isArray(prev.content) ? prev.content : [{ type: 'text', text: prev.content }];
170
- const currArr = Array.isArray(curr.content) ? curr.content : [{ type: 'text', text: curr.content }];
171
- history[i - 1] = { role: 'user', content: [...prevArr, ...currArr] };
172
- history.splice(i, 1);
173
- }
174
- }
175
- }
176
-
177
118
  function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR, bridgeEnabled }) {
178
119
  let client = createAnthropicClient(anthropicConfig);
179
120
 
180
121
  let baseSystemPrompt = buildSystemPrompt(personality, userDir, { bridgeEnabled });
181
122
 
182
- const histories = new Map();
123
+ const histories = new ChatHistory(50);
183
124
  const chatLocks = new Map();
184
- const MAX_HISTORY = 50;
125
+ const chatAbortControllers = new Map();
185
126
 
186
127
  const tools = buildTools(memory, { bridgeEnabled });
187
128
 
@@ -206,12 +147,13 @@ function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR
206
147
  const chatId = context.chatId || 'default';
207
148
 
208
149
  if (isChatBusy(chatId)) {
209
- return 'I\'m still working on the previous request. Give me a moment.';
150
+ return { text: 'I\'m still working on the previous request. Give me a moment.', usage: null, model: null };
210
151
  }
211
152
 
212
153
  const releaseLock = await acquireChatLock(chatId);
154
+ const abortController = new AbortController();
155
+ chatAbortControllers.set(chatId, abortController);
213
156
 
214
- if (!histories.has(chatId)) histories.set(chatId, []);
215
157
  const history = histories.get(chatId);
216
158
 
217
159
  try {
@@ -296,41 +238,15 @@ Model: Default to "sonnet". Use "haiku" for: greetings, brief acknowledgments (t
296
238
  }
297
239
  }
298
240
 
299
- while (history.length >= MAX_HISTORY) {
300
- let cut = 0;
301
- while (cut < history.length - 1) {
302
- const msg = history[cut];
303
- cut++;
304
- if (msg.role === 'assistant' && Array.isArray(msg.content) &&
305
- msg.content.some(b => b.type === 'tool_use')) continue;
306
- if (msg.role === 'user' && Array.isArray(msg.content) &&
307
- msg.content.some(b => b.type === 'tool_result')) continue;
308
- if (msg.role === 'assistant') break;
309
- }
310
- history.splice(0, cut);
311
- if (cut === 0) { history.shift(); history.shift(); break; }
312
- }
313
- while (history.length > 0) {
314
- const first = history[0];
315
- if (first.role !== 'user') { history.shift(); continue; }
316
- if (Array.isArray(first.content) && first.content.some(b => b.type === 'tool_result')) {
317
- history.shift(); continue;
318
- }
319
- break;
320
- }
321
- repairHistory(history);
241
+ histories.prune(chatId);
322
242
 
323
- // Add user message with memory context
324
243
  const enrichedMessage = memoryContext
325
244
  ? userMessage + memoryContext
326
245
  : userMessage;
327
246
  if (context.images?.length) {
328
- history.push({
329
- role: 'user',
330
- content: [...context.images, { type: 'text', text: enrichedMessage }],
331
- });
247
+ histories.pushUser(chatId, [...context.images, { type: 'text', text: enrichedMessage }]);
332
248
  } else {
333
- history.push({ role: 'user', content: enrichedMessage });
249
+ histories.pushUser(chatId, enrichedMessage);
334
250
  }
335
251
 
336
252
  const model = context._model || 'claude-sonnet-4-6';
@@ -345,50 +261,70 @@ Model: Default to "sonnet". Use "haiku" for: greetings, brief acknowledgments (t
345
261
  messages: [...history],
346
262
  tools: runnableTools.length > 0 ? runnableTools : undefined,
347
263
  max_iterations: MAX_TOOL_ITERATIONS,
348
- });
264
+ }, { signal: abortController.signal });
349
265
 
350
266
  let finalMessage;
267
+ let totalUsage = { input_tokens: 0, output_tokens: 0 };
351
268
  for await (const message of runner) {
352
269
  finalMessage = message;
353
270
  if (message.usage) {
271
+ totalUsage.input_tokens += message.usage.input_tokens || 0;
272
+ totalUsage.output_tokens += message.usage.output_tokens || 0;
354
273
  vlog(`[tokens] in=${message.usage.input_tokens} out=${message.usage.output_tokens}`);
355
274
  }
356
275
  }
357
276
 
358
277
  const runnerMessages = runner.params.messages;
359
278
  const newMessages = runnerMessages.slice(history.length);
360
- for (const msg of newMessages) {
361
- history.push(msg);
362
- }
279
+ histories.pushMessages(chatId, newMessages);
363
280
 
364
281
  if (finalMessage.stop_reason === 'tool_use') {
365
282
  const bailoutResults = finalMessage.content
366
283
  .filter(b => b.type === 'tool_use')
367
284
  .map(b => ({ type: 'tool_result', tool_use_id: b.id, content: '[max tool iterations reached]' }));
368
- history.push({ role: 'user', content: [
285
+ histories.pushUser(chatId, [
369
286
  ...bailoutResults,
370
287
  { type: 'text', text: 'You have used too many tool calls. Please provide a final response now based on what you have so far.' },
371
- ] });
288
+ ]);
372
289
  const bailoutResponse = await client.messages.create({
373
- model, max_tokens: 4096, system: systemPrompt, messages: history,
374
- });
375
- history.push({ role: 'assistant', content: bailoutResponse.content });
376
- return bailoutResponse.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
290
+ model, max_tokens: 4096, system: systemPrompt, messages: [...histories.get(chatId)],
291
+ }, { signal: abortController.signal });
292
+ histories.pushAssistant(chatId, bailoutResponse.content);
293
+ if (bailoutResponse.usage) {
294
+ totalUsage.input_tokens += bailoutResponse.usage.input_tokens || 0;
295
+ totalUsage.output_tokens += bailoutResponse.usage.output_tokens || 0;
296
+ }
297
+ const text = bailoutResponse.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
298
+ return { text, usage: totalUsage, model };
377
299
  }
378
300
 
379
- return finalMessage.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
301
+ const text = finalMessage.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
302
+ return { text, usage: totalUsage, model };
380
303
 
381
304
  } catch (e) {
305
+ if (e.message === 'Request was aborted.' || e.constructor?.name === 'APIUserAbortError') {
306
+ return { text: null, usage: null, model: null };
307
+ }
382
308
  if (e.status === 400 && e.message?.includes('tool_use')) {
383
309
  console.error('[claude] Repairing corrupted history after 400 error');
384
- repairHistory(history);
310
+ histories.repair(chatId);
385
311
  }
386
312
  throw e;
387
313
  } finally {
314
+ chatAbortControllers.delete(chatId);
388
315
  releaseLock();
389
316
  }
390
317
  }
391
318
 
319
+ function stopChat(chatId) {
320
+ const controller = chatAbortControllers.get(chatId);
321
+ if (controller) {
322
+ controller.abort();
323
+ return true;
324
+ }
325
+ return false;
326
+ }
327
+
392
328
  function reloadPersonality() {
393
329
  const pDir = userDir ? path.join(userDir, 'personality') : undefined;
394
330
  const newPersonality = require('./personality').loadPersonality(pDir);
@@ -406,33 +342,15 @@ Model: Default to "sonnet". Use "haiku" for: greetings, brief acknowledgments (t
406
342
  }
407
343
 
408
344
  function injectHistory(chatId, role, content) {
409
- if (!histories.has(chatId)) histories.set(chatId, []);
410
- const history = histories.get(chatId);
411
- history.push({ role, content });
345
+ histories.inject(chatId, role, content);
412
346
  }
413
347
 
414
348
  function getContextStats(chatId) {
415
349
  const id = chatId || 'default';
416
- const history = histories.get(id) || [];
417
- const MAX_CONTEXT = 200000;
418
- let chars = baseSystemPrompt.length;
419
- for (const msg of history) {
420
- if (typeof msg.content === 'string') {
421
- chars += msg.content.length;
422
- } else if (Array.isArray(msg.content)) {
423
- for (const b of msg.content) {
424
- if (b.text) chars += b.text.length;
425
- else if (b.content) chars += (typeof b.content === 'string' ? b.content.length : JSON.stringify(b.content).length);
426
- else if (b.type === 'tool_use') chars += JSON.stringify(b.input || {}).length + (b.name?.length || 0);
427
- }
428
- }
429
- }
430
- const estimatedTokens = Math.round(chars / 4);
431
- const pct = Math.min(100, Math.round((estimatedTokens / MAX_CONTEXT) * 100));
432
- return { messages: history.length, estimatedTokens, maxTokens: MAX_CONTEXT, pct };
350
+ return histories.estimateTokens(id, baseSystemPrompt.length);
433
351
  }
434
352
 
435
- return { chat, client, reloadPersonality, clearHistory, injectHistory, getContextStats };
353
+ return { chat, client, reloadPersonality, clearHistory, injectHistory, getContextStats, stopChat };
436
354
  }
437
355
 
438
356
  function buildSystemPrompt(personality, userDir, opts = {}) {
@@ -588,6 +506,14 @@ Examples:
588
506
 
589
507
  Returns the tapped button label, or \`"timeout"\` if the user doesn't respond within the timeout (default 60s).
590
508
 
509
+ ### Scheduling (\`schedule_event\`, \`list_events\`, \`cancel_event\`)
510
+ Schedule reminders and events. The user gets a Telegram message when the time comes.
511
+ - \`schedule_event\` — schedule a reminder with title, due_at (ISO 8601), timezone (IANA), optional description
512
+ - \`list_events\` — list pending/sent/cancelled events
513
+ - \`cancel_event\` — cancel a scheduled event by ID
514
+
515
+ When scheduling: always search memory first for the user's timezone/location. If no timezone found, ask the user or default to UTC. Parse natural language dates relative to the user's timezone.
516
+
591
517
  ### Bridge (\`bridge_ask\`, \`bridge_tell\`)
592
518
  Only available if bridge is enabled. Communicate with partner's AI agent.
593
519
  `);
@@ -859,6 +785,44 @@ function buildTools(memory, opts = {}) {
859
785
  },
860
786
  });
861
787
 
788
+ tools.push({
789
+ name: 'schedule_event',
790
+ description: 'Schedule a reminder or event. The user will receive a Telegram message when the time comes. Always search memory first for the user\'s timezone/location. If no timezone found, ask the user or default to UTC.',
791
+ input_schema: {
792
+ type: 'object',
793
+ properties: {
794
+ title: { type: 'string', description: 'Short title for the reminder/event' },
795
+ due_at: { type: 'string', description: 'ISO 8601 datetime string for when the event is due (e.g. 2026-02-25T15:00:00)' },
796
+ timezone: { type: 'string', description: 'IANA timezone (e.g. Europe/Brussels, America/New_York). Default: UTC' },
797
+ description: { type: 'string', description: 'Context or details about the event. Always include relevant info from the conversation (e.g. what to do, who it involves, where).' },
798
+ },
799
+ required: ['title', 'due_at'],
800
+ },
801
+ });
802
+
803
+ tools.push({
804
+ name: 'list_events',
805
+ description: 'List scheduled events/reminders for the user.',
806
+ input_schema: {
807
+ type: 'object',
808
+ properties: {
809
+ status: { type: 'string', enum: ['pending', 'sent', 'cancelled'], description: 'Filter by status (default: pending)' },
810
+ },
811
+ },
812
+ });
813
+
814
+ tools.push({
815
+ name: 'cancel_event',
816
+ description: 'Cancel a scheduled event/reminder by its ID.',
817
+ input_schema: {
818
+ type: 'object',
819
+ properties: {
820
+ event_id: { type: 'string', description: 'UUID of the event to cancel' },
821
+ },
822
+ required: ['event_id'],
823
+ },
824
+ });
825
+
862
826
  if (opts.bridgeEnabled) {
863
827
  const { buildBridgeTool, buildBridgeTellTool } = require('./bridge');
864
828
  tools.push(buildBridgeTool());
@@ -879,6 +843,8 @@ function buildRunnableTools(tools, memory, context, vlog) {
879
843
  tool.name === 'memory_add' ? `[${input.category || 'fact'}]` :
880
844
  tool.name === 'web_fetch' ? input.url :
881
845
  tool.name === 'background_task' ? input.task?.substring(0, 60) :
846
+ tool.name === 'schedule_event' ? `${input.title} @ ${input.due_at}` :
847
+ tool.name === 'cancel_event' ? input.event_id :
882
848
  JSON.stringify(input).substring(0, 80);
883
849
  vlog(`[tool] ${tool.name}: ${inputSummary}`);
884
850
  return await executeToolCall({ name: tool.name, input }, memory, context);
@@ -1106,6 +1072,39 @@ async function executeToolCall(toolUse, memory, context = {}) {
1106
1072
  return await bridgeTell(input.message, context.userId, context.config, context._notifyFn, input.partner_id);
1107
1073
  }
1108
1074
 
1075
+ case 'schedule_event': {
1076
+ if (!context.scheduler) return 'Scheduler not available (Supabase not configured).';
1077
+ const tz = input.timezone || 'UTC';
1078
+ const localDate = new Date(input.due_at);
1079
+ if (isNaN(localDate.getTime())) return `Invalid date: ${input.due_at}`;
1080
+ const utcDate = toUTC(input.due_at, tz);
1081
+ const event = await context.scheduler.add(context.chatId, input.title, utcDate, tz, input.description || null);
1082
+ const displayTime = new Date(utcDate).toLocaleString('en-US', { timeZone: tz });
1083
+ return `Scheduled: "${input.title}" for ${displayTime} (${tz}) — ID: ${event.id}`;
1084
+ }
1085
+
1086
+ case 'list_events': {
1087
+ if (!context.scheduler) return 'Scheduler not available (Supabase not configured).';
1088
+ const events = await context.scheduler.list({ status: input.status });
1089
+ if (events.length === 0) return `No ${input.status || 'pending'} events.`;
1090
+ return JSON.stringify(events.map(e => ({
1091
+ id: e.id,
1092
+ title: e.title,
1093
+ description: e.description,
1094
+ due_at: e.due_at,
1095
+ timezone: e.timezone,
1096
+ due_local: new Date(e.due_at).toLocaleString('en-US', { timeZone: e.timezone }),
1097
+ status: e.status,
1098
+ })));
1099
+ }
1100
+
1101
+ case 'cancel_event': {
1102
+ if (!context.scheduler) return 'Scheduler not available (Supabase not configured).';
1103
+ const cancelled = await context.scheduler.cancel(input.event_id);
1104
+ if (!cancelled) return `Event not found or not yours: ${input.event_id}`;
1105
+ return `Cancelled: "${cancelled.title}"`;
1106
+ }
1107
+
1109
1108
  default:
1110
1109
  return `Unknown tool: ${name}`;
1111
1110
  }
@@ -1114,6 +1113,25 @@ async function executeToolCall(toolUse, memory, context = {}) {
1114
1113
  }
1115
1114
  }
1116
1115
 
1116
+ function toUTC(dateStr, timezone) {
1117
+ const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/);
1118
+ if (!match) return new Date(dateStr + 'Z').toISOString();
1119
+ const [, y, mo, d, h, mi, s] = match;
1120
+ const wallAsUTC = Date.UTC(+y, +mo - 1, +d, +h, +mi, +(s || 0));
1121
+ if (timezone === 'UTC') return new Date(wallAsUTC).toISOString();
1122
+ const fmt = new Intl.DateTimeFormat('en-US', {
1123
+ timeZone: timezone,
1124
+ year: 'numeric', month: '2-digit', day: '2-digit',
1125
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
1126
+ hour12: false,
1127
+ });
1128
+ const parts = fmt.formatToParts(new Date(wallAsUTC));
1129
+ const get = (type) => parts.find(p => p.type === type)?.value || '00';
1130
+ const hr = +get('hour') === 24 ? 0 : +get('hour');
1131
+ const tzWall = Date.UTC(+get('year'), +get('month') - 1, +get('day'), hr, +get('minute'), +get('second'));
1132
+ return new Date(wallAsUTC - (tzWall - wallAsUTC)).toISOString();
1133
+ }
1134
+
1117
1135
  function getMaxToolIterations() { return MAX_TOOL_ITERATIONS; }
1118
1136
  function setMaxToolIterations(n) { MAX_TOOL_ITERATIONS = n; }
1119
1137
 
package/src/db/migrate.js CHANGED
@@ -130,6 +130,38 @@ async function migrate(supabaseConfig) {
130
130
  CREATE POLICY "service_role_all" ON obol_messages FOR ALL TO service_role USING (true) WITH CHECK (true);
131
131
  EXCEPTION WHEN duplicate_object THEN NULL;
132
132
  END $$;`,
133
+
134
+ // Events table (scheduling & reminders)
135
+ `CREATE TABLE IF NOT EXISTS obol_events (
136
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
137
+ user_id BIGINT NOT NULL,
138
+ chat_id BIGINT NOT NULL,
139
+ title TEXT NOT NULL,
140
+ description TEXT,
141
+ due_at TIMESTAMPTZ NOT NULL,
142
+ timezone TEXT NOT NULL DEFAULT 'UTC',
143
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','cancelled')),
144
+ created_at TIMESTAMPTZ DEFAULT NOW()
145
+ );`,
146
+ `CREATE INDEX IF NOT EXISTS idx_obol_events_due ON obol_events (due_at) WHERE status = 'pending';`,
147
+ `CREATE INDEX IF NOT EXISTS idx_obol_events_user ON obol_events (user_id);`,
148
+ `ALTER TABLE obol_events ENABLE ROW LEVEL SECURITY;`,
149
+ `DO $$ BEGIN
150
+ CREATE POLICY "service_role_all" ON obol_events FOR ALL TO service_role USING (true) WITH CHECK (true);
151
+ EXCEPTION WHEN duplicate_object THEN NULL;
152
+ END $$;`,
153
+
154
+ // Drop redundant user_id from obol_messages (chat_id == user_id for Telegram private chats)
155
+ `DROP INDEX IF EXISTS idx_obol_messages_user;`,
156
+ `ALTER TABLE obol_messages DROP COLUMN IF EXISTS user_id;`,
157
+
158
+ // Atomic access count increment for memory search hits
159
+ `CREATE OR REPLACE FUNCTION increment_memory_access(memory_ids UUID[])
160
+ RETURNS VOID LANGUAGE SQL AS $$
161
+ UPDATE obol_memory
162
+ SET access_count = access_count + 1, accessed_at = NOW()
163
+ WHERE id = ANY(memory_ids);
164
+ $$;`,
133
165
  ];
134
166
 
135
167
  // Save SQL file for manual fallback
@@ -148,24 +180,24 @@ async function migrate(supabaseConfig) {
148
180
  }
149
181
 
150
182
  const projectRef = url.replace('https://', '').replace('.supabase.co', '');
183
+ const batchedSql = sqlStatements.join('\n\n');
151
184
 
152
- for (const sql of sqlStatements) {
153
- try {
154
- const res = await fetch(`https://api.supabase.com/v1/projects/${projectRef}/database/query`, {
155
- method: 'POST',
156
- headers: {
157
- 'Authorization': `Bearer ${accessToken}`,
158
- 'Content-Type': 'application/json',
159
- },
160
- body: JSON.stringify({ query: sql }),
161
- });
162
- if (!res.ok) {
163
- const err = await res.text();
164
- console.log(` ⚠️ SQL warning: ${err.substring(0, 100)}`);
165
- }
166
- } catch (e) {
167
- console.log(` ⚠️ Migration step failed: ${e.message}`);
185
+ try {
186
+ const res = await fetch(`https://api.supabase.com/v1/projects/${projectRef}/database/query`, {
187
+ method: 'POST',
188
+ headers: {
189
+ 'Authorization': `Bearer ${accessToken}`,
190
+ 'Content-Type': 'application/json',
191
+ },
192
+ body: JSON.stringify({ query: batchedSql }),
193
+ signal: AbortSignal.timeout(15000),
194
+ });
195
+ if (!res.ok) {
196
+ const err = await res.text();
197
+ console.log(` ⚠️ Migration warning: ${err.substring(0, 200)}`);
168
198
  }
199
+ } catch (e) {
200
+ console.log(` ⚠️ Migration failed: ${e.message}`);
169
201
  }
170
202
  }
171
203
 
package/src/evolve.js CHANGED
@@ -106,7 +106,8 @@ function syncDir(dir, files) {
106
106
  }
107
107
  for (const f of fs.readdirSync(dir)) {
108
108
  if (!(f in files)) {
109
- fs.unlinkSync(path.join(dir, f));
109
+ const full = path.join(dir, f);
110
+ fs.rmSync(full, { recursive: true, force: true });
110
111
  }
111
112
  }
112
113
  }
@@ -166,6 +167,84 @@ async function backupSnapshot(message, userDir) {
166
167
  } catch {}
167
168
  }
168
169
 
170
+ async function deepConsolidateMemory(claudeClient, memory, messages, evolutionNumber) {
171
+ const transcript = messages.map(m =>
172
+ `${m.role === 'user' ? 'Human' : 'Bot'}: ${m.content.substring(0, 800)}`
173
+ ).join('\n');
174
+
175
+ const response = await claudeClient.messages.create({
176
+ model: MODELS.personality,
177
+ max_tokens: 4096,
178
+ system: `You are doing a deep memory extraction pass during an AI evolution cycle. Extract ALL valuable information from this full conversation history.
179
+
180
+ Return JSON:
181
+ {
182
+ "memories": [
183
+ {
184
+ "content": "specific, detailed fact",
185
+ "category": "fact|preference|decision|lesson|person|project|event|conversation|resource|pattern|context",
186
+ "tags": ["tag1", "tag2"],
187
+ "importance": 0.5
188
+ }
189
+ ]
190
+ }
191
+
192
+ Extract everything worth remembering long-term:
193
+ - Personal details (identity, demographics, location, family, relationships)
194
+ - Every preference and opinion expressed
195
+ - All projects, goals, tasks and their status
196
+ - Technical details (stack, tools, services, APIs)
197
+ - Plans, intentions, next steps
198
+ - Recurring themes and behavioral patterns across the full history
199
+ - Emotional tone and communication preferences
200
+ - Decisions and their reasoning
201
+ - Resources and services mentioned
202
+ - Events, dates, timelines
203
+ - Lessons or realizations
204
+
205
+ Tags: 2-5 specific lowercase keywords.
206
+ Importance: 0.3 minor detail, 0.5 useful, 0.7 important, 0.9 critical.
207
+
208
+ Be thorough — this is a Sonnet deep pass over the full history, not a quick Haiku scan.
209
+ Skip only pure content-free exchanges ("hi", "ok", "bye").`,
210
+ messages: [{ role: 'user', content: transcript }],
211
+ });
212
+
213
+ const text = response.content[0]?.text || '';
214
+ const jsonMatch = text.match(/```json?\s*\n?([\s\S]*?)\n?\s*```/) || text.match(/\{[\s\S]*"memories"\s*:\s*\[[\s\S]*?\]\s*\}/);
215
+ if (!jsonMatch) return 0;
216
+
217
+ let extracted;
218
+ try {
219
+ extracted = JSON.parse(jsonMatch[1] || jsonMatch[0]);
220
+ } catch {
221
+ return 0;
222
+ }
223
+
224
+ if (!extracted.memories?.length) return 0;
225
+
226
+ const validCategories = new Set(['fact','preference','decision','lesson','person','project','event','conversation','resource','pattern','context','email']);
227
+ let stored = 0;
228
+ for (const mem of extracted.memories) {
229
+ if (!mem.content || mem.content.length <= 10) continue;
230
+ try {
231
+ const existing = await memory.search(mem.content, { limit: 1, threshold: 0.92 });
232
+ if (existing.length > 0) continue;
233
+ } catch {}
234
+ const category = validCategories.has(mem.category) ? mem.category : 'fact';
235
+ const tags = Array.isArray(mem.tags) ? mem.tags.slice(0, 5) : [];
236
+ const importance = typeof mem.importance === 'number' ? Math.min(1, Math.max(0, mem.importance)) : 0.5;
237
+ await memory.add(mem.content, {
238
+ category,
239
+ tags,
240
+ importance,
241
+ source: `evolution-${evolutionNumber}`,
242
+ }).catch(() => {});
243
+ stored++;
244
+ }
245
+ return stored;
246
+ }
247
+
169
248
  async function evolve(claudeClient, messageLog, memory, userDir) {
170
249
  const baseDir = userDir || OBOL_DIR;
171
250
  const state = loadEvolutionState(userDir);
@@ -255,6 +334,13 @@ async function evolve(claudeClient, messageLog, memory, userDir) {
255
334
  // ── Step 0: Snapshot before evolution ──
256
335
  await backupSnapshot(`pre-evolution #${evolutionNumber}`, userDir);
257
336
 
337
+ // ── Step 0b: Deep memory consolidation with Sonnet ──
338
+ if (memory && recentMessages.length >= 4) {
339
+ await deepConsolidateMemory(claudeClient, memory, recentMessages, evolutionNumber).catch(e =>
340
+ console.error('[evolve] Deep consolidation failed:', e.message)
341
+ );
342
+ }
343
+
258
344
  // ── Step 1: Run existing tests as baseline ──
259
345
  const baselineResults = runTests(testsDir);
260
346
 
package/src/heartbeat.js CHANGED
@@ -1,11 +1,46 @@
1
1
  const cron = require('node-cron');
2
+ const { createScheduler } = require('./scheduler');
2
3
 
3
- function setupHeartbeat() {
4
- cron.schedule('*/30 * * * *', async () => {
5
- console.log(`[${new Date().toISOString()}] Heartbeat tick`);
4
+ function setupHeartbeat(bot, supabaseConfig) {
5
+ let scheduler = null;
6
+ if (supabaseConfig?.url && supabaseConfig?.serviceKey) {
7
+ scheduler = createScheduler(supabaseConfig);
8
+ }
9
+
10
+ let tickCount = 0;
11
+
12
+ cron.schedule('* * * * *', async () => {
13
+ tickCount++;
14
+ if (tickCount % 30 === 0) {
15
+ console.log(`[${new Date().toISOString()}] Heartbeat tick`);
16
+ }
17
+
18
+ if (!scheduler || !bot) return;
19
+
20
+ try {
21
+ const dueEvents = await scheduler.getDue();
22
+ for (const event of dueEvents) {
23
+ try {
24
+ const tz = event.timezone || 'UTC';
25
+ const dueLocal = new Date(event.due_at).toLocaleString('en-US', { timeZone: tz });
26
+ let text = `⏰ *Reminder:* ${event.title}`;
27
+ if (event.description) text += `\n${event.description}`;
28
+ text += `\n_${dueLocal} (${tz})_`;
29
+
30
+ await bot.api.sendMessage(event.chat_id, text, { parse_mode: 'Markdown' }).catch(() =>
31
+ bot.api.sendMessage(event.chat_id, `⏰ Reminder: ${event.title}${event.description ? '\n' + event.description : ''}`)
32
+ );
33
+ await scheduler.markSent(event.id);
34
+ } catch (e) {
35
+ console.error(`[scheduler] Failed to send event ${event.id}:`, e.message);
36
+ }
37
+ }
38
+ } catch (e) {
39
+ console.error('[scheduler] Failed to check due events:', e.message);
40
+ }
6
41
  });
7
42
 
8
- console.log(' ✅ Heartbeat running (every 30min)');
43
+ console.log(' ✅ Heartbeat running (every 1min)');
9
44
  }
10
45
 
11
46
  module.exports = { setupHeartbeat };