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 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.14",
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
- delay: { type: 'string', description: '"2h", "1d", "3d", or "1w"' },
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', 'delay', 'context'],
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.delay) continue;
165
- const dueAt = resolveDelay(fu.delay);
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
- function resolveDelay(delay) {
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 };
@@ -35,6 +35,13 @@ const OPTIONAL_TOOLS = {
35
35
  config: {},
36
36
  defaultEnabled: true,
37
37
  },
38
+ proactive_news: {
39
+ label: 'Proactive News',
40
+ tools: [],
41
+ config: {
42
+ topics: { label: 'Topics', default: [] },
43
+ },
44
+ },
38
45
  };
39
46
 
40
47
  const BLOCKED_EXEC_PATTERNS = [
@@ -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
 
@@ -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, resolveDelay, handleTool };
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 };
@@ -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 { maybePeriodicImpulse } = require('../curiosity/impulse');
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 IMPULSE_HOURS = new Set([4, 10, 16, 22]);
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 (!IMPULSE_HOURS.has(hour) || minute !== 0) continue;
245
- maybePeriodicImpulse(bot, config, userId).catch(e =>
246
- console.error(`[impulse] Periodic error for user ${userId}:`, e.message)
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(' ✅ Impulse cron running (every 6h per-user timezone)');
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 describeToolCall(client, toolName, inputSummary) {
32
- const key = `${toolName}:${inputSummary}`;
33
- const cached = _toolDescriptionCache.get(key);
34
- if (cached) return Promise.resolve(cached);
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, describeToolCall, TERM_WIDTH };
36
+ module.exports = { buildStatusHtml, formatToolCall, TERM_WIDTH };
@@ -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, describeToolCall } = require('../../status');
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('Processing');
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 { describeToolCall } = require('../../status');
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('Processing');
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, describeToolCall } = require('../../status');
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('Processing');
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 };
@@ -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 };