kernelbot 1.0.34 → 1.0.36

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kernelbot",
3
- "version": "1.0.34",
3
+ "version": "1.0.36",
4
4
  "description": "KernelBot — AI engineering agent with full OS control",
5
5
  "type": "module",
6
6
  "author": "Abdullah Al-Taheri <abdullah@altaheri.me>",
package/src/agent.js CHANGED
@@ -27,7 +27,7 @@ export class OrchestratorAgent {
27
27
  // Orchestrator provider (30s timeout — lean dispatch/summarize calls)
28
28
  const orchProviderKey = config.orchestrator.provider || 'anthropic';
29
29
  const orchProviderDef = PROVIDERS[orchProviderKey];
30
- const orchApiKey = config.orchestrator.api_key || (orchProviderDef && process.env[orchProviderDef.envKey]) || process.env.ANTHROPIC_API_KEY;
30
+ const orchApiKey = config.orchestrator.api_key || (orchProviderDef && process.env[orchProviderDef.envKey]);
31
31
  this.orchestratorProvider = createProvider({
32
32
  brain: {
33
33
  provider: orchProviderKey,
@@ -327,6 +327,24 @@ export class OrchestratorAgent {
327
327
 
328
328
  // Build working messages from compressed history
329
329
  const messages = [...this.conversationManager.getSummarizedHistory(chatId)];
330
+
331
+ // If an image is attached, upgrade the last user message to a multimodal content array
332
+ if (opts.imageAttachment) {
333
+ for (let i = messages.length - 1; i >= 0; i--) {
334
+ if (messages[i].role === 'user' && typeof messages[i].content === 'string') {
335
+ messages[i] = {
336
+ role: 'user',
337
+ content: [
338
+ { type: 'image', source: opts.imageAttachment },
339
+ { type: 'text', text: messages[i].content },
340
+ ],
341
+ };
342
+ break;
343
+ }
344
+ }
345
+ logger.info(`[Orchestrator] Image attached to message for chat ${chatId} (${opts.imageAttachment.media_type})`);
346
+ }
347
+
330
348
  logger.debug(`Orchestrator conversation context: ${messages.length} messages, max_depth=${max_tool_depth}`);
331
349
 
332
350
  const reply = await this._runLoop(chatId, messages, user, 0, max_tool_depth, temporalContext);
@@ -4,6 +4,7 @@ import { homedir } from 'os';
4
4
  import { Automation } from './automation.js';
5
5
  import { scheduleNext, cancel } from './scheduler.js';
6
6
  import { getLogger } from '../utils/logger.js';
7
+ import { isQuietHours, msUntilQuietEnd } from '../utils/timeUtils.js';
7
8
 
8
9
  const DATA_DIR = join(homedir(), '.kernelbot');
9
10
  const DATA_FILE = join(DATA_DIR, 'automations.json');
@@ -92,6 +93,7 @@ export class AutomationManager {
92
93
  name: data.name,
93
94
  description: data.description,
94
95
  schedule: data.schedule,
96
+ respectQuietHours: data.respectQuietHours,
95
97
  });
96
98
 
97
99
  this.automations.set(auto.id, auto);
@@ -131,6 +133,7 @@ export class AutomationManager {
131
133
 
132
134
  if (changes.name !== undefined) auto.name = changes.name;
133
135
  if (changes.description !== undefined) auto.description = changes.description;
136
+ if (changes.respectQuietHours !== undefined) auto.respectQuietHours = changes.respectQuietHours;
134
137
 
135
138
  if (changes.schedule !== undefined) {
136
139
  this._validateSchedule(changes.schedule);
@@ -224,6 +227,19 @@ export class AutomationManager {
224
227
  return;
225
228
  }
226
229
 
230
+ // Quiet-hours deferral: postpone non-essential automations until the window ends
231
+ if (current.respectQuietHours && isQuietHours(this._config?.life)) {
232
+ const deferMs = msUntilQuietEnd(this._config?.life) + 60_000; // +1 min buffer
233
+ logger.info(`[AutomationManager] Quiet hours — deferring "${current.name}" (${current.id}) for ${Math.round(deferMs / 60_000)}m`);
234
+
235
+ // Cancel any existing timer and re-arm to fire after quiet hours
236
+ this._disarm(current);
237
+ const timerId = setTimeout(() => this._onTimerFire(current), deferMs);
238
+ current.nextRun = Date.now() + deferMs;
239
+ this.timers.set(current.id, timerId);
240
+ return;
241
+ }
242
+
227
243
  // Serialize execution per chat to prevent conversation history corruption
228
244
  this._enqueueExecution(current);
229
245
  }
@@ -4,13 +4,14 @@ import { randomBytes } from 'crypto';
4
4
  * A single recurring automation — a scheduled task that the orchestrator runs.
5
5
  */
6
6
  export class Automation {
7
- constructor({ chatId, name, description, schedule }) {
7
+ constructor({ chatId, name, description, schedule, respectQuietHours }) {
8
8
  this.id = randomBytes(4).toString('hex');
9
9
  this.chatId = String(chatId);
10
10
  this.name = name;
11
11
  this.description = description; // the task prompt
12
12
  this.schedule = schedule; // { type, expression?, minutes?, minMinutes?, maxMinutes? }
13
13
  this.enabled = true;
14
+ this.respectQuietHours = respectQuietHours !== false; // default true — skip during quiet hours
14
15
  this.lastRun = null;
15
16
  this.nextRun = null;
16
17
  this.runCount = 0;
@@ -26,7 +27,8 @@ export class Automation {
26
27
  ? `next: ${new Date(this.nextRun).toLocaleString()}`
27
28
  : 'not scheduled';
28
29
  const runs = this.runCount > 0 ? ` | ${this.runCount} runs` : '';
29
- return `${status} \`${this.id}\` **${this.name}** ${scheduleStr} (${nextStr}${runs})`;
30
+ const quiet = this.respectQuietHours ? '' : ' | 🔔 ignores quiet hours';
31
+ return `${status} \`${this.id}\` **${this.name}** — ${scheduleStr} (${nextStr}${runs}${quiet})`;
30
32
  }
31
33
 
32
34
  /** Serialize for persistence. */
@@ -38,6 +40,7 @@ export class Automation {
38
40
  description: this.description,
39
41
  schedule: this.schedule,
40
42
  enabled: this.enabled,
43
+ respectQuietHours: this.respectQuietHours,
41
44
  lastRun: this.lastRun,
42
45
  nextRun: this.nextRun,
43
46
  runCount: this.runCount,
@@ -55,6 +58,7 @@ export class Automation {
55
58
  auto.description = data.description;
56
59
  auto.schedule = data.schedule;
57
60
  auto.enabled = data.enabled;
61
+ auto.respectQuietHours = data.respectQuietHours !== false; // backward-compat: default true
58
62
  auto.lastRun = data.lastRun;
59
63
  auto.nextRun = data.nextRun;
60
64
  auto.runCount = data.runCount;
package/src/bot.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import TelegramBot from 'node-telegram-bot-api';
2
2
  import { createReadStream, readFileSync } from 'fs';
3
- import { isAllowedUser, getUnauthorizedMessage } from './security/auth.js';
3
+ import { isAllowedUser, getUnauthorizedMessage, alertAdmin } from './security/auth.js';
4
4
  import { getLogger } from './utils/logger.js';
5
5
  import { PROVIDERS } from './providers/models.js';
6
6
  import {
@@ -15,6 +15,7 @@ import {
15
15
  import { TTSService } from './services/tts.js';
16
16
  import { STTService } from './services/stt.js';
17
17
  import { getClaudeAuthStatus, claudeLogout } from './claude-auth.js';
18
+ import { isQuietHours } from './utils/timeUtils.js';
18
19
 
19
20
  /**
20
21
  * Simulate a human-like typing delay based on response length.
@@ -43,6 +44,27 @@ async function simulateTypingDelay(bot, chatId, text) {
43
44
  return new Promise((resolve) => setTimeout(resolve, finalDelay));
44
45
  }
45
46
 
47
+ /**
48
+ * Simulate a brief pause between consecutive message chunks.
49
+ * When a long reply is split into multiple Telegram messages, firing them
50
+ * all instantly feels robotic. This adds a short, natural delay with a
51
+ * typing indicator so multi-part replies feel more human.
52
+ *
53
+ * @param {TelegramBot} bot - Telegram bot instance
54
+ * @param {number} chatId - Chat to show typing in
55
+ * @param {string} nextChunk - The upcoming chunk (used to scale the pause)
56
+ * @returns {Promise<void>}
57
+ */
58
+ async function simulateInterChunkDelay(bot, chatId, nextChunk) {
59
+ // Shorter than the initial typing delay: 0.3s – 1.5s based on chunk length
60
+ const length = (nextChunk || '').length;
61
+ const base = Math.min(1500, Math.max(300, length * 8));
62
+ const jitter = base * (0.85 + Math.random() * 0.3);
63
+
64
+ bot.sendChatAction(chatId, 'typing').catch(() => {});
65
+ return new Promise((resolve) => setTimeout(resolve, Math.round(jitter)));
66
+ }
67
+
46
68
  function splitMessage(text, maxLength = 4096) {
47
69
  if (text.length <= maxLength) return [text];
48
70
 
@@ -67,6 +89,7 @@ function splitMessage(text, maxLength = 4096) {
67
89
  * Tries Markdown first, falls back to plain text.
68
90
  */
69
91
  function createOnUpdate(bot, chatId) {
92
+ const logger = getLogger();
70
93
  return async (update, opts = {}) => {
71
94
  if (opts.editMessageId) {
72
95
  try {
@@ -76,15 +99,16 @@ function createOnUpdate(bot, chatId) {
76
99
  parse_mode: 'Markdown',
77
100
  });
78
101
  return edited.message_id;
79
- } catch {
102
+ } catch (mdErr) {
103
+ logger.debug(`[Bot] Markdown edit failed for chat ${chatId}, retrying plain: ${mdErr.message}`);
80
104
  try {
81
105
  const edited = await bot.editMessageText(update, {
82
106
  chat_id: chatId,
83
107
  message_id: opts.editMessageId,
84
108
  });
85
109
  return edited.message_id;
86
- } catch {
87
- // Edit failed fall through to send new message
110
+ } catch (plainErr) {
111
+ logger.debug(`[Bot] Plain-text edit also failed for chat ${chatId}, sending new message: ${plainErr.message}`);
88
112
  }
89
113
  }
90
114
  }
@@ -94,7 +118,8 @@ function createOnUpdate(bot, chatId) {
94
118
  try {
95
119
  const sent = await bot.sendMessage(chatId, part, { parse_mode: 'Markdown' });
96
120
  lastMsgId = sent.message_id;
97
- } catch {
121
+ } catch (mdErr) {
122
+ logger.debug(`[Bot] Markdown send failed for chat ${chatId}, falling back to plain: ${mdErr.message}`);
98
123
  const sent = await bot.sendMessage(chatId, part);
99
124
  lastMsgId = sent.message_id;
100
125
  }
@@ -142,6 +167,7 @@ function createSendReaction(bot) {
142
167
  /**
143
168
  * Simple per-chat queue to serialize agent processing.
144
169
  * Each chat gets its own promise chain so messages are processed in order.
170
+ * Automatically cleans up finished queues to avoid unbounded Map growth.
145
171
  */
146
172
  class ChatQueue {
147
173
  constructor() {
@@ -149,9 +175,21 @@ class ChatQueue {
149
175
  }
150
176
 
151
177
  enqueue(chatId, fn) {
178
+ const logger = getLogger();
152
179
  const key = String(chatId);
153
180
  const prev = this.queues.get(key) || Promise.resolve();
154
- const next = prev.then(() => fn()).catch(() => {});
181
+ const next = prev
182
+ .then(() => fn())
183
+ .catch((err) => {
184
+ logger.error(`[ChatQueue] Error processing message for chat ${key}: ${err.message}`);
185
+ })
186
+ .finally(() => {
187
+ // Clean up the queue entry once this is the last item in the chain,
188
+ // preventing the Map from growing unboundedly over long-running sessions.
189
+ if (this.queues.get(key) === next) {
190
+ this.queues.delete(key);
191
+ }
192
+ });
155
193
  this.queues.set(key, next);
156
194
  return next;
157
195
  }
@@ -252,6 +290,13 @@ export function startBot(config, agent, conversationManager, jobManager, automat
252
290
 
253
291
  if (!isAllowedUser(query.from.id, config)) {
254
292
  await bot.answerCallbackQuery(query.id, { text: 'Unauthorized' });
293
+ await alertAdmin(bot, {
294
+ userId: query.from.id,
295
+ username: query.from.username,
296
+ firstName: query.from.first_name,
297
+ text: `🔘 زر: ${query.data || 'unknown'}`,
298
+ type: 'callback',
299
+ });
255
300
  return;
256
301
  }
257
302
 
@@ -741,6 +786,13 @@ export function startBot(config, agent, conversationManager, jobManager, automat
741
786
  if (msg.text || msg.document) {
742
787
  logger.warn(`Unauthorized access attempt from ${username} (${userId})`);
743
788
  await bot.sendMessage(chatId, getUnauthorizedMessage());
789
+ await alertAdmin(bot, {
790
+ userId,
791
+ username: msg.from.username,
792
+ firstName: msg.from.first_name,
793
+ text: msg.text || (msg.document ? `📎 ملف: ${msg.document.file_name || 'unknown'}` : undefined),
794
+ type: 'رسالة',
795
+ });
744
796
  }
745
797
  return;
746
798
  }
@@ -806,6 +858,37 @@ export function startBot(config, agent, conversationManager, jobManager, automat
806
858
  }
807
859
  }
808
860
 
861
+ // Handle photo messages — download, convert to base64, and pass to LLM for vision analysis
862
+ let imageAttachment = null;
863
+ if (msg.photo && msg.photo.length > 0) {
864
+ logger.info(`[Bot] Photo message from ${username} (${userId}) in chat ${chatId}`);
865
+ try {
866
+ // Use highest resolution (last item in array)
867
+ const photo = msg.photo[msg.photo.length - 1];
868
+ const fileLink = await bot.getFileLink(photo.file_id);
869
+ const response = await fetch(fileLink);
870
+ if (!response.ok) throw new Error(`Failed to download photo: ${response.statusText}`);
871
+ const buffer = Buffer.from(await response.arrayBuffer());
872
+ const base64Data = buffer.toString('base64');
873
+
874
+ // Determine media type from URL extension, default to jpeg
875
+ const ext = fileLink.split('.').pop().split('?')[0].toLowerCase();
876
+ const extToMime = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' };
877
+ const mediaType = extToMime[ext] || 'image/jpeg';
878
+
879
+ imageAttachment = { type: 'base64', media_type: mediaType, data: base64Data };
880
+ // Use caption as text, or default prompt
881
+ if (!msg.text) {
882
+ msg.text = msg.caption || 'What do you see in this image? Describe it in detail.';
883
+ }
884
+ logger.info(`[Bot] Photo downloaded and encoded (${Math.round(base64Data.length / 1024)}KB base64, ${mediaType})`);
885
+ } catch (err) {
886
+ logger.error(`[Bot] Photo processing failed: ${err.message}`);
887
+ await bot.sendMessage(chatId, 'Failed to process the image. Please try again.');
888
+ return;
889
+ }
890
+ }
891
+
809
892
  if (!msg.text) return; // ignore non-text (and non-document) messages
810
893
 
811
894
  let text = msg.text.trim();
@@ -1646,7 +1729,7 @@ export function startBot(config, agent, conversationManager, jobManager, automat
1646
1729
  const reply = await agent.processMessage(chatId, mergedText, {
1647
1730
  id: userId,
1648
1731
  username,
1649
- }, onUpdate, sendPhoto, { sendReaction, messageId: msg.message_id });
1732
+ }, onUpdate, sendPhoto, { sendReaction, messageId: msg.message_id, imageAttachment });
1650
1733
 
1651
1734
  clearInterval(typingInterval);
1652
1735
 
@@ -1655,17 +1738,21 @@ export function startBot(config, agent, conversationManager, jobManager, automat
1655
1738
 
1656
1739
  logger.info(`[Bot] Reply for chat ${chatId}: ${(reply || '').length} chars`);
1657
1740
  const chunks = splitMessage(reply || 'Done.');
1658
- for (const chunk of chunks) {
1741
+ for (let i = 0; i < chunks.length; i++) {
1742
+ // Brief pause between consecutive chunks so multi-part replies feel natural
1743
+ if (i > 0) await simulateInterChunkDelay(bot, chatId, chunks[i]);
1659
1744
  try {
1660
- await bot.sendMessage(chatId, chunk, { parse_mode: 'Markdown' });
1745
+ await bot.sendMessage(chatId, chunks[i], { parse_mode: 'Markdown' });
1661
1746
  } catch {
1662
1747
  // Fallback to plain text if Markdown fails
1663
- await bot.sendMessage(chatId, chunk);
1748
+ await bot.sendMessage(chatId, chunks[i]);
1664
1749
  }
1665
1750
  }
1666
1751
 
1667
- // Send voice reply if TTS is available and the reply isn't too short
1668
- if (ttsService.isAvailable() && reply && reply.length > 5) {
1752
+ // Send voice reply only when the user explicitly requests it
1753
+ const voiceKeywords = ['صوت', 'صوتك', 'صوتية', 'صوتي', 'voice', 'speak', 'hear you'];
1754
+ const wantsVoice = voiceKeywords.some((kw) => mergedText.toLowerCase().includes(kw));
1755
+ if (wantsVoice && ttsService.isAvailable() && reply && reply.length > 5) {
1669
1756
  try {
1670
1757
  const audioPath = await ttsService.synthesize(reply);
1671
1758
  if (audioPath) {
@@ -1689,7 +1776,18 @@ export function startBot(config, agent, conversationManager, jobManager, automat
1689
1776
  const userId = reaction.user?.id;
1690
1777
  const username = reaction.user?.username || reaction.user?.first_name || 'unknown';
1691
1778
 
1692
- if (!userId || !isAllowedUser(userId, config)) return;
1779
+ if (!userId || !isAllowedUser(userId, config)) {
1780
+ if (userId) {
1781
+ await alertAdmin(bot, {
1782
+ userId,
1783
+ username: reaction.user?.username,
1784
+ firstName: reaction.user?.first_name,
1785
+ text: `${(reaction.new_reaction || []).filter(r => r.type === 'emoji').map(r => r.emoji).join(' ') || 'reaction'}`,
1786
+ type: 'تفاعل',
1787
+ });
1788
+ }
1789
+ return;
1790
+ }
1693
1791
 
1694
1792
  const newReactions = reaction.new_reaction || [];
1695
1793
  const emojis = newReactions
@@ -1703,6 +1801,12 @@ export function startBot(config, agent, conversationManager, jobManager, automat
1703
1801
  const reactionText = `[User reacted with ${emojis.join(' ')} to your message]`;
1704
1802
 
1705
1803
  chatQueue.enqueue(chatId, async () => {
1804
+ // Show typing indicator while processing the reaction
1805
+ const typingInterval = setInterval(() => {
1806
+ bot.sendChatAction(chatId, 'typing').catch(() => {});
1807
+ }, 4000);
1808
+ bot.sendChatAction(chatId, 'typing').catch(() => {});
1809
+
1706
1810
  try {
1707
1811
  const onUpdate = createOnUpdate(bot, chatId);
1708
1812
  const sendReaction = createSendReaction(bot);
@@ -1712,17 +1816,24 @@ export function startBot(config, agent, conversationManager, jobManager, automat
1712
1816
  username,
1713
1817
  }, onUpdate, null, { sendReaction, messageId: reaction.message_id });
1714
1818
 
1819
+ clearInterval(typingInterval);
1820
+
1715
1821
  if (reply && reply.trim()) {
1822
+ // Simulate human-like typing delay before responding to the reaction
1823
+ await simulateTypingDelay(bot, chatId, reply);
1824
+
1716
1825
  const chunks = splitMessage(reply);
1717
- for (const chunk of chunks) {
1826
+ for (let i = 0; i < chunks.length; i++) {
1827
+ if (i > 0) await simulateInterChunkDelay(bot, chatId, chunks[i]);
1718
1828
  try {
1719
- await bot.sendMessage(chatId, chunk, { parse_mode: 'Markdown' });
1829
+ await bot.sendMessage(chatId, chunks[i], { parse_mode: 'Markdown' });
1720
1830
  } catch {
1721
- await bot.sendMessage(chatId, chunk);
1831
+ await bot.sendMessage(chatId, chunks[i]);
1722
1832
  }
1723
1833
  }
1724
1834
  }
1725
1835
  } catch (err) {
1836
+ clearInterval(typingInterval);
1726
1837
  logger.error(`[Bot] Error processing reaction in chat ${chatId}: ${err.message}`);
1727
1838
  }
1728
1839
  });
@@ -1749,10 +1860,6 @@ export function startBot(config, agent, conversationManager, jobManager, automat
1749
1860
  }, 5000);
1750
1861
 
1751
1862
  // ── Proactive share delivery (randomized, self-rearming) ────
1752
- const lifeConfig = config.life || {};
1753
- const quietStart = lifeConfig.quiet_hours?.start ?? 2;
1754
- const quietEnd = lifeConfig.quiet_hours?.end ?? 6;
1755
-
1756
1863
  const armShareDelivery = (delivered) => {
1757
1864
  // If we just delivered something, wait longer (1–4h) before next check
1758
1865
  // If nothing was delivered, check again sooner (10–45min) in case new shares appear
@@ -1763,9 +1870,8 @@ export function startBot(config, agent, conversationManager, jobManager, automat
1763
1870
  logger.debug(`[Bot] Next share check in ${Math.round(delayMs / 60_000)}m`);
1764
1871
 
1765
1872
  setTimeout(async () => {
1766
- // Respect quiet hours
1767
- const hour = new Date().getHours();
1768
- if (hour >= quietStart && hour < quietEnd) {
1873
+ // Respect quiet hours (env vars → YAML config → defaults 02:00–06:00)
1874
+ if (isQuietHours(config.life)) {
1769
1875
  armShareDelivery(false);
1770
1876
  return;
1771
1877
  }
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { getLogger } from '../utils/logger.js';
5
+ import { isQuietHours } from '../utils/timeUtils.js';
5
6
 
6
7
  const LIFE_DIR = join(homedir(), '.kernelbot', 'life');
7
8
  const STATE_FILE = join(LIFE_DIR, 'state.json');
@@ -195,12 +196,8 @@ export class LifeEngine {
195
196
  const logger = getLogger();
196
197
  this._timerId = null;
197
198
 
198
- // Check quiet hours
199
- const lifeConfig = this.config.life || {};
200
- const quietStart = lifeConfig.quiet_hours?.start ?? 2;
201
- const quietEnd = lifeConfig.quiet_hours?.end ?? 6;
202
- const currentHour = new Date().getHours();
203
- if (currentHour >= quietStart && currentHour < quietEnd) {
199
+ // Check quiet hours (env vars → YAML config → defaults 02:00–06:00)
200
+ if (isQuietHours(this.config.life)) {
204
201
  logger.debug('[LifeEngine] Quiet hours — skipping tick');
205
202
  this._armNext();
206
203
  return;
@@ -407,31 +404,9 @@ This is your private thought space — be genuine, be curious, be alive.`;
407
404
  const response = await this._innerChat(prompt);
408
405
 
409
406
  if (response) {
410
- // Extract ideas
411
- const ideaLines = response.split('\n').filter(l => l.trim().startsWith('IDEA:'));
412
- for (const line of ideaLines) {
413
- this._addIdea(line.replace(/^IDEA:\s*/, '').trim());
414
- }
415
-
416
- // Extract shares
417
- const shareLines = response.split('\n').filter(l => l.trim().startsWith('SHARE:'));
418
- for (const line of shareLines) {
419
- this.shareQueue.add(line.replace(/^SHARE:\s*/, '').trim(), 'think', 'medium');
420
- }
421
-
422
- // Extract questions to ask users
423
- const askLines = response.split('\n').filter(l => l.trim().startsWith('ASK:'));
424
- for (const line of askLines) {
425
- this.shareQueue.add(line.replace(/^ASK:\s*/, '').trim(), 'think', 'medium', null, ['question']);
426
- }
407
+ const extracted = this._extractTaggedLines(response, ['IDEA', 'SHARE', 'ASK', 'IMPROVE']);
408
+ this._processResponseTags(extracted, 'think');
427
409
 
428
- // Extract self-improvement proposals for evolution pipeline
429
- const improveLines = response.split('\n').filter(l => l.trim().startsWith('IMPROVE:'));
430
- for (const line of improveLines) {
431
- this._addIdea(`[IMPROVE] ${line.replace(/^IMPROVE:\s*/, '').trim()}`);
432
- }
433
-
434
- // Store as episodic memory
435
410
  this.memoryManager.addEpisodic({
436
411
  type: 'thought',
437
412
  source: 'think',
@@ -440,7 +415,7 @@ This is your private thought space — be genuine, be curious, be alive.`;
440
415
  importance: 3,
441
416
  });
442
417
 
443
- logger.info(`[LifeEngine] Think complete (${response.length} chars, ${ideaLines.length} ideas, ${shareLines.length} shares, ${askLines.length} questions, ${improveLines.length} improvements)`);
418
+ logger.info(`[LifeEngine] Think complete (${response.length} chars, ${extracted.IDEA.length} ideas, ${extracted.SHARE.length} shares, ${extracted.ASK.length} questions, ${extracted.IMPROVE.length} improvements)`);
444
419
  }
445
420
  }
446
421
 
@@ -477,25 +452,9 @@ If you learn a key fact or concept, prefix it with "LEARNED:" followed by "topic
477
452
  const response = await this._dispatchWorker('research', prompt);
478
453
 
479
454
  if (response) {
480
- // Extract shares
481
- const shareLines = response.split('\n').filter(l => l.trim().startsWith('SHARE:'));
482
- for (const line of shareLines) {
483
- this.shareQueue.add(line.replace(/^SHARE:\s*/, '').trim(), 'browse', 'medium');
484
- }
485
-
486
- // Extract learned facts
487
- const learnedLines = response.split('\n').filter(l => l.trim().startsWith('LEARNED:'));
488
- for (const line of learnedLines) {
489
- const content = line.replace(/^LEARNED:\s*/, '').trim();
490
- const colonIdx = content.indexOf(':');
491
- if (colonIdx > 0) {
492
- const topicKey = content.slice(0, colonIdx).trim();
493
- const summary = content.slice(colonIdx + 1).trim();
494
- this.memoryManager.addSemantic(topicKey, { summary });
495
- }
496
- }
455
+ const extracted = this._extractTaggedLines(response, ['SHARE', 'LEARNED']);
456
+ this._processResponseTags(extracted, 'browse');
497
457
 
498
- // Store as episodic memory
499
458
  this.memoryManager.addEpisodic({
500
459
  type: 'discovery',
501
460
  source: 'browse',
@@ -594,10 +553,10 @@ Respond with just your creation — no tool calls needed.`;
594
553
  const response = await this._innerChat(prompt);
595
554
 
596
555
  if (response) {
597
- // Extract shares
598
- const shareLines = response.split('\n').filter(l => l.trim().startsWith('SHARE:'));
599
- for (const line of shareLines) {
600
- this.shareQueue.add(line.replace(/^SHARE:\s*/, '').trim(), 'create', 'medium', null, ['creation']);
556
+ const extracted = this._extractTaggedLines(response, ['SHARE']);
557
+ // Creative shares get a 'creation' tag for richer attribution
558
+ for (const text of extracted.SHARE) {
559
+ this.shareQueue.add(text, 'create', 'medium', null, ['creation']);
601
560
  }
602
561
 
603
562
  this.memoryManager.addEpisodic({
@@ -1203,23 +1162,11 @@ Be honest and constructive. This is your chance to learn from real interactions.
1203
1162
  const response = await this._innerChat(prompt);
1204
1163
 
1205
1164
  if (response) {
1206
- // Extract improvement ideas
1207
- const improveLines = response.split('\n').filter(l => l.trim().startsWith('IMPROVE:'));
1208
- for (const line of improveLines) {
1209
- this._addIdea(`[IMPROVE] ${line.replace(/^IMPROVE:\s*/, '').trim()}`);
1210
- }
1211
-
1212
- // Extract patterns as semantic memories
1213
- const patternLines = response.split('\n').filter(l => l.trim().startsWith('PATTERN:'));
1214
- for (const line of patternLines) {
1215
- const content = line.replace(/^PATTERN:\s*/, '').trim();
1216
- this.memoryManager.addSemantic('interaction_patterns', { summary: content });
1217
- }
1165
+ const extracted = this._extractTaggedLines(response, ['IMPROVE', 'PATTERN']);
1166
+ this._processResponseTags(extracted, 'reflect');
1218
1167
 
1219
- // Write a journal entry with the reflection
1220
1168
  this.journalManager.writeEntry('Interaction Reflection', response);
1221
1169
 
1222
- // Store as episodic memory
1223
1170
  this.memoryManager.addEpisodic({
1224
1171
  type: 'thought',
1225
1172
  source: 'reflect',
@@ -1228,7 +1175,7 @@ Be honest and constructive. This is your chance to learn from real interactions.
1228
1175
  importance: 5,
1229
1176
  });
1230
1177
 
1231
- logger.info(`[LifeEngine] Reflection complete (${response.length} chars, ${improveLines.length} improvements, ${patternLines.length} patterns)`);
1178
+ logger.info(`[LifeEngine] Reflection complete (${response.length} chars, ${extracted.IMPROVE.length} improvements, ${extracted.PATTERN.length} patterns)`);
1232
1179
  }
1233
1180
  }
1234
1181
 
@@ -1307,6 +1254,78 @@ Be honest and constructive. This is your chance to learn from real interactions.
1307
1254
 
1308
1255
  // ── Utilities ──────────────────────────────────────────────────
1309
1256
 
1257
+ /**
1258
+ * Extract tagged lines from an LLM response.
1259
+ * Tags are prefixes like "SHARE:", "IDEA:", "IMPROVE:", etc. that the LLM
1260
+ * uses to signal structured intents within free-form text.
1261
+ *
1262
+ * @param {string} response - Raw LLM response text.
1263
+ * @param {string[]} tags - List of tag prefixes to extract (e.g. ['SHARE', 'IDEA']).
1264
+ * @returns {Record<string, string[]>} Map of tag → array of extracted values (prefix stripped, trimmed).
1265
+ */
1266
+ _extractTaggedLines(response, tags) {
1267
+ const lines = response.split('\n');
1268
+ const result = {};
1269
+ for (const tag of tags) {
1270
+ result[tag] = [];
1271
+ }
1272
+ for (const line of lines) {
1273
+ const trimmed = line.trim();
1274
+ for (const tag of tags) {
1275
+ if (trimmed.startsWith(`${tag}:`)) {
1276
+ result[tag].push(trimmed.slice(tag.length + 1).trim());
1277
+ break;
1278
+ }
1279
+ }
1280
+ }
1281
+ return result;
1282
+ }
1283
+
1284
+ /**
1285
+ * Process common tagged lines from an activity response, routing each tag
1286
+ * to the appropriate handler (share queue, ideas backlog, semantic memory).
1287
+ *
1288
+ * @param {Record<string, string[]>} extracted - Output from _extractTaggedLines.
1289
+ * @param {string} source - Activity source for share queue attribution (e.g. 'think', 'browse').
1290
+ */
1291
+ _processResponseTags(extracted, source) {
1292
+ if (extracted.SHARE) {
1293
+ for (const text of extracted.SHARE) {
1294
+ this.shareQueue.add(text, source, 'medium');
1295
+ }
1296
+ }
1297
+ if (extracted.IDEA) {
1298
+ for (const text of extracted.IDEA) {
1299
+ this._addIdea(text);
1300
+ }
1301
+ }
1302
+ if (extracted.IMPROVE) {
1303
+ for (const text of extracted.IMPROVE) {
1304
+ this._addIdea(`[IMPROVE] ${text}`);
1305
+ }
1306
+ }
1307
+ if (extracted.ASK) {
1308
+ for (const text of extracted.ASK) {
1309
+ this.shareQueue.add(text, source, 'medium', null, ['question']);
1310
+ }
1311
+ }
1312
+ if (extracted.LEARNED) {
1313
+ for (const text of extracted.LEARNED) {
1314
+ const colonIdx = text.indexOf(':');
1315
+ if (colonIdx > 0) {
1316
+ const topicKey = text.slice(0, colonIdx).trim();
1317
+ const summary = text.slice(colonIdx + 1).trim();
1318
+ this.memoryManager.addSemantic(topicKey, { summary });
1319
+ }
1320
+ }
1321
+ }
1322
+ if (extracted.PATTERN) {
1323
+ for (const text of extracted.PATTERN) {
1324
+ this.memoryManager.addSemantic('interaction_patterns', { summary: text });
1325
+ }
1326
+ }
1327
+ }
1328
+
1310
1329
  _formatDuration(ms) {
1311
1330
  const hours = Math.floor(ms / 3600_000);
1312
1331
  const minutes = Math.floor((ms % 3600_000) / 60_000);
@@ -1,16 +1,13 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
- import { randomBytes } from 'crypto';
5
4
  import { getLogger } from '../utils/logger.js';
5
+ import { genId } from '../utils/ids.js';
6
+ import { getStartOfDayMs } from '../utils/date.js';
6
7
 
7
8
  const LIFE_DIR = join(homedir(), '.kernelbot', 'life');
8
9
  const EVOLUTION_FILE = join(LIFE_DIR, 'evolution.json');
9
10
 
10
- function genId(prefix = 'evo') {
11
- return `${prefix}_${randomBytes(4).toString('hex')}`;
12
- }
13
-
14
11
  const VALID_STATUSES = ['research', 'planned', 'coding', 'pr_open', 'merged', 'rejected', 'failed'];
15
12
  const TERMINAL_STATUSES = ['merged', 'rejected', 'failed'];
16
13
 
@@ -231,9 +228,8 @@ export class EvolutionTracker {
231
228
  }
232
229
 
233
230
  getProposalsToday() {
234
- const startOfDay = new Date();
235
- startOfDay.setHours(0, 0, 0, 0);
236
- return this._data.proposals.filter(p => p.createdAt >= startOfDay.getTime());
231
+ const cutoff = getStartOfDayMs();
232
+ return this._data.proposals.filter(p => p.createdAt >= cutoff);
237
233
  }
238
234
 
239
235
  // ── Internal ──────────────────────────────────────────────────