assistme 0.3.4 → 0.3.6

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.
@@ -0,0 +1,7 @@
1
+ import {
2
+ JobRunner
3
+ } from "./chunk-4YWS463E.js";
4
+ import "./chunk-JVA6DHXD.js";
5
+ export {
6
+ JobRunner
7
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assistme",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "AssistMe CLI Agent - AI-powered assistant that controls your real browser",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -24,6 +24,7 @@
24
24
  "chalk": "^5.4.1",
25
25
  "commander": "^13.1.0",
26
26
  "conf": "^13.0.1",
27
+ "croner": "^10.0.1",
27
28
  "dotenv": "^16.5.0",
28
29
  "glob": "^11.0.1",
29
30
  "ora": "^8.2.0",
@@ -1,7 +1,17 @@
1
- import type { HookCallbackMatcher, HookCallback, PreToolUseHookInput, PostToolUseHookInput } from "@anthropic-ai/claude-agent-sdk";
1
+ import type {
2
+ HookCallbackMatcher,
3
+ HookCallback,
4
+ PreToolUseHookInput,
5
+ PostToolUseHookInput,
6
+ } from "@anthropic-ai/claude-agent-sdk";
2
7
  import { emitEvent } from "../db/supabase.js";
3
8
  import { log } from "../utils/logger.js";
4
9
  import type { ToolCallRecord } from "./skill-extractor.js";
10
+ import {
11
+ MAX_TOOL_INPUT_LOG_LENGTH,
12
+ MAX_TOOL_RESULT_LENGTH,
13
+ MAX_SKILL_RECORD_RESULT_LENGTH,
14
+ } from "../utils/constants.js";
5
15
 
6
16
  /**
7
17
  * Strip MCP server prefix from tool names for web UI compatibility.
@@ -29,7 +39,7 @@ export function createEventHooks(
29
39
  const displayName = stripMcpPrefix(rawName);
30
40
  const toolInput = preInput.tool_input;
31
41
 
32
- log.tool(displayName, JSON.stringify(toolInput).slice(0, 200));
42
+ log.tool(displayName, JSON.stringify(toolInput).slice(0, MAX_TOOL_INPUT_LOG_LENGTH));
33
43
 
34
44
  await emitEvent(taskId, "tool_use_start", { name: displayName });
35
45
  await emitEvent(taskId, "tool_use_input", { input: toolInput });
@@ -55,22 +65,20 @@ export function createEventHooks(
55
65
  const toolResponse = postInput.tool_response;
56
66
 
57
67
  const resultStr =
58
- typeof toolResponse === "string"
59
- ? toolResponse
60
- : JSON.stringify(toolResponse);
68
+ typeof toolResponse === "string" ? toolResponse : JSON.stringify(toolResponse);
61
69
 
62
- log.result(resultStr.slice(0, 200));
70
+ log.result(resultStr.slice(0, MAX_TOOL_INPUT_LOG_LENGTH));
63
71
 
64
72
  await emitEvent(taskId, "tool_result", {
65
73
  name: displayName,
66
- result: resultStr.slice(0, 10000),
74
+ result: resultStr.slice(0, MAX_TOOL_RESULT_LENGTH),
67
75
  });
68
76
 
69
77
  // Record for post-task skill extraction
70
78
  toolCallRecords.push({
71
79
  name: displayName,
72
80
  input: (toolInput as Record<string, unknown>) || {},
73
- result: resultStr.slice(0, 300),
81
+ result: resultStr.slice(0, MAX_SKILL_RECORD_RESULT_LENGTH),
74
82
  });
75
83
 
76
84
  return {};
@@ -1,5 +1,8 @@
1
1
  import { callMcpHandler } from "../db/api-client.js";
2
2
  import { log } from "../utils/logger.js";
3
+ import { JobRowSchema, JobListRowSchema, JobRunRowSchema, safeParse } from "../utils/schemas.js";
4
+ import { MAX_JOB_SUMMARY_LENGTH } from "../utils/constants.js";
5
+ import { errorMessage } from "../utils/errors.js";
3
6
 
4
7
  // ── Interfaces ─────────────────────────────────────────────────────
5
8
 
@@ -37,32 +40,35 @@ export class JobRunner {
37
40
  */
38
41
  async loadJob(jobName: string): Promise<JobInfo | null> {
39
42
  try {
40
- const data = await callMcpHandler<Array<Record<string, unknown>> | null>(
41
- "job.get_with_skills",
42
- { job_name: jobName }
43
- );
43
+ const data = await callMcpHandler<unknown[]>("job.get_with_skills", {
44
+ job_name: jobName,
45
+ });
44
46
 
45
- if (!data || (data as unknown[]).length === 0) {
47
+ if (!data || !Array.isArray(data) || data.length === 0) {
46
48
  return null;
47
49
  }
48
50
 
49
- const rows = data as Array<Record<string, unknown>>;
50
- const first = rows[0];
51
+ const rows = data.map((row) => safeParse(JobRowSchema, row)).filter(Boolean);
52
+ if (rows.length === 0) return null;
53
+
54
+ const first = rows[0]!;
51
55
 
52
56
  return {
53
- jobId: first.job_id as string,
54
- jobName: first.job_name as string,
55
- jobDescription: first.job_description as string,
56
- skills: rows.map((row) => ({
57
- skillId: row.skill_id as string,
58
- skillName: row.skill_name as string,
59
- skillDescription: (row.skill_description as string) || "",
60
- skillEmoji: (row.skill_emoji as string) || "",
61
- skillContent: (row.skill_content as string) || "",
62
- })),
57
+ jobId: first.job_id,
58
+ jobName: first.job_name,
59
+ jobDescription: first.job_description,
60
+ skills: rows
61
+ .filter((row) => row!.skill_id)
62
+ .map((row) => ({
63
+ skillId: row!.skill_id!,
64
+ skillName: row!.skill_name || "",
65
+ skillDescription: row!.skill_description,
66
+ skillEmoji: row!.skill_emoji,
67
+ skillContent: row!.skill_content,
68
+ })),
63
69
  };
64
70
  } catch (err) {
65
- log.debug(`Failed to load job "${jobName}": ${err}`);
71
+ log.debug(`Failed to load job "${jobName}": ${errorMessage(err)}`);
66
72
  return null;
67
73
  }
68
74
  }
@@ -74,16 +80,19 @@ export class JobRunner {
74
80
  Array<{ id: string; name: string; description: string; skillCount: number }>
75
81
  > {
76
82
  try {
77
- const data = await callMcpHandler<Array<Record<string, unknown>>>("job.list");
78
-
79
- return (data || []).map((row) => ({
80
- id: row.id as string,
81
- name: row.name as string,
82
- description: row.description as string,
83
- skillCount: (row.skill_count as number) || 0,
84
- }));
83
+ const data = await callMcpHandler<unknown[]>("job.list");
84
+
85
+ return (data || [])
86
+ .map((row) => safeParse(JobListRowSchema, row))
87
+ .filter(Boolean)
88
+ .map((row) => ({
89
+ id: row!.id,
90
+ name: row!.name,
91
+ description: row!.description,
92
+ skillCount: row!.skill_count,
93
+ }));
85
94
  } catch (err) {
86
- log.debug(`Failed to list jobs: ${err}`);
95
+ log.debug(`Failed to list jobs: ${errorMessage(err)}`);
87
96
  return [];
88
97
  }
89
98
  }
@@ -106,7 +115,7 @@ export class JobRunner {
106
115
  });
107
116
  return data;
108
117
  } catch (err) {
109
- log.debug(`Job run creation error: ${err}`);
118
+ log.debug(`Job run creation error: ${errorMessage(err)}`);
110
119
  return null;
111
120
  }
112
121
  }
@@ -123,7 +132,7 @@ export class JobRunner {
123
132
  await callMcpHandler("job.complete_run", {
124
133
  run_id: runId,
125
134
  status,
126
- summary: summary?.slice(0, 10000) || null,
135
+ summary: summary?.slice(0, MAX_JOB_SUMMARY_LENGTH) || null,
127
136
  });
128
137
  }
129
138
 
@@ -132,22 +141,25 @@ export class JobRunner {
132
141
  */
133
142
  async getRunHistory(jobName?: string, limit = 10): Promise<JobRunInfo[]> {
134
143
  try {
135
- const data = await callMcpHandler<Array<Record<string, unknown>>>("job.get_runs", {
144
+ const data = await callMcpHandler<unknown[]>("job.get_runs", {
136
145
  job_name: jobName || null,
137
146
  limit,
138
147
  });
139
148
 
140
- return (data || []).map((row) => ({
141
- runId: row.run_id as string,
142
- jobName: row.job_name as string,
143
- status: row.status as string,
144
- triggerType: row.trigger_type as string,
145
- startedAt: row.started_at as string,
146
- completedAt: (row.completed_at as string) || null,
147
- summary: (row.summary as string) || null,
148
- }));
149
+ return (data || [])
150
+ .map((row) => safeParse(JobRunRowSchema, row))
151
+ .filter(Boolean)
152
+ .map((row) => ({
153
+ runId: row!.run_id,
154
+ jobName: row!.job_name,
155
+ status: row!.status,
156
+ triggerType: row!.trigger_type,
157
+ startedAt: row!.started_at,
158
+ completedAt: row!.completed_at ?? null,
159
+ summary: row!.summary ?? null,
160
+ }));
149
161
  } catch (err) {
150
- log.debug(`Run history error: ${err}`);
162
+ log.debug(`Run history error: ${errorMessage(err)}`);
151
163
  return [];
152
164
  }
153
165
  }
@@ -30,6 +30,13 @@ import {
30
30
  } from "./mcp-servers.js";
31
31
  import { createEventHooks } from "./event-hooks.js";
32
32
  import { BASE_SYSTEM_PROMPT } from "./system-prompt.js";
33
+ import {
34
+ MAX_RESPONSE_CONTENT_LENGTH,
35
+ MAX_HISTORY_ENTRIES,
36
+ MAX_HISTORY_RESPONSE_LENGTH,
37
+ MAX_COMPLETE_TASK_RETRIES,
38
+ } from "../utils/constants.js";
39
+ import { errorMessage } from "../utils/errors.js";
33
40
 
34
41
  /**
35
42
  * Manages the task wall-clock timeout.
@@ -80,8 +87,7 @@ class TaskTimeout {
80
87
  }
81
88
  }
82
89
 
83
- const MAX_HISTORY_ENTRIES = 10;
84
- const MAX_RESPONSE_LENGTH = 1500;
90
+ // Constants are now imported from utils/constants.ts
85
91
 
86
92
  export class TaskProcessor {
87
93
  private memoryManager: MemoryManager | null = null;
@@ -124,10 +130,8 @@ export class TaskProcessor {
124
130
  const config = getConfig();
125
131
  resetEventSequence();
126
132
 
127
- // Wall-clock timeout for the entire task (default: 10 minutes)
128
- const taskTimeoutMs =
129
- (((config as unknown as Record<string, unknown>).taskTimeoutMinutes as number) || 10) *
130
- 60_000;
133
+ // Wall-clock timeout for the entire task
134
+ const taskTimeoutMs = config.taskTimeoutMinutes * 60_000;
131
135
 
132
136
  // Set correlation ID for this task's log messages
133
137
  newCorrelationId();
@@ -184,8 +188,8 @@ export class TaskProcessor {
184
188
  for (const entry of history) {
185
189
  historyPrompt += `User: ${entry.prompt}\n`;
186
190
  const truncated =
187
- entry.response.length > MAX_RESPONSE_LENGTH
188
- ? entry.response.slice(0, MAX_RESPONSE_LENGTH) + "…"
191
+ entry.response.length > MAX_HISTORY_RESPONSE_LENGTH
192
+ ? entry.response.slice(0, MAX_HISTORY_RESPONSE_LENGTH) + "…"
189
193
  : entry.response;
190
194
  historyPrompt += `Assistant: ${truncated}\n\n`;
191
195
  }
@@ -274,18 +278,12 @@ export class TaskProcessor {
274
278
  abortController,
275
279
  };
276
280
 
277
- const taskStartTime = Date.now();
278
-
279
281
  try {
280
282
  for await (const message of query({
281
283
  prompt: promptMessages(),
282
284
  options,
283
285
  })) {
284
- // Check timeout
285
- if (Date.now() - taskStartTime > taskTimeoutMs) {
286
- finalResponse += "\n\n[Task timed out]";
287
- break;
288
- }
286
+ // Timeout is handled by TaskTimeout + AbortController
289
287
 
290
288
  switch (message.type) {
291
289
  case "assistant": {
@@ -299,7 +297,8 @@ export class TaskProcessor {
299
297
  text: block.text,
300
298
  });
301
299
  } else if (block.type === "thinking" && "thinking" in block) {
302
- const thinkingText = (block as unknown as { thinking: string }).thinking;
300
+ const thinkingBlock = block as { type: "thinking"; thinking: string };
301
+ const thinkingText = thinkingBlock.thinking;
303
302
  log.debug(`Thinking: ${thinkingText.slice(0, 100)}...`);
304
303
  await emitEvent(task.id, "thinking", {
305
304
  text: thinkingText,
@@ -339,12 +338,11 @@ export class TaskProcessor {
339
338
 
340
339
  default:
341
340
  // Capture session ID from init message for post-task session resume
342
- if (
343
- message.type === "system" &&
344
- "subtype" in message &&
345
- (message as Record<string, unknown>).subtype === "init"
346
- ) {
347
- agentSessionId = (message as Record<string, unknown>).session_id as string;
341
+ if (message.type === "system" && "subtype" in message) {
342
+ const sysMsg = message as { type: string; subtype?: string; session_id?: string };
343
+ if (sysMsg.subtype === "init" && sysMsg.session_id) {
344
+ agentSessionId = sysMsg.session_id;
345
+ }
348
346
  }
349
347
  log.debug(`SDK message type: ${message.type}`);
350
348
  break;
@@ -355,15 +353,14 @@ export class TaskProcessor {
355
353
  }
356
354
 
357
355
  // Truncate finalResponse to avoid edge function payload limits
358
- const MAX_CONTENT_LENGTH = 50_000;
359
356
  const truncatedResponse =
360
- finalResponse.length > MAX_CONTENT_LENGTH
361
- ? finalResponse.slice(0, MAX_CONTENT_LENGTH) + "\n\n[Response truncated]"
357
+ finalResponse.length > MAX_RESPONSE_CONTENT_LENGTH
358
+ ? finalResponse.slice(0, MAX_RESPONSE_CONTENT_LENGTH) + "\n\n[Response truncated]"
362
359
  : finalResponse;
363
360
 
364
361
  // Complete the task (with retry for transient DB failures)
365
362
  await withRetry(() => completeTask(task.id, truncatedResponse, tokenUsage), {
366
- maxRetries: 2,
363
+ maxRetries: MAX_COMPLETE_TASK_RETRIES,
367
364
  baseDelayMs: 300,
368
365
  label: "completeTask",
369
366
  });
@@ -386,7 +383,7 @@ export class TaskProcessor {
386
383
  );
387
384
  }
388
385
  } catch (err) {
389
- const errorMsg = err instanceof Error ? err.message : String(err);
386
+ const errorMsg = errorMessage(err);
390
387
  log.error(`Task failed: ${errorMsg}`);
391
388
 
392
389
  await failTask(task.id, errorMsg);
@@ -1,7 +1,8 @@
1
+ import { Cron } from "croner";
1
2
  import { callMcpHandler } from "../db/api-client.js";
2
3
  import { log } from "../utils/logger.js";
3
-
4
- const SCHEDULER_INTERVAL = 30_000; // Check every 30 seconds
4
+ import { SCHEDULER_INTERVAL_MS } from "../utils/constants.js";
5
+ import { errorMessage } from "../utils/errors.js";
5
6
 
6
7
  export interface ScheduledTask {
7
8
  id: string;
@@ -22,69 +23,31 @@ export interface ScheduledTask {
22
23
  }
23
24
 
24
25
  /**
25
- * Parse a cron expression and calculate the next run time.
26
+ * Parse a cron expression and calculate the next run time using `croner`.
26
27
  * Supports: minute hour day-of-month month day-of-week
27
28
  * Examples: "0 8 * * *" (daily 8am), "0,30 * * * *" (every 30min)
29
+ *
30
+ * Handles edge cases correctly (Feb 29, month boundaries, invalid dates)
31
+ * that the previous hand-rolled parser missed.
28
32
  */
29
33
  export function getNextRunTime(cronExpr: string, timezone: string, fromDate?: Date): Date {
30
34
  const now = fromDate || new Date();
31
- const parts = cronExpr.trim().split(/\s+/);
32
- if (parts.length !== 5) {
33
- throw new Error(`Invalid cron expression: ${cronExpr}`);
34
- }
35
35
 
36
- const [minExpr, hourExpr, domExpr, monExpr, dowExpr] = parts;
37
-
38
- function parseField(expr: string, min: number, max: number): number[] {
39
- const values: number[] = [];
40
- for (const part of expr.split(",")) {
41
- if (part === "*") {
42
- for (let i = min; i <= max; i++) values.push(i);
43
- } else if (part.startsWith("*/")) {
44
- const step = parseInt(part.slice(2));
45
- for (let i = min; i <= max; i += step) values.push(i);
46
- } else if (part.includes("-")) {
47
- const [start, end] = part.split("-").map(Number);
48
- for (let i = start; i <= end; i++) values.push(i);
49
- } else {
50
- values.push(parseInt(part));
51
- }
52
- }
53
- return values.sort((a, b) => a - b);
54
- }
36
+ try {
37
+ const job = new Cron(cronExpr, { timezone: timezone || "UTC" });
38
+ const next = job.nextRun(now);
55
39
 
56
- const minutes = parseField(minExpr, 0, 59);
57
- const hours = parseField(hourExpr, 0, 23);
58
- const daysOfMonth = parseField(domExpr, 1, 31);
59
- const months = parseField(monExpr, 1, 12);
60
- const daysOfWeek = parseField(dowExpr, 0, 6);
61
-
62
- const useUTC = timezone === "UTC";
63
-
64
- const candidate = new Date(now.getTime() + 60_000);
65
- candidate.setSeconds(0, 0);
66
-
67
- for (let i = 0; i < 527040; i++) {
68
- const m = useUTC ? candidate.getUTCMinutes() : candidate.getMinutes();
69
- const h = useUTC ? candidate.getUTCHours() : candidate.getHours();
70
- const dom = useUTC ? candidate.getUTCDate() : candidate.getDate();
71
- const mon = (useUTC ? candidate.getUTCMonth() : candidate.getMonth()) + 1;
72
- const dow = useUTC ? candidate.getUTCDay() : candidate.getDay();
73
-
74
- if (
75
- minutes.includes(m) &&
76
- hours.includes(h) &&
77
- daysOfMonth.includes(dom) &&
78
- months.includes(mon) &&
79
- (dowExpr === "*" || daysOfWeek.includes(dow))
80
- ) {
81
- return candidate;
40
+ if (!next) {
41
+ throw new Error(`No future run time found for cron expression: ${cronExpr}`);
82
42
  }
83
43
 
84
- candidate.setTime(candidate.getTime() + 60_000);
44
+ return next;
45
+ } catch (err) {
46
+ if (err instanceof Error && err.message.includes("No future run time")) {
47
+ throw err;
48
+ }
49
+ throw new Error(`Invalid cron expression "${cronExpr}": ${errorMessage(err)}`);
85
50
  }
86
-
87
- return new Date(now.getTime() + 86400_000);
88
51
  }
89
52
 
90
53
  export class Scheduler {
@@ -98,7 +61,7 @@ export class Scheduler {
98
61
 
99
62
  await this.initializeNextRuns();
100
63
 
101
- this.timer = setInterval(() => this.checkDueTasks(), SCHEDULER_INTERVAL);
64
+ this.timer = setInterval(() => this.checkDueTasks(), SCHEDULER_INTERVAL_MS);
102
65
  log.info("Scheduler started (checking every 30s)");
103
66
  }
104
67
 
@@ -124,7 +87,7 @@ export class Scheduler {
124
87
  }
125
88
  }
126
89
  } catch (err) {
127
- log.debug(`Scheduler init: ${err}`);
90
+ log.debug(`Scheduler init: ${errorMessage(err)}`);
128
91
  }
129
92
  }
130
93
 
@@ -158,7 +121,7 @@ export class Scheduler {
158
121
  last_error: null,
159
122
  });
160
123
  } catch (err) {
161
- const errMsg = err instanceof Error ? err.message : String(err);
124
+ const errMsg = errorMessage(err);
162
125
  await callMcpHandler("schedule.update", {
163
126
  task_id: task.id,
164
127
  last_error: errMsg,
@@ -166,7 +129,7 @@ export class Scheduler {
166
129
  log.error(`Scheduled task "${task.name}" failed: ${errMsg}`);
167
130
  }
168
131
  } catch (err) {
169
- log.debug(`Scheduler check error: ${err}`);
132
+ log.debug(`Scheduler check error: ${errorMessage(err)}`);
170
133
  }
171
134
  }
172
135
  }
@@ -6,24 +6,8 @@ import {
6
6
  import { log } from "../utils/logger.js";
7
7
  import type { SkillManager } from "./skills.js";
8
8
  import { validateSkillName, normalizeSkillName } from "./skills.js";
9
-
10
- // ── Types ───────────────────────────────────────────────────────────
11
-
12
- interface SkillDecision {
13
- action: "create" | "update" | "skip";
14
- // For "create"
15
- name?: string;
16
- description?: string;
17
- instructions?: string;
18
- emoji?: string;
19
- keywords?: string[];
20
- // For "update"
21
- existing_skill_name?: string;
22
- improved_instructions?: string;
23
- improved_description?: string;
24
- // Always present
25
- reason: string;
26
- }
9
+ import { SkillDecisionSchema, type SkillDecision, safeParse } from "../utils/schemas.js";
10
+ import { errorMessage } from "../utils/errors.js";
27
11
 
28
12
  // ── Agent Skills format spec (agentskills.io) ───────────────────────
29
13
 
@@ -78,9 +62,10 @@ export async function evaluateAndMaybeCreateSkill(opts: {
78
62
 
79
63
  // Build existing skills context so the agent knows what already exists
80
64
  const existingSkills = skillManager.getAll();
81
- const existingList = existingSkills.length > 0
82
- ? existingSkills.map((s) => `- ${s.name}: ${s.description}`).join("\n")
83
- : "(no existing skills)";
65
+ const existingList =
66
+ existingSkills.length > 0
67
+ ? existingSkills.map((s) => `- ${s.name}: ${s.description}`).join("\n")
68
+ : "(no existing skills)";
84
69
 
85
70
  const prompt = `${SKILL_EVALUATION_PROMPT}
86
71
 
@@ -111,7 +96,9 @@ Respond with a JSON object now.`;
111
96
  } else if (message.type === "result") {
112
97
  const resultMsg = message as SDKResultMessage;
113
98
  if (resultMsg.subtype === "success" && "total_cost_usd" in resultMsg) {
114
- log.debug(`Skill evaluation cost: $${(resultMsg as { total_cost_usd: number }).total_cost_usd.toFixed(4)}`);
99
+ log.debug(
100
+ `Skill evaluation cost: $${(resultMsg as { total_cost_usd: number }).total_cost_usd.toFixed(4)}`
101
+ );
115
102
  }
116
103
  }
117
104
  }
@@ -123,15 +110,12 @@ Respond with a JSON object now.`;
123
110
  return;
124
111
  }
125
112
 
126
- if (!["create", "update", "skip"].includes(decision.action)) {
127
- log.debug("Skill evaluation: invalid action");
128
- return;
129
- }
113
+ // Action is already validated by Zod schema
130
114
 
131
115
  // Execute the decision
132
116
  await executeSkillDecision(decision, skillManager);
133
117
  } catch (err) {
134
- log.debug(`Skill evaluation error: ${err}`);
118
+ log.debug(`Skill evaluation error: ${errorMessage(err)}`);
135
119
  }
136
120
  }
137
121
 
@@ -149,14 +133,18 @@ async function executeSkillDecision(
149
133
  return;
150
134
  }
151
135
 
152
- // Normalize name to valid kebab-case (model may return invalid format)
153
- let skillName = decision.name;
154
- if (validateSkillName(skillName)) {
155
- skillName = normalizeSkillName(skillName);
156
- if (!skillName || validateSkillName(skillName)) {
157
- log.debug(`Skill create skipped: name "${decision.name}" cannot be normalized`);
158
- return;
159
- }
136
+ // Always normalize, then validate once
137
+ let skillName = normalizeSkillName(decision.name);
138
+ if (!skillName) {
139
+ log.debug(`Skill create skipped: name "${decision.name}" cannot be normalized`);
140
+ return;
141
+ }
142
+ const validationError = validateSkillName(skillName);
143
+ if (validationError) {
144
+ log.debug(`Skill create skipped: ${validationError}`);
145
+ return;
146
+ }
147
+ if (skillName !== decision.name) {
160
148
  log.debug(`Normalized skill name: "${decision.name}" → "${skillName}"`);
161
149
  }
162
150
 
@@ -228,31 +216,36 @@ async function executeSkillDecision(
228
216
  * Attempt to parse a SkillDecision from the model's response text.
229
217
  * Tries the full text first (model returned pure JSON), then falls
230
218
  * back to extracting the outermost balanced `{…}` block.
219
+ * Validates the parsed result against the Zod schema.
231
220
  */
232
221
  function parseJsonResponse(text: string): SkillDecision | null {
233
222
  const trimmed = text.trim();
234
223
 
235
- // Fast path: entire response is JSON
236
- try {
237
- const parsed = JSON.parse(trimmed) as SkillDecision;
238
- if (parsed.action) return parsed;
239
- } catch { /* not pure JSON */ }
224
+ // Try to extract JSON full text first, then balanced `{…}` block
225
+ const candidates: string[] = [trimmed];
240
226
 
241
- // Fallback: find the first balanced `{…}` block
242
227
  const start = trimmed.indexOf("{");
243
- if (start === -1) return null;
244
-
245
- let depth = 0;
246
- for (let i = start; i < trimmed.length; i++) {
247
- if (trimmed[i] === "{") depth++;
248
- else if (trimmed[i] === "}") depth--;
249
- if (depth === 0) {
250
- try {
251
- return JSON.parse(trimmed.slice(start, i + 1)) as SkillDecision;
252
- } catch {
253
- return null;
228
+ if (start !== -1) {
229
+ let depth = 0;
230
+ for (let i = start; i < trimmed.length; i++) {
231
+ if (trimmed[i] === "{") depth++;
232
+ else if (trimmed[i] === "}") depth--;
233
+ if (depth === 0) {
234
+ candidates.push(trimmed.slice(start, i + 1));
235
+ break;
254
236
  }
255
237
  }
256
238
  }
239
+
240
+ for (const candidate of candidates) {
241
+ try {
242
+ const parsed = JSON.parse(candidate);
243
+ const validated = safeParse(SkillDecisionSchema, parsed);
244
+ if (validated) return validated;
245
+ } catch {
246
+ continue;
247
+ }
248
+ }
249
+
257
250
  return null;
258
251
  }