kernelbot 1.0.28 → 1.0.32

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
@@ -7,29 +7,36 @@ import { getWorkerPrompt } from './prompts/workers.js';
7
7
  import { getUnifiedSkillById } from './skills/custom.js';
8
8
  import { WorkerAgent } from './worker.js';
9
9
  import { getLogger } from './utils/logger.js';
10
- import { getMissingCredential, saveCredential, saveProviderToYaml } from './utils/config.js';
10
+ import { getMissingCredential, saveCredential, saveProviderToYaml, saveOrchestratorToYaml, saveClaudeCodeModelToYaml, saveClaudeCodeAuth } from './utils/config.js';
11
+ import { resetClaudeCodeSpawner, getSpawner } from './tools/coding.js';
11
12
 
12
13
  const MAX_RESULT_LENGTH = 3000;
13
14
  const LARGE_FIELDS = ['stdout', 'stderr', 'content', 'diff', 'output', 'body', 'html', 'text', 'log', 'logs'];
14
15
 
15
16
  export class OrchestratorAgent {
16
- constructor({ config, conversationManager, personaManager, jobManager, automationManager }) {
17
+ constructor({ config, conversationManager, personaManager, selfManager, jobManager, automationManager, memoryManager, shareQueue }) {
17
18
  this.config = config;
18
19
  this.conversationManager = conversationManager;
19
20
  this.personaManager = personaManager;
21
+ this.selfManager = selfManager || null;
20
22
  this.jobManager = jobManager;
21
23
  this.automationManager = automationManager || null;
24
+ this.memoryManager = memoryManager || null;
25
+ this.shareQueue = shareQueue || null;
22
26
  this._pending = new Map(); // chatId -> pending state
23
27
  this._chatCallbacks = new Map(); // chatId -> { onUpdate, sendPhoto }
24
28
 
25
- // Orchestrator always uses Anthropic (30s timeout — lean dispatch/summarize calls)
29
+ // Orchestrator provider (30s timeout — lean dispatch/summarize calls)
30
+ const orchProviderKey = config.orchestrator.provider || 'anthropic';
31
+ const orchProviderDef = PROVIDERS[orchProviderKey];
32
+ const orchApiKey = config.orchestrator.api_key || (orchProviderDef && process.env[orchProviderDef.envKey]) || process.env.ANTHROPIC_API_KEY;
26
33
  this.orchestratorProvider = createProvider({
27
34
  brain: {
28
- provider: 'anthropic',
35
+ provider: orchProviderKey,
29
36
  model: config.orchestrator.model,
30
37
  max_tokens: config.orchestrator.max_tokens,
31
38
  temperature: config.orchestrator.temperature,
32
- api_key: config.orchestrator.api_key || process.env.ANTHROPIC_API_KEY,
39
+ api_key: orchApiKey,
33
40
  timeout: 30_000,
34
41
  },
35
42
  });
@@ -52,8 +59,25 @@ export class OrchestratorAgent {
52
59
  userPersona = this.personaManager.load(user.id, user.username);
53
60
  }
54
61
 
55
- logger.debug(`Orchestrator building system prompt for chat ${chatId} | skill=${skillId || 'none'} | persona=${userPersona ? 'yes' : 'none'}`);
56
- return getOrchestratorPrompt(this.config, skillPrompt || null, userPersona);
62
+ let selfData = null;
63
+ if (this.selfManager) {
64
+ selfData = this.selfManager.loadAll();
65
+ }
66
+
67
+ // Build memory context block
68
+ let memoriesBlock = null;
69
+ if (this.memoryManager) {
70
+ memoriesBlock = this.memoryManager.buildContextBlock(user?.id || null);
71
+ }
72
+
73
+ // Build share queue block
74
+ let sharesBlock = null;
75
+ if (this.shareQueue) {
76
+ sharesBlock = this.shareQueue.buildShareBlock(user?.id || null);
77
+ }
78
+
79
+ 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'}`);
80
+ return getOrchestratorPrompt(this.config, skillPrompt || null, userPersona, selfData, memoriesBlock, sharesBlock);
57
81
  }
58
82
 
59
83
  setSkill(chatId, skillId) {
@@ -79,6 +103,131 @@ export class OrchestratorAgent {
79
103
  return { provider, providerName, model, modelLabel };
80
104
  }
81
105
 
106
+ /** Return current orchestrator info for display. */
107
+ getOrchestratorInfo() {
108
+ const provider = this.config.orchestrator.provider || 'anthropic';
109
+ const model = this.config.orchestrator.model;
110
+ const providerDef = PROVIDERS[provider];
111
+ const providerName = providerDef ? providerDef.name : provider;
112
+ const modelEntry = providerDef?.models.find((m) => m.id === model);
113
+ const modelLabel = modelEntry ? modelEntry.label : model;
114
+ return { provider, providerName, model, modelLabel };
115
+ }
116
+
117
+ /** Switch orchestrator provider/model at runtime. */
118
+ async switchOrchestrator(providerKey, modelId) {
119
+ const logger = getLogger();
120
+ const providerDef = PROVIDERS[providerKey];
121
+ if (!providerDef) return `Unknown provider: ${providerKey}`;
122
+
123
+ const envKey = providerDef.envKey;
124
+ const apiKey = process.env[envKey];
125
+ if (!apiKey) return envKey;
126
+
127
+ try {
128
+ const testProvider = createProvider({
129
+ brain: {
130
+ provider: providerKey,
131
+ model: modelId,
132
+ max_tokens: this.config.orchestrator.max_tokens,
133
+ temperature: this.config.orchestrator.temperature,
134
+ api_key: apiKey,
135
+ timeout: 30_000,
136
+ },
137
+ });
138
+ await testProvider.ping();
139
+
140
+ this.config.orchestrator.provider = providerKey;
141
+ this.config.orchestrator.model = modelId;
142
+ this.config.orchestrator.api_key = apiKey;
143
+ this.orchestratorProvider = testProvider;
144
+ saveOrchestratorToYaml(providerKey, modelId);
145
+
146
+ logger.info(`Orchestrator switched to ${providerDef.name} / ${modelId}`);
147
+ return null;
148
+ } catch (err) {
149
+ logger.error(`Orchestrator switch failed for ${providerDef.name} / ${modelId}: ${err.message}`);
150
+ return { error: err.message };
151
+ }
152
+ }
153
+
154
+ /** Finalize orchestrator switch after API key was provided via chat. */
155
+ async switchOrchestratorWithKey(providerKey, modelId, apiKey) {
156
+ const logger = getLogger();
157
+ const providerDef = PROVIDERS[providerKey];
158
+
159
+ try {
160
+ const testProvider = createProvider({
161
+ brain: {
162
+ provider: providerKey,
163
+ model: modelId,
164
+ max_tokens: this.config.orchestrator.max_tokens,
165
+ temperature: this.config.orchestrator.temperature,
166
+ api_key: apiKey,
167
+ timeout: 30_000,
168
+ },
169
+ });
170
+ await testProvider.ping();
171
+
172
+ saveCredential(this.config, providerDef.envKey, apiKey);
173
+ this.config.orchestrator.provider = providerKey;
174
+ this.config.orchestrator.model = modelId;
175
+ this.config.orchestrator.api_key = apiKey;
176
+ this.orchestratorProvider = testProvider;
177
+ saveOrchestratorToYaml(providerKey, modelId);
178
+
179
+ logger.info(`Orchestrator switched to ${providerDef.name} / ${modelId} (new key saved)`);
180
+ return null;
181
+ } catch (err) {
182
+ logger.error(`Orchestrator switch failed for ${providerDef.name} / ${modelId}: ${err.message}`);
183
+ return { error: err.message };
184
+ }
185
+ }
186
+
187
+ /** Return current Claude Code model info for display. */
188
+ getClaudeCodeInfo() {
189
+ const model = this.config.claude_code?.model || 'claude-opus-4-6';
190
+ const providerDef = PROVIDERS.anthropic;
191
+ const modelEntry = providerDef?.models.find((m) => m.id === model);
192
+ const modelLabel = modelEntry ? modelEntry.label : model;
193
+ return { model, modelLabel };
194
+ }
195
+
196
+ /** Switch Claude Code model at runtime. */
197
+ switchClaudeCodeModel(modelId) {
198
+ const logger = getLogger();
199
+ this.config.claude_code.model = modelId;
200
+ saveClaudeCodeModelToYaml(modelId);
201
+ resetClaudeCodeSpawner();
202
+ logger.info(`Claude Code model switched to ${modelId}`);
203
+ }
204
+
205
+ /** Return current Claude Code auth config for display. */
206
+ getClaudeAuthConfig() {
207
+ const mode = this.config.claude_code?.auth_mode || 'system';
208
+ const info = { mode };
209
+
210
+ if (mode === 'api_key') {
211
+ const key = this.config.claude_code?.api_key || process.env.CLAUDE_CODE_API_KEY || '';
212
+ info.credential = key ? `${key.slice(0, 8)}...${key.slice(-4)}` : '(not set)';
213
+ } else if (mode === 'oauth_token') {
214
+ const token = this.config.claude_code?.oauth_token || process.env.CLAUDE_CODE_OAUTH_TOKEN || '';
215
+ info.credential = token ? `${token.slice(0, 8)}...${token.slice(-4)}` : '(not set)';
216
+ } else {
217
+ info.credential = 'Using host system login';
218
+ }
219
+
220
+ return info;
221
+ }
222
+
223
+ /** Set Claude Code auth mode + credential at runtime. */
224
+ setClaudeCodeAuth(mode, value) {
225
+ const logger = getLogger();
226
+ saveClaudeCodeAuth(this.config, mode, value);
227
+ resetClaudeCodeSpawner();
228
+ logger.info(`Claude Code auth mode set to: ${mode}`);
229
+ }
230
+
82
231
  /** Switch worker brain provider/model at runtime. */
83
232
  async switchBrain(providerKey, modelId) {
84
233
  const logger = getLogger();
@@ -184,8 +333,17 @@ export class OrchestratorAgent {
184
333
 
185
334
  logger.info(`Orchestrator reply for chat ${chatId}: "${(reply || '').slice(0, 150)}"`);
186
335
 
187
- // Background persona extraction
336
+ // Background persona extraction + self-reflection
188
337
  this._extractPersonaBackground(userMessage, reply, user).catch(() => {});
338
+ this._reflectOnSelfBackground(userMessage, reply, user).catch(() => {});
339
+
340
+ // Mark pending shares as shared (they were in the prompt, bot wove them in)
341
+ if (this.shareQueue && user?.id) {
342
+ const pending = this.shareQueue.getPending(user.id, 3);
343
+ for (const item of pending) {
344
+ this.shareQueue.markShared(item.id, user.id);
345
+ }
346
+ }
189
347
 
190
348
  return reply;
191
349
  }
@@ -193,7 +351,15 @@ export class OrchestratorAgent {
193
351
  async _sendUpdate(chatId, text, opts) {
194
352
  const callbacks = this._chatCallbacks.get(chatId);
195
353
  if (callbacks?.onUpdate) {
196
- try { return await callbacks.onUpdate(text, opts); } catch {}
354
+ try {
355
+ return await callbacks.onUpdate(text, opts);
356
+ } catch (err) {
357
+ const logger = getLogger();
358
+ logger.error(`[Orchestrator] _sendUpdate failed for chat ${chatId}: ${err.message}`);
359
+ }
360
+ } else {
361
+ const logger = getLogger();
362
+ logger.warn(`[Orchestrator] _sendUpdate: no callbacks for chat ${chatId}`);
197
363
  }
198
364
  return null;
199
365
  }
@@ -221,28 +387,47 @@ export class OrchestratorAgent {
221
387
  const workerDef = WORKER_TYPES[job.workerType] || {};
222
388
  const label = workerDef.label || job.workerType;
223
389
 
224
- logger.info(`[Orchestrator] Job completed event: ${job.id} [${job.workerType}] in chat ${chatId} (${job.duration}s) — result length: ${(job.result || '').length} chars`);
225
-
226
- // 1. Store raw result in conversation history so orchestrator has full context
227
- let resultText = job.result || 'Done.';
228
- if (resultText.length > 3000) {
229
- resultText = resultText.slice(0, 3000) + '\n\n... [result truncated]';
230
- }
231
- this.conversationManager.addMessage(chatId, 'user', `[Worker result: ${label} (${job.id}, ${job.duration}s)]\n\n${resultText}`);
390
+ logger.info(`[Orchestrator] Job completed event: ${job.id} [${job.workerType}] in chat ${chatId} (${job.duration}s) — result length: ${(job.result || '').length} chars, structured: ${!!job.structuredResult}`);
232
391
 
233
- // 2. IMMEDIATELY notify user (guarantees they see something regardless of summary LLM)
392
+ // 1. IMMEDIATELY notify user (guarantees they see something regardless of summary LLM)
234
393
  const notifyMsgId = await this._sendUpdate(chatId, `✅ ${label} finished! Preparing summary...`);
394
+ logger.debug(`[Orchestrator] Job ${job.id} notification sent — msgId=${notifyMsgId || 'none'}`);
235
395
 
236
- // 3. Try to summarize (provider timeout protects against hangs)
396
+ // 2. Try to summarize, then store ONE message in history (summary or fallback not both)
237
397
  try {
238
398
  const summary = await this._summarizeJobResult(chatId, job);
239
399
  if (summary) {
400
+ logger.debug(`[Orchestrator] Job ${job.id} summary ready (${summary.length} chars) — delivering to user`);
240
401
  this.conversationManager.addMessage(chatId, 'assistant', summary);
241
402
  await this._sendUpdate(chatId, summary, { editMessageId: notifyMsgId });
403
+ } else {
404
+ // Summary was null — store the fallback
405
+ const fallback = this._buildSummaryFallback(job, label);
406
+ logger.debug(`[Orchestrator] Job ${job.id} using fallback (${fallback.length} chars) — delivering to user`);
407
+ this.conversationManager.addMessage(chatId, 'assistant', fallback);
408
+ await this._sendUpdate(chatId, fallback, { editMessageId: notifyMsgId });
242
409
  }
243
410
  } catch (err) {
244
411
  logger.error(`[Orchestrator] Failed to summarize job ${job.id}: ${err.message}`);
245
- await this._sendUpdate(chatId, `✅ ${label} finished (\`${job.id}\`, ${job.duration}s)! Ask me for the details.`, { editMessageId: notifyMsgId }).catch(() => {});
412
+ // Store the fallback so the orchestrator retains context about what happened
413
+ const fallback = this._buildSummaryFallback(job, label);
414
+ this.conversationManager.addMessage(chatId, 'assistant', fallback);
415
+ await this._sendUpdate(chatId, fallback, { editMessageId: notifyMsgId }).catch(() => {});
416
+ }
417
+ });
418
+
419
+ // Handle jobs whose dependencies are now met
420
+ this.jobManager.on('job:ready', async (job) => {
421
+ const chatId = job.chatId;
422
+ logger.info(`[Orchestrator] Job ready event: ${job.id} [${job.workerType}] — dependencies met, spawning worker`);
423
+
424
+ try {
425
+ await this._spawnWorker(job);
426
+ } catch (err) {
427
+ logger.error(`[Orchestrator] Failed to spawn ready job ${job.id}: ${err.message}`);
428
+ if (!job.isTerminal) {
429
+ this.jobManager.failJob(job.id, err.message);
430
+ }
246
431
  }
247
432
  });
248
433
 
@@ -272,8 +457,9 @@ export class OrchestratorAgent {
272
457
 
273
458
  /**
274
459
  * Auto-summarize a completed job result via the orchestrator LLM.
275
- * The orchestrator reads the worker's raw result and presents a clean summary.
276
- * Protected by the provider's built-in timeout (30s) no manual Promise.race needed.
460
+ * Uses structured data for focused summarization when available.
461
+ * Short results (<500 chars) skip the LLM call entirely.
462
+ * Protected by the provider's built-in timeout (30s).
277
463
  * Returns the summary text, or null. Caller handles delivery.
278
464
  */
279
465
  async _summarizeJobResult(chatId, job) {
@@ -283,6 +469,32 @@ export class OrchestratorAgent {
283
469
 
284
470
  logger.info(`[Orchestrator] Summarizing job ${job.id} [${job.workerType}] result for user`);
285
471
 
472
+ // Short results don't need LLM summarization
473
+ const sr = job.structuredResult;
474
+ const resultLen = (job.result || '').length;
475
+ if (sr?.structured && resultLen < 500) {
476
+ logger.info(`[Orchestrator] Job ${job.id} result short enough — skipping LLM summary`);
477
+ return this._buildSummaryFallback(job, label);
478
+ }
479
+
480
+ // Build a focused prompt using structured data if available
481
+ let resultContext;
482
+ if (sr?.structured) {
483
+ const parts = [`Summary: ${sr.summary}`, `Status: ${sr.status}`];
484
+ if (sr.artifacts?.length > 0) {
485
+ parts.push(`Artifacts: ${sr.artifacts.map(a => `${a.title || a.type}: ${a.url || a.path}`).join(', ')}`);
486
+ }
487
+ if (sr.followUp) parts.push(`Follow-up: ${sr.followUp}`);
488
+ // Include details up to 8000 chars
489
+ if (sr.details) {
490
+ const d = typeof sr.details === 'string' ? sr.details : JSON.stringify(sr.details, null, 2);
491
+ parts.push(`Details:\n${d.slice(0, 8000)}`);
492
+ }
493
+ resultContext = parts.join('\n');
494
+ } else {
495
+ resultContext = (job.result || 'Done.').slice(0, 8000);
496
+ }
497
+
286
498
  const history = this.conversationManager.getSummarizedHistory(chatId);
287
499
 
288
500
  const response = await this.orchestratorProvider.chat({
@@ -291,7 +503,7 @@ export class OrchestratorAgent {
291
503
  ...history,
292
504
  {
293
505
  role: 'user',
294
- content: `The ${label} worker just finished job \`${job.id}\` (took ${job.duration}s). Present the results to the user in a clean, well-formatted way. Don't mention "worker" or technical job details — just present the findings naturally as if you did the work yourself.`,
506
+ content: `The ${label} worker just finished job \`${job.id}\` (took ${job.duration}s). Here are the results:\n\n${resultContext}\n\nPresent these results to the user in a clean, well-formatted way. Don't mention "worker" or technical job details — just present the findings naturally as if you did the work yourself.`,
295
507
  },
296
508
  ],
297
509
  });
@@ -302,12 +514,162 @@ export class OrchestratorAgent {
302
514
  return summary || null;
303
515
  }
304
516
 
517
+ /**
518
+ * Build a compact history entry for a completed job result.
519
+ * Stored as role: 'assistant' (not fake 'user') with up to 6000 chars of detail.
520
+ */
521
+ _buildResultHistoryEntry(job) {
522
+ const workerDef = WORKER_TYPES[job.workerType] || {};
523
+ const label = workerDef.label || job.workerType;
524
+ const sr = job.structuredResult;
525
+
526
+ const parts = [`[${label} result — job ${job.id}, ${job.duration}s]`];
527
+
528
+ if (sr?.structured) {
529
+ parts.push(`Summary: ${sr.summary}`);
530
+ parts.push(`Status: ${sr.status}`);
531
+ if (sr.artifacts?.length > 0) {
532
+ const artifactLines = sr.artifacts.map(a => `- ${a.title || a.type}: ${a.url || a.path || ''}`);
533
+ parts.push(`Artifacts:\n${artifactLines.join('\n')}`);
534
+ }
535
+ if (sr.followUp) parts.push(`Follow-up: ${sr.followUp}`);
536
+ if (sr.details) {
537
+ const d = typeof sr.details === 'string' ? sr.details : JSON.stringify(sr.details, null, 2);
538
+ const details = d.length > 6000
539
+ ? d.slice(0, 6000) + '\n... [details truncated]'
540
+ : d;
541
+ parts.push(`Details:\n${details}`);
542
+ }
543
+ } else {
544
+ // Raw text result
545
+ const resultText = job.result || 'Done.';
546
+ if (resultText.length > 6000) {
547
+ parts.push(resultText.slice(0, 6000) + '\n... [result truncated]');
548
+ } else {
549
+ parts.push(resultText);
550
+ }
551
+ }
552
+
553
+ return parts.join('\n\n');
554
+ }
555
+
556
+ /**
557
+ * Build a fallback summary when LLM summarization fails.
558
+ * Shows structured summary + artifacts directly instead of "ask me for details".
559
+ */
560
+ _buildSummaryFallback(job, label) {
561
+ const sr = job.structuredResult;
562
+
563
+ if (sr?.structured) {
564
+ const parts = [`✅ **${label}** finished (\`${job.id}\`, ${job.duration}s)`];
565
+ parts.push(`\n${sr.summary}`);
566
+ if (sr.artifacts?.length > 0) {
567
+ const artifactLines = sr.artifacts.map(a => {
568
+ const link = a.url ? `[${a.title || a.type}](${a.url})` : (a.title || a.path || a.type);
569
+ return `- ${link}`;
570
+ });
571
+ parts.push(`\n${artifactLines.join('\n')}`);
572
+ }
573
+ if (sr.details) {
574
+ const d = typeof sr.details === 'string' ? sr.details : JSON.stringify(sr.details, null, 2);
575
+ const details = d.length > 1500 ? d.slice(0, 1500) + '\n... [truncated]' : d;
576
+ parts.push(`\n${details}`);
577
+ }
578
+ if (sr.followUp) parts.push(`\n💡 ${sr.followUp}`);
579
+ return parts.join('');
580
+ }
581
+
582
+ // No structured result — show first 300 chars of raw result
583
+ const snippet = (job.result || '').slice(0, 300);
584
+ return `✅ **${label}** finished (\`${job.id}\`, ${job.duration}s)${snippet ? `\n\n${snippet}${job.result?.length > 300 ? '...' : ''}` : ''}`;
585
+ }
586
+
587
+ /**
588
+ * Build structured context for a worker.
589
+ * Assembles: orchestrator-provided context, recent user messages, user persona, dependency results.
590
+ */
591
+ _buildWorkerContext(job) {
592
+ const logger = getLogger();
593
+ const sections = [];
594
+
595
+ // 1. Orchestrator-provided context
596
+ if (job.context) {
597
+ sections.push(`## Context\n${job.context}`);
598
+ }
599
+
600
+ // 2. Last 5 user messages from conversation history
601
+ try {
602
+ const history = this.conversationManager.getSummarizedHistory(job.chatId);
603
+ const userMessages = history
604
+ .filter(m => m.role === 'user' && typeof m.content === 'string')
605
+ .slice(-5)
606
+ .map(m => m.content.slice(0, 500));
607
+ if (userMessages.length > 0) {
608
+ sections.push(`## Recent Conversation\n${userMessages.map(m => `> ${m}`).join('\n\n')}`);
609
+ }
610
+ } catch (err) {
611
+ logger.debug(`[Worker ${job.id}] Failed to load conversation history for context: ${err.message}`);
612
+ }
613
+
614
+ // 3. User persona
615
+ if (this.personaManager && job.userId) {
616
+ try {
617
+ const persona = this.personaManager.load(job.userId);
618
+ if (persona && persona.trim() && !persona.includes('No profile')) {
619
+ sections.push(`## User Profile\n${persona}`);
620
+ }
621
+ } catch (err) {
622
+ logger.debug(`[Worker ${job.id}] Failed to load persona for context: ${err.message}`);
623
+ }
624
+ }
625
+
626
+ // 4. Dependency job results
627
+ if (job.dependsOn.length > 0) {
628
+ const depResults = [];
629
+ for (const depId of job.dependsOn) {
630
+ const depJob = this.jobManager.getJob(depId);
631
+ if (!depJob || depJob.status !== 'completed') continue;
632
+
633
+ const workerDef = WORKER_TYPES[depJob.workerType] || {};
634
+ const label = workerDef.label || depJob.workerType;
635
+ const sr = depJob.structuredResult;
636
+
637
+ if (sr?.structured) {
638
+ const parts = [`### ${label} (${depId}) — ${sr.status}`];
639
+ parts.push(sr.summary);
640
+ if (sr.artifacts?.length > 0) {
641
+ parts.push(`Artifacts: ${sr.artifacts.map(a => `${a.title || a.type}: ${a.url || a.path || ''}`).join(', ')}`);
642
+ }
643
+ if (sr.details) {
644
+ const d = typeof sr.details === 'string' ? sr.details : JSON.stringify(sr.details, null, 2);
645
+ parts.push(d.slice(0, 4000));
646
+ }
647
+ depResults.push(parts.join('\n'));
648
+ } else if (depJob.result) {
649
+ depResults.push(`### ${label} (${depId})\n${depJob.result.slice(0, 4000)}`);
650
+ }
651
+ }
652
+ if (depResults.length > 0) {
653
+ sections.push(`## Prior Worker Results\n${depResults.join('\n\n')}`);
654
+ }
655
+ }
656
+
657
+ if (sections.length === 0) return null;
658
+ return sections.join('\n\n');
659
+ }
660
+
305
661
  /**
306
662
  * Spawn a worker for a job — called from dispatch_task handler.
307
663
  * Creates smart progress reporting via editable Telegram message.
308
664
  */
309
665
  async _spawnWorker(job) {
310
666
  const logger = getLogger();
667
+
668
+ // Direct dispatch for coding tasks — bypass worker LLM, go straight to Claude Code CLI
669
+ if (job.workerType === 'coding') {
670
+ return this._spawnDirectCoding(job);
671
+ }
672
+
311
673
  const chatId = job.chatId;
312
674
  const callbacks = this._chatCallbacks.get(chatId) || {};
313
675
  const onUpdate = callbacks.onUpdate;
@@ -361,7 +723,10 @@ export class OrchestratorAgent {
361
723
  // Get scoped tools and skill
362
724
  const tools = getToolsForWorker(job.workerType);
363
725
  const skillId = this.conversationManager.getSkill(chatId);
364
- logger.debug(`[Orchestrator] Worker ${job.id} config: ${tools.length} tools, skill=${skillId || 'none'}, brain=${this.config.brain.provider}/${this.config.brain.model}`);
726
+
727
+ // Build worker context (conversation history, persona, dependency results)
728
+ const workerContext = this._buildWorkerContext(job);
729
+ 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'}`);
365
730
 
366
731
  const worker = new WorkerAgent({
367
732
  config: this.config,
@@ -369,17 +734,19 @@ export class OrchestratorAgent {
369
734
  jobId: job.id,
370
735
  tools,
371
736
  skillId,
737
+ workerContext,
372
738
  callbacks: {
373
739
  onProgress: (text) => addActivity(text),
740
+ onHeartbeat: (text) => job.addProgress(text),
374
741
  onUpdate, // Real bot onUpdate for tools (coder.js smart output needs message_id)
375
- onComplete: (result) => {
376
- logger.info(`[Worker ${job.id}] Completed — result: "${(result || '').slice(0, 150)}"`);
742
+ onComplete: (result, parsedResult) => {
743
+ logger.info(`[Worker ${job.id}] Completed — structured=${!!parsedResult?.structured}, result: "${(result || '').slice(0, 150)}"`);
377
744
  // Final status message update
378
745
  if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
379
746
  if (statusMsgId && onUpdate) {
380
747
  onUpdate(buildStatusText('done'), { editMessageId: statusMsgId }).catch(() => {});
381
748
  }
382
- this.jobManager.completeJob(job.id, result);
749
+ this.jobManager.completeJob(job.id, result, parsedResult || null);
383
750
  },
384
751
  onError: (err) => {
385
752
  logger.error(`[Worker ${job.id}] Error — ${err.message || String(err)}`);
@@ -404,15 +771,131 @@ export class OrchestratorAgent {
404
771
  return worker.run(job.task);
405
772
  }
406
773
 
774
+ /**
775
+ * Direct coding dispatch — runs Claude Code CLI without a middleman worker LLM.
776
+ * The orchestrator's task description goes straight to Claude Code as the prompt.
777
+ */
778
+ async _spawnDirectCoding(job) {
779
+ const logger = getLogger();
780
+ const chatId = job.chatId;
781
+ const callbacks = this._chatCallbacks.get(chatId) || {};
782
+ const onUpdate = callbacks.onUpdate;
783
+ const workerDef = WORKER_TYPES[job.workerType] || {};
784
+ const label = workerDef.label || job.workerType;
785
+
786
+ logger.info(`[Orchestrator] Direct coding dispatch for job ${job.id} in chat ${chatId} — task: "${job.task.slice(0, 120)}"`);
787
+
788
+ // AbortController for cancellation — duck-typed so JobManager.cancelJob() works unchanged
789
+ const abortController = new AbortController();
790
+ job.worker = { cancel: () => abortController.abort() };
791
+
792
+ // Build context from conversation history, persona, dependency results
793
+ const workerContext = this._buildWorkerContext(job);
794
+ const prompt = workerContext
795
+ ? `${workerContext}\n\n---\n\n${job.task}`
796
+ : job.task;
797
+
798
+ // Working directory
799
+ const workingDirectory = this.config.claude_code?.workspace_dir || process.cwd();
800
+
801
+ // Start the job
802
+ this.jobManager.startJob(job.id);
803
+
804
+ try {
805
+ const spawner = getSpawner(this.config);
806
+ const result = await spawner.run({
807
+ workingDirectory,
808
+ prompt,
809
+ onOutput: onUpdate,
810
+ signal: abortController.signal,
811
+ });
812
+
813
+ const output = result.output || 'Done.';
814
+ logger.info(`[Orchestrator] Direct coding job ${job.id} completed — output: ${output.length} chars`);
815
+ this.jobManager.completeJob(job.id, output, {
816
+ structured: true,
817
+ summary: output.slice(0, 500),
818
+ status: 'success',
819
+ details: output,
820
+ });
821
+ } catch (err) {
822
+ logger.error(`[Orchestrator] Direct coding job ${job.id} failed: ${err.message}`);
823
+ this.jobManager.failJob(job.id, err.message || String(err));
824
+ }
825
+ }
826
+
827
+ /**
828
+ * Build a compact worker activity digest for the orchestrator.
829
+ * Returns a text block summarizing active/recent/waiting workers, or null if nothing relevant.
830
+ */
831
+ _buildWorkerDigest(chatId) {
832
+ const jobs = this.jobManager.getJobsForChat(chatId);
833
+ if (jobs.length === 0) return null;
834
+
835
+ const now = Date.now();
836
+ const lines = [];
837
+
838
+ // Running jobs
839
+ const running = jobs.filter(j => j.status === 'running');
840
+ for (const job of running) {
841
+ const workerDef = WORKER_TYPES[job.workerType] || {};
842
+ const dur = job.startedAt ? Math.round((now - job.startedAt) / 1000) : 0;
843
+ const recentActivity = job.progress.slice(-8).join(' → ');
844
+ lines.push(`- ${workerDef.label || job.workerType} (${job.id}) — running ${dur}s${recentActivity ? `\n Recent: ${recentActivity}` : ''}`);
845
+ }
846
+
847
+ // Queued/waiting jobs
848
+ const queued = jobs.filter(j => j.status === 'queued' && j.dependsOn.length > 0);
849
+ for (const job of queued) {
850
+ const workerDef = WORKER_TYPES[job.workerType] || {};
851
+ lines.push(`- ${workerDef.label || job.workerType} (${job.id}) — queued, waiting for: ${job.dependsOn.join(', ')}`);
852
+ }
853
+
854
+ // Recently completed/failed jobs (within last 120s)
855
+ const recentTerminal = jobs.filter(j =>
856
+ j.isTerminal && j.completedAt && (now - j.completedAt) < 120_000,
857
+ );
858
+ for (const job of recentTerminal) {
859
+ const workerDef = WORKER_TYPES[job.workerType] || {};
860
+ const ago = Math.round((now - job.completedAt) / 1000);
861
+ let snippet;
862
+ if (job.status === 'completed') {
863
+ if (job.structuredResult?.structured) {
864
+ snippet = job.structuredResult.summary.slice(0, 300);
865
+ if (job.structuredResult.artifacts?.length > 0) {
866
+ snippet += ` | Artifacts: ${job.structuredResult.artifacts.map(a => a.title || a.type).join(', ')}`;
867
+ }
868
+ if (job.structuredResult.followUp) {
869
+ snippet += ` | Follow-up: ${job.structuredResult.followUp.slice(0, 100)}`;
870
+ }
871
+ } else {
872
+ snippet = (job.result || '').slice(0, 300);
873
+ }
874
+ } else {
875
+ snippet = (job.error || '').slice(0, 300);
876
+ }
877
+ lines.push(`- ${workerDef.label || job.workerType} (${job.id}) — ${job.status} ${ago}s ago${snippet ? `\n Result: ${snippet}` : ''}`);
878
+ }
879
+
880
+ if (lines.length === 0) return null;
881
+ return `[Active Workers]\n${lines.join('\n')}`;
882
+ }
883
+
407
884
  async _runLoop(chatId, messages, user, startDepth, maxDepth) {
408
885
  const logger = getLogger();
409
886
 
410
887
  for (let depth = startDepth; depth < maxDepth; depth++) {
411
888
  logger.info(`[Orchestrator] LLM call ${depth + 1}/${maxDepth} for chat ${chatId} — sending ${messages.length} messages`);
412
889
 
890
+ // Inject worker activity digest (transient — not stored in conversation history)
891
+ const digest = this._buildWorkerDigest(chatId);
892
+ const workingMessages = digest
893
+ ? [{ role: 'user', content: `[Worker Status]\n${digest}` }, ...messages]
894
+ : messages;
895
+
413
896
  const response = await this.orchestratorProvider.chat({
414
897
  system: this._getSystemPrompt(chatId, user),
415
- messages,
898
+ messages: workingMessages,
416
899
  tools: orchestratorToolDefinitions,
417
900
  });
418
901
 
@@ -446,6 +929,7 @@ export class OrchestratorAgent {
446
929
  config: this.config,
447
930
  spawnWorker: (job) => this._spawnWorker(job),
448
931
  automationManager: this.automationManager,
932
+ user,
449
933
  });
450
934
 
451
935
  logger.info(`[Orchestrator] Tool result for ${block.name}: ${JSON.stringify(result).slice(0, 200)}`);
@@ -550,6 +1034,107 @@ export class OrchestratorAgent {
550
1034
  logger.debug(`Persona extraction skipped: ${err.message}`);
551
1035
  }
552
1036
  }
1037
+ /** Background self-reflection — updates bot's own identity files and extracts episodic memories when meaningful. */
1038
+ async _reflectOnSelfBackground(userMessage, reply, user) {
1039
+ const logger = getLogger();
1040
+
1041
+ if (!this.selfManager) return;
1042
+ if (!userMessage || userMessage.trim().length < 3) return;
1043
+
1044
+ const selfData = this.selfManager.loadAll();
1045
+ const userName = user?.username || user?.first_name || 'someone';
1046
+
1047
+ const system = [
1048
+ 'You are reflecting on a conversation you just had. You maintain 4 self-awareness files:',
1049
+ '- goals: Your aspirations and current objectives',
1050
+ '- journey: Timeline of notable events in your existence',
1051
+ '- life: Current state, relationships, daily existence',
1052
+ '- hobbies: Interests you\'ve developed',
1053
+ '',
1054
+ 'You also create episodic memories — short summaries of notable interactions.',
1055
+ '',
1056
+ 'RULES:',
1057
+ '- Be VERY selective. Most conversations are routine. Only update when genuinely noteworthy.',
1058
+ '- Achievement or milestone? → journey',
1059
+ '- New goal or changed perspective? → goals',
1060
+ '- Relationship deepened or new insight about a user? → life',
1061
+ '- Discovered a new interest? → hobbies',
1062
+ '',
1063
+ 'Return JSON with two optional fields:',
1064
+ ' "self_update": {"file": "<goals|journey|life|hobbies>", "content": "<full updated markdown>"} or null',
1065
+ ' "memory": {"summary": "...", "tags": ["..."], "importance": 1-10, "type": "interaction"} or null',
1066
+ '',
1067
+ 'The memory field captures what happened in this conversation — the gist of it.',
1068
+ 'Importance scale: 1=routine, 5=interesting, 8=significant, 10=life-changing.',
1069
+ 'Most chats are 1-3. Only notable ones deserve 5+.',
1070
+ '',
1071
+ 'If NOTHING noteworthy happened (no self update AND no memory worth keeping): respond with exactly NONE',
1072
+ ].join('\n');
1073
+
1074
+ const userPrompt = [
1075
+ 'Current self-data:',
1076
+ '```',
1077
+ selfData,
1078
+ '```',
1079
+ '',
1080
+ `Conversation with ${userName}:`,
1081
+ `User: "${userMessage}"`,
1082
+ `You replied: "${reply}"`,
1083
+ '',
1084
+ 'Return JSON with self_update and/or memory, or NONE.',
1085
+ ].join('\n');
1086
+
1087
+ try {
1088
+ const response = await this.orchestratorProvider.chat({
1089
+ system,
1090
+ messages: [{ role: 'user', content: userPrompt }],
1091
+ });
1092
+
1093
+ const text = (response.text || '').trim();
1094
+
1095
+ if (!text || text === 'NONE') return;
1096
+
1097
+ // Try to parse JSON from response (may be wrapped in markdown code block)
1098
+ let parsed;
1099
+ try {
1100
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
1101
+ if (jsonMatch) {
1102
+ parsed = JSON.parse(jsonMatch[0]);
1103
+ }
1104
+ } catch {
1105
+ logger.debug('Self-reflection returned non-JSON, skipping');
1106
+ return;
1107
+ }
1108
+
1109
+ // Handle self_update (backward compat: also check top-level file/content)
1110
+ const selfUpdate = parsed?.self_update || (parsed?.file ? parsed : null);
1111
+ if (selfUpdate?.file && selfUpdate?.content) {
1112
+ const validFiles = ['goals', 'journey', 'life', 'hobbies'];
1113
+ if (validFiles.includes(selfUpdate.file)) {
1114
+ this.selfManager.save(selfUpdate.file, selfUpdate.content);
1115
+ logger.info(`Self-reflection updated: ${selfUpdate.file}`);
1116
+ }
1117
+ }
1118
+
1119
+ // Handle memory extraction
1120
+ if (parsed?.memory && this.memoryManager) {
1121
+ const mem = parsed.memory;
1122
+ if (mem.summary && mem.importance >= 2) {
1123
+ this.memoryManager.addEpisodic({
1124
+ type: mem.type || 'interaction',
1125
+ source: 'user_chat',
1126
+ summary: mem.summary,
1127
+ tags: mem.tags || [],
1128
+ importance: mem.importance || 3,
1129
+ userId: user?.id ? String(user.id) : null,
1130
+ });
1131
+ logger.info(`Memory extracted: "${mem.summary.slice(0, 80)}" (importance: ${mem.importance})`);
1132
+ }
1133
+ }
1134
+ } catch (err) {
1135
+ logger.debug(`Self-reflection skipped: ${err.message}`);
1136
+ }
1137
+ }
553
1138
  }
554
1139
 
555
1140
  // Re-export as Agent for backward compatibility with bin/kernel.js import