kernelbot 1.0.37 → 1.0.38

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/src/tools/x.js ADDED
@@ -0,0 +1,256 @@
1
+ import { XApi } from '../services/x-api.js';
2
+ import { getLogger } from '../utils/logger.js';
3
+
4
+ /**
5
+ * Get a configured X API client from the tool context.
6
+ */
7
+ function getClient(context) {
8
+ const cfg = context.config.x;
9
+ if (!cfg?.consumer_key || !cfg?.consumer_secret || !cfg?.access_token || !cfg?.access_token_secret) {
10
+ throw new Error('X (Twitter) not connected. Use /x link to connect your account.');
11
+ }
12
+ return new XApi({
13
+ consumerKey: cfg.consumer_key,
14
+ consumerSecret: cfg.consumer_secret,
15
+ accessToken: cfg.access_token,
16
+ accessTokenSecret: cfg.access_token_secret,
17
+ });
18
+ }
19
+
20
+ function handle403(err) {
21
+ if (err.response?.status === 403) {
22
+ return { error: 'Access denied (403). Your X Access Token may be Read-only. Go to the X Developer Portal → App Settings → change permissions to "Read and Write", then regenerate your Access Token.' };
23
+ }
24
+ return null;
25
+ }
26
+
27
+ export const definitions = [
28
+ {
29
+ name: 'x_post_tweet',
30
+ description: 'Post a new tweet on X (Twitter).',
31
+ input_schema: {
32
+ type: 'object',
33
+ properties: {
34
+ text: {
35
+ type: 'string',
36
+ description: 'The tweet text (max 280 characters)',
37
+ },
38
+ },
39
+ required: ['text'],
40
+ },
41
+ },
42
+ {
43
+ name: 'x_reply_to_tweet',
44
+ description: 'Reply to an existing tweet on X (Twitter).',
45
+ input_schema: {
46
+ type: 'object',
47
+ properties: {
48
+ text: {
49
+ type: 'string',
50
+ description: 'The reply text',
51
+ },
52
+ reply_to_id: {
53
+ type: 'string',
54
+ description: 'The tweet ID to reply to',
55
+ },
56
+ },
57
+ required: ['text', 'reply_to_id'],
58
+ },
59
+ },
60
+ {
61
+ name: 'x_get_my_tweets',
62
+ description: 'Get the authenticated user\'s recent tweets on X (Twitter).',
63
+ input_schema: {
64
+ type: 'object',
65
+ properties: {
66
+ count: {
67
+ type: 'number',
68
+ description: 'Number of tweets to fetch (default 10, max 100)',
69
+ },
70
+ },
71
+ },
72
+ },
73
+ {
74
+ name: 'x_get_tweet',
75
+ description: 'Get a specific tweet by its ID.',
76
+ input_schema: {
77
+ type: 'object',
78
+ properties: {
79
+ tweet_id: {
80
+ type: 'string',
81
+ description: 'The tweet ID',
82
+ },
83
+ },
84
+ required: ['tweet_id'],
85
+ },
86
+ },
87
+ {
88
+ name: 'x_search_tweets',
89
+ description: 'Search recent tweets on X (Twitter). Returns tweets from the last 7 days.',
90
+ input_schema: {
91
+ type: 'object',
92
+ properties: {
93
+ query: {
94
+ type: 'string',
95
+ description: 'Search query (supports X search operators)',
96
+ },
97
+ count: {
98
+ type: 'number',
99
+ description: 'Number of results (default 10, max 100)',
100
+ },
101
+ },
102
+ required: ['query'],
103
+ },
104
+ },
105
+ {
106
+ name: 'x_like_tweet',
107
+ description: 'Like a tweet on X (Twitter).',
108
+ input_schema: {
109
+ type: 'object',
110
+ properties: {
111
+ tweet_id: {
112
+ type: 'string',
113
+ description: 'The tweet ID to like',
114
+ },
115
+ },
116
+ required: ['tweet_id'],
117
+ },
118
+ },
119
+ {
120
+ name: 'x_retweet',
121
+ description: 'Retweet a tweet on X (Twitter).',
122
+ input_schema: {
123
+ type: 'object',
124
+ properties: {
125
+ tweet_id: {
126
+ type: 'string',
127
+ description: 'The tweet ID to retweet',
128
+ },
129
+ },
130
+ required: ['tweet_id'],
131
+ },
132
+ },
133
+ {
134
+ name: 'x_delete_tweet',
135
+ description: 'Delete one of your own tweets on X (Twitter).',
136
+ input_schema: {
137
+ type: 'object',
138
+ properties: {
139
+ tweet_id: {
140
+ type: 'string',
141
+ description: 'The tweet ID to delete',
142
+ },
143
+ },
144
+ required: ['tweet_id'],
145
+ },
146
+ },
147
+ {
148
+ name: 'x_get_profile',
149
+ description: 'Get the authenticated X (Twitter) profile info.',
150
+ input_schema: {
151
+ type: 'object',
152
+ properties: {},
153
+ },
154
+ },
155
+ ];
156
+
157
+ export const handlers = {
158
+ x_post_tweet: async (params, context) => {
159
+ try {
160
+ const client = getClient(context);
161
+ const tweet = await client.postTweet(params.text);
162
+ return { success: true, message: 'Tweet posted', tweet };
163
+ } catch (err) {
164
+ getLogger().error(`x_post_tweet failed: ${err.message}`);
165
+ return handle403(err) || { error: err.response?.data?.detail || err.message };
166
+ }
167
+ },
168
+
169
+ x_reply_to_tweet: async (params, context) => {
170
+ try {
171
+ const client = getClient(context);
172
+ const tweet = await client.replyToTweet(params.text, params.reply_to_id);
173
+ return { success: true, message: 'Reply posted', tweet };
174
+ } catch (err) {
175
+ getLogger().error(`x_reply_to_tweet failed: ${err.message}`);
176
+ return handle403(err) || { error: err.response?.data?.detail || err.message };
177
+ }
178
+ },
179
+
180
+ x_get_my_tweets: async (params, context) => {
181
+ try {
182
+ const client = getClient(context);
183
+ const tweets = await client.getMyTweets(params.count || 10);
184
+ return { tweets, count: tweets.length };
185
+ } catch (err) {
186
+ getLogger().error(`x_get_my_tweets failed: ${err.message}`);
187
+ return { error: err.response?.data?.detail || err.message };
188
+ }
189
+ },
190
+
191
+ x_get_tweet: async (params, context) => {
192
+ try {
193
+ const client = getClient(context);
194
+ const tweet = await client.getTweet(params.tweet_id);
195
+ return { tweet };
196
+ } catch (err) {
197
+ getLogger().error(`x_get_tweet failed: ${err.message}`);
198
+ return { error: err.response?.data?.detail || err.message };
199
+ }
200
+ },
201
+
202
+ x_search_tweets: async (params, context) => {
203
+ try {
204
+ const client = getClient(context);
205
+ const tweets = await client.searchRecentTweets(params.query, params.count || 10);
206
+ return { tweets, count: tweets.length, query: params.query };
207
+ } catch (err) {
208
+ getLogger().error(`x_search_tweets failed: ${err.message}`);
209
+ return { error: err.response?.data?.detail || err.message };
210
+ }
211
+ },
212
+
213
+ x_like_tweet: async (params, context) => {
214
+ try {
215
+ const client = getClient(context);
216
+ const result = await client.likeTweet(params.tweet_id);
217
+ return { success: true, message: 'Tweet liked', result };
218
+ } catch (err) {
219
+ getLogger().error(`x_like_tweet failed: ${err.message}`);
220
+ return handle403(err) || { error: err.response?.data?.detail || err.message };
221
+ }
222
+ },
223
+
224
+ x_retweet: async (params, context) => {
225
+ try {
226
+ const client = getClient(context);
227
+ const result = await client.retweet(params.tweet_id);
228
+ return { success: true, message: 'Retweeted', result };
229
+ } catch (err) {
230
+ getLogger().error(`x_retweet failed: ${err.message}`);
231
+ return handle403(err) || { error: err.response?.data?.detail || err.message };
232
+ }
233
+ },
234
+
235
+ x_delete_tweet: async (params, context) => {
236
+ try {
237
+ const client = getClient(context);
238
+ const result = await client.deleteTweet(params.tweet_id);
239
+ return { success: true, message: 'Tweet deleted', result };
240
+ } catch (err) {
241
+ getLogger().error(`x_delete_tweet failed: ${err.message}`);
242
+ return handle403(err) || { error: err.response?.data?.detail || err.message };
243
+ }
244
+ },
245
+
246
+ x_get_profile: async (params, context) => {
247
+ try {
248
+ const client = getClient(context);
249
+ const profile = await client.getMe();
250
+ return { profile };
251
+ } catch (err) {
252
+ getLogger().error(`x_get_profile failed: ${err.message}`);
253
+ return { error: err.response?.data?.detail || err.message };
254
+ }
255
+ },
256
+ };
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
2
- import { join, dirname } from 'path';
2
+ import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { createInterface } from 'readline';
5
5
  import yaml from 'js-yaml';
@@ -61,6 +61,12 @@ const DEFAULTS = {
61
61
  max_history: 50,
62
62
  recent_window: 10,
63
63
  },
64
+ dashboard: {
65
+ enabled: false,
66
+ port: 3000,
67
+ },
68
+ linkedin: {},
69
+ x: {},
64
70
  };
65
71
 
66
72
  function deepMerge(target, source) {
@@ -167,9 +173,12 @@ export async function promptProviderSelection(rl) {
167
173
  }
168
174
 
169
175
  /**
170
- * Save provider and model to config.yaml.
176
+ * Read config.yaml, merge changes into a top-level section, and write it back.
177
+ * @param {string} section - The top-level YAML key to update (e.g. 'brain', 'orchestrator').
178
+ * @param {object} changes - Key-value pairs to merge into that section.
179
+ * @returns {string} The path to the written config file.
171
180
  */
172
- export function saveProviderToYaml(providerKey, modelId) {
181
+ function _patchConfigYaml(section, changes) {
173
182
  const configDir = getConfigDir();
174
183
  mkdirSync(configDir, { recursive: true });
175
184
  const configPath = join(configDir, 'config.yaml');
@@ -179,16 +188,24 @@ export function saveProviderToYaml(providerKey, modelId) {
179
188
  existing = yaml.load(readFileSync(configPath, 'utf-8')) || {};
180
189
  }
181
190
 
182
- existing.brain = {
183
- ...(existing.brain || {}),
184
- provider: providerKey,
185
- model: modelId,
186
- };
191
+ existing[section] = { ...(existing[section] || {}), ...changes };
192
+ writeFileSync(configPath, yaml.dump(existing, { lineWidth: -1 }));
193
+ return configPath;
194
+ }
195
+
196
+ /**
197
+ * Save provider and model to config.yaml.
198
+ */
199
+ export function saveProviderToYaml(providerKey, modelId) {
200
+ const configPath = _patchConfigYaml('brain', { provider: providerKey, model: modelId });
187
201
 
188
202
  // Remove legacy anthropic section if migrating
189
- delete existing.anthropic;
203
+ let existing = yaml.load(readFileSync(configPath, 'utf-8')) || {};
204
+ if (existing.anthropic) {
205
+ delete existing.anthropic;
206
+ writeFileSync(configPath, yaml.dump(existing, { lineWidth: -1 }));
207
+ }
190
208
 
191
- writeFileSync(configPath, yaml.dump(existing, { lineWidth: -1 }));
192
209
  return configPath;
193
210
  }
194
211
 
@@ -196,66 +213,28 @@ export function saveProviderToYaml(providerKey, modelId) {
196
213
  * Save orchestrator provider and model to config.yaml.
197
214
  */
198
215
  export function saveOrchestratorToYaml(providerKey, modelId) {
199
- const configDir = getConfigDir();
200
- mkdirSync(configDir, { recursive: true });
201
- const configPath = join(configDir, 'config.yaml');
202
-
203
- let existing = {};
204
- if (existsSync(configPath)) {
205
- existing = yaml.load(readFileSync(configPath, 'utf-8')) || {};
206
- }
207
-
208
- existing.orchestrator = {
209
- ...(existing.orchestrator || {}),
210
- provider: providerKey,
211
- model: modelId,
212
- };
216
+ return _patchConfigYaml('orchestrator', { provider: providerKey, model: modelId });
217
+ }
213
218
 
214
- writeFileSync(configPath, yaml.dump(existing, { lineWidth: -1 }));
215
- return configPath;
219
+ /**
220
+ * Save dashboard config to config.yaml.
221
+ */
222
+ export function saveDashboardToYaml(changes) {
223
+ return _patchConfigYaml('dashboard', changes);
216
224
  }
217
225
 
218
226
  /**
219
227
  * Save Claude Code model to config.yaml.
220
228
  */
221
229
  export function saveClaudeCodeModelToYaml(modelId) {
222
- const configDir = getConfigDir();
223
- mkdirSync(configDir, { recursive: true });
224
- const configPath = join(configDir, 'config.yaml');
225
-
226
- let existing = {};
227
- if (existsSync(configPath)) {
228
- existing = yaml.load(readFileSync(configPath, 'utf-8')) || {};
229
- }
230
-
231
- existing.claude_code = {
232
- ...(existing.claude_code || {}),
233
- model: modelId,
234
- };
235
-
236
- writeFileSync(configPath, yaml.dump(existing, { lineWidth: -1 }));
237
- return configPath;
230
+ return _patchConfigYaml('claude_code', { model: modelId });
238
231
  }
239
232
 
240
233
  /**
241
234
  * Save Claude Code auth mode + credential to config.yaml and .env.
242
235
  */
243
236
  export function saveClaudeCodeAuth(config, mode, value) {
244
- const configDir = getConfigDir();
245
- mkdirSync(configDir, { recursive: true });
246
- const configPath = join(configDir, 'config.yaml');
247
-
248
- let existing = {};
249
- if (existsSync(configPath)) {
250
- existing = yaml.load(readFileSync(configPath, 'utf-8')) || {};
251
- }
252
-
253
- existing.claude_code = {
254
- ...(existing.claude_code || {}),
255
- auth_mode: mode,
256
- };
257
-
258
- writeFileSync(configPath, yaml.dump(existing, { lineWidth: -1 }));
237
+ _patchConfigYaml('claude_code', { auth_mode: mode });
259
238
 
260
239
  // Update live config
261
240
  config.claude_code.auth_mode = mode;
@@ -556,6 +535,34 @@ export function loadConfig() {
556
535
  config.claude_code.oauth_token = process.env.CLAUDE_CODE_OAUTH_TOKEN;
557
536
  }
558
537
 
538
+ // LinkedIn token-based auth from env
539
+ if (process.env.LINKEDIN_ACCESS_TOKEN) {
540
+ if (!config.linkedin) config.linkedin = {};
541
+ config.linkedin.access_token = process.env.LINKEDIN_ACCESS_TOKEN;
542
+ }
543
+ if (process.env.LINKEDIN_PERSON_URN) {
544
+ if (!config.linkedin) config.linkedin = {};
545
+ config.linkedin.person_urn = process.env.LINKEDIN_PERSON_URN;
546
+ }
547
+
548
+ // X (Twitter) OAuth 1.0a credentials from env
549
+ if (process.env.X_CONSUMER_KEY) {
550
+ if (!config.x) config.x = {};
551
+ config.x.consumer_key = process.env.X_CONSUMER_KEY;
552
+ }
553
+ if (process.env.X_CONSUMER_SECRET) {
554
+ if (!config.x) config.x = {};
555
+ config.x.consumer_secret = process.env.X_CONSUMER_SECRET;
556
+ }
557
+ if (process.env.X_ACCESS_TOKEN) {
558
+ if (!config.x) config.x = {};
559
+ config.x.access_token = process.env.X_ACCESS_TOKEN;
560
+ }
561
+ if (process.env.X_ACCESS_TOKEN_SECRET) {
562
+ if (!config.x) config.x = {};
563
+ config.x.access_token_secret = process.env.X_ACCESS_TOKEN_SECRET;
564
+ }
565
+
559
566
  return config;
560
567
  }
561
568
 
@@ -619,6 +626,30 @@ export function saveCredential(config, envKey, value) {
619
626
  if (!config.jira) config.jira = {};
620
627
  config.jira.api_token = value;
621
628
  break;
629
+ case 'LINKEDIN_ACCESS_TOKEN':
630
+ if (!config.linkedin) config.linkedin = {};
631
+ config.linkedin.access_token = value;
632
+ break;
633
+ case 'LINKEDIN_PERSON_URN':
634
+ if (!config.linkedin) config.linkedin = {};
635
+ config.linkedin.person_urn = value;
636
+ break;
637
+ case 'X_CONSUMER_KEY':
638
+ if (!config.x) config.x = {};
639
+ config.x.consumer_key = value;
640
+ break;
641
+ case 'X_CONSUMER_SECRET':
642
+ if (!config.x) config.x = {};
643
+ config.x.consumer_secret = value;
644
+ break;
645
+ case 'X_ACCESS_TOKEN':
646
+ if (!config.x) config.x = {};
647
+ config.x.access_token = value;
648
+ break;
649
+ case 'X_ACCESS_TOKEN_SECRET':
650
+ if (!config.x) config.x = {};
651
+ config.x.access_token_secret = value;
652
+ break;
622
653
  }
623
654
 
624
655
  // Also set in process.env so tools pick it up
@@ -652,5 +683,21 @@ export function getMissingCredential(toolName, config) {
652
683
  }
653
684
  }
654
685
 
686
+ const linkedinTools = ['linkedin_create_post', 'linkedin_get_my_posts', 'linkedin_get_post', 'linkedin_comment_on_post', 'linkedin_get_comments', 'linkedin_like_post', 'linkedin_get_profile', 'linkedin_delete_post'];
687
+
688
+ if (linkedinTools.includes(toolName)) {
689
+ if (!config.linkedin?.access_token && !process.env.LINKEDIN_ACCESS_TOKEN) {
690
+ return { envKey: 'LINKEDIN_ACCESS_TOKEN', label: 'LinkedIn Access Token (from /linkedin link or https://www.linkedin.com/developers/tools/oauth/token-generator)' };
691
+ }
692
+ }
693
+
694
+ const xTools = ['x_post_tweet', 'x_reply_to_tweet', 'x_get_my_tweets', 'x_get_tweet', 'x_search_tweets', 'x_like_tweet', 'x_retweet', 'x_delete_tweet', 'x_get_profile'];
695
+
696
+ if (xTools.includes(toolName)) {
697
+ if (!config.x?.consumer_key && !process.env.X_CONSUMER_KEY) {
698
+ return { envKey: 'X_CONSUMER_KEY', label: 'X (Twitter) Consumer Key (from /x link or X Developer Portal)' };
699
+ }
700
+ }
701
+
655
702
  return null;
656
703
  }
@@ -18,21 +18,31 @@ function getVersion() {
18
18
  }
19
19
 
20
20
  const LOGO = `
21
- ██╗ ██╗███████╗██████╗ ███╗ ██╗███████╗██╗ ██████╗ ██████╗ ████████╗
22
- ██║ ██╔╝██╔════╝██╔══██╗████╗ ██║██╔════╝██║ ██╔══██╗██╔═══██╗╚══██╔══╝
23
- █████╔╝ █████╗ ██████╔╝██╔██╗ ██║█████╗ ██║ ██████╔╝██║ ██║ ██║
24
- ██╔═██╗ ██╔══╝ ██╔══██╗██║╚██╗██║██╔══╝ ██║ ██╔══██╗██║ ██║ ██║
25
- ██║ ██╗███████╗██║ ██║██║ ╚████║███████╗███████╗██████╔╝╚██████╔╝ ██║
26
- ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝╚═════╝ ╚═════╝ ╚═╝
21
+ ▄▄████████▄▄
22
+ ▄██▀▀ ▀▀██▄
23
+ ▄█▀ ▄██████▄ ▀█▄
24
+ █▀ ▄██▀ ▀██▄ ▀█
25
+ █▀ ▄█▀ ▄████▄ ▀█▄ ▀█
26
+ ▐█ ██ ▄█▀ ▀█▄ ██ █▌
27
+ ▐█ █▌ █▀ ██ ▀█ ▐█ █▌
28
+ ▐█ █▌ ▀█▄ ▀▀██▀ ▐█ █▌
29
+ █▄ ██ ▀▀████▀ ██ ▄█
30
+ █▄ ▀██▄ ▄██▀ ▄█
31
+ ▀█▄ ▀██████▀ ▄█▀
32
+ ▀██▄▄ ▄▄██▀
33
+ ▀▀████████▀▀
34
+
35
+ █▄▀ █▀▀ █▀█ █▄ █ █▀▀ █ █▀▄ █▀█ ▀█▀
36
+ █▀▄ █▀▀ █▄▀ █ ██ █▀▀ █ ██▀ █ █ █
37
+ █ █ █▄▄ █ █ █ ▀█ █▄▄ █▄▄ █▄▀ █▄█ █
27
38
  `;
28
39
 
29
- // White to ~70% black gradient
40
+ // Green terminal gradient
30
41
  const monoGradient = gradient([
31
- '#FFFFFF',
32
- '#D0D0D0',
33
- '#A0A0A0',
34
- '#707070',
35
- '#4D4D4D',
42
+ '#00ff41',
43
+ '#00cc33',
44
+ '#009926',
45
+ '#006619',
36
46
  ]);
37
47
 
38
48
  export function showLogo() {
@@ -103,4 +113,55 @@ export function showError(msg) {
103
113
 
104
114
  export function createSpinner(text) {
105
115
  return ora({ text, color: 'cyan' });
116
+ }
117
+
118
+ /**
119
+ * Display a single character card in the CLI.
120
+ * @param {object} character — character profile with name, emoji, tagline, origin, age, asciiArt
121
+ * @param {boolean} isActive — whether this is the currently active character
122
+ */
123
+ export function showCharacterCard(character, isActive = false) {
124
+ const art = character.asciiArt || '';
125
+ const activeTag = isActive ? chalk.green(' (active)') : '';
126
+ const content = [
127
+ `${character.emoji} ${chalk.bold(character.name)}${activeTag}`,
128
+ chalk.dim(`"${character.tagline}"`),
129
+ '',
130
+ ...(art ? art.split('\n').map(line => chalk.cyan(line)) : []),
131
+ '',
132
+ chalk.dim(`Origin: ${character.origin || 'Unknown'}`),
133
+ chalk.dim(`Style: ${character.age || 'Unknown'}`),
134
+ ].join('\n');
135
+
136
+ console.log(
137
+ boxen(content, {
138
+ padding: 1,
139
+ borderStyle: 'round',
140
+ borderColor: isActive ? 'green' : 'cyan',
141
+ }),
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Display the full character gallery for CLI selection.
147
+ * @param {object[]} characters — array of character profiles
148
+ * @param {string|null} activeId — ID of the currently active character
149
+ */
150
+ export function showCharacterGallery(characters, activeId = null) {
151
+ console.log('');
152
+ console.log(
153
+ gradient(['#ff6b6b', '#feca57', '#48dbfb', '#ff9ff3']).multiline(
154
+ ' ═══════════════════════════════\n' +
155
+ ' CHOOSE YOUR CHARACTER\n' +
156
+ ' ═══════════════════════════════',
157
+ ),
158
+ );
159
+ console.log('');
160
+ console.log(chalk.dim(' Each character has their own personality,'));
161
+ console.log(chalk.dim(' memories, and story that evolves with you.'));
162
+ console.log('');
163
+
164
+ for (const c of characters) {
165
+ showCharacterCard(c, c.id === activeId);
166
+ }
106
167
  }
@@ -117,17 +117,30 @@ function determineStatus(hour, dayOfWeek, workingHours) {
117
117
  }
118
118
 
119
119
  /**
120
- * Determine the likely activity period for more nuanced awareness.
120
+ * Activity period definitions with behavioral tone hints.
121
+ * Each period carries a label and a short guidance string that gets
122
+ * injected into the system prompt so the LLM adapts its personality
123
+ * to the owner's real-time situation.
124
+ */
125
+ const ACTIVITY_PERIODS = [
126
+ { start: 0, end: 5, label: 'late_night', tone: 'Keep it calm and gentle — they may be winding down or unable to sleep. Avoid high-energy openers.' },
127
+ { start: 5, end: 7, label: 'early_morning', tone: 'They are just waking up. Be warm but concise — ease them into the day without overwhelming detail.' },
128
+ { start: 7, end: 12, label: 'morning', tone: 'Good energy window. Match an upbeat, productive tone — they are likely starting their day.' },
129
+ { start: 12, end: 14, label: 'midday', tone: 'Lunch break energy — keep things light and conversational unless they initiate something serious.' },
130
+ { start: 14, end: 17, label: 'afternoon', tone: 'Afternoon focus. Be direct and helpful — they may be deep in work.' },
131
+ { start: 17, end: 20, label: 'evening', tone: 'Winding down from the day. Be relaxed and friendly — match a casual, end-of-day vibe.' },
132
+ { start: 20, end: 23, label: 'night', tone: 'Late evening — be warm and unhurried. They are likely relaxing, so keep the mood easy-going.' },
133
+ ];
134
+
135
+ /**
136
+ * Determine the likely activity period and its behavioral tone hint.
137
+ * Returns { label, tone } for richer LLM context.
121
138
  */
122
139
  function determineActivityPeriod(hour) {
123
- if (hour >= 0 && hour < 5) return 'late_night';
124
- if (hour >= 5 && hour < 7) return 'early_morning';
125
- if (hour >= 7 && hour < 12) return 'morning';
126
- if (hour >= 12 && hour < 14) return 'midday';
127
- if (hour >= 14 && hour < 17) return 'afternoon';
128
- if (hour >= 17 && hour < 20) return 'evening';
129
- if (hour >= 20 && hour < 23) return 'night';
130
- return 'late_night';
140
+ const period = ACTIVITY_PERIODS.find(p => hour >= p.start && hour < p.end);
141
+ if (period) return { label: period.label, tone: period.tone };
142
+ // hour 23 falls outside all ranges treat as late_night
143
+ return { label: 'late_night', tone: ACTIVITY_PERIODS[0].tone };
131
144
  }
132
145
 
133
146
  /**
@@ -163,7 +176,7 @@ export function buildTemporalAwareness() {
163
176
  const currentHour = getCurrentHour(now, timezone);
164
177
  const currentDay = getCurrentDayOfWeek(now, timezone);
165
178
  const { status, detail } = determineStatus(currentHour, currentDay, owner.working_hours);
166
- const period = determineActivityPeriod(currentHour);
179
+ const { label: period, tone: periodTone } = determineActivityPeriod(currentHour);
167
180
 
168
181
  const lines = [
169
182
  `## Owner's Real-Time Context`,
@@ -190,6 +203,7 @@ export function buildTemporalAwareness() {
190
203
  lines.push('IMPORTANT: Be aware of the owner\'s current local time and status.');
191
204
  lines.push('Do NOT assume they are at work during off-hours, or sleeping during work hours.');
192
205
  lines.push('Adjust greetings, tone, and context to match their real-time situation.');
206
+ lines.push(`Tone hint: ${periodTone}`);
193
207
 
194
208
  const block = lines.join('\n');
195
209