obol-ai 0.2.6 → 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.6",
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": {
@@ -22,7 +22,7 @@
22
22
  "author": "Jo Vinkenroye <jestersimpps@gmail.com>",
23
23
  "license": "MIT",
24
24
  "dependencies": {
25
- "@anthropic-ai/sdk": "^0.39.0",
25
+ "@anthropic-ai/sdk": "^0.78.0",
26
26
  "@supabase/supabase-js": "^2.49.1",
27
27
  "@xenova/transformers": "^2.17.2",
28
28
  "commander": "^13.1.0",
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,133 +238,93 @@ 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';
337
253
  vlog(`[model] ${model} | history=${history.length} msgs`);
338
254
  const systemPrompt = baseSystemPrompt + `\nCurrent time: ${new Date().toISOString()}`;
339
- let response = await client.messages.create({
255
+ const runnableTools = buildRunnableTools(tools, memory, context, vlog);
256
+
257
+ const runner = client.beta.messages.toolRunner({
340
258
  model,
341
259
  max_tokens: 4096,
342
260
  system: systemPrompt,
343
- messages: history,
344
- tools: tools.length > 0 ? tools : undefined,
345
- });
346
-
347
- let toolIterations = 0;
348
- while (response.stop_reason === 'tool_use') {
349
- toolIterations++;
350
- if (toolIterations > MAX_TOOL_ITERATIONS) {
351
- const bailoutContent = response.content;
352
- history.push({ role: 'assistant', content: bailoutContent });
353
- const bailoutResults = bailoutContent
354
- .filter(b => b.type === 'tool_use')
355
- .map(b => ({ type: 'tool_result', tool_use_id: b.id, content: '[max tool iterations reached]' }));
356
- history.push({ role: 'user', content: [
357
- ...bailoutResults,
358
- { type: 'text', text: 'You have used too many tool calls. Please provide a final response now based on what you have so far.' },
359
- ] });
360
- response = await client.messages.create({
361
- model,
362
- max_tokens: 4096,
363
- system: systemPrompt,
364
- messages: history,
365
- });
366
- break;
261
+ messages: [...history],
262
+ tools: runnableTools.length > 0 ? runnableTools : undefined,
263
+ max_iterations: MAX_TOOL_ITERATIONS,
264
+ }, { signal: abortController.signal });
265
+
266
+ let finalMessage;
267
+ let totalUsage = { input_tokens: 0, output_tokens: 0 };
268
+ for await (const message of runner) {
269
+ finalMessage = message;
270
+ if (message.usage) {
271
+ totalUsage.input_tokens += message.usage.input_tokens || 0;
272
+ totalUsage.output_tokens += message.usage.output_tokens || 0;
273
+ vlog(`[tokens] in=${message.usage.input_tokens} out=${message.usage.output_tokens}`);
367
274
  }
368
-
369
- const assistantContent = response.content;
370
- history.push({ role: 'assistant', content: assistantContent });
371
-
372
- const toolResults = [];
373
- for (const block of assistantContent) {
374
- if (block.type === 'tool_use') {
375
- const inputSummary = block.name === 'exec' ? block.input.command :
376
- block.name === 'write_file' ? block.input.path :
377
- block.name === 'read_file' ? block.input.path :
378
- block.name === 'memory_search' ? block.input.query :
379
- block.name === 'memory_add' ? `[${block.input.category || 'fact'}]` :
380
- block.name === 'web_fetch' ? block.input.url :
381
- block.name === 'background_task' ? block.input.task?.substring(0, 60) :
382
- JSON.stringify(block.input).substring(0, 80);
383
- vlog(`[tool] ${block.name}: ${inputSummary}`);
384
- const result = await executeToolCall(block, memory, context);
385
- toolResults.push({
386
- type: 'tool_result',
387
- tool_use_id: block.id,
388
- content: result,
389
- });
390
- }
391
- }
392
-
393
- history.push({ role: 'user', content: toolResults });
394
-
395
- response = await client.messages.create({
396
- model,
397
- max_tokens: 4096,
398
- system: systemPrompt,
399
- messages: history,
400
- tools,
401
- });
402
275
  }
403
276
 
404
- const textBlocks = response.content.filter(b => b.type === 'text');
405
- const replyText = textBlocks.map(b => b.text).join('\n');
406
-
407
- if (response.usage) {
408
- vlog(`[tokens] in=${response.usage.input_tokens} out=${response.usage.output_tokens}`);
277
+ const runnerMessages = runner.params.messages;
278
+ const newMessages = runnerMessages.slice(history.length);
279
+ histories.pushMessages(chatId, newMessages);
280
+
281
+ if (finalMessage.stop_reason === 'tool_use') {
282
+ const bailoutResults = finalMessage.content
283
+ .filter(b => b.type === 'tool_use')
284
+ .map(b => ({ type: 'tool_result', tool_use_id: b.id, content: '[max tool iterations reached]' }));
285
+ histories.pushUser(chatId, [
286
+ ...bailoutResults,
287
+ { type: 'text', text: 'You have used too many tool calls. Please provide a final response now based on what you have so far.' },
288
+ ]);
289
+ const bailoutResponse = await client.messages.create({
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 };
409
299
  }
410
300
 
411
- history.push({ role: 'assistant', content: response.content });
412
-
413
- return replyText;
301
+ const text = finalMessage.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
302
+ return { text, usage: totalUsage, model };
414
303
 
415
304
  } catch (e) {
305
+ if (e.message === 'Request was aborted.' || e.constructor?.name === 'APIUserAbortError') {
306
+ return { text: null, usage: null, model: null };
307
+ }
416
308
  if (e.status === 400 && e.message?.includes('tool_use')) {
417
309
  console.error('[claude] Repairing corrupted history after 400 error');
418
- repairHistory(history);
310
+ histories.repair(chatId);
419
311
  }
420
312
  throw e;
421
313
  } finally {
314
+ chatAbortControllers.delete(chatId);
422
315
  releaseLock();
423
316
  }
424
317
  }
425
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
+
426
328
  function reloadPersonality() {
427
329
  const pDir = userDir ? path.join(userDir, 'personality') : undefined;
428
330
  const newPersonality = require('./personality').loadPersonality(pDir);
@@ -440,33 +342,15 @@ Model: Default to "sonnet". Use "haiku" for: greetings, brief acknowledgments (t
440
342
  }
441
343
 
442
344
  function injectHistory(chatId, role, content) {
443
- if (!histories.has(chatId)) histories.set(chatId, []);
444
- const history = histories.get(chatId);
445
- history.push({ role, content });
345
+ histories.inject(chatId, role, content);
446
346
  }
447
347
 
448
348
  function getContextStats(chatId) {
449
349
  const id = chatId || 'default';
450
- const history = histories.get(id) || [];
451
- const MAX_CONTEXT = 200000;
452
- let chars = baseSystemPrompt.length;
453
- for (const msg of history) {
454
- if (typeof msg.content === 'string') {
455
- chars += msg.content.length;
456
- } else if (Array.isArray(msg.content)) {
457
- for (const b of msg.content) {
458
- if (b.text) chars += b.text.length;
459
- else if (b.content) chars += (typeof b.content === 'string' ? b.content.length : JSON.stringify(b.content).length);
460
- else if (b.type === 'tool_use') chars += JSON.stringify(b.input || {}).length + (b.name?.length || 0);
461
- }
462
- }
463
- }
464
- const estimatedTokens = Math.round(chars / 4);
465
- const pct = Math.min(100, Math.round((estimatedTokens / MAX_CONTEXT) * 100));
466
- return { messages: history.length, estimatedTokens, maxTokens: MAX_CONTEXT, pct };
350
+ return histories.estimateTokens(id, baseSystemPrompt.length);
467
351
  }
468
352
 
469
- return { chat, client, reloadPersonality, clearHistory, injectHistory, getContextStats };
353
+ return { chat, client, reloadPersonality, clearHistory, injectHistory, getContextStats, stopChat };
470
354
  }
471
355
 
472
356
  function buildSystemPrompt(personality, userDir, opts = {}) {
@@ -622,6 +506,14 @@ Examples:
622
506
 
623
507
  Returns the tapped button label, or \`"timeout"\` if the user doesn't respond within the timeout (default 60s).
624
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
+
625
517
  ### Bridge (\`bridge_ask\`, \`bridge_tell\`)
626
518
  Only available if bridge is enabled. Communicate with partner's AI agent.
627
519
  `);
@@ -893,6 +785,44 @@ function buildTools(memory, opts = {}) {
893
785
  },
894
786
  });
895
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
+
896
826
  if (opts.bridgeEnabled) {
897
827
  const { buildBridgeTool, buildBridgeTellTool } = require('./bridge');
898
828
  tools.push(buildBridgeTool());
@@ -902,6 +832,26 @@ function buildTools(memory, opts = {}) {
902
832
  return tools;
903
833
  }
904
834
 
835
+ function buildRunnableTools(tools, memory, context, vlog) {
836
+ return tools.map(tool => ({
837
+ ...tool,
838
+ run: async (input) => {
839
+ const inputSummary = tool.name === 'exec' ? input.command :
840
+ tool.name === 'write_file' ? input.path :
841
+ tool.name === 'read_file' ? input.path :
842
+ tool.name === 'memory_search' ? input.query :
843
+ tool.name === 'memory_add' ? `[${input.category || 'fact'}]` :
844
+ tool.name === 'web_fetch' ? input.url :
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 :
848
+ JSON.stringify(input).substring(0, 80);
849
+ vlog(`[tool] ${tool.name}: ${inputSummary}`);
850
+ return await executeToolCall({ name: tool.name, input }, memory, context);
851
+ },
852
+ }));
853
+ }
854
+
905
855
  function resolveUserPath(inputPath, userDir) {
906
856
  if (!userDir) throw new Error('userDir is required for path resolution');
907
857
  const resolved = path.isAbsolute(inputPath)
@@ -1122,6 +1072,39 @@ async function executeToolCall(toolUse, memory, context = {}) {
1122
1072
  return await bridgeTell(input.message, context.userId, context.config, context._notifyFn, input.partner_id);
1123
1073
  }
1124
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
+
1125
1108
  default:
1126
1109
  return `Unknown tool: ${name}`;
1127
1110
  }
@@ -1130,6 +1113,25 @@ async function executeToolCall(toolUse, memory, context = {}) {
1130
1113
  }
1131
1114
  }
1132
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
+
1133
1135
  function getMaxToolIterations() { return MAX_TOOL_ITERATIONS; }
1134
1136
  function setMaxToolIterations(n) { MAX_TOOL_ITERATIONS = n; }
1135
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