kernelbot 1.0.39 → 1.0.40

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 (80) hide show
  1. package/bin/kernel.js +5 -5
  2. package/config.example.yaml +1 -1
  3. package/package.json +1 -1
  4. package/skills/business/business-analyst.md +32 -0
  5. package/skills/business/product-manager.md +32 -0
  6. package/skills/business/project-manager.md +32 -0
  7. package/skills/business/startup-advisor.md +32 -0
  8. package/skills/creative/music-producer.md +32 -0
  9. package/skills/creative/photographer.md +32 -0
  10. package/skills/creative/video-producer.md +32 -0
  11. package/skills/data/bi-analyst.md +37 -0
  12. package/skills/data/data-scientist.md +38 -0
  13. package/skills/data/ml-engineer.md +38 -0
  14. package/skills/design/graphic-designer.md +38 -0
  15. package/skills/design/product-designer.md +41 -0
  16. package/skills/design/ui-ux.md +38 -0
  17. package/skills/education/curriculum-designer.md +32 -0
  18. package/skills/education/language-teacher.md +32 -0
  19. package/skills/education/tutor.md +32 -0
  20. package/skills/engineering/data-eng.md +55 -0
  21. package/skills/engineering/devops.md +56 -0
  22. package/skills/engineering/mobile-dev.md +55 -0
  23. package/skills/engineering/security-eng.md +55 -0
  24. package/skills/engineering/sr-backend.md +55 -0
  25. package/skills/engineering/sr-frontend.md +55 -0
  26. package/skills/finance/accountant.md +35 -0
  27. package/skills/finance/crypto-defi.md +39 -0
  28. package/skills/finance/financial-analyst.md +35 -0
  29. package/skills/healthcare/health-wellness.md +32 -0
  30. package/skills/healthcare/medical-researcher.md +33 -0
  31. package/skills/legal/contract-reviewer.md +35 -0
  32. package/skills/legal/legal-advisor.md +36 -0
  33. package/skills/marketing/content-marketer.md +38 -0
  34. package/skills/marketing/growth.md +38 -0
  35. package/skills/marketing/seo.md +43 -0
  36. package/skills/marketing/social-media.md +43 -0
  37. package/skills/writing/academic-writer.md +33 -0
  38. package/skills/writing/copywriter.md +32 -0
  39. package/skills/writing/creative-writer.md +32 -0
  40. package/skills/writing/tech-writer.md +33 -0
  41. package/src/agent.js +153 -118
  42. package/src/automation/scheduler.js +36 -3
  43. package/src/bot.js +147 -64
  44. package/src/coder.js +30 -8
  45. package/src/conversation.js +96 -19
  46. package/src/dashboard/dashboard.css +6 -0
  47. package/src/dashboard/dashboard.js +28 -1
  48. package/src/dashboard/index.html +12 -0
  49. package/src/dashboard/server.js +77 -15
  50. package/src/life/codebase.js +2 -1
  51. package/src/life/daydream_engine.js +386 -0
  52. package/src/life/engine.js +1 -0
  53. package/src/life/evolution.js +4 -3
  54. package/src/prompts/orchestrator.js +1 -1
  55. package/src/prompts/system.js +1 -1
  56. package/src/prompts/workers.js +8 -1
  57. package/src/providers/anthropic.js +3 -1
  58. package/src/providers/base.js +33 -0
  59. package/src/providers/index.js +1 -1
  60. package/src/providers/models.js +22 -0
  61. package/src/providers/openai-compat.js +3 -0
  62. package/src/services/x-api.js +14 -3
  63. package/src/skills/loader.js +382 -0
  64. package/src/swarm/worker-registry.js +2 -2
  65. package/src/tools/browser.js +10 -3
  66. package/src/tools/coding.js +16 -0
  67. package/src/tools/docker.js +13 -0
  68. package/src/tools/git.js +31 -29
  69. package/src/tools/jira.js +11 -2
  70. package/src/tools/monitor.js +9 -1
  71. package/src/tools/network.js +34 -0
  72. package/src/tools/orchestrator-tools.js +2 -1
  73. package/src/tools/os.js +20 -6
  74. package/src/utils/config.js +1 -1
  75. package/src/utils/display.js +1 -1
  76. package/src/utils/logger.js +1 -1
  77. package/src/utils/timeAwareness.js +72 -0
  78. package/src/worker.js +26 -33
  79. package/src/skills/catalog.js +0 -506
  80. package/src/skills/custom.js +0 -128
package/src/tools/git.js CHANGED
@@ -10,22 +10,29 @@ function getWorkspaceDir(config) {
10
10
  return dir;
11
11
  }
12
12
 
13
- function injectToken(url, config) {
13
+ /**
14
+ * Get the auth header value for GitHub HTTPS operations.
15
+ * Uses extraheader instead of embedding credentials in the URL,
16
+ * preventing token leaks in git remote -v, error messages, and process listings.
17
+ */
18
+ function getGitAuthEnv(config) {
14
19
  const token = config.github?.token || process.env.GITHUB_TOKEN;
15
- if (!token) return url;
20
+ if (!token) return null;
21
+ const base64 = Buffer.from(`x-access-token:${token}`).toString('base64');
22
+ return `AUTHORIZATION: basic ${base64}`;
23
+ }
16
24
 
17
- // Inject token into HTTPS GitHub URLs for auth-free push/pull
18
- try {
19
- const parsed = new URL(url);
20
- if (parsed.hostname === 'github.com' && parsed.protocol === 'https:') {
21
- parsed.username = token;
22
- parsed.password = 'x-oauth-basic';
23
- return parsed.toString();
24
- }
25
- } catch {
26
- // Not a parseable URL (e.g. org/repo shorthand before expansion)
25
+ /**
26
+ * Configure a simple-git instance with auth via extraheader (not URL embedding).
27
+ */
28
+ function configureGitAuth(git, config) {
29
+ const authHeader = getGitAuthEnv(config);
30
+ if (authHeader) {
31
+ git.env('GIT_CONFIG_COUNT', '1');
32
+ git.env('GIT_CONFIG_KEY_0', 'http.extraheader');
33
+ git.env('GIT_CONFIG_VALUE_0', authHeader);
27
34
  }
28
- return url;
35
+ return git;
29
36
  }
30
37
 
31
38
  export const definitions = [
@@ -107,15 +114,19 @@ export const handlers = {
107
114
  url = `https://github.com/${repo}.git`;
108
115
  }
109
116
 
110
- // Inject GitHub token for authenticated clone (enables push later)
111
- const authUrl = injectToken(url, context.config);
112
-
113
117
  const repoName = dest || repo.split('/').pop().replace('.git', '');
118
+ // Prevent path traversal — dest must not escape workspace directory
119
+ if (repoName.includes('..') || repoName.startsWith('/')) {
120
+ return { error: 'Invalid destination: path traversal is not allowed' };
121
+ }
114
122
  const targetDir = join(workspaceDir, repoName);
123
+ if (!targetDir.startsWith(workspaceDir)) {
124
+ return { error: 'Invalid destination: path escapes workspace directory' };
125
+ }
115
126
 
116
127
  try {
117
- const git = simpleGit();
118
- await git.clone(authUrl, targetDir);
128
+ const git = configureGitAuth(simpleGit(), context.config);
129
+ await git.clone(url, targetDir);
119
130
  return { success: true, path: targetDir };
120
131
  } catch (err) {
121
132
  getLogger().error(`git_clone failed for ${params.repo}: ${err.message}`);
@@ -155,17 +166,8 @@ export const handlers = {
155
166
  git_push: async (params, context) => {
156
167
  const { dir, force = false } = params;
157
168
  try {
158
- const git = simpleGit(dir);
159
-
160
- // Ensure remote URL has auth token for push
161
- const remotes = await git.getRemotes(true);
162
- const origin = remotes.find((r) => r.name === 'origin');
163
- if (origin) {
164
- const authUrl = injectToken(origin.refs.push || origin.refs.fetch, context.config);
165
- if (authUrl !== (origin.refs.push || origin.refs.fetch)) {
166
- await git.remote(['set-url', 'origin', authUrl]);
167
- }
168
- }
169
+ // Use extraheader auth instead of modifying remote URLs
170
+ const git = configureGitAuth(simpleGit(dir), context.config);
169
171
 
170
172
  const branch = (await git.branchLocal()).current;
171
173
  const options = ['-u'];
package/src/tools/jira.js CHANGED
@@ -186,7 +186,11 @@ export const handlers = {
186
186
  const client = getJiraClient(context.config);
187
187
  const assignee = params.assignee || 'currentUser()';
188
188
  const maxResults = params.max_results || 20;
189
- const jql = `assignee = ${assignee} ORDER BY updated DESC`;
189
+ // Sanitize assignee to prevent JQL injection allow currentUser() or quote the value
190
+ const safeAssignee = assignee === 'currentUser()'
191
+ ? 'currentUser()'
192
+ : `"${assignee.replace(/["\\]/g, '')}"`;
193
+ const jql = `assignee = ${safeAssignee} ORDER BY updated DESC`;
190
194
 
191
195
  const { data } = await client.get('/search', {
192
196
  params: {
@@ -215,7 +219,12 @@ export const handlers = {
215
219
  try {
216
220
  const client = getJiraClient(context.config);
217
221
  const maxResults = params.max_results || 20;
218
- const jql = `project = ${params.project_key} ORDER BY updated DESC`;
222
+ // Sanitize project_key to prevent JQL injection only allow alphanumeric and underscore
223
+ const safeProjectKey = params.project_key.replace(/[^a-zA-Z0-9_]/g, '');
224
+ if (safeProjectKey !== params.project_key) {
225
+ return { error: `Invalid project key: "${params.project_key}". Only alphanumeric characters and underscores are allowed.` };
226
+ }
227
+ const jql = `project = "${safeProjectKey}" ORDER BY updated DESC`;
219
228
 
220
229
  const { data } = await client.get('/search', {
221
230
  params: {
@@ -75,7 +75,15 @@ export const handlers = {
75
75
  return await run(`journalctl -n ${finalLines}${filterArg} --no-pager`);
76
76
  }
77
77
 
78
- // Reading a log file
78
+ // Reading a log file — restrict to known safe directories
79
+ const { resolve: resolvePath } = await import('path');
80
+ const resolvedSource = resolvePath(source);
81
+ const ALLOWED_LOG_DIRS = ['/var/log', process.cwd(), process.env.HOME || ''];
82
+ const isAllowed = ALLOWED_LOG_DIRS.some(dir => dir && resolvedSource.startsWith(dir));
83
+ if (!isAllowed) {
84
+ return { error: `Blocked: log source must be within /var/log, the project directory, or home directory` };
85
+ }
86
+
79
87
  const filterCmd = filter ? ` | grep -i ${shellEscape(filter)}` : '';
80
88
  return await run(`tail -n ${finalLines} ${shellEscape(source)}${filterCmd}`);
81
89
  },
@@ -37,6 +37,25 @@ export const definitions = [
37
37
  },
38
38
  ];
39
39
 
40
+ // SSRF protection: block requests to internal/cloud metadata addresses
41
+ const BLOCKED_HOSTS = [
42
+ /^127\./,
43
+ /^10\./,
44
+ /^172\.(1[6-9]|2\d|3[01])\./,
45
+ /^192\.168\./,
46
+ /^169\.254\./, // AWS/cloud metadata endpoint
47
+ /^0\./,
48
+ /^localhost$/i,
49
+ /^metadata\.google\.internal$/i,
50
+ /^\[::1\]$/,
51
+ /^\[fd/i, // IPv6 private
52
+ /^\[fe80:/i, // IPv6 link-local
53
+ ];
54
+
55
+ function isBlockedHost(host) {
56
+ return BLOCKED_HOSTS.some(pattern => pattern.test(host));
57
+ }
58
+
40
59
  export const handlers = {
41
60
  check_port: async (params) => {
42
61
  const logger = getLogger();
@@ -44,6 +63,11 @@ export const handlers = {
44
63
  const port = parseInt(params.port, 10);
45
64
  if (!Number.isFinite(port) || port <= 0 || port > 65535) return { error: 'Invalid port number' };
46
65
 
66
+ // SSRF protection: block internal network probing
67
+ if (host !== 'localhost' && isBlockedHost(host)) {
68
+ return { error: 'Blocked: cannot probe internal or cloud metadata addresses' };
69
+ }
70
+
47
71
  logger.debug(`check_port: checking ${host}:${port}`);
48
72
  // Use nc (netcat) for port check — works on both macOS and Linux
49
73
  const result = await run(`nc -z -w 3 ${shellEscape(host)} ${port} 2>&1 && echo "OPEN" || echo "CLOSED"`, 5000);
@@ -60,6 +84,16 @@ export const handlers = {
60
84
  curl_url: async (params) => {
61
85
  const { url, method = 'GET', headers, body } = params;
62
86
 
87
+ // SSRF protection: block requests to internal networks and cloud metadata
88
+ try {
89
+ const parsed = new URL(url);
90
+ if (isBlockedHost(parsed.hostname)) {
91
+ return { error: 'Blocked: cannot access internal or cloud metadata addresses' };
92
+ }
93
+ } catch {
94
+ return { error: 'Invalid URL' };
95
+ }
96
+
63
97
  let cmd = `curl -s -w "\\n---HTTP_STATUS:%{http_code}" -X ${shellEscape(method)}`;
64
98
 
65
99
  if (headers) {
@@ -770,7 +770,8 @@ export async function executeOrchestratorTool(name, input, context) {
770
770
  if (!conversationManager) return { error: 'Conversation system not available.' };
771
771
 
772
772
  const { query, chat_id } = input;
773
- const targetChatId = chat_id || chatId;
773
+ // Prevent cross-chat history access — only allow searching own chat
774
+ const targetChatId = chatId;
774
775
  logger.info(`[search_conversations] Searching chat ${targetChatId} for: "${query}"`);
775
776
 
776
777
  const history = conversationManager.getHistory(targetChatId);
package/src/tools/os.js CHANGED
@@ -137,12 +137,26 @@ export const handlers = {
137
137
  const { config } = context;
138
138
  const blockedPaths = config.security?.blocked_paths || [];
139
139
 
140
- // Simple check: if the command references a blocked path, reject
141
- for (const bp of blockedPaths) {
142
- const expanded = expandPath(bp);
143
- if (command.includes(expanded)) {
144
- logger.warn(`execute_command blocked: command references restricted path ${bp}`);
145
- return { error: `Blocked: command references restricted path ${bp}` };
140
+ // Tokenize the command to extract all path-like arguments, then resolve
141
+ // each one and check against blocked paths. This prevents bypasses via
142
+ // shell operators (&&, |, ;), quoting, or subshells.
143
+ const shellTokens = command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
144
+ for (const token of shellTokens) {
145
+ // Strip surrounding quotes
146
+ const cleaned = token.replace(/^["']|["']$/g, '');
147
+ // Skip tokens that look like flags or shell operators
148
+ if (/^[-|;&<>]/.test(cleaned) || cleaned.length === 0) continue;
149
+ try {
150
+ const resolved = expandPath(cleaned);
151
+ for (const bp of blockedPaths) {
152
+ const expandedBp = expandPath(bp);
153
+ if (resolved.startsWith(expandedBp) || resolved === expandedBp) {
154
+ logger.warn(`execute_command blocked: argument "${cleaned}" references restricted path ${bp}`);
155
+ return { error: `Blocked: command references restricted path ${bp}` };
156
+ }
157
+ }
158
+ } catch {
159
+ // Not a valid path — skip
146
160
  }
147
161
  }
148
162
 
@@ -38,7 +38,7 @@ const DEFAULTS = {
38
38
  claude_code: {
39
39
  model: 'claude-opus-4-6',
40
40
  max_turns: 50,
41
- timeout_seconds: 600,
41
+ timeout_seconds: 86400,
42
42
  workspace_dir: null, // defaults to ~/.kernelbot/workspaces
43
43
  auth_mode: 'system', // system | api_key | oauth_token
44
44
  },
@@ -121,7 +121,7 @@ export function showCharacterCard(character, isActive = false) {
121
121
  ...(art ? art.split("\n").map((line) => chalk.cyan(line)) : []),
122
122
  "",
123
123
  chalk.dim(`Origin: ${character.origin || "Unknown"}`),
124
- chalk.dim(`Style: ${character.age || "Unknown"}`),
124
+ chalk.dim(`Age: ${character.age || "Unknown"}`),
125
125
  ].join("\n");
126
126
 
127
127
  console.log(
@@ -16,7 +16,7 @@ export function createLogger(config) {
16
16
  format: winston.format.combine(
17
17
  winston.format.colorize(),
18
18
  winston.format.printf(({ level, message, timestamp }) => {
19
- return `[KernelBot] ${level}: ${message}`;
19
+ return `${timestamp} [KernelBot] ${level}: ${message}`;
20
20
  }),
21
21
  ),
22
22
  }),
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Time Awareness Utility — Riyadh-local helpers.
3
+ *
4
+ * Provides timezone-aware helpers anchored to Asia/Riyadh so that
5
+ * automated tasks can respect the user's real-world schedule.
6
+ *
7
+ * Quiet hours (02:00 – 10:00 Riyadh time) are intentionally wider than
8
+ * the generic defaults in timeUtils.js because the owner fasts during
9
+ * Ramadan and typically sleeps through the early-morning hours.
10
+ *
11
+ * @module utils/timeAwareness
12
+ */
13
+
14
+ const TIMEZONE = 'Asia/Riyadh';
15
+ const QUIET_START_HOUR = 2;
16
+ const QUIET_END_HOUR = 10;
17
+
18
+ /**
19
+ * Return the current date/time in the Asia/Riyadh timezone.
20
+ *
21
+ * The returned object contains the full ISO-style formatted string,
22
+ * the numeric hour (0-23), and the raw Date for further processing.
23
+ *
24
+ * @returns {{ formatted: string, hour: number, date: Date }}
25
+ */
26
+ export function getCurrentRiyadhTime() {
27
+ const now = new Date();
28
+
29
+ const formatted = now.toLocaleString('en-US', {
30
+ weekday: 'long',
31
+ year: 'numeric',
32
+ month: 'long',
33
+ day: 'numeric',
34
+ hour: '2-digit',
35
+ minute: '2-digit',
36
+ second: '2-digit',
37
+ hour12: true,
38
+ timeZone: TIMEZONE,
39
+ });
40
+
41
+ const hourParts = new Intl.DateTimeFormat('en-US', {
42
+ hour: 'numeric',
43
+ hour12: false,
44
+ timeZone: TIMEZONE,
45
+ }).formatToParts(now);
46
+
47
+ const hour = parseInt(
48
+ hourParts.find((p) => p.type === 'hour')?.value || '0',
49
+ 10,
50
+ );
51
+
52
+ return { formatted, hour, date: now };
53
+ }
54
+
55
+ /**
56
+ * Check whether the current Riyadh time falls within quiet hours.
57
+ *
58
+ * Quiet hours are defined as **02:00 – 10:00 (Asia/Riyadh)** to
59
+ * respect the user's sleep schedule during Ramadan fasting, when
60
+ * they tend to sleep through the early-morning period.
61
+ *
62
+ * Automated tasks (notifications, self-improvement PRs, noisy jobs)
63
+ * should check this flag and defer non-urgent work until after the
64
+ * quiet window closes.
65
+ *
66
+ * @returns {boolean} `true` when the current Riyadh time is between
67
+ * 02:00 and 10:00 (inclusive start, exclusive end).
68
+ */
69
+ export function isQuietHours() {
70
+ const { hour } = getCurrentRiyadhTime();
71
+ return hour >= QUIET_START_HOUR && hour < QUIET_END_HOUR;
72
+ }
package/src/worker.js CHANGED
@@ -3,7 +3,7 @@ import { executeTool } from './tools/index.js';
3
3
  import { closeSession } from './tools/browser.js';
4
4
  import { getMissingCredential } from './utils/config.js';
5
5
  import { getWorkerPrompt } from './prompts/workers.js';
6
- import { getUnifiedSkillById } from './skills/custom.js';
6
+ import { buildSkillPrompt } from './skills/loader.js';
7
7
  import { getLogger } from './utils/logger.js';
8
8
  import { truncateToolResult } from './utils/truncate.js';
9
9
 
@@ -23,17 +23,17 @@ export class WorkerAgent {
23
23
  * @param {string} opts.workerType - coding, browser, system, devops, research
24
24
  * @param {string} opts.jobId - Job ID for logging
25
25
  * @param {Array} opts.tools - Scoped tool definitions
26
- * @param {string|null} opts.skillId - Active skill ID (for worker prompt)
26
+ * @param {string[]|null} opts.skillIds - Active skill IDs (for worker prompt)
27
27
  * @param {string|null} opts.workerContext - Structured context (conversation history, persona, dependency results)
28
28
  * @param {object} opts.callbacks - { onProgress, onComplete, onError }
29
29
  * @param {AbortController} opts.abortController - For cancellation
30
30
  */
31
- constructor({ config, workerType, jobId, tools, skillId, workerContext, callbacks, abortController }) {
31
+ constructor({ config, workerType, jobId, tools, skillIds, workerContext, callbacks, abortController }) {
32
32
  this.config = config;
33
33
  this.workerType = workerType;
34
34
  this.jobId = jobId;
35
35
  this.tools = tools;
36
- this.skillId = skillId;
36
+ this.skillIds = skillIds || [];
37
37
  this.workerContext = workerContext || null;
38
38
  this.callbacks = callbacks || {};
39
39
  this.abortController = abortController || new AbortController();
@@ -45,8 +45,8 @@ export class WorkerAgent {
45
45
  // Create provider from worker brain config
46
46
  this.provider = createProvider(config);
47
47
 
48
- // Build system prompt
49
- const skillPrompt = skillId ? getUnifiedSkillById(skillId)?.systemPrompt : null;
48
+ // Build system prompt with combined skill expertise
49
+ const skillPrompt = buildSkillPrompt(this.skillIds);
50
50
  this.systemPrompt = getWorkerPrompt(workerType, config, skillPrompt);
51
51
 
52
52
  // Safety ceiling — not a real limit, just prevents infinite loops
@@ -54,7 +54,7 @@ export class WorkerAgent {
54
54
  this.maxIterations = 200;
55
55
 
56
56
  const logger = getLogger();
57
- logger.info(`[Worker ${jobId}] Created: type=${workerType}, provider=${config.brain.provider}/${config.brain.model}, tools=${tools.length}, skill=${skillId || 'none'}, context=${workerContext ? 'yes' : 'none'}`);
57
+ logger.info(`[Worker ${jobId}] Created: type=${workerType}, provider=${config.brain.provider}/${config.brain.model}, tools=${tools.length}, skills=${this.skillIds.length > 0 ? this.skillIds.join(',') : 'none'}, context=${workerContext ? 'yes' : 'none'}`);
58
58
  }
59
59
 
60
60
  /** Cancel this worker. */
@@ -297,41 +297,34 @@ export class WorkerAgent {
297
297
 
298
298
  const _str = (v) => typeof v === 'string' ? v : (v ? JSON.stringify(v, null, 2) : '');
299
299
 
300
+ /** Try to build a structured result from a parsed JSON object. */
301
+ const _fromParsed = (parsed) => {
302
+ if (!parsed?.summary || !parsed?.status) return null;
303
+ return {
304
+ structured: true,
305
+ summary: String(parsed.summary || ''),
306
+ status: String(parsed.status || 'success'),
307
+ details: _str(parsed.details),
308
+ artifacts: Array.isArray(parsed.artifacts) ? parsed.artifacts : [],
309
+ followUp: parsed.followUp ? String(parsed.followUp) : null,
310
+ toolsUsed: this._toolCallCount,
311
+ errors: this._errors,
312
+ };
313
+ };
314
+
300
315
  // Try to extract JSON from ```json ... ``` fences
301
316
  const fenceMatch = text.match(/```json\s*\n?([\s\S]*?)\n?\s*```/);
302
317
  if (fenceMatch) {
303
318
  try {
304
- const parsed = JSON.parse(fenceMatch[1]);
305
- if (parsed.summary && parsed.status) {
306
- return {
307
- structured: true,
308
- summary: String(parsed.summary || ''),
309
- status: String(parsed.status || 'success'),
310
- details: _str(parsed.details),
311
- artifacts: Array.isArray(parsed.artifacts) ? parsed.artifacts : [],
312
- followUp: parsed.followUp ? String(parsed.followUp) : null,
313
- toolsUsed: this._toolCallCount,
314
- errors: this._errors,
315
- };
316
- }
319
+ const result = _fromParsed(JSON.parse(fenceMatch[1]));
320
+ if (result) return result;
317
321
  } catch { /* fall through */ }
318
322
  }
319
323
 
320
324
  // Try raw JSON parse (no fences)
321
325
  try {
322
- const parsed = JSON.parse(text);
323
- if (parsed.summary && parsed.status) {
324
- return {
325
- structured: true,
326
- summary: String(parsed.summary || ''),
327
- status: String(parsed.status || 'success'),
328
- details: _str(parsed.details),
329
- artifacts: Array.isArray(parsed.artifacts) ? parsed.artifacts : [],
330
- followUp: parsed.followUp ? String(parsed.followUp) : null,
331
- toolsUsed: this._toolCallCount,
332
- errors: this._errors,
333
- };
334
- }
326
+ const result = _fromParsed(JSON.parse(text));
327
+ if (result) return result;
335
328
  } catch { /* fall through */ }
336
329
 
337
330
  // Fallback: wrap raw text