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 +2 -2
- package/src/background.js +3 -2
- package/src/claude.js +184 -182
- package/src/db/migrate.js +48 -16
- package/src/evolve.js +87 -1
- package/src/heartbeat.js +39 -4
- package/src/history.js +341 -0
- package/src/index.js +8 -17
- package/src/memory.js +5 -5
- package/src/messages.js +45 -20
- package/src/scheduler.js +75 -0
- package/src/telegram.js +62 -9
- package/src/tenant.js +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "obol-ai",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
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
|
|
123
|
+
const histories = new ChatHistory(50);
|
|
183
124
|
const chatLocks = new Map();
|
|
184
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|