obol-ai 0.3.10 → 0.3.12

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,10 @@
1
+ ## 0.3.12
2
+ - add patterns_view, patterns_delete, knowledge_remove, memory_stats tools
3
+
4
+ ## 0.3.11
5
+ - add model stats toggle to /tools menu
6
+ - per-user timezone support for all engagement cycles
7
+
1
8
  ## 0.3.10
2
9
  - add CLI commands for evolve and curiosity, verbose logging
3
10
  - fix pattern analysis: enforce factual observations, use incrementObservation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
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": {
@@ -8,7 +8,7 @@ const { buildTools, buildRunnableTools, addToolCache } = require('./tool-registr
8
8
  const { withCacheBreakpoints, sanitizeMessages, stripToolBlocks } = require('./cache');
9
9
  const { getMaxToolIterations } = require('./constants');
10
10
 
11
- function createClaude(anthropicConfig, { personality, memory, selfMemory, userDir = OBOL_DIR, bridgeEnabled, botName }) {
11
+ function createClaude(anthropicConfig, { personality, memory, selfMemory, patterns, userDir = OBOL_DIR, bridgeEnabled, botName }) {
12
12
  let client = createAnthropicClient(anthropicConfig);
13
13
 
14
14
  let baseSystemPrompt = buildSystemPrompt(personality, userDir, { bridgeEnabled, botName });
@@ -18,7 +18,7 @@ function createClaude(anthropicConfig, { personality, memory, selfMemory, userDi
18
18
  const chatAbortControllers = new Map();
19
19
  const chatForceControllers = new Map();
20
20
 
21
- const tools = buildTools(memory, { bridgeEnabled, selfMemory });
21
+ const tools = buildTools(memory, { bridgeEnabled, selfMemory, patterns });
22
22
 
23
23
  function acquireChatLock(chatId) {
24
24
  if (!chatLocks.has(chatId)) chatLocks.set(chatId, { promise: Promise.resolve(), busy: false });
@@ -105,6 +105,7 @@ function createClaude(anthropicConfig, { personality, memory, selfMemory, userDi
105
105
  context._forceSignal = forceController.signal;
106
106
  context.claude = { chat, clearHistory, client };
107
107
  context.selfMemory = selfMemory;
108
+ context.patterns = patterns;
108
109
  const runnableTools = buildRunnableTools(tools, memory, context, vlog);
109
110
  let activeModel = model;
110
111
 
@@ -29,6 +29,12 @@ const OPTIONAL_TOOLS = {
29
29
  tools: ['mermaid_chart'],
30
30
  config: {},
31
31
  },
32
+ model_stats: {
33
+ label: 'Model Stats',
34
+ tools: [],
35
+ config: {},
36
+ defaultEnabled: true,
37
+ },
32
38
  };
33
39
 
34
40
  const BLOCKED_EXEC_PATTERNS = [
@@ -15,6 +15,7 @@ const historyTool = require('./tools/history');
15
15
  const agentTool = require('./tools/agent');
16
16
  const sttTool = require('./tools/stt');
17
17
  const mermaidTool = require('./tools/mermaid');
18
+ const patternsTool = require('./tools/patterns');
18
19
 
19
20
  const TOOL_MODULES = [
20
21
  execTool,
@@ -54,6 +55,10 @@ const INPUT_SUMMARIES = {
54
55
  create_pdf: (i) => i.filename || 'document',
55
56
  text_to_speech: (i) => i.text?.substring(0, 60),
56
57
  tts_voices: (i) => i.language || 'all',
58
+ memory_stats: () => 'stats',
59
+ knowledge_remove: (i) => i.ids?.join(', '),
60
+ patterns_view: (i) => i.dimension || 'all',
61
+ patterns_delete: (i) => i.key,
57
62
  chat_history: (i) => `${i.date}${i.role ? ` [${i.role}]` : ''}`,
58
63
  };
59
64
 
@@ -77,6 +82,10 @@ function buildTools(memory, opts = {}) {
77
82
  tools.push(...knowledgeTool.definitions);
78
83
  }
79
84
 
85
+ if (opts.patterns) {
86
+ tools.push(...patternsTool.definitions);
87
+ }
88
+
80
89
  if (opts.bridgeEnabled) {
81
90
  tools.push(...bridgeTool.getDefinitions());
82
91
  }
@@ -91,6 +100,7 @@ function buildHandlerMap() {
91
100
  }
92
101
  Object.assign(map, memoryTool.handlers);
93
102
  Object.assign(map, knowledgeTool.handlers);
103
+ Object.assign(map, patternsTool.handlers);
94
104
  Object.assign(map, bridgeTool.handlers);
95
105
  return map;
96
106
  }
@@ -103,7 +113,8 @@ function buildRunnableTools(tools, memory, context, vlog) {
103
113
  if (toolPrefs) {
104
114
  for (const [featureKey, feature] of Object.entries(OPTIONAL_TOOLS)) {
105
115
  const pref = toolPrefs.get(featureKey);
106
- if (!pref || !pref.enabled) {
116
+ const enabled = pref ? pref.enabled : (feature.defaultEnabled || false);
117
+ if (!enabled) {
107
118
  for (const t of feature.tools) disabledTools.add(t);
108
119
  }
109
120
  }
@@ -49,6 +49,17 @@ const definitions = [
49
49
  required: ['content'],
50
50
  },
51
51
  },
52
+ {
53
+ name: 'knowledge_remove',
54
+ description: 'Remove one or more entries from your own knowledge/self-memory by ID. Use knowledge_search first to find IDs.',
55
+ input_schema: {
56
+ type: 'object',
57
+ properties: {
58
+ ids: { type: 'array', items: { type: 'string' }, description: 'IDs to remove' },
59
+ },
60
+ required: ['ids'],
61
+ },
62
+ },
52
63
  ];
53
64
 
54
65
  function formatEntry(m) {
@@ -100,6 +111,13 @@ const handlers = {
100
111
  });
101
112
  return `Interest stored: ${result.id}`;
102
113
  },
114
+
115
+ async knowledge_remove(input, _memory, context) {
116
+ const selfMemory = context.selfMemory;
117
+ if (!selfMemory) return 'Self memory not available.';
118
+ await Promise.all(input.ids.map(id => selfMemory.forget(id)));
119
+ return `Removed ${input.ids.length} entr${input.ids.length !== 1 ? 'ies' : 'y'}`;
120
+ },
103
121
  };
104
122
 
105
123
  const requiresSelfMemory = true;
@@ -53,6 +53,11 @@ const definitions = [
53
53
  },
54
54
  },
55
55
  },
56
+ {
57
+ name: 'memory_stats',
58
+ description: 'Show memory statistics — total count and breakdown by category.',
59
+ input_schema: { type: 'object', properties: {} },
60
+ },
56
61
  ];
57
62
 
58
63
  function formatMemory(m) {
@@ -96,6 +101,11 @@ const handlers = {
96
101
  });
97
102
  return JSON.stringify(results.map(formatMemory));
98
103
  },
104
+
105
+ async memory_stats(_input, memory) {
106
+ const { total, breakdown } = await memory.stats();
107
+ return `Total memories: ${total}\n\nBy category:\n${breakdown}`;
108
+ },
99
109
  };
100
110
 
101
111
  const requiresMemory = true;
@@ -0,0 +1,52 @@
1
+ const definitions = [
2
+ {
3
+ name: 'patterns_view',
4
+ description: 'View behavioral patterns learned about the user. Shows timing, mood, humor, engagement, communication, and topic patterns.',
5
+ input_schema: {
6
+ type: 'object',
7
+ properties: {
8
+ dimension: { type: 'string', enum: ['timing', 'mood', 'humor', 'engagement', 'communication', 'topics'], description: 'Filter by dimension (omit for all)' },
9
+ },
10
+ },
11
+ },
12
+ {
13
+ name: 'patterns_delete',
14
+ description: 'Delete a behavioral pattern by its key. Use patterns_view first to see available keys.',
15
+ input_schema: {
16
+ type: 'object',
17
+ properties: {
18
+ key: { type: 'string', description: 'Pattern key to delete (e.g. "timing.active_hours", "mood.stress_signals")' },
19
+ },
20
+ required: ['key'],
21
+ },
22
+ },
23
+ ];
24
+
25
+ function formatPattern(p) {
26
+ const obs = p.observation_count ? ` (${p.observation_count} observations)` : '';
27
+ const conf = p.confidence != null ? ` [confidence: ${p.confidence}]` : '';
28
+ return `[${p.dimension}] ${p.key}: ${p.summary}${conf}${obs}`;
29
+ }
30
+
31
+ const handlers = {
32
+ async patterns_view(_input, _memory, context) {
33
+ if (!context.patterns) return 'Patterns not available (Supabase not configured).';
34
+ const patterns = _input.dimension
35
+ ? await context.patterns.getByDimension(_input.dimension)
36
+ : await context.patterns.getAll();
37
+ if (!patterns.length) return _input.dimension ? `No ${_input.dimension} patterns found.` : 'No patterns learned yet.';
38
+ return patterns.map(formatPattern).join('\n');
39
+ },
40
+
41
+ async patterns_delete(input, _memory, context) {
42
+ if (!context.patterns) return 'Patterns not available (Supabase not configured).';
43
+ const existing = await context.patterns.get(input.key);
44
+ if (!existing) return `Pattern not found: ${input.key}`;
45
+ await context.patterns.remove(input.key);
46
+ return `Deleted pattern: ${input.key}`;
47
+ },
48
+ };
49
+
50
+ const requiresPatterns = true;
51
+
52
+ module.exports = { definitions, handlers, requiresPatterns };
package/src/cli/config.js CHANGED
@@ -1,12 +1,18 @@
1
1
  const inquirer = require('inquirer');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
- const { loadConfig, saveConfig, CONFIG_FILE, USERS_DIR } = require('../config');
4
+ const { loadConfig, saveConfig, CONFIG_FILE, USERS_DIR, isValidTimezone } = require('../config');
5
5
  const { getNestedValue, setNestedValue, formatValue, updatePassSecret } = require('./config-utils');
6
6
  const { manageUsers } = require('./manage-users');
7
7
  const { runOAuthFlow } = require('./oauth');
8
8
 
9
9
  const SECTIONS = [
10
+ {
11
+ name: 'General',
12
+ fields: [
13
+ { key: 'timezone', label: 'Timezone (IANA)', secret: false, validate: (v) => isValidTimezone(v.trim()) ? true : 'Invalid IANA timezone (e.g. Europe/Brussels, America/New_York)' },
14
+ ],
15
+ },
10
16
  {
11
17
  name: 'Anthropic',
12
18
  fields: [
@@ -210,6 +216,7 @@ async function config() {
210
216
  } else {
211
217
  opts.default = currentVal != null ? String(currentVal) : '';
212
218
  }
219
+ if (field.validate) opts.validate = field.validate;
213
220
  const { newVal } = await inquirer.prompt([opts]);
214
221
 
215
222
  if (isPassRef) {
package/src/cli/init.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const inquirer = require('inquirer');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
- const { getConfigDir, saveConfig, loadConfig, CONFIG_FILE, ensureUserDir } = require('../config');
4
+ const { getConfigDir, saveConfig, loadConfig, CONFIG_FILE, ensureUserDir, isValidTimezone } = require('../config');
5
5
  const { validateAnthropic, validateTelegram, validateSupabase } = require('../auth/validators');
6
6
  const { setupAnthropicOAuth } = require('./oauth');
7
7
  const { setupSupabaseNew, setupSupabaseExisting } = require('./supabase-setup');
@@ -64,7 +64,7 @@ async function init(opts = {}) {
64
64
 
65
65
  const config = {};
66
66
  let step = 0;
67
- const totalSteps = 5;
67
+ const totalSteps = 6;
68
68
  const stepLabel = (name) => `─── Step ${++step}/${totalSteps}: ${name} ───`;
69
69
 
70
70
  // Step 1: Anthropic
@@ -178,7 +178,20 @@ async function init(opts = {}) {
178
178
  config.owner = { name: ownerName };
179
179
  config.bot = { name: botName };
180
180
 
181
- // Step 5: Access control
181
+ // Step 5: Timezone
182
+ console.log('\n' + stepLabel('Timezone') + '\n');
183
+ const detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
184
+ console.log(` Your system timezone: ${detectedTz}\n`);
185
+ const { timezone } = await inquirer.prompt([{
186
+ type: 'input',
187
+ name: 'timezone',
188
+ message: 'Timezone (IANA):',
189
+ default: detectedTz,
190
+ validate: (v) => isValidTimezone(v.trim()) ? true : 'Invalid IANA timezone (e.g. Europe/Brussels, America/New_York)',
191
+ }]);
192
+ config.timezone = timezone.trim();
193
+
194
+ // Step 6: Access control
182
195
  console.log('\n' + stepLabel('Access control') + '\n');
183
196
  console.log(' Each allowed user gets their own isolated brain — separate');
184
197
  console.log(' personality, memory, evolution cycle, and workspace.');
@@ -190,14 +203,23 @@ async function init(opts = {}) {
190
203
  console.log(' Since you have multiple users, name each one:\n');
191
204
  config.users = {};
192
205
  for (const userId of config.telegram.allowedUsers) {
193
- const { userName } = await inquirer.prompt([{
194
- type: 'input',
195
- name: 'userName',
196
- message: `Name for user ${userId}:`,
197
- default: config.owner.name,
198
- validate: (v) => v.length > 0,
199
- }]);
200
- config.users[String(userId)] = { name: userName };
206
+ const { userName, userTz } = await inquirer.prompt([
207
+ {
208
+ type: 'input',
209
+ name: 'userName',
210
+ message: `Name for user ${userId}:`,
211
+ default: config.owner.name,
212
+ validate: (v) => v.length > 0,
213
+ },
214
+ {
215
+ type: 'input',
216
+ name: 'userTz',
217
+ message: `Timezone for user ${userId}:`,
218
+ default: config.timezone,
219
+ validate: (v) => isValidTimezone(v.trim()) ? true : 'Invalid IANA timezone',
220
+ },
221
+ ]);
222
+ config.users[String(userId)] = { name: userName, timezone: userTz.trim() };
201
223
  }
202
224
 
203
225
  const { bridgeEnabled } = await inquirer.prompt([{
@@ -1,6 +1,6 @@
1
1
  const inquirer = require('inquirer');
2
2
  const fs = require('fs');
3
- const { ensureUserDir, getUserDir } = require('../config');
3
+ const { ensureUserDir, getUserDir, isValidTimezone } = require('../config');
4
4
  const { detectTelegramUserId } = require('./init-utils');
5
5
  const { setNestedValue } = require('./config-utils');
6
6
 
@@ -28,7 +28,10 @@ async function manageUsers(cfg, saveConfig) {
28
28
  } else {
29
29
  for (const id of currentUsers) {
30
30
  const hasDir = fs.existsSync(getUserDir(id));
31
- console.log(` ${id}${hasDir ? ' ✅' : ' (no workspace yet)'}`);
31
+ const name = cfg.users?.[String(id)]?.name;
32
+ const tz = cfg.users?.[String(id)]?.timezone;
33
+ const label = [name, tz].filter(Boolean).join(' — ');
34
+ console.log(` ${id}${label ? ` (${label})` : ''}${hasDir ? ' ✅' : ' (no workspace yet)'}`);
32
35
  }
33
36
  }
34
37
  console.log('');
@@ -41,6 +44,7 @@ async function manageUsers(cfg, saveConfig) {
41
44
  { name: 'Add user (detect from bot messages)', value: 'detect' },
42
45
  { name: 'Add user (enter ID manually)', value: 'manual' },
43
46
  ...(currentUsers.length > 0 ? [{ name: 'Rename user', value: 'rename' }] : []),
47
+ ...(currentUsers.length > 0 ? [{ name: 'Set user timezone', value: 'timezone' }] : []),
44
48
  ...(currentUsers.length > 0 ? [{ name: 'Remove user', value: 'remove' }] : []),
45
49
  new inquirer.Separator(),
46
50
  { name: 'Back', value: 'back' },
@@ -140,6 +144,36 @@ async function manageUsers(cfg, saveConfig) {
140
144
  }
141
145
  }
142
146
 
147
+ if (action === 'timezone') {
148
+ const { tzId } = await inquirer.prompt([{
149
+ type: 'list',
150
+ name: 'tzId',
151
+ message: 'Set timezone for which user?',
152
+ choices: [
153
+ ...currentUsers.map(id => {
154
+ const name = cfg.users?.[String(id)]?.name;
155
+ const tz = cfg.users?.[String(id)]?.timezone || cfg.timezone || 'UTC';
156
+ return { name: `${id}${name ? ` — ${name}` : ''} (${tz})`, value: id };
157
+ }),
158
+ new inquirer.Separator(),
159
+ { name: 'Cancel', value: null },
160
+ ],
161
+ }]);
162
+ if (tzId !== null) {
163
+ const currentTz = cfg.users?.[String(tzId)]?.timezone || cfg.timezone || '';
164
+ const { newTz } = await inquirer.prompt([{
165
+ type: 'input',
166
+ name: 'newTz',
167
+ message: `Timezone for user ${tzId} (IANA):`,
168
+ default: currentTz,
169
+ validate: (v) => isValidTimezone(v.trim()) ? true : 'Invalid IANA timezone (e.g. Europe/Brussels, America/New_York)',
170
+ }]);
171
+ if (!cfg.users) cfg.users = {};
172
+ if (!cfg.users[String(tzId)]) cfg.users[String(tzId)] = {};
173
+ cfg.users[String(tzId)].timezone = newTz.trim();
174
+ }
175
+ }
176
+
143
177
  if (action === 'remove') {
144
178
  const { removeId } = await inquirer.prompt([{
145
179
  type: 'list',
package/src/config.js CHANGED
@@ -164,6 +164,18 @@ function ensureUserDir(userId) {
164
164
  return dir;
165
165
  }
166
166
 
167
+ /** @param {object} config @param {number|string} userId @returns {string} */
168
+ function getUserTimezone(config, userId) {
169
+ return config.users?.[String(userId)]?.timezone || config.timezone || 'UTC';
170
+ }
171
+
172
+ function isValidTimezone(tz) {
173
+ try {
174
+ Intl.DateTimeFormat(undefined, { timeZone: tz });
175
+ return true;
176
+ } catch { return false; }
177
+ }
178
+
167
179
  function listUsers() {
168
180
  if (!fs.existsSync(USERS_DIR)) return [];
169
181
  return fs.readdirSync(USERS_DIR).filter(f =>
@@ -184,4 +196,6 @@ module.exports = {
184
196
  getUserDir,
185
197
  ensureUserDir,
186
198
  listUsers,
199
+ getUserTimezone,
200
+ isValidTimezone,
187
201
  };
@@ -1,6 +1,7 @@
1
1
  const Anthropic = require('@anthropic-ai/sdk');
2
2
  const { createSelfMemory } = require('../memory/self');
3
3
  const { getTenant } = require('../tenant');
4
+ const { getUserTimezone } = require('../config');
4
5
 
5
6
  const IMPULSE_MODEL = 'claude-haiku-4-5-20251001';
6
7
  const COOLDOWN_MS = 24 * 60 * 60 * 1000;
@@ -109,7 +110,7 @@ async function maybeImpulse(bot, config, tenant, chatId, lastUserMsg, lastAssist
109
110
  const { text: context, hasSubstance } = await gatherContext(tenant, config.supabase, {
110
111
  lastUserMsg,
111
112
  lastAssistantMsg,
112
- timezone: config.timezone,
113
+ timezone: getUserTimezone(config, tenant.userId),
113
114
  });
114
115
  if (!hasSubstance) return;
115
116
 
@@ -130,7 +131,7 @@ async function maybePeriodicImpulse(bot, config, userId) {
130
131
 
131
132
  const { text: context, hasSubstance } = await gatherContext(tenant, config.supabase, {
132
133
  periodic: true,
133
- timezone: config.timezone,
134
+ timezone: getUserTimezone(config, userId),
134
135
  });
135
136
  if (!hasSubstance) return;
136
137
 
@@ -2,7 +2,7 @@ const cron = require('node-cron');
2
2
  const { createScheduler } = require('./scheduler');
3
3
  const { getTenant } = require('../tenant');
4
4
  const { shouldEvolveNow, evolve } = require('../evolve');
5
- const { ensureUserDir } = require('../config');
5
+ const { ensureUserDir, getUserTimezone } = require('../config');
6
6
  const { runAnalysis } = require('../analysis');
7
7
  const { runCuriosity } = require('../curiosity');
8
8
  const { runCuriosityDispatch } = require('../curiosity/dispatch');
@@ -34,7 +34,7 @@ function getLocalHour(timezone) {
34
34
  async function runEvolutionForUser(bot, config, userId) {
35
35
  if (_evolutionRunning.has(userId)) return;
36
36
 
37
- const timezone = config.timezone || 'UTC';
37
+ const timezone = getUserTimezone(config, userId);
38
38
  const userDir = ensureUserDir(userId);
39
39
 
40
40
  if (!shouldEvolveNow(userDir, timezone)) return;
@@ -127,7 +127,7 @@ async function runCuriosityOnce(config, allowedUsers) {
127
127
  ? await tenant.scheduler.list({ status: 'pending', limit: 5 }).catch(() => [])
128
128
  : [];
129
129
  const userProfile = tenant.personality?.user || null;
130
- return { userId, chatId: userId, timezone: config.timezone || 'UTC', patterns, events, scheduler: tenant.scheduler, userProfile };
130
+ return { userId, chatId: userId, timezone: getUserTimezone(config, userId), patterns, events, scheduler: tenant.scheduler, userProfile };
131
131
  } catch { return null; }
132
132
  }));
133
133
  await runCuriosityDispatch(client, selfMemory, userDispatchData.filter(Boolean));
@@ -140,7 +140,7 @@ async function runCuriosityOnce(config, allowedUsers) {
140
140
  }
141
141
 
142
142
  async function runAnalysisForUser(bot, config, userId) {
143
- const timezone = config.timezone || 'UTC';
143
+ const timezone = getUserTimezone(config, userId);
144
144
  try {
145
145
  const tenant = await getTenant(userId, config);
146
146
  if (!tenant.messageLog || !tenant.scheduler || !tenant.patterns) return;
@@ -203,34 +203,32 @@ function setupHeartbeat(bot, config) {
203
203
  const allowedUsers = config?.telegram?.allowedUsers || [];
204
204
  if (allowedUsers.length > 0) {
205
205
  cron.schedule('* * * * *', async () => {
206
- const timezone = config.timezone || 'UTC';
207
- const { hour, minute } = getLocalHour(timezone);
208
- if (hour !== 3 || minute !== 0) return;
209
-
210
206
  for (const userId of allowedUsers) {
207
+ const tz = getUserTimezone(config, userId);
208
+ const { hour, minute } = getLocalHour(tz);
209
+ if (hour !== 3 || minute !== 0) continue;
211
210
  runEvolutionForUser(bot, config, userId).catch(e =>
212
211
  console.error(`[evolution] Unhandled error for user ${userId}:`, e.message)
213
212
  );
214
213
  }
215
214
  });
216
- console.log(` ✅ Evolution cron running (daily 3am ${config.timezone || 'UTC'})`);
215
+ console.log(' ✅ Evolution cron running (daily 3am per-user timezone)');
217
216
 
218
217
  cron.schedule('* * * * *', async () => {
219
- const timezone = config.timezone || 'UTC';
220
- const { hour, minute } = getLocalHour(timezone);
221
- if (!ANALYSIS_HOURS.has(hour) || minute !== 0) return;
222
-
223
218
  for (const userId of allowedUsers) {
219
+ const tz = getUserTimezone(config, userId);
220
+ const { hour, minute } = getLocalHour(tz);
221
+ if (!ANALYSIS_HOURS.has(hour) || minute !== 0) continue;
224
222
  runAnalysisForUser(bot, config, userId).catch(e =>
225
223
  console.error(`[analysis] Unhandled error for user ${userId}:`, e.message)
226
224
  );
227
225
  }
228
226
  });
229
- console.log(` ✅ Analysis cron running (every 3h ${config.timezone || 'UTC'})`);
227
+ console.log(' ✅ Analysis cron running (every 3h per-user timezone)');
230
228
 
231
229
  cron.schedule('* * * * *', async () => {
232
- const timezone = config.timezone || 'UTC';
233
- const { hour, minute } = getLocalHour(timezone);
230
+ const tz = config.timezone || 'UTC';
231
+ const { hour, minute } = getLocalHour(tz);
234
232
  if (!CURIOSITY_HOURS.has(hour) || minute !== 0) return;
235
233
 
236
234
  runCuriosityOnce(config, allowedUsers).catch(e =>
@@ -240,17 +238,16 @@ function setupHeartbeat(bot, config) {
240
238
  console.log(` ✅ Curiosity cron running (every 6h ${config.timezone || 'UTC'})`);
241
239
 
242
240
  cron.schedule('* * * * *', async () => {
243
- const timezone = config.timezone || 'UTC';
244
- const { hour, minute } = getLocalHour(timezone);
245
- if (!IMPULSE_HOURS.has(hour) || minute !== 0) return;
246
-
247
241
  for (const userId of allowedUsers) {
242
+ const tz = getUserTimezone(config, userId);
243
+ const { hour, minute } = getLocalHour(tz);
244
+ if (!IMPULSE_HOURS.has(hour) || minute !== 0) continue;
248
245
  maybePeriodicImpulse(bot, config, userId).catch(e =>
249
246
  console.error(`[impulse] Periodic error for user ${userId}:`, e.message)
250
247
  );
251
248
  }
252
249
  });
253
- console.log(` ✅ Impulse cron running (every 6h ${config.timezone || 'UTC'})`);
250
+ console.log(' ✅ Impulse cron running (every 6h per-user timezone)');
254
251
  }
255
252
 
256
253
  console.log(' ✅ Heartbeat running (every 1min)');
@@ -301,7 +298,7 @@ async function buildProactiveContext(tenant, timezone, query) {
301
298
 
302
299
  async function runAgenticEvent(bot, config, event) {
303
300
  const tenant = await getTenant(event.user_id, config);
304
- const timezone = event.timezone || config.timezone || 'UTC';
301
+ const timezone = event.timezone || getUserTimezone(config, event.user_id);
305
302
 
306
303
  const query = event.description || event.instructions;
307
304
  const context = await buildProactiveContext(tenant, timezone, query).catch(() => '');
@@ -4,11 +4,14 @@ const { OPTIONAL_TOOLS } = require('../../claude');
4
4
  const { clearVoiceFlow, sendVoiceLanguagePicker } = require('../voice');
5
5
  const { TERM_SEP } = require('../constants');
6
6
 
7
+ function isEnabled(pref, feature) {
8
+ return pref ? pref.enabled : (feature.defaultEnabled || false);
9
+ }
10
+
7
11
  function buildToolsMessage(toolPrefs) {
8
12
  const lines = [`◈ TOOLS`, TERM_SEP, ``];
9
13
  for (const [key, feature] of Object.entries(OPTIONAL_TOOLS)) {
10
- const pref = toolPrefs.get(key);
11
- const enabled = pref?.enabled || false;
14
+ const enabled = isEnabled(toolPrefs.get(key), feature);
12
15
  lines.push(` ${enabled ? '◉' : '○'} ${feature.label}`);
13
16
  }
14
17
  lines.push(``, TERM_SEP);
@@ -20,8 +23,7 @@ function buildToolsKeyboard(toolPrefs) {
20
23
  const entries = Object.entries(OPTIONAL_TOOLS);
21
24
  for (let i = 0; i < entries.length; i++) {
22
25
  const [key, feature] = entries[i];
23
- const pref = toolPrefs.get(key);
24
- const enabled = pref?.enabled || false;
26
+ const enabled = isEnabled(toolPrefs.get(key), feature);
25
27
  keyboard.text(`${enabled ? '◉' : '○'} ${feature.label}`, `tool:${key}`);
26
28
  if ((i + 1) % 2 === 0 && i < entries.length - 1) keyboard.row();
27
29
  }
@@ -49,10 +51,10 @@ async function handleToolCallback(ctx, featureKey, answer, { getTenant: gt, conf
49
51
  const tenant = await gt(ctx.from.id, cfg);
50
52
  if (!tenant.toolPrefsApi) return answer({ text: 'Not available' });
51
53
 
52
- const newEnabled = await tenant.toolPrefsApi.toggle(featureKey);
54
+ const feature = OPTIONAL_TOOLS[featureKey];
55
+ const newEnabled = await tenant.toolPrefsApi.toggle(featureKey, feature.defaultEnabled);
53
56
  await tenant.reloadToolPrefs();
54
57
 
55
- const feature = OPTIONAL_TOOLS[featureKey];
56
58
  await answer({ text: `${feature.label}: ${newEnabled ? 'ON' : 'OFF'}` });
57
59
 
58
60
  const text = buildToolsMessage(tenant.toolPrefs);
@@ -124,7 +124,9 @@ async function processMediaItems(ctx, items, { config, allowedUsers, bot, create
124
124
  await sendHtml(ctx, response).catch(() => {});
125
125
  }
126
126
 
127
- if (usage && model) {
127
+ const statsPref = tenant.toolPrefs?.get('model_stats');
128
+ const showStats = statsPref ? statsPref.enabled : true;
129
+ if (showStats && usage && model) {
128
130
  const tag = model.includes('opus') ? 'opus' : model.includes('haiku') ? 'haiku' : 'sonnet';
129
131
  const tokIn = usage.input_tokens >= 1000 ? `${(usage.input_tokens/1000).toFixed(1)}k` : usage.input_tokens;
130
132
  const tokOut = usage.output_tokens >= 1000 ? `${(usage.output_tokens/1000).toFixed(1)}k` : usage.output_tokens;
@@ -120,7 +120,9 @@ async function processSpecial(ctx, prompt, deps) {
120
120
  await sendHtml(ctx, response).catch(() => {});
121
121
  }
122
122
 
123
- if (usage && model) {
123
+ const statsPref = tenant.toolPrefs?.get('model_stats');
124
+ const showStats = statsPref ? statsPref.enabled : true;
125
+ if (showStats && usage && model) {
124
126
  const tag = model.includes('opus') ? 'opus' : model.includes('haiku') ? 'haiku' : 'sonnet';
125
127
  const tokIn = usage.input_tokens >= 1000 ? `${(usage.input_tokens / 1000).toFixed(1)}k` : usage.input_tokens;
126
128
  const tokOut = usage.output_tokens >= 1000 ? `${(usage.output_tokens / 1000).toFixed(1)}k` : usage.output_tokens;
@@ -230,7 +230,9 @@ async function processTextMessage(ctx, fullMessage, { config, allowedUsers, bot,
230
230
  sendTtsVoiceSummary(ctx, tenant, response).catch(e => console.error('[tts] Auto-summary failed:', e.message));
231
231
  }
232
232
 
233
- if (usage && model) {
233
+ const statsPref = tenant.toolPrefs?.get('model_stats');
234
+ const showStats = statsPref ? statsPref.enabled : true;
235
+ if (showStats && usage && model) {
234
236
  const tag = model.includes('opus') ? 'opus' : model.includes('haiku') ? 'haiku' : 'sonnet';
235
237
  const tokIn = usage.input_tokens >= 1000 ? `${(usage.input_tokens/1000).toFixed(1)}k` : usage.input_tokens;
236
238
  const tokOut = usage.output_tokens >= 1000 ? `${(usage.output_tokens/1000).toFixed(1)}k` : usage.output_tokens;
package/src/tenant.js CHANGED
@@ -38,7 +38,7 @@ async function createTenant(userId, config) {
38
38
  const selfMemory = config.supabase ? await createSelfMemory(config.supabase, userId) : null;
39
39
  const patterns = config.supabase ? await createPatterns(config.supabase, userId) : null;
40
40
  const bridgeEnabled = isBridgeEnabled(config) && (config.telegram?.allowedUsers?.length || 0) >= 2;
41
- const claude = createClaude(config.anthropic, { personality, memory, selfMemory, userDir, bridgeEnabled, botName: config.bot?.name });
41
+ const claude = createClaude(config.anthropic, { personality, memory, selfMemory, patterns, userDir, bridgeEnabled, botName: config.bot?.name });
42
42
  const scheduler = config.supabase ? createScheduler(config.supabase, userId) : null;
43
43
  const messageLog = config.supabase ? createMessageLog(config.supabase, memory, config.anthropic, userId, userDir) : null;
44
44
  const toolPrefsApi = config.supabase ? createToolPrefs(config.supabase, userId) : null;
package/src/toolprefs.js CHANGED
@@ -39,10 +39,11 @@ function createToolPrefs(supabaseConfig, userId) {
39
39
  return data[0];
40
40
  }
41
41
 
42
- async function toggle(toolName) {
42
+ async function toggle(toolName, defaultEnabled = false) {
43
43
  const all = await getAll();
44
44
  const current = all.get(toolName);
45
- const newEnabled = !(current?.enabled);
45
+ const currentEnabled = current ? current.enabled : defaultEnabled;
46
+ const newEnabled = !currentEnabled;
46
47
  await set(toolName, newEnabled, current?.config || {});
47
48
  return newEnabled;
48
49
  }