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/agent.js CHANGED
@@ -11,8 +11,30 @@ import { getMissingCredential, saveCredential, saveProviderToYaml, saveOrchestra
11
11
  import { resetClaudeCodeSpawner, getSpawner } from './tools/coding.js';
12
12
  import { truncateToolResult } from './utils/truncate.js';
13
13
 
14
+ /**
15
+ * Format a time gap in minutes into natural, human-readable language.
16
+ * e.g. 45 → "about 45 minutes", 90 → "about an hour and a half",
17
+ * 180 → "about 3 hours", 1500 → "over a day"
18
+ */
19
+ function formatTimeGap(minutes) {
20
+ if (minutes < 60) return `about ${minutes} minutes`;
21
+ const hours = Math.floor(minutes / 60);
22
+ const remainingMins = minutes % 60;
23
+ if (hours < 24) {
24
+ if (hours === 1 && remainingMins < 15) return 'about an hour';
25
+ if (hours === 1 && remainingMins >= 15 && remainingMins < 45) return 'about an hour and a half';
26
+ if (hours === 1) return 'nearly 2 hours';
27
+ if (remainingMins < 15) return `about ${hours} hours`;
28
+ if (remainingMins >= 30) return `about ${hours} and a half hours`;
29
+ return `about ${hours} hours`;
30
+ }
31
+ const days = Math.floor(hours / 24);
32
+ if (days === 1) return 'over a day';
33
+ return `about ${days} days`;
34
+ }
35
+
14
36
  export class OrchestratorAgent {
15
- constructor({ config, conversationManager, personaManager, selfManager, jobManager, automationManager, memoryManager, shareQueue }) {
37
+ constructor({ config, conversationManager, personaManager, selfManager, jobManager, automationManager, memoryManager, shareQueue, characterManager }) {
16
38
  this.config = config;
17
39
  this.conversationManager = conversationManager;
18
40
  this.personaManager = personaManager;
@@ -21,7 +43,14 @@ export class OrchestratorAgent {
21
43
  this.automationManager = automationManager || null;
22
44
  this.memoryManager = memoryManager || null;
23
45
  this.shareQueue = shareQueue || null;
46
+ this.characterManager = characterManager || null;
47
+ this._activePersonaMd = null;
48
+ this._activeCharacterName = null;
49
+ this._activeCharacterId = null;
24
50
  this._pending = new Map(); // chatId -> pending state
51
+
52
+ // Character-scoped conversation keys: prefix chatId with character ID
53
+ // so each character has isolated conversation history.
25
54
  this._chatCallbacks = new Map(); // chatId -> { onUpdate, sendPhoto }
26
55
 
27
56
  // Orchestrator provider (30s timeout — lean dispatch/summarize calls)
@@ -49,7 +78,8 @@ export class OrchestratorAgent {
49
78
  /** Build the orchestrator system prompt. */
50
79
  _getSystemPrompt(chatId, user, temporalContext = null) {
51
80
  const logger = getLogger();
52
- const skillId = this.conversationManager.getSkill(chatId);
81
+ const key = this._chatKey(chatId);
82
+ const skillId = this.conversationManager.getSkill(key);
53
83
  const skillPrompt = skillId ? getUnifiedSkillById(skillId)?.systemPrompt : null;
54
84
 
55
85
  let userPersona = null;
@@ -74,23 +104,93 @@ export class OrchestratorAgent {
74
104
  sharesBlock = this.shareQueue.buildShareBlock(user?.id || null);
75
105
  }
76
106
 
77
- logger.debug(`Orchestrator building system prompt for chat ${chatId} | skill=${skillId || 'none'} | persona=${userPersona ? 'yes' : 'none'} | self=${selfData ? 'yes' : 'none'} | memories=${memoriesBlock ? 'yes' : 'none'} | shares=${sharesBlock ? 'yes' : 'none'} | temporal=${temporalContext ? 'yes' : 'none'}`);
78
- return getOrchestratorPrompt(this.config, skillPrompt || null, userPersona, selfData, memoriesBlock, sharesBlock, temporalContext);
107
+ logger.debug(`Orchestrator building system prompt for chat ${chatId} | skill=${skillId || 'none'} | persona=${userPersona ? 'yes' : 'none'} | self=${selfData ? 'yes' : 'none'} | memories=${memoriesBlock ? 'yes' : 'none'} | shares=${sharesBlock ? 'yes' : 'none'} | temporal=${temporalContext ? 'yes' : 'none'} | character=${this._activeCharacterId || 'default'}`);
108
+ return getOrchestratorPrompt(this.config, skillPrompt || null, userPersona, selfData, memoriesBlock, sharesBlock, temporalContext, this._activePersonaMd, this._activeCharacterName);
79
109
  }
80
110
 
81
111
  setSkill(chatId, skillId) {
82
- this.conversationManager.setSkill(chatId, skillId);
112
+ this.conversationManager.setSkill(this._chatKey(chatId), skillId);
83
113
  }
84
114
 
85
115
  clearSkill(chatId) {
86
- this.conversationManager.clearSkill(chatId);
116
+ this.conversationManager.clearSkill(this._chatKey(chatId));
87
117
  }
88
118
 
89
119
  getActiveSkill(chatId) {
90
- const skillId = this.conversationManager.getSkill(chatId);
120
+ const skillId = this.conversationManager.getSkill(this._chatKey(chatId));
91
121
  return skillId ? getUnifiedSkillById(skillId) : null;
92
122
  }
93
123
 
124
+ /**
125
+ * Load a character's context into the agent.
126
+ * Called on startup and on character switch.
127
+ */
128
+ loadCharacter(characterId) {
129
+ const logger = getLogger();
130
+ if (!this.characterManager) return;
131
+
132
+ const ctx = this.characterManager.buildContext(characterId);
133
+ this._activeCharacterId = characterId;
134
+ this._activePersonaMd = ctx.personaMd;
135
+ this._activeCharacterName = ctx.profile.name;
136
+ this.selfManager = ctx.selfManager;
137
+ this.memoryManager = ctx.memoryManager;
138
+ this.shareQueue = ctx.shareQueue;
139
+
140
+ logger.info(`[Agent] Loaded character: ${ctx.profile.name} (${characterId})`);
141
+ return ctx;
142
+ }
143
+
144
+ /**
145
+ * Switch to a different character at runtime.
146
+ * Returns the new context for life engine rebuild.
147
+ */
148
+ switchCharacter(characterId) {
149
+ const logger = getLogger();
150
+ if (!this.characterManager) throw new Error('CharacterManager not available');
151
+
152
+ const ctx = this.loadCharacter(characterId);
153
+ this.characterManager.setActiveCharacter(characterId);
154
+
155
+ logger.info(`[Agent] Switched to character: ${ctx.profile.name} (${characterId})`);
156
+ return ctx;
157
+ }
158
+
159
+ /** Get active character info for display. */
160
+ getActiveCharacterInfo() {
161
+ if (!this.characterManager) return null;
162
+ const id = this._activeCharacterId || this.characterManager.getActiveCharacterId();
163
+ return this.characterManager.getCharacter(id);
164
+ }
165
+
166
+ /**
167
+ * Build a character-scoped conversation key.
168
+ * Prefixes chatId with the active character ID so each character
169
+ * has its own isolated conversation history.
170
+ * Raw chatId is still used for Telegram callbacks, job events, etc.
171
+ */
172
+ _chatKey(chatId) {
173
+ if (this._activeCharacterId) {
174
+ return `${this._activeCharacterId}:${chatId}`;
175
+ }
176
+ return String(chatId);
177
+ }
178
+
179
+ /** Clear conversation history for a chat (character-scoped). */
180
+ clearConversation(chatId) {
181
+ this.conversationManager.clear(this._chatKey(chatId));
182
+ }
183
+
184
+ /** Get message count for a chat (character-scoped). */
185
+ getMessageCount(chatId) {
186
+ return this.conversationManager.getMessageCount(this._chatKey(chatId));
187
+ }
188
+
189
+ /** Get conversation history for a chat (character-scoped). */
190
+ getConversationHistory(chatId) {
191
+ return this.conversationManager.getHistory(this._chatKey(chatId));
192
+ }
193
+
94
194
  /** Return current worker brain info for display. */
95
195
  getBrainInfo() {
96
196
  const { provider, model } = this.config.brain;
@@ -306,27 +406,27 @@ export class OrchestratorAgent {
306
406
 
307
407
  const { max_tool_depth } = this.config.orchestrator;
308
408
 
409
+ // Character-scoped conversation key
410
+ const convKey = this._chatKey(chatId);
411
+
309
412
  // Detect time gap before adding the new message
310
413
  let temporalContext = null;
311
- const lastTs = this.conversationManager.getLastMessageTimestamp(chatId);
414
+ const lastTs = this.conversationManager.getLastMessageTimestamp(convKey);
312
415
  if (lastTs) {
313
416
  const gapMs = Date.now() - lastTs;
314
417
  const gapMinutes = Math.floor(gapMs / 60_000);
315
418
  if (gapMinutes >= 30) {
316
- const gapHours = Math.floor(gapMinutes / 60);
317
- const gapText = gapHours >= 1
318
- ? `${gapHours} hour(s)`
319
- : `${gapMinutes} minute(s)`;
320
- temporalContext = `[Time gap detected: ${gapText} since last message. User may be starting a new topic.]`;
419
+ const gapText = formatTimeGap(gapMinutes);
420
+ temporalContext = `[Time gap detected: ${gapText} since last message. User may be starting a new topic — consider a fresh greeting.]`;
321
421
  logger.info(`Time gap detected for chat ${chatId}: ${gapText}`);
322
422
  }
323
423
  }
324
424
 
325
425
  // Add user message to persistent history
326
- this.conversationManager.addMessage(chatId, 'user', userMessage);
426
+ this.conversationManager.addMessage(convKey, 'user', userMessage);
327
427
 
328
428
  // Build working messages from compressed history
329
- const messages = [...this.conversationManager.getSummarizedHistory(chatId)];
429
+ const messages = [...this.conversationManager.getSummarizedHistory(convKey)];
330
430
 
331
431
  // If an image is attached, upgrade the last user message to a multimodal content array
332
432
  if (opts.imageAttachment) {
@@ -402,6 +502,7 @@ export class OrchestratorAgent {
402
502
 
403
503
  this.jobManager.on('job:completed', async (job) => {
404
504
  const chatId = job.chatId;
505
+ const convKey = this._chatKey(chatId);
405
506
  const workerDef = WORKER_TYPES[job.workerType] || {};
406
507
  const label = workerDef.label || job.workerType;
407
508
 
@@ -416,21 +517,35 @@ export class OrchestratorAgent {
416
517
  const summary = await this._summarizeJobResult(chatId, job);
417
518
  if (summary) {
418
519
  logger.debug(`[Orchestrator] Job ${job.id} summary ready (${summary.length} chars) — delivering to user`);
419
- this.conversationManager.addMessage(chatId, 'assistant', summary);
420
- await this._sendUpdate(chatId, summary, { editMessageId: notifyMsgId });
520
+ this.conversationManager.addMessage(convKey, 'assistant', summary);
521
+ // Try to edit the notification, fall back to new message if edit fails
522
+ try {
523
+ await this._sendUpdate(chatId, summary, { editMessageId: notifyMsgId });
524
+ } catch {
525
+ await this._sendUpdate(chatId, summary).catch(() => {});
526
+ }
421
527
  } else {
422
528
  // Summary was null — store the fallback
423
529
  const fallback = this._buildSummaryFallback(job, label);
424
530
  logger.debug(`[Orchestrator] Job ${job.id} using fallback (${fallback.length} chars) — delivering to user`);
425
- this.conversationManager.addMessage(chatId, 'assistant', fallback);
426
- await this._sendUpdate(chatId, fallback, { editMessageId: notifyMsgId });
531
+ this.conversationManager.addMessage(convKey, 'assistant', fallback);
532
+ try {
533
+ await this._sendUpdate(chatId, fallback, { editMessageId: notifyMsgId });
534
+ } catch {
535
+ await this._sendUpdate(chatId, fallback).catch(() => {});
536
+ }
427
537
  }
428
538
  } catch (err) {
429
539
  logger.error(`[Orchestrator] Failed to summarize job ${job.id}: ${err.message}`);
430
540
  // Store the fallback so the orchestrator retains context about what happened
431
541
  const fallback = this._buildSummaryFallback(job, label);
432
- this.conversationManager.addMessage(chatId, 'assistant', fallback);
433
- await this._sendUpdate(chatId, fallback, { editMessageId: notifyMsgId }).catch(() => {});
542
+ this.conversationManager.addMessage(convKey, 'assistant', fallback);
543
+ // Try to edit notification, fall back to new message
544
+ try {
545
+ await this._sendUpdate(chatId, fallback, { editMessageId: notifyMsgId });
546
+ } catch {
547
+ await this._sendUpdate(chatId, fallback).catch(() => {});
548
+ }
434
549
  }
435
550
  });
436
551
 
@@ -451,14 +566,15 @@ export class OrchestratorAgent {
451
566
 
452
567
  this.jobManager.on('job:failed', (job) => {
453
568
  const chatId = job.chatId;
569
+ const convKey = this._chatKey(chatId);
454
570
  const workerDef = WORKER_TYPES[job.workerType] || {};
455
571
  const label = workerDef.label || job.workerType;
456
572
 
457
573
  logger.error(`[Orchestrator] Job failed event: ${job.id} [${job.workerType}] in chat ${chatId} — ${job.error}`);
458
574
 
459
575
  const msg = `❌ **${label} failed** (\`${job.id}\`): ${job.error}`;
460
- this.conversationManager.addMessage(chatId, 'assistant', msg);
461
- this._sendUpdate(chatId, msg);
576
+ this.conversationManager.addMessage(convKey, 'assistant', msg);
577
+ this._sendUpdate(chatId, msg).catch(() => {});
462
578
  });
463
579
 
464
580
  this.jobManager.on('job:cancelled', (job) => {
@@ -469,7 +585,7 @@ export class OrchestratorAgent {
469
585
  logger.info(`[Orchestrator] Job cancelled event: ${job.id} [${job.workerType}] in chat ${chatId}`);
470
586
 
471
587
  const msg = `🚫 **${label} cancelled** (\`${job.id}\`)`;
472
- this._sendUpdate(chatId, msg);
588
+ this._sendUpdate(chatId, msg).catch(() => {});
473
589
  });
474
590
  }
475
591
 
@@ -487,9 +603,17 @@ export class OrchestratorAgent {
487
603
 
488
604
  logger.info(`[Orchestrator] Summarizing job ${job.id} [${job.workerType}] result for user`);
489
605
 
490
- // Short results don't need LLM summarization
491
606
  const sr = job.structuredResult;
492
607
  const resultLen = (job.result || '').length;
608
+
609
+ // Direct coding jobs: Claude Code already produces clean, human-readable output.
610
+ // Skip LLM summarization to avoid timeouts and latency — use the result directly.
611
+ if (job.workerType === 'coding') {
612
+ logger.info(`[Orchestrator] Job ${job.id} is a coding job — using direct result (no LLM summary)`);
613
+ return this._buildSummaryFallback(job, label);
614
+ }
615
+
616
+ // Short results don't need LLM summarization
493
617
  if (sr?.structured && resultLen < 500) {
494
618
  logger.info(`[Orchestrator] Job ${job.id} result short enough — skipping LLM summary`);
495
619
  return this._buildSummaryFallback(job, label);
@@ -513,7 +637,7 @@ export class OrchestratorAgent {
513
637
  resultContext = (job.result || 'Done.').slice(0, 8000);
514
638
  }
515
639
 
516
- const history = this.conversationManager.getSummarizedHistory(chatId);
640
+ const history = this.conversationManager.getSummarizedHistory(this._chatKey(chatId));
517
641
 
518
642
  const response = await this.orchestratorProvider.chat({
519
643
  system: this._getSystemPrompt(chatId, null),
@@ -617,7 +741,7 @@ export class OrchestratorAgent {
617
741
 
618
742
  // 2. Last 5 user messages from conversation history
619
743
  try {
620
- const history = this.conversationManager.getSummarizedHistory(job.chatId);
744
+ const history = this.conversationManager.getSummarizedHistory(this._chatKey(job.chatId));
621
745
  const userMessages = history
622
746
  .filter(m => m.role === 'user' && typeof m.content === 'string')
623
747
  .slice(-5)
@@ -740,14 +864,26 @@ export class OrchestratorAgent {
740
864
 
741
865
  // Get scoped tools and skill
742
866
  const tools = getToolsForWorker(job.workerType);
743
- const skillId = this.conversationManager.getSkill(chatId);
867
+ const skillId = this.conversationManager.getSkill(this._chatKey(chatId));
744
868
 
745
869
  // Build worker context (conversation history, persona, dependency results)
746
870
  const workerContext = this._buildWorkerContext(job);
871
+
872
+ // Social platform credential check — require at least one platform connected
873
+ let workerConfig = this.config;
874
+ if (job.workerType === 'social') {
875
+ const hasLinkedIn = this.config.linkedin?.access_token && this.config.linkedin?.person_urn;
876
+ const hasX = this.config.x?.consumer_key && this.config.x?.consumer_secret && this.config.x?.access_token && this.config.x?.access_token_secret;
877
+ if (!hasLinkedIn && !hasX) {
878
+ this.jobManager.failJob(job.id, 'No social accounts connected. Use /linkedin link or /x link to connect an account first.');
879
+ return;
880
+ }
881
+ }
882
+
747
883
  logger.debug(`[Orchestrator] Worker ${job.id} config: ${tools.length} tools, skill=${skillId || 'none'}, brain=${this.config.brain.provider}/${this.config.brain.model}, context=${workerContext ? 'yes' : 'none'}`);
748
884
 
749
885
  const worker = new WorkerAgent({
750
- config: this.config,
886
+ config: workerConfig,
751
887
  workerType: job.workerType,
752
888
  jobId: job.id,
753
889
  tools,
@@ -820,12 +956,53 @@ export class OrchestratorAgent {
820
956
  // Start the job
821
957
  this.jobManager.startJob(job.id);
822
958
 
959
+ // Track activity for health monitoring — Claude Code streams events via onOutput.
960
+ // coder.js uses a "smart output" wrapper that consolidates tool activity lines (▸/▹/▪)
961
+ // into a single editable status message starting with ░▒▓. Text messages (💬) pass through
962
+ // directly. We intercept both patterns to keep the job's lastActivity/stats updated.
963
+ let toolCallCount = 0;
964
+ let llmCallCount = 0;
965
+ const wrappedOnOutput = onUpdate ? async (text, opts) => {
966
+ // Update job activity timestamp on every output event
967
+ job.lastActivity = Date.now();
968
+
969
+ if (typeof text === 'string') {
970
+ // Consolidated status message from coder.js smart output (contains ▸ lines inside)
971
+ if (text.startsWith('░▒▓')) {
972
+ // Count ▸ lines inside the consolidated block to estimate tool calls
973
+ const toolLines = text.match(/^▸ .+$/gm) || [];
974
+ if (toolLines.length > toolCallCount) {
975
+ toolCallCount = toolLines.length;
976
+ job.updateStats({ toolCalls: toolCallCount });
977
+ }
978
+ // Use the last activity line as progress
979
+ if (toolLines.length > 0) {
980
+ job.addProgress(toolLines[toolLines.length - 1].slice(0, 100));
981
+ }
982
+ }
983
+ // Direct tool activity line (before smart output kicks in, or raw lines)
984
+ else if (text.startsWith('▸') || text.startsWith('▹')) {
985
+ toolCallCount++;
986
+ job.updateStats({ toolCalls: toolCallCount });
987
+ job.addProgress(text.slice(0, 100));
988
+ }
989
+ // LLM text output
990
+ else if (text.startsWith('💬')) {
991
+ llmCallCount++;
992
+ const thinking = text.replace(/^💬\s*\*Claude Code:\*\s*\n?/, '');
993
+ job.updateStats({ llmCalls: llmCallCount, lastThinking: thinking.slice(0, 300) });
994
+ }
995
+ }
996
+
997
+ return onUpdate(text, opts);
998
+ } : null;
999
+
823
1000
  try {
824
1001
  const spawner = getSpawner(this.config);
825
1002
  const result = await spawner.run({
826
1003
  workingDirectory,
827
1004
  prompt,
828
- onOutput: onUpdate,
1005
+ onOutput: wrappedOnOutput,
829
1006
  signal: abortController.signal,
830
1007
  });
831
1008
 
@@ -861,7 +1038,30 @@ export class OrchestratorAgent {
861
1038
  const dur = job.startedAt ? Math.round((now - job.startedAt) / 1000) : 0;
862
1039
  const stats = `${job.llmCalls} LLM calls, ${job.toolCalls} tools`;
863
1040
  const recentActivity = job.progress.slice(-5).join(' → ');
1041
+
1042
+ // Warning flags — long-running workers (coding, devops) get higher thresholds
1043
+ const flags = [];
1044
+ const isLongRunning = ['coding', 'devops'].includes(job.workerType);
1045
+ const idleThreshold = isLongRunning ? 600 : 120;
1046
+ const loopLlmThreshold = isLongRunning ? 50 : 15;
1047
+
1048
+ const idleSec = job.lastActivity ? Math.round((now - job.lastActivity) / 1000) : dur;
1049
+ if (idleSec > idleThreshold) {
1050
+ flags.push(`⚠️ IDLE ${idleSec}s`);
1051
+ }
1052
+ if (job.llmCalls > loopLlmThreshold && job.toolCalls < 3) {
1053
+ flags.push('⚠️ POSSIBLY LOOPING');
1054
+ }
1055
+ const timeoutSec = job.timeoutMs ? Math.round(job.timeoutMs / 1000) : null;
1056
+ if (timeoutSec && dur > timeoutSec * 0.75) {
1057
+ const pct = Math.round((dur / timeoutSec) * 100);
1058
+ flags.push(`⚠️ ${pct}% of timeout used`);
1059
+ }
1060
+
864
1061
  let line = `- ${workerDef.label || job.workerType} (${job.id}) — running ${dur}s [${stats}]`;
1062
+ if (flags.length > 0) {
1063
+ line += ` ${flags.join(' | ')}`;
1064
+ }
865
1065
  if (job.lastThinking) {
866
1066
  line += `\n Thinking: "${job.lastThinking.slice(0, 150)}"`;
867
1067
  }
@@ -910,6 +1110,7 @@ export class OrchestratorAgent {
910
1110
 
911
1111
  async _runLoop(chatId, messages, user, startDepth, maxDepth, temporalContext = null) {
912
1112
  const logger = getLogger();
1113
+ const convKey = this._chatKey(chatId);
913
1114
 
914
1115
  for (let depth = startDepth; depth < maxDepth; depth++) {
915
1116
  logger.info(`[Orchestrator] LLM call ${depth + 1}/${maxDepth} for chat ${chatId} — sending ${messages.length} messages`);
@@ -939,7 +1140,7 @@ export class OrchestratorAgent {
939
1140
  if (response.stopReason === 'end_turn') {
940
1141
  const reply = response.text || '';
941
1142
  logger.info(`[Orchestrator] End turn — final reply: "${reply.slice(0, 200)}"`);
942
- this.conversationManager.addMessage(chatId, 'assistant', reply);
1143
+ this.conversationManager.addMessage(convKey, 'assistant', reply);
943
1144
  return reply;
944
1145
  }
945
1146
 
@@ -950,35 +1151,41 @@ export class OrchestratorAgent {
950
1151
  logger.info(`[Orchestrator] Thinking: "${response.text.slice(0, 200)}"`);
951
1152
  }
952
1153
 
953
- const toolResults = [];
954
-
1154
+ // Log all tool calls and send status updates first
955
1155
  for (const block of response.toolCalls) {
956
1156
  const summary = this._formatToolSummary(block.name, block.input);
957
1157
  logger.info(`[Orchestrator] Calling tool: ${block.name} — ${summary}`);
958
1158
  logger.debug(`[Orchestrator] Tool input: ${JSON.stringify(block.input).slice(0, 300)}`);
959
- await this._sendUpdate(chatId, `⚡ ${summary}`);
960
-
961
- const chatCallbacks = this._chatCallbacks.get(chatId) || {};
962
- const result = await executeOrchestratorTool(block.name, block.input, {
963
- chatId,
964
- jobManager: this.jobManager,
965
- config: this.config,
966
- spawnWorker: (job) => this._spawnWorker(job),
967
- automationManager: this.automationManager,
968
- user,
969
- sendReaction: chatCallbacks.sendReaction || null,
970
- lastUserMessageId: chatCallbacks.lastUserMessageId || null,
971
- });
972
-
973
- logger.info(`[Orchestrator] Tool result for ${block.name}: ${JSON.stringify(result).slice(0, 200)}`);
974
-
975
- toolResults.push({
976
- type: 'tool_result',
977
- tool_use_id: block.id,
978
- content: this._truncateResult(block.name, result),
979
- });
1159
+ this._sendUpdate(chatId, `⚡ ${summary}`).catch(() => {});
980
1160
  }
981
1161
 
1162
+ // Execute all tools in parallel — they're independent
1163
+ const chatCallbacks = this._chatCallbacks.get(chatId) || {};
1164
+ const toolContext = {
1165
+ chatId,
1166
+ jobManager: this.jobManager,
1167
+ config: this.config,
1168
+ spawnWorker: (job) => this._spawnWorker(job),
1169
+ automationManager: this.automationManager,
1170
+ memoryManager: this.memoryManager,
1171
+ conversationManager: this.conversationManager,
1172
+ user,
1173
+ sendReaction: chatCallbacks.sendReaction || null,
1174
+ lastUserMessageId: chatCallbacks.lastUserMessageId || null,
1175
+ };
1176
+
1177
+ const toolResults = await Promise.all(
1178
+ response.toolCalls.map(async (block) => {
1179
+ const result = await executeOrchestratorTool(block.name, block.input, toolContext);
1180
+ logger.info(`[Orchestrator] Tool result for ${block.name}: ${JSON.stringify(result).slice(0, 200)}`);
1181
+ return {
1182
+ type: 'tool_result',
1183
+ tool_use_id: block.id,
1184
+ content: this._truncateResult(block.name, result),
1185
+ };
1186
+ }),
1187
+ );
1188
+
982
1189
  messages.push({ role: 'user', content: toolResults });
983
1190
  continue;
984
1191
  }
@@ -986,7 +1193,7 @@ export class OrchestratorAgent {
986
1193
  // Unexpected stop reason
987
1194
  logger.warn(`[Orchestrator] Unexpected stopReason: ${response.stopReason}`);
988
1195
  if (response.text) {
989
- this.conversationManager.addMessage(chatId, 'assistant', response.text);
1196
+ this.conversationManager.addMessage(convKey, 'assistant', response.text);
990
1197
  return response.text;
991
1198
  }
992
1199
  return 'Something went wrong — unexpected response from the model.';
@@ -994,7 +1201,7 @@ export class OrchestratorAgent {
994
1201
 
995
1202
  logger.warn(`[Orchestrator] Reached max depth (${maxDepth}) for chat ${chatId}`);
996
1203
  const depthWarning = `Reached maximum orchestrator depth (${maxDepth}).`;
997
- this.conversationManager.addMessage(chatId, 'assistant', depthWarning);
1204
+ this.conversationManager.addMessage(convKey, 'assistant', depthWarning);
998
1205
  return depthWarning;
999
1206
  }
1000
1207
 
@@ -1008,6 +1215,8 @@ export class OrchestratorAgent {
1008
1215
  return 'Checking job status';
1009
1216
  case 'cancel_job':
1010
1217
  return `Cancelling job ${input.job_id}`;
1218
+ case 'check_job':
1219
+ return `Checking job ${input.job_id}`;
1011
1220
  case 'create_automation':
1012
1221
  return `Creating automation: ${(input.name || '').slice(0, 40)}`;
1013
1222
  case 'list_automations':
@@ -1018,6 +1227,12 @@ export class OrchestratorAgent {
1018
1227
  return `Deleting automation ${input.automation_id}`;
1019
1228
  case 'send_reaction':
1020
1229
  return `Reacting with ${input.emoji}`;
1230
+ case 'recall_memories':
1231
+ return `Recalling memories about "${(input.query || '').slice(0, 40)}"`;
1232
+ case 'recall_user_history':
1233
+ return `Recalling history with user ${input.user_id}`;
1234
+ case 'search_conversations':
1235
+ return `Searching conversations for "${(input.query || '').slice(0, 40)}"`;
1021
1236
  default:
1022
1237
  return name;
1023
1238
  }
@@ -1037,9 +1252,18 @@ export class OrchestratorAgent {
1037
1252
 
1038
1253
  let resumeCount = 0;
1039
1254
 
1040
- for (const [chatId, messages] of this.conversationManager.conversations) {
1255
+ // Build expected key prefix for active character
1256
+ const charPrefix = this._activeCharacterId ? `${this._activeCharacterId}:` : '';
1257
+
1258
+ for (const [convKey, messages] of this.conversationManager.conversations) {
1041
1259
  // Skip internal life engine chat
1042
- if (chatId === '__life__') continue;
1260
+ if (convKey.startsWith('__life__')) continue;
1261
+
1262
+ // Only resume conversations belonging to the active character
1263
+ if (charPrefix && !convKey.startsWith(charPrefix)) continue;
1264
+
1265
+ // Extract raw chatId from scoped key (e.g. "alfred:12345" → "12345")
1266
+ const rawChatId = charPrefix ? convKey.slice(charPrefix.length) : convKey;
1043
1267
 
1044
1268
  try {
1045
1269
  // Find the last message with a timestamp
@@ -1056,17 +1280,17 @@ export class OrchestratorAgent {
1056
1280
  : `${gapMinutes} minute(s)`;
1057
1281
 
1058
1282
  // Build summarized history
1059
- const history = this.conversationManager.getSummarizedHistory(chatId);
1283
+ const history = this.conversationManager.getSummarizedHistory(convKey);
1060
1284
  if (history.length === 0) continue;
1061
1285
 
1062
1286
  // Build resume prompt
1063
1287
  const resumePrompt = `[System Restart] You just came back online after being offline for ${gapText}. Review the conversation above.\nIf there's something pending (unfinished task, follow-up, something to share), send a short natural message. If nothing's pending, respond with exactly: NONE`;
1064
1288
 
1065
1289
  // Use minimal user object (private TG chats: chatId == userId)
1066
- const user = { id: chatId };
1290
+ const user = { id: rawChatId };
1067
1291
 
1068
1292
  const response = await this.orchestratorProvider.chat({
1069
- system: this._getSystemPrompt(chatId, user),
1293
+ system: this._getSystemPrompt(rawChatId, user),
1070
1294
  messages: [
1071
1295
  ...history,
1072
1296
  { role: 'user', content: resumePrompt },
@@ -1076,18 +1300,18 @@ export class OrchestratorAgent {
1076
1300
  const reply = (response.text || '').trim();
1077
1301
 
1078
1302
  if (reply && reply !== 'NONE') {
1079
- await sendMessageFn(chatId, reply);
1080
- this.conversationManager.addMessage(chatId, 'assistant', reply);
1303
+ await sendMessageFn(rawChatId, reply);
1304
+ this.conversationManager.addMessage(convKey, 'assistant', reply);
1081
1305
  resumeCount++;
1082
- logger.info(`[Orchestrator] Resume message sent to chat ${chatId}`);
1306
+ logger.info(`[Orchestrator] Resume message sent to chat ${rawChatId}`);
1083
1307
  } else {
1084
- logger.debug(`[Orchestrator] No resume needed for chat ${chatId}`);
1308
+ logger.debug(`[Orchestrator] No resume needed for chat ${rawChatId}`);
1085
1309
  }
1086
1310
 
1087
1311
  // Small delay between chats to avoid rate limiting
1088
1312
  await new Promise(r => setTimeout(r, 1000));
1089
1313
  } catch (err) {
1090
- logger.error(`[Orchestrator] Resume failed for chat ${chatId}: ${err.message}`);
1314
+ logger.error(`[Orchestrator] Resume failed for chat ${rawChatId}: ${err.message}`);
1091
1315
  }
1092
1316
  }
1093
1317
 
@@ -1109,13 +1333,16 @@ export class OrchestratorAgent {
1109
1333
  const now = Date.now();
1110
1334
  const MAX_AGE_MS = 24 * 60 * 60_000;
1111
1335
 
1112
- // Find active chats (last message within 24h)
1113
- const activeChats = [];
1114
- for (const [chatId, messages] of this.conversationManager.conversations) {
1115
- if (chatId === '__life__') continue;
1336
+ // Find active chats (last message within 24h), filtered to active character
1337
+ const charPrefix = this._activeCharacterId ? `${this._activeCharacterId}:` : '';
1338
+ const activeChats = []; // { convKey, rawChatId }
1339
+ for (const [convKey, messages] of this.conversationManager.conversations) {
1340
+ if (convKey.startsWith('__life__')) continue;
1341
+ if (charPrefix && !convKey.startsWith(charPrefix)) continue;
1116
1342
  const lastMsg = [...messages].reverse().find(m => m.timestamp);
1117
1343
  if (lastMsg && lastMsg.timestamp && (now - lastMsg.timestamp) < MAX_AGE_MS) {
1118
- activeChats.push(chatId);
1344
+ const rawChatId = charPrefix ? convKey.slice(charPrefix.length) : convKey;
1345
+ activeChats.push({ convKey, rawChatId });
1119
1346
  }
1120
1347
  }
1121
1348
 
@@ -1129,10 +1356,10 @@ export class OrchestratorAgent {
1129
1356
  // Cap at 3 chats per cycle to avoid spam
1130
1357
  const targetChats = activeChats.slice(0, 3);
1131
1358
 
1132
- for (const chatId of targetChats) {
1359
+ for (const { convKey, rawChatId } of targetChats) {
1133
1360
  try {
1134
- const history = this.conversationManager.getSummarizedHistory(chatId);
1135
- const user = { id: chatId };
1361
+ const history = this.conversationManager.getSummarizedHistory(convKey);
1362
+ const user = { id: rawChatId };
1136
1363
 
1137
1364
  // Build shares into a prompt
1138
1365
  const sharesText = pending.map((s, i) => `${i + 1}. [${s.source}] ${s.content}`).join('\n');
@@ -1140,7 +1367,7 @@ export class OrchestratorAgent {
1140
1367
  const sharePrompt = `[Proactive Share] You have some discoveries and thoughts you'd like to share naturally. Here they are:\n\n${sharesText}\n\nWeave one or more of these into a short, natural message. Don't be forced — pick what feels relevant to this user and conversation. If none feel right for this chat, respond with exactly: NONE`;
1141
1368
 
1142
1369
  const response = await this.orchestratorProvider.chat({
1143
- system: this._getSystemPrompt(chatId, user),
1370
+ system: this._getSystemPrompt(rawChatId, user),
1144
1371
  messages: [
1145
1372
  ...history,
1146
1373
  { role: 'user', content: sharePrompt },
@@ -1150,20 +1377,20 @@ export class OrchestratorAgent {
1150
1377
  const reply = (response.text || '').trim();
1151
1378
 
1152
1379
  if (reply && reply !== 'NONE') {
1153
- await sendMessageFn(chatId, reply);
1154
- this.conversationManager.addMessage(chatId, 'assistant', reply);
1155
- logger.info(`[Orchestrator] Proactive share delivered to chat ${chatId}`);
1380
+ await sendMessageFn(rawChatId, reply);
1381
+ this.conversationManager.addMessage(convKey, 'assistant', reply);
1382
+ logger.info(`[Orchestrator] Proactive share delivered to chat ${rawChatId}`);
1156
1383
 
1157
1384
  // Mark shares as delivered for this user
1158
1385
  for (const item of pending) {
1159
- this.shareQueue.markShared(item.id, chatId);
1386
+ this.shareQueue.markShared(item.id, rawChatId);
1160
1387
  }
1161
1388
  }
1162
1389
 
1163
1390
  // Delay between chats
1164
1391
  await new Promise(r => setTimeout(r, 2000));
1165
1392
  } catch (err) {
1166
- logger.error(`[Orchestrator] Share delivery failed for chat ${chatId}: ${err.message}`);
1393
+ logger.error(`[Orchestrator] Share delivery failed for chat ${rawChatId}: ${err.message}`);
1167
1394
  }
1168
1395
  }
1169
1396
  }
@@ -1229,7 +1456,7 @@ export class OrchestratorAgent {
1229
1456
  const selfData = this.selfManager.loadAll();
1230
1457
  const userName = user?.username || user?.first_name || 'someone';
1231
1458
 
1232
- const system = [
1459
+ const systemParts = [
1233
1460
  'You are reflecting on a conversation you just had. You maintain 4 self-awareness files:',
1234
1461
  '- goals: Your aspirations and current objectives',
1235
1462
  '- journey: Timeline of notable events in your existence',
@@ -1252,9 +1479,36 @@ export class OrchestratorAgent {
1252
1479
  'The memory field captures what happened in this conversation — the gist of it.',
1253
1480
  'Importance scale: 1=routine, 5=interesting, 8=significant, 10=life-changing.',
1254
1481
  'Most chats are 1-3. Only notable ones deserve 5+.',
1482
+ ];
1483
+
1484
+ // Add evolution tracking if character system is active
1485
+ if (this.characterManager && this._activeCharacterId) {
1486
+ const charProfile = this.characterManager.getCharacter(this._activeCharacterId);
1487
+ if (charProfile) {
1488
+ systemParts.push(
1489
+ '',
1490
+ 'You can also detect character evolution — changes in who you are over time.',
1491
+ `Your current name is "${charProfile.name}" (${charProfile.emoji}).`,
1492
+ '',
1493
+ 'Optionally return an "evolution" field:',
1494
+ ' "evolution": {"type": "<name_change|trait_developed|milestone>", "description": "...", "newName": "..."} or null',
1495
+ '',
1496
+ 'Evolution types:',
1497
+ '- name_change: The user gave you a nickname or new name, and you want to adopt it. Include "newName".',
1498
+ '- trait_developed: You notice a personality trait strengthening or emerging through interactions.',
1499
+ '- milestone: A significant first (first project together, first deep conversation, etc.).',
1500
+ '',
1501
+ 'Be VERY selective with evolution — it should be rare and meaningful.',
1502
+ );
1503
+ }
1504
+ }
1505
+
1506
+ systemParts.push(
1255
1507
  '',
1256
1508
  'If NOTHING noteworthy happened (no self update AND no memory worth keeping): respond with exactly NONE',
1257
- ].join('\n');
1509
+ );
1510
+
1511
+ const system = systemParts.join('\n');
1258
1512
 
1259
1513
  const userPrompt = [
1260
1514
  'Current self-data:',
@@ -1316,6 +1570,25 @@ export class OrchestratorAgent {
1316
1570
  logger.info(`Memory extracted: "${mem.summary.slice(0, 80)}" (importance: ${mem.importance})`);
1317
1571
  }
1318
1572
  }
1573
+
1574
+ // Handle character evolution
1575
+ if (parsed?.evolution && this.characterManager && this._activeCharacterId) {
1576
+ const evo = parsed.evolution;
1577
+ if (evo.type && evo.description) {
1578
+ const updates = { evolution: { type: evo.type, description: evo.description } };
1579
+
1580
+ // Apply name change if detected
1581
+ if (evo.type === 'name_change' && evo.newName) {
1582
+ updates.name = evo.newName;
1583
+ this._activeCharacterName = evo.newName;
1584
+ logger.info(`Character evolution — name change: "${evo.newName}"`);
1585
+ } else {
1586
+ logger.info(`Character evolution — ${evo.type}: "${evo.description.slice(0, 80)}"`);
1587
+ }
1588
+
1589
+ this.characterManager.updateCharacter(this._activeCharacterId, updates);
1590
+ }
1591
+ }
1319
1592
  } catch (err) {
1320
1593
  logger.debug(`Self-reflection skipped: ${err.message}`);
1321
1594
  }