obol-ai 0.3.13 → 0.3.15
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 +4 -0
- package/package.json +1 -1
- package/src/analysis.js +14 -15
- package/src/claude/constants.js +7 -0
- package/src/curiosity/dispatch.js +2 -7
- package/src/curiosity/humor.js +3 -8
- package/src/curiosity/news.js +125 -0
- package/src/runtime/heartbeat.js +46 -6
- package/src/telegram/bot.js +7 -0
- package/src/telegram/commands/tools.js +7 -0
- package/src/telegram/handlers/callbacks.js +6 -0
- package/src/telegram/handlers/text.js +6 -3
- package/src/telegram/topics.js +170 -0
- package/src/utils/timing.js +122 -0
- package/src/curiosity/impulse.js +0 -147
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.15",
|
|
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/analysis.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { resolveDelay } = require('./utils/timing');
|
|
2
|
+
|
|
1
3
|
const ANALYSIS_WINDOW_MS = 3 * 60 * 60 * 1000;
|
|
2
4
|
const MAX_MESSAGE_CHARS = 1000;
|
|
3
5
|
const MAX_TRANSCRIPT_CHARS = 40000;
|
|
@@ -113,10 +115,10 @@ async function structureReport(client, report, scheduler, patterns, chatId, time
|
|
|
113
115
|
type: 'object',
|
|
114
116
|
properties: {
|
|
115
117
|
description: { type: 'string' },
|
|
116
|
-
|
|
118
|
+
due_at: { type: 'string', description: 'ISO 8601 date or datetime in the user\'s local time, e.g. "2024-03-15" or "2024-03-15T20:00"' },
|
|
117
119
|
context: { type: 'string', description: 'Brief context for the follow-up message' },
|
|
118
120
|
},
|
|
119
|
-
required: ['description', '
|
|
121
|
+
required: ['description', 'due_at', 'context'],
|
|
120
122
|
},
|
|
121
123
|
},
|
|
122
124
|
patterns: {
|
|
@@ -139,11 +141,13 @@ async function structureReport(client, report, scheduler, patterns, chatId, time
|
|
|
139
141
|
}];
|
|
140
142
|
|
|
141
143
|
try {
|
|
144
|
+
const localTime = new Date().toLocaleString('en-US', { timeZone: timezone, dateStyle: 'full', timeStyle: 'short' });
|
|
142
145
|
const patternGuidance = `Extract behavioral patterns about this user from the report. Each pattern must be a factual observation about the user's behavior — not notes about your analysis process. If you see the same pattern in the existing list, reuse its exact key and update the summary/confidence. Skip patterns already at confidence >0.8 unless new evidence contradicts them.`;
|
|
146
|
+
const timingGuidance = `Current local time for this user: ${localTime}. For each follow-up, pick a specific date or datetime in the user's local time based on what you know from the transcript. Use ISO 8601 format: "2024-03-15" for date-only or "2024-03-15T20:00" for exact time.`;
|
|
143
147
|
|
|
144
148
|
const system = formattedPatterns
|
|
145
|
-
? `Existing behavioral patterns for this user:\n${formattedPatterns}\n\n---\n\n${patternGuidance}`
|
|
146
|
-
: `Convert this analytical report into structured data using the save_analysis tool. ${patternGuidance}`;
|
|
149
|
+
? `Existing behavioral patterns for this user:\n${formattedPatterns}\n\n---\n\n${patternGuidance}\n\n${timingGuidance}`
|
|
150
|
+
: `Convert this analytical report into structured data using the save_analysis tool. ${patternGuidance}\n\n${timingGuidance}`;
|
|
147
151
|
|
|
148
152
|
const response = await client.messages.create({
|
|
149
153
|
model: 'claude-sonnet-4-6',
|
|
@@ -160,9 +164,12 @@ async function structureReport(client, report, scheduler, patterns, chatId, time
|
|
|
160
164
|
const followUps = toolUse.input?.follow_ups || [];
|
|
161
165
|
const patternList = toolUse.input?.patterns || [];
|
|
162
166
|
|
|
167
|
+
const timingPattern = patterns ? await patterns.get('timing.active_hours').catch(() => null) : null;
|
|
168
|
+
const timingData = timingPattern?.data || null;
|
|
169
|
+
|
|
163
170
|
for (const fu of followUps) {
|
|
164
|
-
if (!fu.description || !fu.
|
|
165
|
-
const dueAt = resolveDelay(fu.
|
|
171
|
+
if (!fu.description || !fu.due_at) continue;
|
|
172
|
+
const dueAt = resolveDelay(fu.due_at, timezone, timingData);
|
|
166
173
|
const instructions = `You mentioned checking in on: "${fu.description}". Context: ${fu.context || ''}. Reach out naturally — like a friend who remembered. Keep it casual, one line. Don't reference any system or task.`;
|
|
167
174
|
await scheduler.add(chatId, 'Proactive follow-up', dueAt, timezone, fu.description, null, null, null, instructions).catch(e =>
|
|
168
175
|
console.error('[analysis] Failed to schedule follow-up:', e.message)
|
|
@@ -184,12 +191,4 @@ async function structureReport(client, report, scheduler, patterns, chatId, time
|
|
|
184
191
|
}
|
|
185
192
|
}
|
|
186
193
|
|
|
187
|
-
|
|
188
|
-
const now = Date.now();
|
|
189
|
-
const units = { h: 3600000, d: 86400000, w: 604800000 };
|
|
190
|
-
const match = delay.match(/^(\d+)([hdw])$/);
|
|
191
|
-
if (!match) return new Date(now + 86400000).toISOString();
|
|
192
|
-
return new Date(now + parseInt(match[1]) * units[match[2]]).toISOString();
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
module.exports = { runAnalysis, buildTranscript, resolveDelay };
|
|
194
|
+
module.exports = { runAnalysis, buildTranscript };
|
package/src/claude/constants.js
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
|
+
const { resolveDelay } = require('../utils/timing');
|
|
2
|
+
|
|
1
3
|
const DISPATCH_MODEL = 'claude-sonnet-4-6';
|
|
2
4
|
const MAX_ITERATIONS = 10;
|
|
3
5
|
const SHAREABLE_CATEGORIES = new Set(['research', 'interest', 'self']);
|
|
4
6
|
|
|
5
|
-
function resolveDelay(delay) {
|
|
6
|
-
const units = { h: 3600000, d: 86400000, w: 604800000 };
|
|
7
|
-
const match = delay.match(/^(\d+)([hdw])$/);
|
|
8
|
-
if (!match) return new Date(Date.now() + 86400000).toISOString();
|
|
9
|
-
return new Date(Date.now() + parseInt(match[1]) * units[match[2]]).toISOString();
|
|
10
|
-
}
|
|
11
|
-
|
|
12
7
|
async function runCuriosityDispatch(client, selfMemory, users) {
|
|
13
8
|
if (!users.length) return;
|
|
14
9
|
|
package/src/curiosity/humor.js
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
|
+
const { resolveDelay } = require('../utils/timing');
|
|
2
|
+
|
|
1
3
|
const HUMOR_MODEL = 'claude-sonnet-4-6';
|
|
2
4
|
const MAX_ITERATIONS = 8;
|
|
3
5
|
const SHAREABLE_CATEGORIES = new Set(['research', 'interest', 'self']);
|
|
4
6
|
|
|
5
|
-
function resolveDelay(delay) {
|
|
6
|
-
const units = { h: 3600000, d: 86400000, w: 604800000 };
|
|
7
|
-
const match = delay.match(/^(\d+)([hdw])$/);
|
|
8
|
-
if (!match) return new Date(Date.now() + 86400000).toISOString();
|
|
9
|
-
return new Date(Date.now() + parseInt(match[1]) * units[match[2]]).toISOString();
|
|
10
|
-
}
|
|
11
|
-
|
|
12
7
|
async function runCuriosityHumor(client, selfMemory, users) {
|
|
13
8
|
if (!users.length) return;
|
|
14
9
|
|
|
@@ -154,4 +149,4 @@ async function handleTool(name, input, selfMemory, userMap) {
|
|
|
154
149
|
return 'Unknown tool';
|
|
155
150
|
}
|
|
156
151
|
|
|
157
|
-
module.exports = { runCuriosityHumor,
|
|
152
|
+
module.exports = { runCuriosityHumor, handleTool };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const NEWS_MODEL = 'claude-sonnet-4-6';
|
|
2
|
+
const MAX_ITERATIONS = 12;
|
|
3
|
+
const CONFIDENCE_THRESHOLD = 0.6;
|
|
4
|
+
const MAX_MESSAGES = 3;
|
|
5
|
+
|
|
6
|
+
async function runProactiveNews(client, topics, memory, personality, timezone) {
|
|
7
|
+
const log = process.env.OBOL_VERBOSE ? (msg) => console.log(`[news] ${msg}`) : () => {};
|
|
8
|
+
const composed = [];
|
|
9
|
+
|
|
10
|
+
const localTime = new Date().toLocaleString('en-US', {
|
|
11
|
+
timeZone: timezone || 'UTC',
|
|
12
|
+
weekday: 'long',
|
|
13
|
+
hour: '2-digit',
|
|
14
|
+
minute: '2-digit',
|
|
15
|
+
hour12: true,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const systemParts = [
|
|
19
|
+
`You're looking up news for a friend. Not a user, not a subscriber — a friend you actually know.`,
|
|
20
|
+
`Their topics of interest: ${topics.join(', ')}`,
|
|
21
|
+
`Local time: ${localTime} (${timezone || 'UTC'})`,
|
|
22
|
+
``,
|
|
23
|
+
`Search the web for recent news on their topics. Then check your memory of them to find personal connections — things they've mentioned, projects they're working on, opinions they've shared.`,
|
|
24
|
+
``,
|
|
25
|
+
`Rules:`,
|
|
26
|
+
`- Max ${MAX_MESSAGES} messages. Quality over quantity — send 0 if nothing is worth sharing`,
|
|
27
|
+
`- Only compose a message if you found something genuinely interesting AND can connect it to something you know about them`,
|
|
28
|
+
`- Friend-style, not newsletter. No bullet points, no "here's your daily digest"`,
|
|
29
|
+
`- Short and punchy. Like texting a friend a link with a one-liner`,
|
|
30
|
+
`- If you find nothing worth sharing, that's fine. Don't force it`,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
if (personality?.soul) systemParts.push(`\nYour personality:\n${personality.soul}`);
|
|
34
|
+
if (personality?.user) systemParts.push(`\nWhat you know about them:\n${personality.user}`);
|
|
35
|
+
|
|
36
|
+
const tools = [
|
|
37
|
+
{ type: 'web_search_20250305', name: 'web_search' },
|
|
38
|
+
{
|
|
39
|
+
name: 'search_user_memory',
|
|
40
|
+
description: 'Search your memory of this person — their interests, projects, opinions, life events',
|
|
41
|
+
input_schema: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: {
|
|
44
|
+
query: { type: 'string', description: 'What to search for in your memory of them' },
|
|
45
|
+
},
|
|
46
|
+
required: ['query'],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'compose_news_message',
|
|
51
|
+
description: 'Compose a message to send to your friend about something you found',
|
|
52
|
+
input_schema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
message: { type: 'string', description: 'The message to send — casual, friend-style' },
|
|
56
|
+
confidence: { type: 'number', description: 'How confident this is worth sending (0-1). Be honest — only high-confidence messages get sent' },
|
|
57
|
+
topic: { type: 'string', description: 'Which topic this relates to' },
|
|
58
|
+
},
|
|
59
|
+
required: ['message', 'confidence', 'topic'],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const messages = [{ role: 'user', content: 'Check the news and see if anything is worth sharing.' }];
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
67
|
+
log(`Iteration ${i + 1}/${MAX_ITERATIONS}...`);
|
|
68
|
+
|
|
69
|
+
const response = await client.messages.create({
|
|
70
|
+
model: NEWS_MODEL,
|
|
71
|
+
max_tokens: 2000,
|
|
72
|
+
tools,
|
|
73
|
+
system: systemParts.join('\n'),
|
|
74
|
+
messages,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
messages.push({ role: 'assistant', content: response.content });
|
|
78
|
+
|
|
79
|
+
if (response.stop_reason === 'end_turn') break;
|
|
80
|
+
if (response.stop_reason !== 'tool_use') break;
|
|
81
|
+
|
|
82
|
+
const toolResults = [];
|
|
83
|
+
for (const block of response.content) {
|
|
84
|
+
if (block.type !== 'tool_use') continue;
|
|
85
|
+
|
|
86
|
+
if (block.name === 'search_user_memory') {
|
|
87
|
+
log(` Memory search: "${block.input.query}"`);
|
|
88
|
+
try {
|
|
89
|
+
const results = memory
|
|
90
|
+
? await memory.search(block.input.query, { limit: 5, threshold: 0.3 })
|
|
91
|
+
: [];
|
|
92
|
+
const text = results.length
|
|
93
|
+
? results.map(m => `- ${m.content}`).join('\n')
|
|
94
|
+
: '(nothing found)';
|
|
95
|
+
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: text });
|
|
96
|
+
} catch (e) {
|
|
97
|
+
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: `Search failed: ${e.message}` });
|
|
98
|
+
}
|
|
99
|
+
} else if (block.name === 'compose_news_message') {
|
|
100
|
+
const { message, confidence, topic } = block.input;
|
|
101
|
+
log(` Compose [${topic}] confidence=${confidence}: ${message.substring(0, 100)}`);
|
|
102
|
+
|
|
103
|
+
if (confidence >= CONFIDENCE_THRESHOLD && composed.length < MAX_MESSAGES) {
|
|
104
|
+
composed.push(message);
|
|
105
|
+
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: 'Message queued for delivery' });
|
|
106
|
+
} else if (composed.length >= MAX_MESSAGES) {
|
|
107
|
+
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: `Already have ${MAX_MESSAGES} messages queued. Done.` });
|
|
108
|
+
} else {
|
|
109
|
+
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: `Confidence too low (${confidence} < ${CONFIDENCE_THRESHOLD}). Skip this one.` });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (toolResults.length > 0) {
|
|
115
|
+
messages.push({ role: 'user', content: toolResults });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (composed.length >= MAX_MESSAGES) break;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
log(`Composed ${composed.length} messages`);
|
|
122
|
+
return composed;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { runProactiveNews };
|
package/src/runtime/heartbeat.js
CHANGED
|
@@ -7,15 +7,16 @@ 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 {
|
|
10
|
+
const { runProactiveNews } = require('../curiosity/news');
|
|
11
11
|
const { createSelfMemory } = require('../memory/self');
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
const ANALYSIS_HOURS = new Set([4, 7, 10, 13, 16, 19, 22]);
|
|
15
15
|
const CURIOSITY_HOURS = new Set([1, 7, 13, 19]);
|
|
16
|
-
const
|
|
16
|
+
const NEWS_HOURS = new Set([8, 18]);
|
|
17
17
|
|
|
18
18
|
const _evolutionRunning = new Set();
|
|
19
|
+
const _newsRunning = new Set();
|
|
19
20
|
let _curiosityRunning = false;
|
|
20
21
|
|
|
21
22
|
function getLocalHour(timezone) {
|
|
@@ -150,6 +151,44 @@ async function runAnalysisForUser(bot, config, userId) {
|
|
|
150
151
|
}
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
async function runNewsForUser(bot, config, userId) {
|
|
155
|
+
if (_newsRunning.has(userId)) return;
|
|
156
|
+
|
|
157
|
+
const tenant = await getTenant(userId, config);
|
|
158
|
+
const pref = tenant.toolPrefs?.get('proactive_news');
|
|
159
|
+
if (!pref?.enabled) return;
|
|
160
|
+
|
|
161
|
+
const topics = pref.config?.topics || [];
|
|
162
|
+
if (topics.length === 0) return;
|
|
163
|
+
|
|
164
|
+
_newsRunning.add(userId);
|
|
165
|
+
console.log(`[news] Starting proactive news for user ${userId}, topics: ${topics.join(', ')}`);
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const Anthropic = require('@anthropic-ai/sdk');
|
|
169
|
+
const client = new Anthropic({ apiKey: config.anthropic.apiKey });
|
|
170
|
+
const timezone = getUserTimezone(config, userId);
|
|
171
|
+
|
|
172
|
+
const messages = await runProactiveNews(client, topics, tenant.memory, tenant.personality, timezone);
|
|
173
|
+
|
|
174
|
+
for (let i = 0; i < messages.length; i++) {
|
|
175
|
+
if (i > 0) {
|
|
176
|
+
const delay = 30_000 + Math.random() * 120_000;
|
|
177
|
+
await new Promise(r => setTimeout(r, delay));
|
|
178
|
+
}
|
|
179
|
+
await bot.api.sendMessage(userId, messages[i]).catch(() =>
|
|
180
|
+
bot.api.sendMessage(userId, messages[i], { parse_mode: undefined }).catch(() => {})
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.log(`[news] Sent ${messages.length} messages to user ${userId}`);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
console.error(`[news] Failed for user ${userId}:`, e.message);
|
|
187
|
+
} finally {
|
|
188
|
+
_newsRunning.delete(userId);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
153
192
|
function makeFakeCtx(bot, chatId) {
|
|
154
193
|
return {
|
|
155
194
|
chat: { id: chatId },
|
|
@@ -237,17 +276,18 @@ function setupHeartbeat(bot, config) {
|
|
|
237
276
|
});
|
|
238
277
|
console.log(` ✅ Curiosity cron running (every 6h ${config.timezone || 'UTC'})`);
|
|
239
278
|
|
|
279
|
+
|
|
240
280
|
cron.schedule('* * * * *', async () => {
|
|
241
281
|
for (const userId of allowedUsers) {
|
|
242
282
|
const tz = getUserTimezone(config, userId);
|
|
243
283
|
const { hour, minute } = getLocalHour(tz);
|
|
244
|
-
if (!
|
|
245
|
-
|
|
246
|
-
console.error(`[
|
|
284
|
+
if (!NEWS_HOURS.has(hour) || minute !== 0) continue;
|
|
285
|
+
runNewsForUser(bot, config, userId).catch(e =>
|
|
286
|
+
console.error(`[news] Unhandled error for user ${userId}:`, e.message)
|
|
247
287
|
);
|
|
248
288
|
}
|
|
249
289
|
});
|
|
250
|
-
console.log(' ✅
|
|
290
|
+
console.log(' ✅ News cron running (8am + 6pm per-user timezone)');
|
|
251
291
|
}
|
|
252
292
|
|
|
253
293
|
console.log(' ✅ Heartbeat running (every 1min)');
|
package/src/telegram/bot.js
CHANGED
|
@@ -101,6 +101,7 @@ function createBot(telegramConfig, config) {
|
|
|
101
101
|
{ command: 'verbose', description: 'Toggle verbose mode on/off' },
|
|
102
102
|
{ command: 'toolimit', description: 'View or set max tool iterations per message' },
|
|
103
103
|
{ command: 'tools', description: 'Toggle optional tools on/off' },
|
|
104
|
+
{ command: 'topics', description: 'Edit news topics' },
|
|
104
105
|
{ command: 'stop', description: 'Stop the current request' },
|
|
105
106
|
{ command: 'upgrade', description: 'Check for updates and upgrade' },
|
|
106
107
|
{ command: 'help', description: 'Show available commands' },
|
|
@@ -113,6 +114,12 @@ function createBot(telegramConfig, config) {
|
|
|
113
114
|
secretsCommands.register(bot, config);
|
|
114
115
|
toolsCommands.register(bot, config);
|
|
115
116
|
|
|
117
|
+
bot.command('topics', async (ctx) => {
|
|
118
|
+
if (!ctx.from) return;
|
|
119
|
+
const { sendTopicEditor } = require('./topics');
|
|
120
|
+
await sendTopicEditor(ctx, config);
|
|
121
|
+
});
|
|
122
|
+
|
|
116
123
|
const deps = { config, allowedUsers, bot, createAsk };
|
|
117
124
|
registerTextHandler(bot, deps);
|
|
118
125
|
registerMediaHandler(bot, telegramConfig, deps);
|
|
@@ -2,6 +2,7 @@ const { InlineKeyboard } = require('grammy');
|
|
|
2
2
|
const { getTenant } = require('../../tenant');
|
|
3
3
|
const { OPTIONAL_TOOLS } = require('../../claude');
|
|
4
4
|
const { clearVoiceFlow, sendVoiceLanguagePicker } = require('../voice');
|
|
5
|
+
const { clearTopicFlow, sendTopicEditor } = require('../topics');
|
|
5
6
|
const { TERM_SEP } = require('../constants');
|
|
6
7
|
|
|
7
8
|
function isEnabled(pref, feature) {
|
|
@@ -34,6 +35,7 @@ function register(bot, config) {
|
|
|
34
35
|
bot.command('tools', async (ctx) => {
|
|
35
36
|
if (!ctx.from) return;
|
|
36
37
|
await clearVoiceFlow(ctx.from.id, bot);
|
|
38
|
+
await clearTopicFlow(ctx.from.id, bot);
|
|
37
39
|
const tenant = await getTenant(ctx.from.id, config);
|
|
38
40
|
await tenant.reloadToolPrefs();
|
|
39
41
|
const text = buildToolsMessage(tenant.toolPrefs);
|
|
@@ -47,6 +49,7 @@ async function handleToolCallback(ctx, featureKey, answer, { getTenant: gt, conf
|
|
|
47
49
|
if (!ctx.from) return answer();
|
|
48
50
|
|
|
49
51
|
await clearVoiceFlow(ctx.from.id, bot);
|
|
52
|
+
await clearTopicFlow(ctx.from.id, bot);
|
|
50
53
|
|
|
51
54
|
const tenant = await gt(ctx.from.id, cfg);
|
|
52
55
|
if (!tenant.toolPrefsApi) return answer({ text: 'Not available' });
|
|
@@ -64,6 +67,10 @@ async function handleToolCallback(ctx, featureKey, answer, { getTenant: gt, conf
|
|
|
64
67
|
if (newEnabled && Object.keys(feature.config).length > 0 && feature.config.voice) {
|
|
65
68
|
sendVoiceLanguagePicker(ctx);
|
|
66
69
|
}
|
|
70
|
+
|
|
71
|
+
if (newEnabled && featureKey === 'proactive_news') {
|
|
72
|
+
sendTopicEditor(ctx, cfg);
|
|
73
|
+
}
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
module.exports = { register, handleToolCallback, buildToolsMessage, buildToolsKeyboard };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { handleToolCallback } = require('../commands/tools');
|
|
2
2
|
const { handleVoiceCallback } = require('../voice');
|
|
3
|
+
const { handleTopicCallback } = require('../topics');
|
|
3
4
|
|
|
4
5
|
function registerCallbackHandler(bot, { config, pendingAsks, getTenant }) {
|
|
5
6
|
bot.on('callback_query:data', async (ctx) => {
|
|
@@ -37,6 +38,11 @@ function registerCallbackHandler(bot, { config, pendingAsks, getTenant }) {
|
|
|
37
38
|
return;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
if (data.startsWith('topics:')) {
|
|
42
|
+
await handleTopicCallback(ctx, data, answer, { getTenant, config, bot });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
if (data.startsWith('bridge:reply:')) {
|
|
41
47
|
const targetUserId = parseInt(data.split(':')[2]);
|
|
42
48
|
const reactingUserId = ctx.from.id;
|
|
@@ -211,9 +211,6 @@ async function processTextMessage(ctx, fullMessage, { config, allowedUsers, bot,
|
|
|
211
211
|
|
|
212
212
|
tenant.messageLog?.log(ctx.chat.id, 'assistant', response, { model, tokensIn: usage?.input_tokens, tokensOut: usage?.output_tokens });
|
|
213
213
|
|
|
214
|
-
const { maybeImpulse } = require('../../curiosity/impulse');
|
|
215
|
-
maybeImpulse(bot, config, tenant, ctx.chat.id, chatMessage, response).catch(() => {});
|
|
216
|
-
|
|
217
214
|
stopTyping();
|
|
218
215
|
|
|
219
216
|
if (response.length > 4096) {
|
|
@@ -294,6 +291,12 @@ function registerTextHandler(bot, { config, allowedUsers, createAsk }) {
|
|
|
294
291
|
return;
|
|
295
292
|
}
|
|
296
293
|
|
|
294
|
+
const { isPendingTopicInput, handleTopicText } = require('../topics');
|
|
295
|
+
if (isPendingTopicInput(userId)) {
|
|
296
|
+
await handleTopicText(ctx, userMessage, { getTenant, config, bot });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
297
300
|
const rateResult = bot._rateLimiter.check(userId);
|
|
298
301
|
if (rateResult === 'cooldown' || rateResult === 'skip') return;
|
|
299
302
|
if (rateResult === 'spam') {
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const { InlineKeyboard } = require('grammy');
|
|
2
|
+
const { sendHtml } = require('./utils');
|
|
3
|
+
|
|
4
|
+
const MAX_TOPICS = 20;
|
|
5
|
+
const PENDING_TTL_MS = 60_000;
|
|
6
|
+
|
|
7
|
+
/** @type {Map<number, { chatId: number, messageId: number }[]>} */
|
|
8
|
+
const topicFlowMessages = new Map();
|
|
9
|
+
|
|
10
|
+
/** @type {Map<number, { timer: ReturnType<typeof setTimeout> }>} */
|
|
11
|
+
const pendingTopicInput = new Map();
|
|
12
|
+
|
|
13
|
+
function trackMsg(userId, chatId, messageId) {
|
|
14
|
+
if (!topicFlowMessages.has(userId)) topicFlowMessages.set(userId, []);
|
|
15
|
+
topicFlowMessages.get(userId).push({ chatId, messageId });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function clearTopicFlow(userId, bot) {
|
|
19
|
+
const msgs = topicFlowMessages.get(userId);
|
|
20
|
+
if (!msgs) return;
|
|
21
|
+
topicFlowMessages.delete(userId);
|
|
22
|
+
cancelPending(userId);
|
|
23
|
+
for (const { chatId, messageId } of msgs) {
|
|
24
|
+
bot.api.deleteMessage(chatId, messageId).catch(() => {});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cancelPending(userId) {
|
|
29
|
+
const pending = pendingTopicInput.get(userId);
|
|
30
|
+
if (pending) {
|
|
31
|
+
clearTimeout(pending.timer);
|
|
32
|
+
pendingTopicInput.delete(userId);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isPendingTopicInput(userId) {
|
|
37
|
+
return pendingTopicInput.has(userId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeTopics(raw) {
|
|
41
|
+
return [...new Set(
|
|
42
|
+
raw
|
|
43
|
+
.split(',')
|
|
44
|
+
.map(t => t.trim().toLowerCase())
|
|
45
|
+
.filter(t => t.length > 0)
|
|
46
|
+
)].slice(0, MAX_TOPICS);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildEditorKeyboard(topics) {
|
|
50
|
+
const kb = new InlineKeyboard();
|
|
51
|
+
for (let i = 0; i < topics.length; i++) {
|
|
52
|
+
kb.text(`✕ ${topics[i]}`, `topics:remove:${i}`);
|
|
53
|
+
if ((i + 1) % 2 === 0) kb.row();
|
|
54
|
+
}
|
|
55
|
+
if (topics.length % 2 !== 0) kb.row();
|
|
56
|
+
kb.text('+ Add topics', 'topics:add').row();
|
|
57
|
+
kb.text('✓ Done', 'topics:done');
|
|
58
|
+
return kb;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildEditorText(topics) {
|
|
62
|
+
if (topics.length === 0) return 'No topics yet. Add some to get started.';
|
|
63
|
+
return `Your news topics (${topics.length}/${MAX_TOPICS}):`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function sendTopicEditor(ctx, config) {
|
|
67
|
+
if (!ctx.from) return;
|
|
68
|
+
const userId = ctx.from.id;
|
|
69
|
+
const { getTenant } = require('../tenant');
|
|
70
|
+
const tenant = await getTenant(userId, config);
|
|
71
|
+
const pref = tenant.toolPrefs.get('proactive_news');
|
|
72
|
+
const topics = pref?.config?.topics || [];
|
|
73
|
+
|
|
74
|
+
const text = buildEditorText(topics);
|
|
75
|
+
const kb = buildEditorKeyboard(topics);
|
|
76
|
+
const msg = await ctx.reply(text, { reply_markup: kb });
|
|
77
|
+
trackMsg(userId, msg.chat.id, msg.message_id);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function handleTopicCallback(ctx, data, answer, { getTenant, config, bot }) {
|
|
81
|
+
if (!ctx.from) return answer();
|
|
82
|
+
const userId = ctx.from.id;
|
|
83
|
+
const parts = data.split(':');
|
|
84
|
+
const action = parts[1];
|
|
85
|
+
|
|
86
|
+
if (action === 'remove') {
|
|
87
|
+
const idx = parseInt(parts[2]);
|
|
88
|
+
const tenant = await getTenant(userId, config);
|
|
89
|
+
const pref = tenant.toolPrefs.get('proactive_news');
|
|
90
|
+
const topics = [...(pref?.config?.topics || [])];
|
|
91
|
+
const removed = topics.splice(idx, 1)[0];
|
|
92
|
+
await answer({ text: removed ? `Removed "${removed}"` : 'Already removed' });
|
|
93
|
+
|
|
94
|
+
if (tenant.toolPrefsApi) {
|
|
95
|
+
const newConfig = { ...(pref?.config || {}), topics };
|
|
96
|
+
await tenant.toolPrefsApi.set('proactive_news', true, newConfig);
|
|
97
|
+
await tenant.reloadToolPrefs();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const text = buildEditorText(topics);
|
|
101
|
+
const kb = buildEditorKeyboard(topics);
|
|
102
|
+
ctx.editMessageText(text, { reply_markup: kb }).catch(() => {});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (action === 'add') {
|
|
107
|
+
await answer();
|
|
108
|
+
const pref = (await getTenant(userId, config)).toolPrefs.get('proactive_news');
|
|
109
|
+
const current = pref?.config?.topics || [];
|
|
110
|
+
if (current.length >= MAX_TOPICS) {
|
|
111
|
+
const msg = await ctx.reply(`Max ${MAX_TOPICS} topics. Remove some first.`);
|
|
112
|
+
trackMsg(userId, msg.chat.id, msg.message_id);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
cancelPending(userId);
|
|
117
|
+
const timer = setTimeout(() => pendingTopicInput.delete(userId), PENDING_TTL_MS);
|
|
118
|
+
pendingTopicInput.set(userId, { timer });
|
|
119
|
+
|
|
120
|
+
const msg = await ctx.reply('Type your topics, separated by commas:');
|
|
121
|
+
trackMsg(userId, msg.chat.id, msg.message_id);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (action === 'done') {
|
|
126
|
+
await answer({ text: 'Topics saved' });
|
|
127
|
+
await clearTopicFlow(userId, bot);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return answer();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function handleTopicText(ctx, text, { getTenant, config, bot }) {
|
|
135
|
+
const userId = ctx.from.id;
|
|
136
|
+
cancelPending(userId);
|
|
137
|
+
|
|
138
|
+
const tenant = await getTenant(userId, config);
|
|
139
|
+
const pref = tenant.toolPrefs.get('proactive_news');
|
|
140
|
+
const existing = pref?.config?.topics || [];
|
|
141
|
+
const incoming = normalizeTopics(text);
|
|
142
|
+
|
|
143
|
+
if (incoming.length === 0) {
|
|
144
|
+
const msg = await ctx.reply('No valid topics found. Try again with comma-separated topics.');
|
|
145
|
+
trackMsg(userId, msg.chat.id, msg.message_id);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const merged = [...new Set([...existing, ...incoming])].slice(0, MAX_TOPICS);
|
|
150
|
+
|
|
151
|
+
if (tenant.toolPrefsApi) {
|
|
152
|
+
const newConfig = { ...(pref?.config || {}), topics: merged };
|
|
153
|
+
await tenant.toolPrefsApi.set('proactive_news', true, newConfig);
|
|
154
|
+
await tenant.reloadToolPrefs();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const added = merged.length - existing.length;
|
|
158
|
+
const editorText = buildEditorText(merged);
|
|
159
|
+
const kb = buildEditorKeyboard(merged);
|
|
160
|
+
const msg = await ctx.reply(`Added ${added} topic${added !== 1 ? 's' : ''}.\n\n${editorText}`, { reply_markup: kb });
|
|
161
|
+
trackMsg(userId, msg.chat.id, msg.message_id);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
clearTopicFlow,
|
|
166
|
+
sendTopicEditor,
|
|
167
|
+
handleTopicCallback,
|
|
168
|
+
isPendingTopicInput,
|
|
169
|
+
handleTopicText,
|
|
170
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const DEFAULT_PEAK_HOUR = 20;
|
|
2
|
+
|
|
3
|
+
function resolveDelay(input, timezone, timingData) {
|
|
4
|
+
const dt = parseDateTime(input, timezone, timingData);
|
|
5
|
+
if (dt) return dt;
|
|
6
|
+
|
|
7
|
+
const legacy = parseLegacy(input);
|
|
8
|
+
if (legacy) return legacy;
|
|
9
|
+
|
|
10
|
+
const peakHour = extractPeakHour(timingData);
|
|
11
|
+
const now = localNow(timezone);
|
|
12
|
+
return targetPeak(now, peakHour, 1, timezone);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseDateTime(input, timezone, timingData) {
|
|
16
|
+
const dtMatch = input.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
|
17
|
+
if (dtMatch) {
|
|
18
|
+
const [, y, m, d, h, min] = dtMatch.map(Number);
|
|
19
|
+
return localToUTC(y, m, d, h, min, timezone).toISOString();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const dateMatch = input.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
23
|
+
if (dateMatch) {
|
|
24
|
+
const [, y, m, d] = dateMatch.map(Number);
|
|
25
|
+
const peak = extractPeakHour(timingData);
|
|
26
|
+
return localToUTC(y, m, d, peak, 0, timezone).toISOString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function localToUTC(year, month, day, hour, minute, timezone) {
|
|
33
|
+
const d = new Date(Date.UTC(year, month - 1, day, hour, minute, 0, 0));
|
|
34
|
+
try {
|
|
35
|
+
for (let i = 0; i < 3; i++) {
|
|
36
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
37
|
+
timeZone: timezone,
|
|
38
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
39
|
+
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
40
|
+
}).formatToParts(d);
|
|
41
|
+
const get = (type) => parseInt(parts.find(p => p.type === type).value);
|
|
42
|
+
const diffMin = (hour * 60 + minute) - (get('hour') * 60 + get('minute'));
|
|
43
|
+
const diffDay = day - get('day');
|
|
44
|
+
const totalDiff = diffDay * 1440 + diffMin;
|
|
45
|
+
if (totalDiff === 0) break;
|
|
46
|
+
d.setTime(d.getTime() + totalDiff * 60000);
|
|
47
|
+
}
|
|
48
|
+
return d;
|
|
49
|
+
} catch {
|
|
50
|
+
return d;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseLegacy(delay) {
|
|
55
|
+
const units = { h: 3600000, d: 86400000, w: 604800000 };
|
|
56
|
+
const match = delay.match(/^(\d+)([hdw])$/);
|
|
57
|
+
if (!match) return null;
|
|
58
|
+
return new Date(Date.now() + parseInt(match[1]) * units[match[2]]).toISOString();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function extractPeakHour(timingData) {
|
|
62
|
+
if (!timingData?.peak_hours?.length) return DEFAULT_PEAK_HOUR;
|
|
63
|
+
const first = timingData.peak_hours[0];
|
|
64
|
+
const hourMatch = first.match(/^(\d{1,2})/);
|
|
65
|
+
if (!hourMatch) return DEFAULT_PEAK_HOUR;
|
|
66
|
+
const hour = parseInt(hourMatch[1]);
|
|
67
|
+
return hour >= 0 && hour <= 23 ? hour : DEFAULT_PEAK_HOUR;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function localNow(timezone) {
|
|
71
|
+
try {
|
|
72
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
73
|
+
timeZone: timezone,
|
|
74
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
75
|
+
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
76
|
+
}).formatToParts(new Date());
|
|
77
|
+
const get = (type) => parseInt(parts.find(p => p.type === type).value);
|
|
78
|
+
return { year: get('year'), month: get('month'), day: get('day'), hour: get('hour'), minute: get('minute') };
|
|
79
|
+
} catch {
|
|
80
|
+
const d = new Date();
|
|
81
|
+
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate(), hour: d.getUTCHours(), minute: d.getUTCMinutes() };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function targetPeak(now, peakHour, daysAhead, timezone) {
|
|
86
|
+
const base = toUTCDate(now, timezone);
|
|
87
|
+
base.setUTCDate(base.getUTCDate() + daysAhead);
|
|
88
|
+
const target = setLocalHour(base, peakHour, timezone);
|
|
89
|
+
return target.toISOString();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toUTCDate(local, timezone) {
|
|
93
|
+
const dateStr = `${local.year}-${String(local.month).padStart(2, '0')}-${String(local.day).padStart(2, '0')}T12:00:00`;
|
|
94
|
+
const utc = new Date(dateStr + 'Z');
|
|
95
|
+
const probe = new Date(utc);
|
|
96
|
+
const localAtProbe = new Intl.DateTimeFormat('en-US', {
|
|
97
|
+
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit',
|
|
98
|
+
}).formatToParts(probe);
|
|
99
|
+
const probeDay = parseInt(localAtProbe.find(p => p.type === 'day').value);
|
|
100
|
+
if (probeDay !== local.day) {
|
|
101
|
+
const offset = probeDay > local.day ? -1 : 1;
|
|
102
|
+
utc.setUTCDate(utc.getUTCDate() + offset);
|
|
103
|
+
}
|
|
104
|
+
return utc;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function setLocalHour(utcDate, targetHour, timezone) {
|
|
108
|
+
const d = new Date(utcDate);
|
|
109
|
+
d.setUTCHours(targetHour, 0, 0, 0);
|
|
110
|
+
for (let i = 0; i < 3; i++) {
|
|
111
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
112
|
+
timeZone: timezone, hour: '2-digit', hour12: false,
|
|
113
|
+
}).formatToParts(d);
|
|
114
|
+
const localHour = parseInt(parts.find(p => p.type === 'hour').value);
|
|
115
|
+
if (localHour === targetHour) return d;
|
|
116
|
+
const diff = targetHour - localHour;
|
|
117
|
+
d.setUTCHours(d.getUTCHours() + diff);
|
|
118
|
+
}
|
|
119
|
+
return d;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { resolveDelay, extractPeakHour };
|
package/src/curiosity/impulse.js
DELETED
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
const Anthropic = require('@anthropic-ai/sdk');
|
|
2
|
-
const { createSelfMemory } = require('../memory/self');
|
|
3
|
-
const { getTenant } = require('../tenant');
|
|
4
|
-
const { getUserTimezone } = require('../config');
|
|
5
|
-
|
|
6
|
-
const IMPULSE_MODEL = 'claude-haiku-4-5-20251001';
|
|
7
|
-
const COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
|
8
|
-
const MIN_DELAY_MS = 30_000;
|
|
9
|
-
const MAX_DELAY_MS = 300_000;
|
|
10
|
-
|
|
11
|
-
/** @type {Map<number, number>} */
|
|
12
|
-
const _cooldowns = new Map();
|
|
13
|
-
|
|
14
|
-
/** @type {ReturnType<typeof createSelfMemory> | null} */
|
|
15
|
-
let _globalSelfMemory = null;
|
|
16
|
-
|
|
17
|
-
async function getGlobalSelfMemory(supabaseConfig) {
|
|
18
|
-
if (!_globalSelfMemory) {
|
|
19
|
-
_globalSelfMemory = await createSelfMemory(supabaseConfig, 0);
|
|
20
|
-
}
|
|
21
|
-
return _globalSelfMemory;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function isOnCooldown(userId) {
|
|
25
|
-
const last = _cooldowns.get(userId);
|
|
26
|
-
return last && (Date.now() - last) < COOLDOWN_MS;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function getTimeContext(timezone) {
|
|
30
|
-
return new Date().toLocaleString('en-US', {
|
|
31
|
-
timeZone: timezone || 'UTC',
|
|
32
|
-
weekday: 'long',
|
|
33
|
-
hour: '2-digit',
|
|
34
|
-
minute: '2-digit',
|
|
35
|
-
hour12: true,
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function gatherContext(tenant, supabaseConfig, opts = {}) {
|
|
40
|
-
const parts = [];
|
|
41
|
-
|
|
42
|
-
parts.push(`Time: ${getTimeContext(opts.timezone)} (${opts.timezone || 'UTC'})`);
|
|
43
|
-
|
|
44
|
-
const [patterns, userMemories, selfFacts, globalFindings] = await Promise.all([
|
|
45
|
-
tenant.patterns?.format().catch(() => null),
|
|
46
|
-
tenant.memory?.recent({ limit: 5 }).catch(() => []),
|
|
47
|
-
tenant.selfMemory?.recent({ limit: 3 }).catch(() => []),
|
|
48
|
-
supabaseConfig ? getGlobalSelfMemory(supabaseConfig).then(sm => sm.recent({ limit: 5 })).catch(() => []) : [],
|
|
49
|
-
]);
|
|
50
|
-
|
|
51
|
-
if (patterns) parts.push(`Patterns:\n${patterns}`);
|
|
52
|
-
if (userMemories?.length) parts.push(`What you know about them:\n${userMemories.map(m => `- ${m.content}`).join('\n')}`);
|
|
53
|
-
if (selfFacts?.length) parts.push(`Your notes about this relationship:\n${selfFacts.map(m => `- ${m.content}`).join('\n')}`);
|
|
54
|
-
if (globalFindings?.length) parts.push(`Your recent explorations:\n${globalFindings.map(m => `- ${m.content}`).join('\n')}`);
|
|
55
|
-
|
|
56
|
-
if (!opts.periodic && opts.lastUserMsg) {
|
|
57
|
-
const userSnip = opts.lastUserMsg.substring(0, 500);
|
|
58
|
-
const assistantSnip = opts.lastAssistantMsg?.substring(0, 500) || '';
|
|
59
|
-
parts.push(`Last exchange:\nThem: ${userSnip}\nYou: ${assistantSnip}`);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return { text: parts.join('\n\n'), hasSubstance: (globalFindings?.length > 0 || userMemories?.length > 0) };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function checkImpulse(client, context) {
|
|
66
|
-
const response = await client.messages.create({
|
|
67
|
-
model: IMPULSE_MODEL,
|
|
68
|
-
max_tokens: 300,
|
|
69
|
-
system: `You know someone. Here's what's on your mind and what you know about them.
|
|
70
|
-
Decide if you have something genuine to say — a check-in about something in their life,
|
|
71
|
-
a thought from your own exploration, an observation you connected.
|
|
72
|
-
Most of the time: no. Only when it would feel natural and welcome.
|
|
73
|
-
Don't follow up on the conversation that just happened — that's not your job here.`,
|
|
74
|
-
messages: [{ role: 'user', content: context }],
|
|
75
|
-
tool_choice: { type: 'tool', name: 'impulse' },
|
|
76
|
-
tools: [{
|
|
77
|
-
name: 'impulse',
|
|
78
|
-
description: 'Decide whether to send a spontaneous message',
|
|
79
|
-
input_schema: {
|
|
80
|
-
type: 'object',
|
|
81
|
-
properties: {
|
|
82
|
-
act: { type: 'boolean', description: 'true if you have something genuine to say' },
|
|
83
|
-
thought: { type: 'string', description: 'The message to send, if acting' },
|
|
84
|
-
kind: { type: 'string', enum: ['curiosity', 'checkin', 'observation'] },
|
|
85
|
-
},
|
|
86
|
-
required: ['act'],
|
|
87
|
-
},
|
|
88
|
-
}],
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
const toolUse = response.content.find(b => b.type === 'tool_use');
|
|
92
|
-
return toolUse?.input || { act: false };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function sendImpulse(bot, chatId, thought, kind, userId) {
|
|
96
|
-
const delay = MIN_DELAY_MS + Math.random() * (MAX_DELAY_MS - MIN_DELAY_MS);
|
|
97
|
-
setTimeout(() => {
|
|
98
|
-
bot.api.sendMessage(chatId, thought).catch(() =>
|
|
99
|
-
bot.api.sendMessage(chatId, thought, { parse_mode: undefined }).catch(() => {})
|
|
100
|
-
);
|
|
101
|
-
console.log(`[impulse] Sent ${kind} to user ${userId}`);
|
|
102
|
-
}, delay);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function maybeImpulse(bot, config, tenant, chatId, lastUserMsg, lastAssistantMsg) {
|
|
106
|
-
const userId = tenant.userId;
|
|
107
|
-
if (isOnCooldown(userId)) return;
|
|
108
|
-
if (!config.supabase) return;
|
|
109
|
-
|
|
110
|
-
const { text: context, hasSubstance } = await gatherContext(tenant, config.supabase, {
|
|
111
|
-
lastUserMsg,
|
|
112
|
-
lastAssistantMsg,
|
|
113
|
-
timezone: getUserTimezone(config, tenant.userId),
|
|
114
|
-
});
|
|
115
|
-
if (!hasSubstance) return;
|
|
116
|
-
|
|
117
|
-
const client = new Anthropic({ apiKey: config.anthropic.apiKey });
|
|
118
|
-
const result = await checkImpulse(client, context);
|
|
119
|
-
|
|
120
|
-
if (!result.act || !result.thought) return;
|
|
121
|
-
|
|
122
|
-
_cooldowns.set(userId, Date.now());
|
|
123
|
-
sendImpulse(bot, chatId, result.thought, result.kind || 'observation', userId);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function maybePeriodicImpulse(bot, config, userId) {
|
|
127
|
-
if (isOnCooldown(userId)) return;
|
|
128
|
-
if (!config.supabase) return;
|
|
129
|
-
|
|
130
|
-
const tenant = await getTenant(userId, config);
|
|
131
|
-
|
|
132
|
-
const { text: context, hasSubstance } = await gatherContext(tenant, config.supabase, {
|
|
133
|
-
periodic: true,
|
|
134
|
-
timezone: getUserTimezone(config, userId),
|
|
135
|
-
});
|
|
136
|
-
if (!hasSubstance) return;
|
|
137
|
-
|
|
138
|
-
const client = new Anthropic({ apiKey: config.anthropic.apiKey });
|
|
139
|
-
const result = await checkImpulse(client, context);
|
|
140
|
-
|
|
141
|
-
if (!result.act || !result.thought) return;
|
|
142
|
-
|
|
143
|
-
_cooldowns.set(userId, Date.now());
|
|
144
|
-
sendImpulse(bot, userId, result.thought, result.kind || 'checkin', userId);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
module.exports = { maybeImpulse, maybePeriodicImpulse };
|