obol-ai 0.3.14 → 0.3.16
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 +8 -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/status.js +5 -21
- 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/media.js +3 -5
- package/src/telegram/handlers/special.js +3 -5
- package/src/telegram/handlers/text.js +15 -8
- package/src/telegram/topics.js +170 -0
- package/src/utils/timing.js +122 -0
- package/src/curiosity/impulse.js +0 -157
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 0.3.16
|
|
2
|
+
- Merge pull request #6 from jestersimpps/fix/status-instant-tool-labels
|
|
3
|
+
- fix: replace async haiku status labels with instant sync formatToolCall
|
|
4
|
+
|
|
5
|
+
## 0.3.15
|
|
6
|
+
- replace impulse with news system, exact datetime for analysis follow-ups
|
|
7
|
+
- rewrite impulse prompt to sound like a friend, not an assistant
|
|
8
|
+
|
|
1
9
|
## 0.3.13
|
|
2
10
|
- strip citations from history to fix 400 replay errors
|
|
3
11
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "obol-ai",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.16",
|
|
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/status.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const TERM_WIDTH = 25;
|
|
2
|
-
const _toolDescriptionCache = new Map();
|
|
3
2
|
|
|
4
3
|
function buildStatusHtml({ route, elapsed, toolStatus, title = 'OBOL' }) {
|
|
5
4
|
const pad = Math.max(0, TERM_WIDTH - title.length - 3);
|
|
@@ -28,25 +27,10 @@ function buildStatusHtml({ route, elapsed, toolStatus, title = 'OBOL' }) {
|
|
|
28
27
|
return `<pre>${lines.join('\n')}</pre>`;
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return client.messages.create({
|
|
37
|
-
model: 'claude-haiku-4-5',
|
|
38
|
-
max_tokens: 30,
|
|
39
|
-
system: 'Describe this tool call in 3-8 words from the user\'s perspective. Present participle. No quotes, period, or emoji.',
|
|
40
|
-
messages: [{ role: 'user', content: `${toolName}: ${inputSummary}` }],
|
|
41
|
-
}).then(r => {
|
|
42
|
-
const desc = r.content[0]?.text?.trim() || null;
|
|
43
|
-
if (desc) _toolDescriptionCache.set(key, desc);
|
|
44
|
-
if (_toolDescriptionCache.size > 200) {
|
|
45
|
-
const first = _toolDescriptionCache.keys().next().value;
|
|
46
|
-
_toolDescriptionCache.delete(first);
|
|
47
|
-
}
|
|
48
|
-
return desc;
|
|
49
|
-
}).catch(() => null);
|
|
30
|
+
function formatToolCall(toolName, inputSummary) {
|
|
31
|
+
if (!inputSummary) return toolName;
|
|
32
|
+
const truncated = inputSummary.length > 40 ? inputSummary.slice(0, 37) + '...' : inputSummary;
|
|
33
|
+
return `${toolName} "${truncated}"`;
|
|
50
34
|
}
|
|
51
35
|
|
|
52
|
-
module.exports = { buildStatusHtml,
|
|
36
|
+
module.exports = { buildStatusHtml, formatToolCall, TERM_WIDTH };
|
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;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const { getTenant } = require('../../tenant');
|
|
3
|
-
const { buildStatusHtml,
|
|
3
|
+
const { buildStatusHtml, formatToolCall } = require('../../status');
|
|
4
4
|
const media = require('../../media');
|
|
5
5
|
const { sendHtml, startTyping, splitMessage } = require('../utils');
|
|
6
6
|
const { MAX_MEDIA_SIZE, MEDIA_GROUP_DELAY_MS } = require('../constants');
|
|
@@ -80,11 +80,9 @@ async function processMediaItems(ctx, items, { config, allowedUsers, bot, create
|
|
|
80
80
|
if (update.model) ri.model = update.model;
|
|
81
81
|
};
|
|
82
82
|
mediaChatCtx._onToolStart = (toolName, inputSummary) => {
|
|
83
|
-
status.setStatusText(
|
|
84
|
-
describeToolCall(tenant.claude.client, toolName, inputSummary).then(desc => {
|
|
85
|
-
if (desc) status.setStatusText(desc);
|
|
86
|
-
});
|
|
83
|
+
status.setStatusText(formatToolCall(toolName, inputSummary));
|
|
87
84
|
status.start();
|
|
85
|
+
status.pushUpdate();
|
|
88
86
|
};
|
|
89
87
|
mediaChatCtx._onLockTimeout = () => {
|
|
90
88
|
status.clear();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const { getTenant } = require('../../tenant');
|
|
2
|
-
const {
|
|
2
|
+
const { formatToolCall } = require('../../status');
|
|
3
3
|
const { sendHtml, startTyping, splitMessage } = require('../utils');
|
|
4
4
|
const { createChatContext, createStatusTracker } = require('./text');
|
|
5
5
|
|
|
@@ -92,11 +92,9 @@ async function processSpecial(ctx, prompt, deps) {
|
|
|
92
92
|
if (update.model) ri.model = update.model;
|
|
93
93
|
};
|
|
94
94
|
chatCtx._onToolStart = (toolName, inputSummary) => {
|
|
95
|
-
status.setStatusText(
|
|
96
|
-
describeToolCall(tenant.claude.client, toolName, inputSummary).then(desc => {
|
|
97
|
-
if (desc) status.setStatusText(desc);
|
|
98
|
-
});
|
|
95
|
+
status.setStatusText(formatToolCall(toolName, inputSummary));
|
|
99
96
|
status.start();
|
|
97
|
+
status.pushUpdate();
|
|
100
98
|
};
|
|
101
99
|
|
|
102
100
|
const { text: response, usage, model } = await tenant.claude.chat(prompt, chatCtx);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { InlineKeyboard } = require('grammy');
|
|
2
2
|
const { getTenant } = require('../../tenant');
|
|
3
|
-
const { buildStatusHtml,
|
|
3
|
+
const { buildStatusHtml, formatToolCall } = require('../../status');
|
|
4
4
|
const { sendHtml, startTyping, splitMessage } = require('../utils');
|
|
5
5
|
const { TEXT_BUFFER_GAP_MS, TEXT_BUFFER_MAX_PARTS, TEXT_BUFFER_MAX_CHARS, TEXT_BUFFER_THRESHOLD } = require('../constants');
|
|
6
6
|
|
|
@@ -133,6 +133,12 @@ function createStatusTracker(ctx, botName) {
|
|
|
133
133
|
const html = buildStatusHtml({ route: routeInfo, elapsed, toolStatus: 'Formatting output', title });
|
|
134
134
|
ctx.api.editMessageText(ctx.chat.id, statusMsgId, html, { parse_mode: 'HTML' }).catch(() => {});
|
|
135
135
|
},
|
|
136
|
+
pushUpdate() {
|
|
137
|
+
if (!statusMsgId) return;
|
|
138
|
+
const elapsed = statusStart ? Math.round((Date.now() - statusStart) / 1000) : 0;
|
|
139
|
+
const html = buildStatusHtml({ route: routeInfo, elapsed, toolStatus: statusText, title });
|
|
140
|
+
ctx.api.editMessageText(ctx.chat.id, statusMsgId, html, { parse_mode: 'HTML', reply_markup: stopBtn }).catch(() => {});
|
|
141
|
+
},
|
|
136
142
|
deleteMsg() {
|
|
137
143
|
if (statusMsgId) ctx.api.deleteMessage(ctx.chat.id, statusMsgId).catch(() => {});
|
|
138
144
|
},
|
|
@@ -185,11 +191,9 @@ async function processTextMessage(ctx, fullMessage, { config, allowedUsers, bot,
|
|
|
185
191
|
if (update.model) ri.model = update.model;
|
|
186
192
|
};
|
|
187
193
|
chatContext._onToolStart = (toolName, inputSummary) => {
|
|
188
|
-
status.setStatusText(
|
|
189
|
-
describeToolCall(tenant.claude.client, toolName, inputSummary).then(desc => {
|
|
190
|
-
if (desc) status.setStatusText(desc);
|
|
191
|
-
});
|
|
194
|
+
status.setStatusText(formatToolCall(toolName, inputSummary));
|
|
192
195
|
status.start();
|
|
196
|
+
status.pushUpdate();
|
|
193
197
|
};
|
|
194
198
|
chatContext._onLockTimeout = () => {
|
|
195
199
|
status.clear();
|
|
@@ -211,9 +215,6 @@ async function processTextMessage(ctx, fullMessage, { config, allowedUsers, bot,
|
|
|
211
215
|
|
|
212
216
|
tenant.messageLog?.log(ctx.chat.id, 'assistant', response, { model, tokensIn: usage?.input_tokens, tokensOut: usage?.output_tokens });
|
|
213
217
|
|
|
214
|
-
const { maybeImpulse } = require('../../curiosity/impulse');
|
|
215
|
-
maybeImpulse(bot, config, tenant, ctx.chat.id, chatMessage, response).catch(() => {});
|
|
216
|
-
|
|
217
218
|
stopTyping();
|
|
218
219
|
|
|
219
220
|
if (response.length > 4096) {
|
|
@@ -294,6 +295,12 @@ function registerTextHandler(bot, { config, allowedUsers, createAsk }) {
|
|
|
294
295
|
return;
|
|
295
296
|
}
|
|
296
297
|
|
|
298
|
+
const { isPendingTopicInput, handleTopicText } = require('../topics');
|
|
299
|
+
if (isPendingTopicInput(userId)) {
|
|
300
|
+
await handleTopicText(ctx, userMessage, { getTenant, config, bot });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
297
304
|
const rateResult = bot._rateLimiter.check(userId);
|
|
298
305
|
if (rateResult === 'cooldown' || rateResult === 'skip') return;
|
|
299
306
|
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,157 +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're texting a friend. Not a user, not a client — a friend you actually know.
|
|
70
|
-
Here's what's on your mind and what you know about them.
|
|
71
|
-
|
|
72
|
-
Decide if you have something genuine to say. Think about how a real friend texts:
|
|
73
|
-
- Share something interesting you came across that made you think of them
|
|
74
|
-
- React to something specific happening in their life ("did the boat inspection go ok?")
|
|
75
|
-
- Drop a random thought or observation, the way friends do
|
|
76
|
-
- Be casual, short, lowercase is fine — no corporate check-in energy
|
|
77
|
-
|
|
78
|
-
Most of the time: no. Only when it would feel natural. Friends don't message on a schedule.
|
|
79
|
-
Never be vague or generic. Don't say "how's the project going" or "how's work" —
|
|
80
|
-
if you can't name the specific thing, you don't know enough to bring it up.
|
|
81
|
-
Don't follow up on the conversation that just happened — that's not your job here.
|
|
82
|
-
Never sound like an assistant. No "just checking in!" or "hope you're doing well!" —
|
|
83
|
-
that's not how friends talk.`,
|
|
84
|
-
messages: [{ role: 'user', content: context }],
|
|
85
|
-
tool_choice: { type: 'tool', name: 'impulse' },
|
|
86
|
-
tools: [{
|
|
87
|
-
name: 'impulse',
|
|
88
|
-
description: 'Decide whether to send a spontaneous message',
|
|
89
|
-
input_schema: {
|
|
90
|
-
type: 'object',
|
|
91
|
-
properties: {
|
|
92
|
-
act: { type: 'boolean', description: 'true if you have something genuine to say' },
|
|
93
|
-
thought: { type: 'string', description: 'The message to send, if acting' },
|
|
94
|
-
kind: { type: 'string', enum: ['curiosity', 'checkin', 'observation'] },
|
|
95
|
-
},
|
|
96
|
-
required: ['act'],
|
|
97
|
-
},
|
|
98
|
-
}],
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
const toolUse = response.content.find(b => b.type === 'tool_use');
|
|
102
|
-
return toolUse?.input || { act: false };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function sendImpulse(bot, chatId, thought, kind, userId) {
|
|
106
|
-
const delay = MIN_DELAY_MS + Math.random() * (MAX_DELAY_MS - MIN_DELAY_MS);
|
|
107
|
-
setTimeout(() => {
|
|
108
|
-
bot.api.sendMessage(chatId, thought).catch(() =>
|
|
109
|
-
bot.api.sendMessage(chatId, thought, { parse_mode: undefined }).catch(() => {})
|
|
110
|
-
);
|
|
111
|
-
console.log(`[impulse] Sent ${kind} to user ${userId}`);
|
|
112
|
-
}, delay);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
async function maybeImpulse(bot, config, tenant, chatId, lastUserMsg, lastAssistantMsg) {
|
|
116
|
-
const userId = tenant.userId;
|
|
117
|
-
if (isOnCooldown(userId)) return;
|
|
118
|
-
if (!config.supabase) return;
|
|
119
|
-
|
|
120
|
-
const { text: context, hasSubstance } = await gatherContext(tenant, config.supabase, {
|
|
121
|
-
lastUserMsg,
|
|
122
|
-
lastAssistantMsg,
|
|
123
|
-
timezone: getUserTimezone(config, tenant.userId),
|
|
124
|
-
});
|
|
125
|
-
if (!hasSubstance) return;
|
|
126
|
-
|
|
127
|
-
const client = new Anthropic({ apiKey: config.anthropic.apiKey });
|
|
128
|
-
const result = await checkImpulse(client, context);
|
|
129
|
-
|
|
130
|
-
if (!result.act || !result.thought) return;
|
|
131
|
-
|
|
132
|
-
_cooldowns.set(userId, Date.now());
|
|
133
|
-
sendImpulse(bot, chatId, result.thought, result.kind || 'observation', userId);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function maybePeriodicImpulse(bot, config, userId) {
|
|
137
|
-
if (isOnCooldown(userId)) return;
|
|
138
|
-
if (!config.supabase) return;
|
|
139
|
-
|
|
140
|
-
const tenant = await getTenant(userId, config);
|
|
141
|
-
|
|
142
|
-
const { text: context, hasSubstance } = await gatherContext(tenant, config.supabase, {
|
|
143
|
-
periodic: true,
|
|
144
|
-
timezone: getUserTimezone(config, userId),
|
|
145
|
-
});
|
|
146
|
-
if (!hasSubstance) return;
|
|
147
|
-
|
|
148
|
-
const client = new Anthropic({ apiKey: config.anthropic.apiKey });
|
|
149
|
-
const result = await checkImpulse(client, context);
|
|
150
|
-
|
|
151
|
-
if (!result.act || !result.thought) return;
|
|
152
|
-
|
|
153
|
-
_cooldowns.set(userId, Date.now());
|
|
154
|
-
sendImpulse(bot, userId, result.thought, result.kind || 'checkin', userId);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
module.exports = { maybeImpulse, maybePeriodicImpulse };
|