obol-ai 0.3.6 → 0.3.7
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/CHANGELOG.md +3 -0
- package/package.json +1 -1
- package/src/claude/cache.js +26 -1
- package/src/claude/chat.js +2 -2
- package/src/claude/prompt.js +1 -2
- package/src/cli/prompt-debug.js +1 -0
- package/src/curiosity/impulse.js +146 -0
- package/src/runtime/heartbeat.js +15 -0
- package/src/telegram/handlers/text.js +8 -2
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "obol-ai",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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/claude/cache.js
CHANGED
|
@@ -31,4 +31,29 @@ function withCacheBreakpoints(messages) {
|
|
|
31
31
|
return result;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
function stripToolBlocks(messages) {
|
|
35
|
+
const result = [];
|
|
36
|
+
for (const msg of messages) {
|
|
37
|
+
let text = '';
|
|
38
|
+
if (typeof msg.content === 'string') {
|
|
39
|
+
text = msg.content;
|
|
40
|
+
} else if (Array.isArray(msg.content)) {
|
|
41
|
+
text = msg.content
|
|
42
|
+
.filter(b => b.type === 'text' && b.text)
|
|
43
|
+
.map(b => b.text)
|
|
44
|
+
.join('\n');
|
|
45
|
+
}
|
|
46
|
+
if (!text.trim()) continue;
|
|
47
|
+
if (result.length > 0 && result[result.length - 1].role === msg.role) {
|
|
48
|
+
result[result.length - 1].content += '\n' + text;
|
|
49
|
+
} else {
|
|
50
|
+
result.push({ role: msg.role, content: text });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
while (result.length > 0 && result[0].role !== 'user') {
|
|
54
|
+
result.shift();
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { withCacheBreakpoints, sanitizeMessages, stripToolBlocks };
|
package/src/claude/chat.js
CHANGED
|
@@ -5,7 +5,7 @@ const { createAnthropicClient, ensureFreshToken } = require('./client');
|
|
|
5
5
|
const { routeMessage } = require('./router');
|
|
6
6
|
const { buildSystemPrompt, buildSystemBlock, buildRuntimePrefix, withRuntimeContext } = require('./prompt');
|
|
7
7
|
const { buildTools, buildRunnableTools, addToolCache } = require('./tool-registry');
|
|
8
|
-
const { withCacheBreakpoints, sanitizeMessages } = require('./cache');
|
|
8
|
+
const { withCacheBreakpoints, sanitizeMessages, stripToolBlocks } = require('./cache');
|
|
9
9
|
const { getMaxToolIterations } = require('./constants');
|
|
10
10
|
|
|
11
11
|
function createClaude(anthropicConfig, { personality, memory, selfMemory, userDir = OBOL_DIR, bridgeEnabled, botName }) {
|
|
@@ -126,7 +126,7 @@ function createClaude(anthropicConfig, { personality, memory, selfMemory, userDi
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
if (activeModel.includes('haiku')) {
|
|
129
|
-
const haikuMessages = withCacheBreakpoints(withRuntimeContext([...history], runtimePrefix));
|
|
129
|
+
const haikuMessages = withCacheBreakpoints(withRuntimeContext(stripToolBlocks([...history]), runtimePrefix));
|
|
130
130
|
context._onPromptReady?.({ system: systemPrompt, messages: haikuMessages, model: activeModel, tools: [] });
|
|
131
131
|
|
|
132
132
|
const haikuResponse = await client.messages.create({
|
package/src/claude/prompt.js
CHANGED
|
@@ -184,8 +184,7 @@ Structure tips:
|
|
|
184
184
|
2\\. *LinkedIn* — Matthew Chittle wants to connect \`21:31\`
|
|
185
185
|
3\\. *DeepLearning\\.AI* — AI Dev 26 × SF speakers \`13:20\`
|
|
186
186
|
|
|
187
|
-
*Copyable
|
|
188
|
-
\`user@example.com\`, \`https://example.com\`, \`npm install foo\`
|
|
187
|
+
*Copyable content* — drafts, emails, letters, templates, and anything meant to be copied must ALWAYS be in a \`\`\`code block\`\`\`. Never render copyable content as plain text or with formatting — code blocks make it easy to copy and clearly separate the draft from your commentary. Short values (email addresses, URLs, API keys, commands) use inline \`backticks\`.
|
|
189
188
|
|
|
190
189
|
*Human-in-the-loop* — after listing emails or before acting, use \`telegram_ask\` to offer inline buttons rather than asking the user to type a reply.
|
|
191
190
|
`);
|
package/src/cli/prompt-debug.js
CHANGED
|
@@ -217,6 +217,7 @@ async function runChat(opts, config) {
|
|
|
217
217
|
for (const msg of historyMessages) {
|
|
218
218
|
claude.injectHistory(userId, msg.role, msg.content);
|
|
219
219
|
}
|
|
220
|
+
claude.repairHistory(userId);
|
|
220
221
|
|
|
221
222
|
console.log('\n\x1b[1m' + hr('═') + '\x1b[0m');
|
|
222
223
|
console.log(`\x1b[1m CHAT DEBUG — user ${userId}\x1b[0m`);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const Anthropic = require('@anthropic-ai/sdk');
|
|
2
|
+
const { createSelfMemory } = require('../memory/self');
|
|
3
|
+
const { getTenant } = require('../tenant');
|
|
4
|
+
|
|
5
|
+
const IMPULSE_MODEL = 'claude-haiku-4-5-20251001';
|
|
6
|
+
const COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
const MIN_DELAY_MS = 30_000;
|
|
8
|
+
const MAX_DELAY_MS = 300_000;
|
|
9
|
+
|
|
10
|
+
/** @type {Map<number, number>} */
|
|
11
|
+
const _cooldowns = new Map();
|
|
12
|
+
|
|
13
|
+
/** @type {ReturnType<typeof createSelfMemory> | null} */
|
|
14
|
+
let _globalSelfMemory = null;
|
|
15
|
+
|
|
16
|
+
async function getGlobalSelfMemory(supabaseConfig) {
|
|
17
|
+
if (!_globalSelfMemory) {
|
|
18
|
+
_globalSelfMemory = await createSelfMemory(supabaseConfig, 0);
|
|
19
|
+
}
|
|
20
|
+
return _globalSelfMemory;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isOnCooldown(userId) {
|
|
24
|
+
const last = _cooldowns.get(userId);
|
|
25
|
+
return last && (Date.now() - last) < COOLDOWN_MS;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getTimeContext(timezone) {
|
|
29
|
+
return new Date().toLocaleString('en-US', {
|
|
30
|
+
timeZone: timezone || 'UTC',
|
|
31
|
+
weekday: 'long',
|
|
32
|
+
hour: '2-digit',
|
|
33
|
+
minute: '2-digit',
|
|
34
|
+
hour12: true,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function gatherContext(tenant, supabaseConfig, opts = {}) {
|
|
39
|
+
const parts = [];
|
|
40
|
+
|
|
41
|
+
parts.push(`Time: ${getTimeContext(opts.timezone)} (${opts.timezone || 'UTC'})`);
|
|
42
|
+
|
|
43
|
+
const [patterns, userMemories, selfFacts, globalFindings] = await Promise.all([
|
|
44
|
+
tenant.patterns?.format().catch(() => null),
|
|
45
|
+
tenant.memory?.recent({ limit: 5 }).catch(() => []),
|
|
46
|
+
tenant.selfMemory?.recent({ limit: 3 }).catch(() => []),
|
|
47
|
+
supabaseConfig ? getGlobalSelfMemory(supabaseConfig).then(sm => sm.recent({ limit: 5 })).catch(() => []) : [],
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
if (patterns) parts.push(`Patterns:\n${patterns}`);
|
|
51
|
+
if (userMemories?.length) parts.push(`What you know about them:\n${userMemories.map(m => `- ${m.content}`).join('\n')}`);
|
|
52
|
+
if (selfFacts?.length) parts.push(`Your notes about this relationship:\n${selfFacts.map(m => `- ${m.content}`).join('\n')}`);
|
|
53
|
+
if (globalFindings?.length) parts.push(`Your recent explorations:\n${globalFindings.map(m => `- ${m.content}`).join('\n')}`);
|
|
54
|
+
|
|
55
|
+
if (!opts.periodic && opts.lastUserMsg) {
|
|
56
|
+
const userSnip = opts.lastUserMsg.substring(0, 500);
|
|
57
|
+
const assistantSnip = opts.lastAssistantMsg?.substring(0, 500) || '';
|
|
58
|
+
parts.push(`Last exchange:\nThem: ${userSnip}\nYou: ${assistantSnip}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { text: parts.join('\n\n'), hasSubstance: (globalFindings?.length > 0 || userMemories?.length > 0) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function checkImpulse(client, context) {
|
|
65
|
+
const response = await client.messages.create({
|
|
66
|
+
model: IMPULSE_MODEL,
|
|
67
|
+
max_tokens: 300,
|
|
68
|
+
system: `You know someone. Here's what's on your mind and what you know about them.
|
|
69
|
+
Decide if you have something genuine to say — a check-in about something in their life,
|
|
70
|
+
a thought from your own exploration, an observation you connected.
|
|
71
|
+
Most of the time: no. Only when it would feel natural and welcome.
|
|
72
|
+
Don't follow up on the conversation that just happened — that's not your job here.`,
|
|
73
|
+
messages: [{ role: 'user', content: context }],
|
|
74
|
+
tool_choice: { type: 'tool', name: 'impulse' },
|
|
75
|
+
tools: [{
|
|
76
|
+
name: 'impulse',
|
|
77
|
+
description: 'Decide whether to send a spontaneous message',
|
|
78
|
+
input_schema: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: {
|
|
81
|
+
act: { type: 'boolean', description: 'true if you have something genuine to say' },
|
|
82
|
+
thought: { type: 'string', description: 'The message to send, if acting' },
|
|
83
|
+
kind: { type: 'string', enum: ['curiosity', 'checkin', 'observation'] },
|
|
84
|
+
},
|
|
85
|
+
required: ['act'],
|
|
86
|
+
},
|
|
87
|
+
}],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const toolUse = response.content.find(b => b.type === 'tool_use');
|
|
91
|
+
return toolUse?.input || { act: false };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sendImpulse(bot, chatId, thought, kind, userId) {
|
|
95
|
+
const delay = MIN_DELAY_MS + Math.random() * (MAX_DELAY_MS - MIN_DELAY_MS);
|
|
96
|
+
setTimeout(() => {
|
|
97
|
+
bot.api.sendMessage(chatId, thought).catch(() =>
|
|
98
|
+
bot.api.sendMessage(chatId, thought, { parse_mode: undefined }).catch(() => {})
|
|
99
|
+
);
|
|
100
|
+
console.log(`[impulse] Sent ${kind} to user ${userId}`);
|
|
101
|
+
}, delay);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function maybeImpulse(bot, config, tenant, chatId, lastUserMsg, lastAssistantMsg) {
|
|
105
|
+
const userId = tenant.userId;
|
|
106
|
+
if (isOnCooldown(userId)) return;
|
|
107
|
+
if (!config.supabase) return;
|
|
108
|
+
|
|
109
|
+
const { text: context, hasSubstance } = await gatherContext(tenant, config.supabase, {
|
|
110
|
+
lastUserMsg,
|
|
111
|
+
lastAssistantMsg,
|
|
112
|
+
timezone: config.timezone,
|
|
113
|
+
});
|
|
114
|
+
if (!hasSubstance) return;
|
|
115
|
+
|
|
116
|
+
const client = new Anthropic({ apiKey: config.anthropic.apiKey });
|
|
117
|
+
const result = await checkImpulse(client, context);
|
|
118
|
+
|
|
119
|
+
if (!result.act || !result.thought) return;
|
|
120
|
+
|
|
121
|
+
_cooldowns.set(userId, Date.now());
|
|
122
|
+
sendImpulse(bot, chatId, result.thought, result.kind || 'observation', userId);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function maybePeriodicImpulse(bot, config, userId) {
|
|
126
|
+
if (isOnCooldown(userId)) return;
|
|
127
|
+
if (!config.supabase) return;
|
|
128
|
+
|
|
129
|
+
const tenant = await getTenant(userId, config);
|
|
130
|
+
|
|
131
|
+
const { text: context, hasSubstance } = await gatherContext(tenant, config.supabase, {
|
|
132
|
+
periodic: true,
|
|
133
|
+
timezone: config.timezone,
|
|
134
|
+
});
|
|
135
|
+
if (!hasSubstance) return;
|
|
136
|
+
|
|
137
|
+
const client = new Anthropic({ apiKey: config.anthropic.apiKey });
|
|
138
|
+
const result = await checkImpulse(client, context);
|
|
139
|
+
|
|
140
|
+
if (!result.act || !result.thought) return;
|
|
141
|
+
|
|
142
|
+
_cooldowns.set(userId, Date.now());
|
|
143
|
+
sendImpulse(bot, userId, result.thought, result.kind || 'checkin', userId);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { maybeImpulse, maybePeriodicImpulse };
|
package/src/runtime/heartbeat.js
CHANGED
|
@@ -7,11 +7,13 @@ const { runAnalysis } = require('../analysis');
|
|
|
7
7
|
const { runCuriosity } = require('../curiosity');
|
|
8
8
|
const { runCuriosityDispatch } = require('../curiosity/dispatch');
|
|
9
9
|
const { runCuriosityHumor } = require('../curiosity/humor');
|
|
10
|
+
const { maybePeriodicImpulse } = require('../curiosity/impulse');
|
|
10
11
|
const { createSelfMemory } = require('../memory/self');
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
const ANALYSIS_HOURS = new Set([4, 7, 10, 13, 16, 19, 22]);
|
|
14
15
|
const CURIOSITY_HOURS = new Set([1, 7, 13, 19]);
|
|
16
|
+
const IMPULSE_HOURS = new Set([4, 10, 16, 22]);
|
|
15
17
|
|
|
16
18
|
const _evolutionRunning = new Set();
|
|
17
19
|
let _curiosityRunning = false;
|
|
@@ -236,6 +238,19 @@ function setupHeartbeat(bot, config) {
|
|
|
236
238
|
);
|
|
237
239
|
});
|
|
238
240
|
console.log(` ✅ Curiosity cron running (every 6h ${config.timezone || 'UTC'})`);
|
|
241
|
+
|
|
242
|
+
cron.schedule('* * * * *', async () => {
|
|
243
|
+
const timezone = config.timezone || 'UTC';
|
|
244
|
+
const { hour, minute } = getLocalHour(timezone);
|
|
245
|
+
if (!IMPULSE_HOURS.has(hour) || minute !== 0) return;
|
|
246
|
+
|
|
247
|
+
for (const userId of allowedUsers) {
|
|
248
|
+
maybePeriodicImpulse(bot, config, userId).catch(e =>
|
|
249
|
+
console.error(`[impulse] Periodic error for user ${userId}:`, e.message)
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
console.log(` ✅ Impulse cron running (every 6h ${config.timezone || 'UTC'})`);
|
|
239
254
|
}
|
|
240
255
|
|
|
241
256
|
console.log(' ✅ Heartbeat running (every 1min)');
|
|
@@ -44,9 +44,12 @@ function createVerboseBatcher(ctx) {
|
|
|
44
44
|
const flush = () => {
|
|
45
45
|
if (timer) { clearTimeout(timer); timer = null; }
|
|
46
46
|
if (buffer.length === 0) return;
|
|
47
|
-
const
|
|
47
|
+
const raw = buffer.join('\n');
|
|
48
48
|
buffer = [];
|
|
49
|
-
|
|
49
|
+
const escaped = raw.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
50
|
+
ctx.reply(`<code>${escaped}</code>`, { parse_mode: 'HTML' }).catch(() =>
|
|
51
|
+
ctx.reply(raw).catch(() => {})
|
|
52
|
+
);
|
|
50
53
|
};
|
|
51
54
|
|
|
52
55
|
const notify = (/** @type {string} */ msg) => {
|
|
@@ -208,6 +211,9 @@ async function processTextMessage(ctx, fullMessage, { config, allowedUsers, bot,
|
|
|
208
211
|
|
|
209
212
|
tenant.messageLog?.log(ctx.chat.id, 'assistant', response, { model, tokensIn: usage?.input_tokens, tokensOut: usage?.output_tokens });
|
|
210
213
|
|
|
214
|
+
const { maybeImpulse } = require('../../curiosity/impulse');
|
|
215
|
+
maybeImpulse(bot, config, tenant, ctx.chat.id, chatMessage, response).catch(() => {});
|
|
216
|
+
|
|
211
217
|
stopTyping();
|
|
212
218
|
|
|
213
219
|
if (response.length > 4096) {
|