kernelbot 1.0.26 → 1.0.30

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.
Files changed (61) hide show
  1. package/.env.example +4 -0
  2. package/README.md +198 -124
  3. package/bin/kernel.js +208 -4
  4. package/config.example.yaml +14 -1
  5. package/package.json +1 -1
  6. package/src/agent.js +839 -209
  7. package/src/automation/automation-manager.js +377 -0
  8. package/src/automation/automation.js +79 -0
  9. package/src/automation/index.js +2 -0
  10. package/src/automation/scheduler.js +141 -0
  11. package/src/bot.js +1001 -18
  12. package/src/claude-auth.js +93 -0
  13. package/src/coder.js +48 -6
  14. package/src/conversation.js +33 -0
  15. package/src/intents/detector.js +50 -0
  16. package/src/intents/index.js +2 -0
  17. package/src/intents/planner.js +58 -0
  18. package/src/persona.js +68 -0
  19. package/src/prompts/orchestrator.js +124 -0
  20. package/src/prompts/persona.md +21 -0
  21. package/src/prompts/system.js +59 -6
  22. package/src/prompts/workers.js +148 -0
  23. package/src/providers/anthropic.js +23 -16
  24. package/src/providers/base.js +76 -2
  25. package/src/providers/index.js +1 -0
  26. package/src/providers/models.js +2 -1
  27. package/src/providers/openai-compat.js +5 -3
  28. package/src/security/audit.js +0 -0
  29. package/src/security/auth.js +0 -0
  30. package/src/security/confirm.js +7 -2
  31. package/src/self.js +122 -0
  32. package/src/services/stt.js +139 -0
  33. package/src/services/tts.js +124 -0
  34. package/src/skills/catalog.js +506 -0
  35. package/src/skills/custom.js +128 -0
  36. package/src/swarm/job-manager.js +216 -0
  37. package/src/swarm/job.js +85 -0
  38. package/src/swarm/worker-registry.js +79 -0
  39. package/src/tools/browser.js +458 -335
  40. package/src/tools/categories.js +3 -3
  41. package/src/tools/coding.js +5 -0
  42. package/src/tools/docker.js +0 -0
  43. package/src/tools/git.js +0 -0
  44. package/src/tools/github.js +0 -0
  45. package/src/tools/index.js +3 -0
  46. package/src/tools/jira.js +0 -0
  47. package/src/tools/monitor.js +0 -0
  48. package/src/tools/network.js +0 -0
  49. package/src/tools/orchestrator-tools.js +428 -0
  50. package/src/tools/os.js +14 -1
  51. package/src/tools/persona.js +32 -0
  52. package/src/tools/process.js +0 -0
  53. package/src/utils/config.js +153 -15
  54. package/src/utils/display.js +0 -0
  55. package/src/utils/logger.js +0 -0
  56. package/src/worker.js +396 -0
  57. package/.agents/skills/interface-design/SKILL.md +0 -391
  58. package/.agents/skills/interface-design/references/critique.md +0 -67
  59. package/.agents/skills/interface-design/references/example.md +0 -86
  60. package/.agents/skills/interface-design/references/principles.md +0 -235
  61. package/.agents/skills/interface-design/references/validation.md +0 -48
@@ -12,12 +12,23 @@ const DEFAULTS = {
12
12
  name: 'KernelBot',
13
13
  description: 'AI engineering agent with full OS control',
14
14
  },
15
+ orchestrator: {
16
+ provider: 'anthropic',
17
+ model: 'claude-opus-4-6',
18
+ max_tokens: 2048,
19
+ temperature: 0.3,
20
+ max_tool_depth: 5,
21
+ },
15
22
  brain: {
16
23
  provider: 'anthropic',
17
24
  model: 'claude-sonnet-4-20250514',
18
25
  max_tokens: 4096,
19
26
  temperature: 0.3,
20
- max_tool_depth: 12,
27
+ },
28
+ swarm: {
29
+ max_concurrent_jobs: 3,
30
+ job_timeout_seconds: 300,
31
+ cleanup_interval_minutes: 30,
21
32
  },
22
33
  telegram: {
23
34
  allowed_users: [],
@@ -28,6 +39,7 @@ const DEFAULTS = {
28
39
  max_turns: 50,
29
40
  timeout_seconds: 600,
30
41
  workspace_dir: null, // defaults to ~/.kernelbot/workspaces
42
+ auth_mode: 'system', // system | api_key | oauth_token
31
43
  },
32
44
  github: {
33
45
  default_branch: 'main',
@@ -180,34 +192,132 @@ export function saveProviderToYaml(providerKey, modelId) {
180
192
  return configPath;
181
193
  }
182
194
 
195
+ /**
196
+ * Save orchestrator provider and model to config.yaml.
197
+ */
198
+ export function saveOrchestratorToYaml(providerKey, modelId) {
199
+ const configDir = getConfigDir();
200
+ mkdirSync(configDir, { recursive: true });
201
+ const configPath = join(configDir, 'config.yaml');
202
+
203
+ let existing = {};
204
+ if (existsSync(configPath)) {
205
+ existing = yaml.load(readFileSync(configPath, 'utf-8')) || {};
206
+ }
207
+
208
+ existing.orchestrator = {
209
+ ...(existing.orchestrator || {}),
210
+ provider: providerKey,
211
+ model: modelId,
212
+ };
213
+
214
+ writeFileSync(configPath, yaml.dump(existing, { lineWidth: -1 }));
215
+ return configPath;
216
+ }
217
+
218
+ /**
219
+ * Save Claude Code model to config.yaml.
220
+ */
221
+ export function saveClaudeCodeModelToYaml(modelId) {
222
+ const configDir = getConfigDir();
223
+ mkdirSync(configDir, { recursive: true });
224
+ const configPath = join(configDir, 'config.yaml');
225
+
226
+ let existing = {};
227
+ if (existsSync(configPath)) {
228
+ existing = yaml.load(readFileSync(configPath, 'utf-8')) || {};
229
+ }
230
+
231
+ existing.claude_code = {
232
+ ...(existing.claude_code || {}),
233
+ model: modelId,
234
+ };
235
+
236
+ writeFileSync(configPath, yaml.dump(existing, { lineWidth: -1 }));
237
+ return configPath;
238
+ }
239
+
240
+ /**
241
+ * Save Claude Code auth mode + credential to config.yaml and .env.
242
+ */
243
+ export function saveClaudeCodeAuth(config, mode, value) {
244
+ const configDir = getConfigDir();
245
+ mkdirSync(configDir, { recursive: true });
246
+ const configPath = join(configDir, 'config.yaml');
247
+
248
+ let existing = {};
249
+ if (existsSync(configPath)) {
250
+ existing = yaml.load(readFileSync(configPath, 'utf-8')) || {};
251
+ }
252
+
253
+ existing.claude_code = {
254
+ ...(existing.claude_code || {}),
255
+ auth_mode: mode,
256
+ };
257
+
258
+ writeFileSync(configPath, yaml.dump(existing, { lineWidth: -1 }));
259
+
260
+ // Update live config
261
+ config.claude_code.auth_mode = mode;
262
+
263
+ if (mode === 'api_key' && value) {
264
+ saveCredential(config, 'CLAUDE_CODE_API_KEY', value);
265
+ config.claude_code.api_key = value;
266
+ } else if (mode === 'oauth_token' && value) {
267
+ saveCredential(config, 'CLAUDE_CODE_OAUTH_TOKEN', value);
268
+ config.claude_code.oauth_token = value;
269
+ }
270
+ // mode === 'system' — no credentials to save
271
+ }
272
+
183
273
  /**
184
274
  * Full interactive flow: change brain model + optionally enter API key.
185
275
  */
186
276
  export async function changeBrainModel(config, rl) {
277
+ const { createProvider } = await import('../providers/index.js');
187
278
  const { providerKey, modelId } = await promptProviderSelection(rl);
188
279
 
189
280
  const providerDef = PROVIDERS[providerKey];
281
+
282
+ // Resolve API key
283
+ const envKey = providerDef.envKey;
284
+ let apiKey = process.env[envKey];
285
+ if (!apiKey) {
286
+ const key = await ask(rl, chalk.cyan(`\n ${providerDef.name} API key (${envKey}): `));
287
+ if (!key.trim()) {
288
+ console.log(chalk.yellow('\n No API key provided. Brain not changed.\n'));
289
+ return config;
290
+ }
291
+ apiKey = key.trim();
292
+ }
293
+
294
+ // Validate the new provider before saving anything
295
+ console.log(chalk.dim(`\n Verifying ${providerDef.name} / ${modelId}...`));
296
+ const testConfig = { ...config, brain: { ...config.brain, provider: providerKey, model: modelId, api_key: apiKey } };
297
+ try {
298
+ const testProvider = createProvider(testConfig);
299
+ await testProvider.ping();
300
+ } catch (err) {
301
+ console.log(chalk.red(`\n ✖ Verification failed: ${err.message}`));
302
+ console.log(chalk.yellow(` Brain not changed. Keeping current model.\n`));
303
+ return config;
304
+ }
305
+
306
+ // Validation passed — save everything
190
307
  const savedPath = saveProviderToYaml(providerKey, modelId);
191
- console.log(chalk.dim(`\n Saved to ${savedPath}`));
308
+ console.log(chalk.dim(` Saved to ${savedPath}`));
192
309
 
193
- // Update live config
194
310
  config.brain.provider = providerKey;
195
311
  config.brain.model = modelId;
312
+ config.brain.api_key = apiKey;
196
313
 
197
- // Check if we have the API key for this provider
198
- const envKey = providerDef.envKey;
199
- const currentKey = process.env[envKey];
200
- if (!currentKey) {
201
- const key = await ask(rl, chalk.cyan(`\n ${providerDef.name} API key (${envKey}): `));
202
- if (key.trim()) {
203
- saveCredential(config, envKey, key.trim());
204
- config.brain.api_key = key.trim();
205
- console.log(chalk.dim(' Saved.\n'));
206
- }
207
- } else {
208
- config.brain.api_key = currentKey;
314
+ // Save the key if it was newly entered
315
+ if (!process.env[envKey]) {
316
+ saveCredential(config, envKey, apiKey);
317
+ console.log(chalk.dim(' API key saved.\n'));
209
318
  }
210
319
 
320
+ console.log(chalk.green(` ✔ Brain switched to ${providerDef.name} / ${modelId}\n`));
211
321
  return config;
212
322
  }
213
323
 
@@ -299,6 +409,16 @@ export function loadConfig() {
299
409
 
300
410
  const config = deepMerge(DEFAULTS, fileConfig);
301
411
 
412
+ // Orchestrator — resolve API key based on configured provider
413
+ const orchProvider = PROVIDERS[config.orchestrator.provider];
414
+ if (orchProvider && process.env[orchProvider.envKey]) {
415
+ config.orchestrator.api_key = process.env[orchProvider.envKey];
416
+ }
417
+ // Legacy fallback: ANTHROPIC_API_KEY for anthropic orchestrator
418
+ if (config.orchestrator.provider === 'anthropic' && !config.orchestrator.api_key && process.env.ANTHROPIC_API_KEY) {
419
+ config.orchestrator.api_key = process.env.ANTHROPIC_API_KEY;
420
+ }
421
+
302
422
  // Overlay env vars for brain API key based on provider
303
423
  const providerDef = PROVIDERS[config.brain.provider];
304
424
  if (providerDef && process.env[providerDef.envKey]) {
@@ -316,6 +436,16 @@ export function loadConfig() {
316
436
  if (!config.github) config.github = {};
317
437
  config.github.token = process.env.GITHUB_TOKEN;
318
438
  }
439
+ // ElevenLabs voice credentials
440
+ if (process.env.ELEVENLABS_API_KEY) {
441
+ if (!config.elevenlabs) config.elevenlabs = {};
442
+ config.elevenlabs.api_key = process.env.ELEVENLABS_API_KEY;
443
+ }
444
+ if (process.env.ELEVENLABS_VOICE_ID) {
445
+ if (!config.elevenlabs) config.elevenlabs = {};
446
+ config.elevenlabs.voice_id = process.env.ELEVENLABS_VOICE_ID;
447
+ }
448
+
319
449
  if (process.env.JIRA_BASE_URL || process.env.JIRA_EMAIL || process.env.JIRA_API_TOKEN) {
320
450
  if (!config.jira) config.jira = {};
321
451
  if (process.env.JIRA_BASE_URL) config.jira.base_url = process.env.JIRA_BASE_URL;
@@ -323,6 +453,14 @@ export function loadConfig() {
323
453
  if (process.env.JIRA_API_TOKEN) config.jira.api_token = process.env.JIRA_API_TOKEN;
324
454
  }
325
455
 
456
+ // Claude Code auth credentials from env
457
+ if (process.env.CLAUDE_CODE_API_KEY) {
458
+ config.claude_code.api_key = process.env.CLAUDE_CODE_API_KEY;
459
+ }
460
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
461
+ config.claude_code.oauth_token = process.env.CLAUDE_CODE_OAUTH_TOKEN;
462
+ }
463
+
326
464
  return config;
327
465
  }
328
466
 
File without changes
File without changes
package/src/worker.js ADDED
@@ -0,0 +1,396 @@
1
+ import { createProvider } from './providers/index.js';
2
+ import { executeTool } from './tools/index.js';
3
+ import { closeSession } from './tools/browser.js';
4
+ import { getMissingCredential } from './utils/config.js';
5
+ import { getWorkerPrompt } from './prompts/workers.js';
6
+ import { getUnifiedSkillById } from './skills/custom.js';
7
+ import { getLogger } from './utils/logger.js';
8
+
9
+ const MAX_RESULT_LENGTH = 3000;
10
+ const LARGE_FIELDS = ['stdout', 'stderr', 'content', 'diff', 'output', 'body', 'html', 'text', 'log', 'logs'];
11
+
12
+ /**
13
+ * WorkerAgent — runs a scoped agent loop in the background.
14
+ * Extracted from Agent._runLoop() with simplifications:
15
+ * - No conversation persistence
16
+ * - No intent detection or persona extraction
17
+ * - No completion gate
18
+ * - Checks cancellation before each iteration and tool execution
19
+ * - Reports progress via callbacks
20
+ */
21
+ export class WorkerAgent {
22
+ /**
23
+ * @param {object} opts
24
+ * @param {object} opts.config - Full app config (opts.config.brain used for LLM)
25
+ * @param {string} opts.workerType - coding, browser, system, devops, research
26
+ * @param {string} opts.jobId - Job ID for logging
27
+ * @param {Array} opts.tools - Scoped tool definitions
28
+ * @param {string|null} opts.skillId - Active skill ID (for worker prompt)
29
+ * @param {string|null} opts.workerContext - Structured context (conversation history, persona, dependency results)
30
+ * @param {object} opts.callbacks - { onProgress, onComplete, onError }
31
+ * @param {AbortController} opts.abortController - For cancellation
32
+ */
33
+ constructor({ config, workerType, jobId, tools, skillId, workerContext, callbacks, abortController }) {
34
+ this.config = config;
35
+ this.workerType = workerType;
36
+ this.jobId = jobId;
37
+ this.tools = tools;
38
+ this.skillId = skillId;
39
+ this.workerContext = workerContext || null;
40
+ this.callbacks = callbacks || {};
41
+ this.abortController = abortController || new AbortController();
42
+ this._cancelled = false;
43
+ this._toolCallCount = 0;
44
+ this._errors = [];
45
+
46
+ // Create provider from worker brain config
47
+ this.provider = createProvider(config);
48
+
49
+ // Build system prompt
50
+ const skillPrompt = skillId ? getUnifiedSkillById(skillId)?.systemPrompt : null;
51
+ this.systemPrompt = getWorkerPrompt(workerType, config, skillPrompt);
52
+
53
+ // Safety ceiling — not a real limit, just prevents infinite loops
54
+ // The real limit is the job timeout enforced by JobManager
55
+ this.maxIterations = 200;
56
+
57
+ const logger = getLogger();
58
+ logger.info(`[Worker ${jobId}] Created: type=${workerType}, provider=${config.brain.provider}/${config.brain.model}, tools=${tools.length}, skill=${skillId || 'none'}, context=${workerContext ? 'yes' : 'none'}`);
59
+ }
60
+
61
+ /** Cancel this worker. */
62
+ cancel() {
63
+ this._cancelled = true;
64
+ this.abortController.abort();
65
+ getLogger().info(`[Worker ${this.jobId}] Cancel signal sent — aborting ${this.workerType} worker`);
66
+ }
67
+
68
+ /** Run the worker loop with the given task. */
69
+ async run(task) {
70
+ const logger = getLogger();
71
+ logger.info(`[Worker ${this.jobId}] Starting task: "${task.slice(0, 150)}"`);
72
+
73
+ // Build first message: context sections + task
74
+ let firstMessage = '';
75
+ if (this.workerContext) {
76
+ firstMessage += this.workerContext + '\n\n---\n\n';
77
+ }
78
+ firstMessage += task;
79
+
80
+ const messages = [{ role: 'user', content: firstMessage }];
81
+
82
+ try {
83
+ const result = await this._runLoop(messages);
84
+ if (this._cancelled) {
85
+ logger.info(`[Worker ${this.jobId}] Run completed but worker was cancelled — skipping callbacks`);
86
+ return;
87
+ }
88
+ const parsed = this._parseResult(result);
89
+ logger.info(`[Worker ${this.jobId}] Run finished successfully — structured=${!!parsed.structured}, result: "${(result || '').slice(0, 150)}"`);
90
+ if (this.callbacks.onComplete) this.callbacks.onComplete(result, parsed);
91
+ } catch (err) {
92
+ if (this._cancelled) {
93
+ logger.info(`[Worker ${this.jobId}] Run threw error but worker was cancelled — ignoring: ${err.message}`);
94
+ return;
95
+ }
96
+ logger.error(`[Worker ${this.jobId}] Run failed: ${err.message}`);
97
+ if (this.callbacks.onError) this.callbacks.onError(err);
98
+ } finally {
99
+ // Clean up browser session for this worker (frees the Puppeteer page)
100
+ closeSession(this.jobId).catch(() => {});
101
+ logger.info(`[Worker ${this.jobId}] Browser session cleaned up`);
102
+ }
103
+ }
104
+
105
+ async _runLoop(messages) {
106
+ const logger = getLogger();
107
+ let consecutiveAllFailIterations = 0; // Track iterations where ALL tool calls fail
108
+
109
+ for (let depth = 0; depth < this.maxIterations; depth++) {
110
+ if (this._cancelled) {
111
+ logger.info(`[Worker ${this.jobId}] Cancelled before iteration ${depth + 1}`);
112
+ throw new Error('Worker cancelled');
113
+ }
114
+
115
+ logger.info(`[Worker ${this.jobId}] LLM call ${depth + 1} — sending ${messages.length} messages`);
116
+
117
+ const response = await this.provider.chat({
118
+ system: this.systemPrompt,
119
+ messages,
120
+ tools: this.tools,
121
+ signal: this.abortController.signal,
122
+ });
123
+
124
+ logger.info(`[Worker ${this.jobId}] LLM response: stopReason=${response.stopReason}, text=${(response.text || '').length} chars, toolCalls=${(response.toolCalls || []).length}`);
125
+
126
+ if (this._cancelled) {
127
+ logger.info(`[Worker ${this.jobId}] Cancelled after LLM response`);
128
+ throw new Error('Worker cancelled');
129
+ }
130
+
131
+ // End turn — return the text
132
+ if (response.stopReason === 'end_turn') {
133
+ logger.info(`[Worker ${this.jobId}] End turn — final response: "${(response.text || '').slice(0, 200)}"`);
134
+ return response.text || 'Task completed.';
135
+ }
136
+
137
+ // Tool use
138
+ if (response.stopReason === 'tool_use') {
139
+ messages.push({ role: 'assistant', content: response.rawContent });
140
+
141
+ // Log thinking text
142
+ if (response.text && response.text.trim()) {
143
+ logger.info(`[Worker ${this.jobId}] Thinking: "${response.text.slice(0, 200)}"`);
144
+ }
145
+
146
+ const toolResults = [];
147
+
148
+ for (const block of response.toolCalls) {
149
+ if (this._cancelled) {
150
+ logger.info(`[Worker ${this.jobId}] Cancelled before executing tool ${block.name}`);
151
+ throw new Error('Worker cancelled');
152
+ }
153
+
154
+ const summary = this._formatToolSummary(block.name, block.input);
155
+ logger.info(`[Worker ${this.jobId}] Executing tool: ${block.name} — ${summary}`);
156
+ logger.debug(`[Worker ${this.jobId}] Tool input: ${JSON.stringify(block.input).slice(0, 300)}`);
157
+ this._reportProgress(`🔧 ${summary}`);
158
+
159
+ this._toolCallCount++;
160
+
161
+ const result = await executeTool(block.name, block.input, {
162
+ config: this.config,
163
+ user: null, // workers don't have user context
164
+ personaManager: null,
165
+ onUpdate: this.callbacks.onUpdate || null, // Real bot onUpdate (returns message_id for coder.js smart output)
166
+ sendPhoto: this.callbacks.sendPhoto || null,
167
+ sessionId: this.jobId, // Per-worker browser session isolation
168
+ signal: this.abortController.signal, // For killing child processes on cancellation
169
+ });
170
+
171
+ // Track errors
172
+ if (result && typeof result === 'object' && result.error) {
173
+ this._errors.push({ tool: block.name, error: result.error });
174
+ }
175
+
176
+ const resultStr = this._truncateResult(block.name, result);
177
+ logger.info(`[Worker ${this.jobId}] Tool ${block.name} result: ${resultStr.slice(0, 200)}`);
178
+
179
+ toolResults.push({
180
+ type: 'tool_result',
181
+ tool_use_id: block.id,
182
+ content: resultStr,
183
+ });
184
+ }
185
+
186
+ // Track consecutive all-fail iterations (circuit breaker)
187
+ const allFailed = toolResults.every(tr => {
188
+ try { const parsed = JSON.parse(tr.content); return !!parsed.error; } catch { return false; }
189
+ });
190
+ if (allFailed) {
191
+ consecutiveAllFailIterations++;
192
+ logger.warn(`[Worker ${this.jobId}] All ${toolResults.length} tool calls failed (streak: ${consecutiveAllFailIterations})`);
193
+ if (consecutiveAllFailIterations >= 3) {
194
+ logger.warn(`[Worker ${this.jobId}] Circuit breaker: 3 consecutive all-fail iterations — forcing stop`);
195
+ messages.push({ role: 'user', content: toolResults });
196
+ messages.push({
197
+ role: 'user',
198
+ content: 'STOP: All your tool calls have failed 3 times in a row. Do NOT call any more tools. Summarize whatever you have found so far, or explain what went wrong.',
199
+ });
200
+ const bailResponse = await this.provider.chat({
201
+ system: this.systemPrompt,
202
+ messages,
203
+ tools: [], // No tools — force text response
204
+ signal: this.abortController.signal,
205
+ });
206
+ return bailResponse.text || 'All tool calls failed repeatedly. Could not complete the task.';
207
+ }
208
+ } else {
209
+ consecutiveAllFailIterations = 0;
210
+ }
211
+
212
+ messages.push({ role: 'user', content: toolResults });
213
+ continue;
214
+ }
215
+
216
+ // Unexpected stop reason
217
+ logger.warn(`[Worker ${this.jobId}] Unexpected stopReason: ${response.stopReason}`);
218
+ return response.text || 'Worker finished with unexpected response.';
219
+ }
220
+
221
+ // Safety ceiling hit (should basically never happen — job timeout is the real limit)
222
+ logger.warn(`[Worker ${this.jobId}] Hit safety ceiling (${this.maxIterations} iterations) — requesting final summary`);
223
+ this._reportProgress(`⏳ Summarizing results...`);
224
+
225
+ try {
226
+ messages.push({
227
+ role: 'user',
228
+ content: 'You have reached the iteration limit. Summarize everything you have found and accomplished so far. Return a complete, detailed summary of all results, data, and findings.',
229
+ });
230
+
231
+ const summaryResponse = await this.provider.chat({
232
+ system: this.systemPrompt,
233
+ messages,
234
+ tools: [], // No tools — force text-only response
235
+ signal: this.abortController.signal,
236
+ });
237
+
238
+ const summary = summaryResponse.text || '';
239
+ logger.info(`[Worker ${this.jobId}] Final summary: "${summary.slice(0, 200)}"`);
240
+
241
+ if (summary.length > 10) {
242
+ return summary;
243
+ }
244
+ } catch (err) {
245
+ logger.warn(`[Worker ${this.jobId}] Summary call failed: ${err.message}`);
246
+ }
247
+
248
+ // Fallback: extract any text the LLM produced during the loop
249
+ const lastAssistantText = this._extractLastAssistantText(messages);
250
+ if (lastAssistantText) {
251
+ logger.info(`[Worker ${this.jobId}] Falling back to last assistant text: "${lastAssistantText.slice(0, 200)}"`);
252
+ return lastAssistantText;
253
+ }
254
+
255
+ return 'Worker finished but could not produce a final summary.';
256
+ }
257
+
258
+ /** Extract the last meaningful assistant text from message history. */
259
+ _extractLastAssistantText(messages) {
260
+ for (let i = messages.length - 1; i >= 0; i--) {
261
+ const msg = messages[i];
262
+ if (msg.role !== 'assistant') continue;
263
+
264
+ if (typeof msg.content === 'string' && msg.content.trim()) {
265
+ return msg.content.trim();
266
+ }
267
+ if (Array.isArray(msg.content)) {
268
+ const texts = msg.content
269
+ .filter(b => b.type === 'text' && b.text?.trim())
270
+ .map(b => b.text.trim());
271
+ if (texts.length > 0) return texts.join('\n');
272
+ }
273
+ }
274
+ return null;
275
+ }
276
+
277
+ /**
278
+ * Parse the worker's final text into a structured WorkerResult.
279
+ * Attempts JSON parse from ```json fences, falls back to wrapping raw text.
280
+ */
281
+ _parseResult(text) {
282
+ if (!text) {
283
+ return {
284
+ structured: false,
285
+ summary: 'Task completed.',
286
+ status: 'success',
287
+ details: '',
288
+ artifacts: [],
289
+ followUp: null,
290
+ toolsUsed: this._toolCallCount,
291
+ errors: this._errors,
292
+ };
293
+ }
294
+
295
+ // Try to extract JSON from ```json ... ``` fences
296
+ const fenceMatch = text.match(/```json\s*\n?([\s\S]*?)\n?\s*```/);
297
+ if (fenceMatch) {
298
+ try {
299
+ const parsed = JSON.parse(fenceMatch[1]);
300
+ if (parsed.summary && parsed.status) {
301
+ return {
302
+ structured: true,
303
+ summary: parsed.summary || '',
304
+ status: parsed.status || 'success',
305
+ details: parsed.details || '',
306
+ artifacts: Array.isArray(parsed.artifacts) ? parsed.artifacts : [],
307
+ followUp: parsed.followUp || null,
308
+ toolsUsed: this._toolCallCount,
309
+ errors: this._errors,
310
+ };
311
+ }
312
+ } catch { /* fall through */ }
313
+ }
314
+
315
+ // Try raw JSON parse (no fences)
316
+ try {
317
+ const parsed = JSON.parse(text);
318
+ if (parsed.summary && parsed.status) {
319
+ return {
320
+ structured: true,
321
+ summary: parsed.summary || '',
322
+ status: parsed.status || 'success',
323
+ details: parsed.details || '',
324
+ artifacts: Array.isArray(parsed.artifacts) ? parsed.artifacts : [],
325
+ followUp: parsed.followUp || null,
326
+ toolsUsed: this._toolCallCount,
327
+ errors: this._errors,
328
+ };
329
+ }
330
+ } catch { /* fall through */ }
331
+
332
+ // Fallback: wrap raw text
333
+ return {
334
+ structured: false,
335
+ summary: text.slice(0, 200),
336
+ status: 'success',
337
+ details: text,
338
+ artifacts: [],
339
+ followUp: null,
340
+ toolsUsed: this._toolCallCount,
341
+ errors: this._errors,
342
+ };
343
+ }
344
+
345
+ _reportProgress(text) {
346
+ if (this.callbacks.onProgress) {
347
+ try { this.callbacks.onProgress(text); } catch {}
348
+ }
349
+ if (this.callbacks.onHeartbeat) {
350
+ try { this.callbacks.onHeartbeat(text); } catch {}
351
+ }
352
+ }
353
+
354
+ _truncateResult(name, result) {
355
+ let str = JSON.stringify(result);
356
+ if (str.length <= MAX_RESULT_LENGTH) return str;
357
+
358
+ if (result && typeof result === 'object') {
359
+ const truncated = { ...result };
360
+ for (const field of LARGE_FIELDS) {
361
+ if (typeof truncated[field] === 'string' && truncated[field].length > 500) {
362
+ truncated[field] = truncated[field].slice(0, 500) + `\n... [truncated ${truncated[field].length - 500} chars]`;
363
+ }
364
+ }
365
+ str = JSON.stringify(truncated);
366
+ if (str.length <= MAX_RESULT_LENGTH) return str;
367
+ }
368
+
369
+ return str.slice(0, MAX_RESULT_LENGTH) + `\n... [truncated, total ${str.length} chars]`;
370
+ }
371
+
372
+ _formatToolSummary(name, input) {
373
+ const _short = (s, len = 80) => s && s.length > len ? s.slice(0, len) + '...' : s;
374
+ const _host = (url) => { try { return new URL(url).hostname; } catch { return url; } };
375
+
376
+ switch (name) {
377
+ case 'web_search': return `Searching: "${_short(input.query, 60)}"`;
378
+ case 'browse_website': return `Opening ${_host(input.url)}`;
379
+ case 'interact_with_page': return 'Interacting with page';
380
+ case 'extract_content': return 'Extracting content';
381
+ case 'screenshot_website': return `Screenshot of ${_host(input.url)}`;
382
+ case 'execute_command': return `Running: ${_short(input.command, 60)}`;
383
+ case 'read_file': return `Reading ${_short(input.path)}`;
384
+ case 'write_file': return `Writing ${_short(input.path)}`;
385
+ case 'git_clone': return `Cloning ${_short(input.repo)}`;
386
+ case 'git_checkout': return `Switching to ${input.branch}`;
387
+ case 'git_commit': return `Committing: "${_short(input.message, 50)}"`;
388
+ case 'git_push': return 'Pushing changes';
389
+ case 'github_create_pr': return `Creating PR: "${_short(input.title, 50)}"`;
390
+ case 'spawn_claude_code': return `Coding: ${_short(input.prompt, 60)}`;
391
+ case 'docker_exec': return `Running in ${_short(input.container)}`;
392
+ case 'docker_compose': return `Docker compose ${input.action}`;
393
+ default: return `${name}`;
394
+ }
395
+ }
396
+ }