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 +1 -1
- package/src/background.js +3 -2
- package/src/claude.js +144 -126
- 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": {
|
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,41 +238,15 @@ 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';
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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
|
|
43
|
+
console.log(' ✅ Heartbeat running (every 1min)');
|
|
9
44
|
}
|
|
10
45
|
|
|
11
46
|
module.exports = { setupHeartbeat };
|