@jiggai/recipes 0.4.39 → 0.4.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.
@@ -2,7 +2,7 @@
2
2
  "id": "recipes",
3
3
  "name": "Recipes",
4
4
  "description": "Markdown recipes that scaffold agents and teams (workspace-local).",
5
- "version": "0.4.39",
5
+ "version": "0.4.40",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiggai/recipes",
3
- "version": "0.4.39",
3
+ "version": "0.4.40",
4
4
  "description": "ClawRecipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
5
5
  "main": "index.ts",
6
6
  "type": "commonjs",
@@ -43,7 +43,8 @@ export class GenericDriver implements MediaDriver {
43
43
  }
44
44
 
45
45
  // Execute the script with stdin input (most common interface)
46
- const scriptOutput = runScript({
46
+ const scriptOutput = await runScript({
47
+ api: opts.api,
47
48
  runner,
48
49
  script: scriptPath,
49
50
  stdin: prompt,
@@ -78,7 +78,8 @@ export class KlingVideo implements MediaDriver {
78
78
  // The official skill is a Node.js script (not Python)
79
79
  const runner = 'node';
80
80
 
81
- const scriptOutput = runScript({
81
+ const scriptOutput = await runScript({
82
+ api: opts.api,
82
83
  runner,
83
84
  script: scriptPath,
84
85
  args: [
@@ -26,7 +26,8 @@ export class LumaVideo implements MediaDriver {
26
26
  const runner = await findVenvPython(skillDir);
27
27
 
28
28
  // Execute the script with stdin input
29
- const scriptOutput = runScript({
29
+ const scriptOutput = await runScript({
30
+ api: opts.api,
30
31
  runner,
31
32
  script: scriptPath,
32
33
  stdin: prompt,
@@ -42,7 +43,7 @@ export class LumaVideo implements MediaDriver {
42
43
 
43
44
  // Parse the MEDIA: output
44
45
  const filePath = parseMediaOutput(scriptOutput);
45
-
46
+
46
47
  if (!filePath) {
47
48
  throw new Error(`No MEDIA: path found in script output. Output: ${scriptOutput}`);
48
49
  }
@@ -39,7 +39,8 @@ export class NanoBananaPro implements MediaDriver {
39
39
  const resolution = maxDim >= 3840 ? '4K' : maxDim >= 1792 ? '2K' : '1K';
40
40
 
41
41
  // Execute the script with argparse CLI interface
42
- const scriptOutput = runScript({
42
+ const scriptOutput = await runScript({
43
+ api: opts.api,
43
44
  runner,
44
45
  script: scriptPath,
45
46
  args: ['--prompt', prompt, '--filename', filename, '--resolution', resolution],
@@ -28,7 +28,8 @@ export class OpenAIImageGen implements MediaDriver {
28
28
  const size = String(config?.size ?? '1024x1024');
29
29
 
30
30
  // Execute the script with stdin input
31
- const scriptOutput = runScript({
31
+ const scriptOutput = await runScript({
32
+ api: opts.api,
32
33
  runner,
33
34
  script: scriptPath,
34
35
  stdin: prompt,
@@ -26,7 +26,8 @@ export class RunwayVideo implements MediaDriver {
26
26
  const runner = await findVenvPython(skillDir);
27
27
 
28
28
  // Execute the script with stdin input
29
- const scriptOutput = runScript({
29
+ const scriptOutput = await runScript({
30
+ api: opts.api,
30
31
  runner,
31
32
  script: scriptPath,
32
33
  stdin: prompt,
@@ -42,7 +43,7 @@ export class RunwayVideo implements MediaDriver {
42
43
 
43
44
  // Parse the MEDIA: output
44
45
  const filePath = parseMediaOutput(scriptOutput);
45
-
46
+
46
47
  if (!filePath) {
47
48
  throw new Error(`No MEDIA: path found in script output. Output: ${scriptOutput}`);
48
49
  }
@@ -1,4 +1,7 @@
1
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
2
+
1
3
  export interface MediaDriverInvokeOpts {
4
+ api: OpenClawPluginApi;
2
5
  prompt: string;
3
6
  outputDir: string;
4
7
  env: Record<string, string>;
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
- import { execFileSync } from 'child_process';
3
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
4
4
 
5
5
  /**
6
6
  * Find a skill directory by searching common skill roots
@@ -33,7 +33,7 @@ export async function findSkillDir(slug: string): Promise<string | null> {
33
33
  */
34
34
  export async function findVenvPython(skillDir: string): Promise<string> {
35
35
  const venvPython = path.join(skillDir, '.venv', 'bin', 'python');
36
-
36
+
37
37
  try {
38
38
  await fs.access(venvPython);
39
39
  return venvPython;
@@ -48,7 +48,7 @@ export async function findVenvPython(skillDir: string): Promise<string> {
48
48
  export async function loadConfigEnv(): Promise<Record<string, string>> {
49
49
  const homedir = process.env.HOME || '/home/control';
50
50
  const configPath = path.join(homedir, '.openclaw', 'openclaw.json');
51
-
51
+
52
52
  try {
53
53
  const cfgRaw = await fs.readFile(configPath, 'utf8');
54
54
  const cfgParsed = JSON.parse(cfgRaw);
@@ -82,9 +82,12 @@ export function parseMediaOutput(stdout: string): string {
82
82
  }
83
83
 
84
84
  /**
85
- * Execute a script with proper error handling and output capture
85
+ * Execute a script via the OpenClaw exec tool so this plugin package does not
86
+ * directly import child_process. We still pass argv as discrete args and feed
87
+ * prompt text via stdin through a small Python wrapper script.
86
88
  */
87
89
  export interface RunScriptOpts {
90
+ api: OpenClawPluginApi;
88
91
  runner: string;
89
92
  script: string;
90
93
  args?: string[];
@@ -94,26 +97,75 @@ export interface RunScriptOpts {
94
97
  timeout: number;
95
98
  }
96
99
 
97
- export function runScript(opts: RunScriptOpts): string {
100
+ function buildPythonExecSnippet(opts: RunScriptOpts): string {
98
101
  const { runner, script, args = [], stdin, env, cwd, timeout } = opts;
102
+ const mergedEnv = {
103
+ ...env,
104
+ MEDIA_OUTPUT_DIR: cwd,
105
+ };
106
+
107
+ const payload = {
108
+ runner,
109
+ script,
110
+ args,
111
+ stdin: stdin ?? '',
112
+ env: mergedEnv,
113
+ cwd,
114
+ timeoutMs: timeout,
115
+ };
116
+
117
+ // Base64-encode the payload to avoid shell injection and heredoc delimiter collisions.
118
+ const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64');
119
+
120
+ return [
121
+ `python3 -c '`,
122
+ `import base64, json, os, subprocess, sys;`,
123
+ `payload = json.loads(base64.b64decode("${payloadB64}").decode());`,
124
+ `env = os.environ.copy();`,
125
+ `env.update({k: str(v) for k, v in payload["env"].items()});`,
126
+ `res = subprocess.run(`,
127
+ ` [payload["runner"], payload["script"], *payload.get("args", [])],`,
128
+ ` input=payload.get("stdin", ""),`,
129
+ ` text=True,`,
130
+ ` capture_output=True,`,
131
+ ` cwd=payload["cwd"],`,
132
+ ` env=env,`,
133
+ ` timeout=max(1, int(payload.get("timeoutMs", 1000) / 1000))`,
134
+ `);`,
135
+ `sys.stdout.write(res.stdout);`,
136
+ `sys.stderr.write(res.stderr);`,
137
+ `raise SystemExit(res.returncode)`,
138
+ `'`,
139
+ ].join('\n');
140
+ }
141
+
142
+ export async function runScript(opts: RunScriptOpts): Promise<string> {
143
+ const { api, timeout } = opts;
144
+ const timeoutMs = Math.max(1000, timeout + 5000);
145
+ const command = buildPythonExecSnippet(opts);
99
146
 
100
147
  try {
101
- return execFileSync(runner, [script, ...args], {
102
- cwd,
103
- timeout,
104
- encoding: 'utf8',
105
- input: stdin,
106
- env: {
107
- ...process.env,
108
- ...env,
109
- MEDIA_OUTPUT_DIR: cwd,
110
- },
111
- }).trim();
148
+ // Use the plugin SDK's runtime exec — available to all plugins without
149
+ // gateway tool permissions (unlike toolsInvoke('exec') which is session-gated).
150
+ const result = await api.runtime.system.runCommandWithTimeout(
151
+ ['bash', '-c', command],
152
+ { timeoutMs, cwd: opts.cwd },
153
+ );
154
+
155
+ if (result.code !== 0) {
156
+ const msg = [
157
+ `Script execution failed with exit code ${result.code}`,
158
+ result.stdout ? `\n--- stdout ---\n${result.stdout.trim()}` : '',
159
+ result.stderr ? `\n--- stderr ---\n${result.stderr.trim()}` : '',
160
+ ].filter(Boolean).join('');
161
+ throw new Error(msg);
162
+ }
163
+
164
+ return (result.stdout || '').trim();
112
165
  } catch (err) {
113
- // Surface stderr/stdout to make debugging skill scripts possible
114
- const e = err as any;
115
- const stdout = typeof e?.stdout === 'string' ? e.stdout : (Buffer.isBuffer(e?.stdout) ? e.stdout.toString('utf8') : '');
116
- const stderr = typeof e?.stderr === 'string' ? e.stderr : (Buffer.isBuffer(e?.stderr) ? e.stderr.toString('utf8') : '');
166
+ const e = err as Error & { stdout?: string; stderr?: string };
167
+ const stdout = e?.stdout ?? '';
168
+ const stderr = e?.stderr ?? '';
117
169
  const msg = [
118
170
  e?.message ? String(e.message) : 'Script execution failed',
119
171
  stdout ? `\n--- stdout ---\n${stdout.trim()}` : '',
@@ -128,7 +180,7 @@ export function runScript(opts: RunScriptOpts): string {
128
180
  */
129
181
  export async function findScriptInSkill(skillDir: string, scriptCandidates: string[]): Promise<string | null> {
130
182
  const searchDirs = [skillDir, path.join(skillDir, 'scripts')];
131
-
183
+
132
184
  for (const dir of searchDirs) {
133
185
  for (const candidate of scriptCandidates) {
134
186
  const scriptPath = path.join(dir, candidate);
@@ -140,6 +192,6 @@ export async function findScriptInSkill(skillDir: string, scriptCandidates: stri
140
192
  }
141
193
  }
142
194
  }
143
-
195
+
144
196
  return null;
145
- }
197
+ }
@@ -0,0 +1,69 @@
1
+ import { ToolsInvokeError } from '../../toolsInvoke.js';
2
+
3
+ export type ErrorCategory = 'funding' | 'rate-limit' | 'auth' | 'timeout' | 'unknown';
4
+
5
+ const FUNDING_PATTERNS = [
6
+ /insufficient.*(credits?|funds?|balance)/i,
7
+ /billing/i,
8
+ /payment\s+required/i,
9
+ /quota\s+exceeded/i,
10
+ /out\s+of\s+credits/i,
11
+ /budget\s+(exceeded|limit)/i,
12
+ /no\s+(active\s+)?subscription/i,
13
+ /plan\s+(limit|exceeded)/i,
14
+ ];
15
+
16
+ const RATE_LIMIT_PATTERNS = [
17
+ /rate\s+limit/i,
18
+ /too\s+many\s+requests/i,
19
+ /throttl/i,
20
+ ];
21
+
22
+ const AUTH_PATTERNS = [
23
+ /unauthorized/i,
24
+ /invalid.*api.?key/i,
25
+ /forbidden/i,
26
+ /authentication\s+failed/i,
27
+ /access\s+denied/i,
28
+ ];
29
+
30
+ function classifyByHttpStatus(status: number): ErrorCategory | null {
31
+ if (status === 402) return 'funding';
32
+ if (status === 429) return 'rate-limit';
33
+ if (status === 401 || status === 403) return 'auth';
34
+ if (status === 408 || status === 504) return 'timeout';
35
+ return null;
36
+ }
37
+
38
+ function classifyByMessage(message: string, error: unknown): ErrorCategory | null {
39
+ if (FUNDING_PATTERNS.some((p) => p.test(message))) return 'funding';
40
+ if (RATE_LIMIT_PATTERNS.some((p) => p.test(message))) return 'rate-limit';
41
+ if (AUTH_PATTERNS.some((p) => p.test(message))) return 'auth';
42
+ if (error instanceof Error && error.name === 'AbortError') return 'timeout';
43
+ if (/timed?\s*out/i.test(message)) return 'timeout';
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Classify an error into a category based on HTTP status and message content.
49
+ * Returns 'unknown' if the error doesn't match any known pattern.
50
+ */
51
+ export function classifyError(error: unknown): ErrorCategory {
52
+ const httpStatus = error instanceof ToolsInvokeError ? error.httpStatus : 0;
53
+ const message = error instanceof Error ? error.message : String(error ?? '');
54
+
55
+ return classifyByHttpStatus(httpStatus) ?? classifyByMessage(message, error) ?? 'unknown';
56
+ }
57
+
58
+ const CATEGORY_LABELS: Record<ErrorCategory, string> = {
59
+ 'funding': 'Funding issue — the model provider may be out of credits or require payment',
60
+ 'rate-limit': 'Rate limit — the model provider is throttling requests',
61
+ 'auth': 'Authentication failure — the API key may be invalid or expired',
62
+ 'timeout': 'Timeout — the request took too long to complete',
63
+ 'unknown': 'Unknown error',
64
+ };
65
+
66
+ /** Human-readable label for an error category. */
67
+ export function errorCategoryLabel(category: ErrorCategory): string {
68
+ return CATEGORY_LABELS[category] ?? CATEGORY_LABELS['unknown'];
69
+ }
@@ -4,6 +4,7 @@ import crypto from 'node:crypto';
4
4
  import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
5
5
  import type { ToolTextResult } from '../../toolsInvoke';
6
6
  import { toolsInvoke } from '../../toolsInvoke';
7
+ import { classifyError, errorCategoryLabel } from './workflow-error-classify';
7
8
  import { resolveTeamDir } from '../workspace';
8
9
  import { getDriver } from './media-drivers/registry';
9
10
  import { GenericDriver } from './media-drivers/generic.driver';
@@ -911,6 +912,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
911
912
  text = JSON.stringify(payload, null, 2);
912
913
  } catch (e) {
913
914
  const eRec = asRecord(e);
915
+ const errorCategory = classifyError(e);
914
916
  const errorDetails = {
915
917
  message: e instanceof Error ? e.message : String(e),
916
918
  name: e instanceof Error ? e.name : undefined,
@@ -919,6 +921,8 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
919
921
  details: eRec['details'],
920
922
  data: eRec['data'],
921
923
  cause: e instanceof Error && 'cause' in e ? (e as Error & { cause?: unknown }).cause : undefined,
924
+ errorCategory,
925
+ errorCategoryLabel: errorCategory !== 'unknown' ? errorCategoryLabel(errorCategory) : undefined,
922
926
  };
923
927
  const errMsg = `LLM execution failed for node ${nodeLabel(node)}: ${errorDetails.message}`;
924
928
  const errorTs = new Date().toISOString();
@@ -928,18 +932,18 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
928
932
  updatedAt: errorTs,
929
933
  nodeStates: {
930
934
  ...(cur.nodeStates ?? {}),
931
- [node.id]: { status: 'error', ts: errorTs, error: errMsg, details: errorDetails },
935
+ [node.id]: { status: 'error', ts: errorTs, error: errMsg, details: errorDetails, errorCategory },
932
936
  },
933
937
  events: [
934
938
  ...cur.events,
935
- { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, details: errorDetails },
939
+ { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, details: errorDetails, errorCategory },
936
940
  ],
937
941
  nodeResults: [
938
942
  ...(cur.nodeResults ?? []),
939
- { nodeId: node.id, kind: node.kind, agentId: agentIdExec, error: errMsg, details: errorDetails },
943
+ { nodeId: node.id, kind: node.kind, agentId: agentIdExec, error: errMsg, details: errorDetails, errorCategory },
940
944
  ],
941
945
  }));
942
- results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error' });
946
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', errorCategory });
943
947
  continue;
944
948
  }
945
949
 
@@ -1356,16 +1360,17 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1356
1360
  nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath), nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
1357
1361
  }));
1358
1362
  } catch (e) {
1359
- await fs.writeFile(artifactPath, JSON.stringify({ ok: false, tool: toolName, error: (e as Error).message }, null, 2) + '\n', 'utf8');
1363
+ const errorCategory = classifyError(e);
1364
+ await fs.writeFile(artifactPath, JSON.stringify({ ok: false, tool: toolName, error: (e as Error).message, errorCategory }, null, 2) + '\n', 'utf8');
1360
1365
  const errorTs = new Date().toISOString();
1361
1366
  await appendRunLog(runPath, (cur) => ({
1362
1367
  ...cur,
1363
1368
  status: 'error',
1364
- nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs } },
1365
- events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, tool: toolName, message: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
1366
- nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, error: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
1369
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, errorCategory } },
1370
+ events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, tool: toolName, message: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath), errorCategory }],
1371
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, error: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath), errorCategory }],
1367
1372
  }));
1368
- results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', error: (e as Error).message });
1373
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', error: (e as Error).message, errorCategory });
1369
1374
  continue;
1370
1375
  }
1371
1376
  } else if (kind === 'media-image' || kind === 'media-video' || kind === 'media-audio') {
@@ -1477,6 +1482,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1477
1482
  let payload: Record<string, unknown>;
1478
1483
  if (driver) {
1479
1484
  const result = await driver.invoke({
1485
+ api,
1480
1486
  prompt: refinedPrompt,
1481
1487
  outputDir: mediaDir,
1482
1488
  env: mergedEnv,
@@ -1503,6 +1509,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1503
1509
  }
1504
1510
  text = JSON.stringify(payload, null, 2);
1505
1511
  } catch (e) {
1512
+ const errorCategory = classifyError(e);
1506
1513
  const errDetails = e instanceof Error
1507
1514
  ? { message: e.message, name: e.name, stack: e.stack?.split('\n').slice(0, 5).join(' | ') }
1508
1515
  : { message: String(e) };
@@ -1512,11 +1519,11 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1512
1519
  ...cur,
1513
1520
  status: 'error',
1514
1521
  updatedAt: errorTs,
1515
- nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, error: errMsg } },
1516
- events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg }],
1517
- nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdMedia || agentId, error: errMsg }],
1522
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, error: errMsg, errorCategory } },
1523
+ events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, errorCategory }],
1524
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdMedia || agentId, error: errMsg, errorCategory }],
1518
1525
  }));
1519
- results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error' });
1526
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', errorCategory });
1520
1527
  continue;
1521
1528
  }
1522
1529
 
@@ -6,6 +6,19 @@ export const TOOLS_INVOKE_TIMEOUT_MS = 120_000;
6
6
  export const RETRY_DELAY_BASE_MS = 150;
7
7
  export const GATEWAY_DEFAULT_PORT = 18789;
8
8
 
9
+ /**
10
+ * Custom error class that preserves HTTP status from gateway responses.
11
+ * Used downstream to classify errors (e.g. 402 → funding, 429 → rate-limit).
12
+ */
13
+ export class ToolsInvokeError extends Error {
14
+ httpStatus: number;
15
+ constructor(message: string, httpStatus: number) {
16
+ super(message);
17
+ this.name = 'ToolsInvokeError';
18
+ this.httpStatus = httpStatus;
19
+ }
20
+ }
21
+
9
22
  export type ToolTextResult = { content?: Array<{ type: string; text?: string }> };
10
23
 
11
24
  export type ToolsInvokeRequest = {
@@ -43,7 +56,7 @@ async function doSingleToolsInvoke<T>(url: string, token: string, req: ToolsInvo
43
56
  }).finally(() => clearTimeout(t));
44
57
 
45
58
  const json = (await res.json()) as ToolsInvokeResponse;
46
- if (!res.ok || !json.ok) throw new Error(parseToolsInvokeError(json, res.status));
59
+ if (!res.ok || !json.ok) throw new ToolsInvokeError(parseToolsInvokeError(json, res.status), res.status);
47
60
  return json.result as T;
48
61
  }
49
62