@jiggai/recipes 0.4.34 → 0.4.36

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,50 @@
1
+ export interface MediaDriverInvokeOpts {
2
+ prompt: string;
3
+ outputDir: string;
4
+ env: Record<string, string>;
5
+ timeout: number;
6
+ config?: Record<string, unknown>;
7
+ }
8
+
9
+ const DEFAULT_DURATION_SECONDS = 15;
10
+
11
+ /** Parse duration from node config (e.g. "5s", "10", 15) → seconds as string. */
12
+ export function parseDuration(config?: Record<string, unknown>): string {
13
+ const raw = config?.duration;
14
+ if (raw == null) return String(DEFAULT_DURATION_SECONDS);
15
+ const s = String(raw).replace(/s$/i, '').trim();
16
+ const n = parseInt(s, 10);
17
+ if (Number.isNaN(n) || n <= 0) return String(DEFAULT_DURATION_SECONDS);
18
+ return String(n);
19
+ }
20
+
21
+ export interface MediaDriverResult {
22
+ filePath: string;
23
+ metadata?: Record<string, unknown>;
24
+ }
25
+
26
+ export interface DurationConstraints {
27
+ /** Minimum duration in seconds */
28
+ minSeconds: number;
29
+ /** Maximum duration in seconds */
30
+ maxSeconds: number;
31
+ /** Default duration in seconds */
32
+ defaultSeconds: number;
33
+ /** Allowed step increments (null = any integer) */
34
+ stepSeconds?: number;
35
+ }
36
+
37
+ export interface MediaDriver {
38
+ /** ClawHub slug or skill folder name */
39
+ slug: string;
40
+ /** What this driver produces */
41
+ mediaType: 'image' | 'video' | 'audio';
42
+ /** Display name for UI */
43
+ displayName: string;
44
+ /** Env vars needed (checked for availability in provider dropdown) */
45
+ requiredEnvVars: string[];
46
+ /** Duration constraints for video/audio providers (null for image) */
47
+ durationConstraints: DurationConstraints | null;
48
+ /** Run the generation */
49
+ invoke(opts: MediaDriverInvokeOpts): Promise<MediaDriverResult>;
50
+ }
@@ -0,0 +1,149 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import { execSync } from 'child_process';
4
+
5
+ /**
6
+ * Find a skill directory by searching common skill roots
7
+ */
8
+ export async function findSkillDir(slug: string): Promise<string | null> {
9
+ const homedir = process.env.HOME || '/home/control';
10
+ const skillRoots = [
11
+ path.join(homedir, '.openclaw', 'skills'),
12
+ path.join(homedir, '.openclaw', 'workspace', 'skills'),
13
+ path.join(homedir, '.openclaw', 'workspace'),
14
+ ];
15
+
16
+ for (const root of skillRoots) {
17
+ const skillDir = path.join(root, slug);
18
+ try {
19
+ const stat = await fs.stat(skillDir);
20
+ if (stat.isDirectory()) {
21
+ return skillDir;
22
+ }
23
+ } catch {
24
+ // Directory doesn't exist, continue searching
25
+ }
26
+ }
27
+
28
+ return null;
29
+ }
30
+
31
+ /**
32
+ * Find the appropriate Python runner for a skill directory
33
+ */
34
+ export async function findVenvPython(skillDir: string): Promise<string> {
35
+ const venvPython = path.join(skillDir, '.venv', 'bin', 'python');
36
+
37
+ try {
38
+ await fs.access(venvPython);
39
+ return venvPython;
40
+ } catch {
41
+ return 'python3';
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Load environment variables from OpenClaw config
47
+ */
48
+ export async function loadConfigEnv(): Promise<Record<string, string>> {
49
+ const homedir = process.env.HOME || '/home/control';
50
+ const configPath = path.join(homedir, '.openclaw', 'openclaw.json');
51
+
52
+ try {
53
+ const cfgRaw = await fs.readFile(configPath, 'utf8');
54
+ const cfgParsed = JSON.parse(cfgRaw);
55
+
56
+ // openclaw.json supports multiple shapes historically:
57
+ // - { env: { KEY: "..." } }
58
+ // - { env: { vars: { KEY: "..." } } } (current)
59
+ const envBlock = (cfgParsed as any)?.env;
60
+ const maybeVars = envBlock && typeof envBlock === 'object' ? (envBlock as any).vars : null;
61
+ const rawVars = (maybeVars && typeof maybeVars === 'object') ? maybeVars : envBlock;
62
+
63
+ if (rawVars && typeof rawVars === 'object') {
64
+ return Object.fromEntries(
65
+ Object.entries(rawVars).filter(([, v]) => typeof v === 'string')
66
+ ) as Record<string, string>;
67
+ }
68
+
69
+ return {};
70
+ } catch {
71
+ // Config read failed — proceed with empty env
72
+ return {};
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Parse media file path from script output
78
+ */
79
+ export function parseMediaOutput(stdout: string): string {
80
+ const mediaMatch = stdout.match(/MEDIA:(.+)$/m);
81
+ return mediaMatch ? mediaMatch[1].trim() : '';
82
+ }
83
+
84
+ /**
85
+ * Execute a script with proper error handling and output capture
86
+ */
87
+ export interface RunScriptOpts {
88
+ runner: string;
89
+ script: string;
90
+ args?: string[];
91
+ stdin?: string;
92
+ env: Record<string, string>;
93
+ cwd: string;
94
+ timeout: number;
95
+ }
96
+
97
+ export function runScript(opts: RunScriptOpts): string {
98
+ const { runner, script, args = [], stdin, env, cwd, timeout } = opts;
99
+
100
+ const command = args.length > 0
101
+ ? `${runner} ${JSON.stringify(script)} ${args.map(arg => JSON.stringify(arg)).join(' ')}`
102
+ : `${runner} ${JSON.stringify(script)}`;
103
+
104
+ try {
105
+ return execSync(command, {
106
+ cwd,
107
+ timeout,
108
+ encoding: 'utf8',
109
+ input: stdin,
110
+ env: {
111
+ ...process.env,
112
+ ...env,
113
+ MEDIA_OUTPUT_DIR: cwd,
114
+ },
115
+ }).trim();
116
+ } catch (err) {
117
+ // Surface stderr/stdout to make debugging skill scripts possible
118
+ const e = err as any;
119
+ const stdout = typeof e?.stdout === 'string' ? e.stdout : (Buffer.isBuffer(e?.stdout) ? e.stdout.toString('utf8') : '');
120
+ const stderr = typeof e?.stderr === 'string' ? e.stderr : (Buffer.isBuffer(e?.stderr) ? e.stderr.toString('utf8') : '');
121
+ const msg = [
122
+ e?.message ? String(e.message) : 'Script execution failed',
123
+ stdout ? `\n--- stdout ---\n${stdout.trim()}` : '',
124
+ stderr ? `\n--- stderr ---\n${stderr.trim()}` : '',
125
+ ].filter(Boolean).join('');
126
+ throw new Error(msg);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Search for a script file in skill directory and scripts/ subdirectory
132
+ */
133
+ export async function findScriptInSkill(skillDir: string, scriptCandidates: string[]): Promise<string | null> {
134
+ const searchDirs = [skillDir, path.join(skillDir, 'scripts')];
135
+
136
+ for (const dir of searchDirs) {
137
+ for (const candidate of scriptCandidates) {
138
+ const scriptPath = path.join(dir, candidate);
139
+ try {
140
+ await fs.access(scriptPath);
141
+ return scriptPath;
142
+ } catch {
143
+ // File doesn't exist, continue searching
144
+ }
145
+ }
146
+ }
147
+
148
+ return null;
149
+ }
@@ -1,10 +1,12 @@
1
1
  import fs from 'node:fs/promises';
2
- import { execSync } from 'node:child_process';
3
2
  import path from 'node:path';
4
3
  import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
5
4
  import type { ToolTextResult } from '../../toolsInvoke';
6
5
  import { toolsInvoke } from '../../toolsInvoke';
7
6
  import { resolveTeamDir } from '../workspace';
7
+ import { getDriver } from './media-drivers/registry';
8
+ import { GenericDriver } from './media-drivers/generic.driver';
9
+ import { loadConfigEnv } from './media-drivers/utils';
8
10
  import type { WorkflowLane } from './workflow-types';
9
11
  import { dequeueNextTask, enqueueTask, releaseTaskClaim, compactQueue } from './workflow-queue';
10
12
  import { loadPriorLlmInput, loadProposedPostTextFromPriorNode } from './workflow-node-output-readers';
@@ -191,6 +193,19 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
191
193
 
192
194
  const results: Array<{ taskId: string; runId: string; nodeId: string; status: string }> = [];
193
195
 
196
+ // Default lock TTL (used when we don't know the node config yet).
197
+ // This must be comfortably larger than typical media generation durations.
198
+ const DEFAULT_LOCK_TTL_MS = 30 * 60 * 1000;
199
+
200
+ // Once we know the node config, we can set a tighter (but still safe) TTL.
201
+ const MIN_NODE_LOCK_TTL_MS = 10 * 60 * 1000;
202
+ const LOCK_TTL_BUFFER_MS = 2 * 60 * 1000;
203
+ const getNodeLockTtlMs = (node: WorkflowNode): number => {
204
+ const timeoutMsRaw = asRecord(node?.config ?? {})['timeoutMs'];
205
+ const timeoutMs = typeof timeoutMsRaw === 'number' && Number.isFinite(timeoutMsRaw) ? timeoutMsRaw : 0;
206
+ return Math.max(MIN_NODE_LOCK_TTL_MS, timeoutMs + LOCK_TTL_BUFFER_MS);
207
+ };
208
+
194
209
  for (let i = 0; i < limit; i++) {
195
210
  const dq = await dequeueNextTask(teamDir, agentId, { workerId, leaseSeconds: 120 });
196
211
  if (!dq.ok || !dq.task) break;
@@ -207,9 +222,19 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
207
222
 
208
223
  await ensureDir(lockDir);
209
224
 
225
+ const claimedAtIso = new Date().toISOString();
226
+ const lockInfo = {
227
+ workerId,
228
+ pid: process.pid,
229
+ taskId: task.id,
230
+ claimedAt: claimedAtIso,
231
+ ttlMs: DEFAULT_LOCK_TTL_MS,
232
+ expiresAt: new Date(Date.now() + DEFAULT_LOCK_TTL_MS).toISOString(),
233
+ };
234
+
210
235
  // Node-level lock to prevent double execution.
211
236
  try {
212
- await fs.writeFile(lockPath, JSON.stringify({ workerId, taskId: task.id, claimedAt: new Date().toISOString() }, null, 2), { encoding: 'utf8', flag: 'wx' });
237
+ await fs.writeFile(lockPath, JSON.stringify(lockInfo, null, 2), { encoding: 'utf8', flag: 'wx' });
213
238
  lockHeld = true;
214
239
  } catch {
215
240
  // Lock exists. Treat it as contention unless it looks stale.
@@ -217,10 +242,27 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
217
242
  let unlocked = false;
218
243
  try {
219
244
  const raw = await readTextFile(lockPath);
220
- const parsed = JSON.parse(raw) as { claimedAt?: string };
245
+ const parsed = JSON.parse(raw) as { claimedAt?: string; ttlMs?: number; expiresAt?: string };
246
+
247
+ const expiresAtMs = parsed?.expiresAt ? Date.parse(String(parsed.expiresAt)) : NaN;
221
248
  const claimedAtMs = parsed?.claimedAt ? Date.parse(String(parsed.claimedAt)) : NaN;
222
- const ageMs = Number.isFinite(claimedAtMs) ? Date.now() - claimedAtMs : NaN;
223
- const stale = Number.isFinite(ageMs) && ageMs > 10 * 60 * 1000;
249
+ const parsedTtlMs = typeof parsed?.ttlMs === 'number' && Number.isFinite(parsed.ttlMs) ? parsed.ttlMs : NaN;
250
+
251
+ const computedExpiryMs = Number.isFinite(claimedAtMs) && Number.isFinite(parsedTtlMs)
252
+ ? claimedAtMs + parsedTtlMs
253
+ : NaN;
254
+
255
+ // Prefer explicit expiresAt from the lock file; otherwise fall back to (claimedAt + ttlMs).
256
+ // If neither exists (older locks), fall back to DEFAULT_LOCK_TTL_MS.
257
+ const effectiveExpiryMs = Number.isFinite(expiresAtMs)
258
+ ? expiresAtMs
259
+ : Number.isFinite(computedExpiryMs)
260
+ ? computedExpiryMs
261
+ : Number.isFinite(claimedAtMs)
262
+ ? claimedAtMs + DEFAULT_LOCK_TTL_MS
263
+ : NaN;
264
+
265
+ const stale = Number.isFinite(effectiveExpiryMs) && Date.now() > effectiveExpiryMs;
224
266
  if (stale) {
225
267
  await fs.unlink(lockPath);
226
268
  unlocked = true;
@@ -231,7 +273,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
231
273
 
232
274
  if (unlocked) {
233
275
  try {
234
- await fs.writeFile(lockPath, JSON.stringify({ workerId, taskId: task.id, claimedAt: new Date().toISOString() }, null, 2), { encoding: 'utf8', flag: 'wx' });
276
+ await fs.writeFile(lockPath, JSON.stringify(lockInfo, null, 2), { encoding: 'utf8', flag: 'wx' });
235
277
  lockHeld = true;
236
278
  } catch { // intentional: lock contention, skip task
237
279
  results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_locked' });
@@ -253,35 +295,48 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
253
295
  const runId = task.runId;
254
296
 
255
297
  const { run } = await loadRunFile(teamDir, runsDir, runId);
256
- const workflowFile = String(run.workflow.file);
257
- const workflowPath = path.join(workflowsDir, workflowFile);
258
- const workflowRaw = await readTextFile(workflowPath);
259
- const workflow = normalizeWorkflow(JSON.parse(workflowRaw));
260
-
261
- const nodeIdx = workflow.nodes.findIndex((n) => String(n.id) === String(task.nodeId));
262
- if (nodeIdx < 0) throw new Error(`Node not found in workflow: ${task.nodeId}`);
263
- const node = workflow.nodes[nodeIdx]!;
264
-
265
- // Stale-task guard: expired claim recovery can surface older queue entries from behind the
266
- // cursor. Before executing a dequeued task, verify that this node is still actually runnable
267
- // for the current run state. Otherwise we can resurrect pre-approval work and overwrite
268
- // canonical node outputs for runs that already advanced.
269
- const currentRun = (await loadRunFile(teamDir, runsDir, task.runId)).run;
270
- const currentNodeStates = loadNodeStatesFromRun(currentRun);
271
- const currentStatus = currentNodeStates[String(node.id)]?.status;
272
- const currentlyRunnableIdx = pickNextRunnableNodeIndex({ workflow, run: currentRun });
273
- if (
274
- currentStatus === 'success' ||
275
- currentStatus === 'error' ||
276
- currentStatus === 'waiting' ||
277
- currentlyRunnableIdx === null ||
278
- String(workflow.nodes[currentlyRunnableIdx]?.id ?? '') !== String(node.id)
279
- ) {
280
- results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_stale' });
281
- continue;
282
- }
298
+ const workflowFile = String(run.workflow.file);
299
+ const workflowPath = path.join(workflowsDir, workflowFile);
300
+ const workflowRaw = await readTextFile(workflowPath);
301
+ const workflow = normalizeWorkflow(JSON.parse(workflowRaw));
302
+
303
+ const nodeIdx = workflow.nodes.findIndex((n) => String(n.id) === String(task.nodeId));
304
+ if (nodeIdx < 0) throw new Error(`Node not found in workflow: ${task.nodeId}`);
305
+ const node = workflow.nodes[nodeIdx]!;
306
+
307
+ // Now that we know the node, tighten the lock TTL based on node.config.timeoutMs.
308
+ try {
309
+ const nodeLockTtlMs = getNodeLockTtlMs(node);
310
+ if (nodeLockTtlMs !== lockInfo.ttlMs) {
311
+ await fs.writeFile(
312
+ lockPath,
313
+ JSON.stringify({ ...lockInfo, ttlMs: nodeLockTtlMs, expiresAt: new Date(Date.now() + nodeLockTtlMs).toISOString() }, null, 2),
314
+ { encoding: 'utf8' },
315
+ );
316
+ }
317
+ } catch { // intentional: best-effort lock metadata update
318
+ // ignore
319
+ }
320
+
321
+ // Stale-task guard: expired claim recovery can surface older queue entries from behind the
322
+ // cursor. Before executing a dequeued task, verify that this node is still actually runnable
323
+ // for the current run state. Otherwise we can resurrect pre-approval work and overwrite
324
+ // canonical node outputs for runs that already advanced.
325
+ const currentNodeStates = loadNodeStatesFromRun(run);
326
+ const currentStatus = currentNodeStates[String(node.id)]?.status;
327
+ const currentlyRunnableIdx = pickNextRunnableNodeIndex({ workflow, run });
328
+ if (
329
+ currentStatus === 'success' ||
330
+ currentStatus === 'error' ||
331
+ currentStatus === 'waiting' ||
332
+ currentlyRunnableIdx === null ||
333
+ String(workflow.nodes[currentlyRunnableIdx]?.id ?? '') !== String(node.id)
334
+ ) {
335
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_stale' });
336
+ continue;
337
+ }
283
338
 
284
- // Determine current lane + ticket path.
339
+ // Determine current lane + ticket path.
285
340
  const laneRaw = String(run.ticket.lane);
286
341
  assertLane(laneRaw);
287
342
  let curLane: WorkflowLane = laneRaw as WorkflowLane;
@@ -1012,13 +1067,13 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1012
1067
  const timeoutMsRaw = Number(asString(config['timeoutMs'] ?? '300000'));
1013
1068
  const timeoutMs = Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0 ? timeoutMsRaw : 300000;
1014
1069
 
1015
- // ── Step 1: Prompt refinement (optional) ──
1016
- // skipRefinement: when the upstream LLM already produced a clean brief,
1017
- // skip the extra llm-task call that tends to over-elaborate.
1018
- const skipRefinement = String(config['skipRefinement'] ?? config['skip_refinement'] ?? 'false').toLowerCase() === 'true';
1070
+ // ── Step 1: Prompt refinement (opt-in) ──
1071
+ // addRefinement: explicitly request an LLM refinement pass.
1072
+ // Default is OFF upstream LLM nodes should produce ready-to-use briefs.
1073
+ const addRefinement = String(config['addRefinement'] ?? config['add_refinement'] ?? 'false').toLowerCase() === 'true';
1019
1074
  let refinedPrompt = prompt.trim();
1020
1075
 
1021
- if (!skipRefinement && mediaType !== 'image') {
1076
+ if (addRefinement && mediaType !== 'image') {
1022
1077
  // Use llm-task refinement for non-image media (video/audio)
1023
1078
  const step1Text = [
1024
1079
  `You are a media prompt engineer for teamId=${teamId}.`,
@@ -1061,149 +1116,43 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1061
1116
  refinedPrompt = refinedPrompt.slice(0, MAX_IMAGE_PROMPT_LEN).replace(/\s+\S*$/, '') + '...';
1062
1117
  }
1063
1118
 
1064
- // ── Step 2: Invoke the skill script to generate actual media ─────
1065
- const homedir = process.env.HOME || '/home/control';
1066
- const scriptCandidates = mediaType === 'image'
1067
- ? ['generate_image.py', 'generate_image.sh', 'generate.sh']
1068
- : ['generate_video.py', 'generate_video.sh', 'generate.py', 'generate.sh'];
1069
-
1070
- // Auto-discover: if provider specifies a skill, try that first, then scan all skills
1071
- const providerSkill = provider.startsWith('skill-') ? provider.replace(/^skill-/, '') : '';
1072
- const skillRoots = [
1073
- path.join(homedir, '.openclaw', 'skills'),
1074
- path.join(homedir, '.openclaw', 'workspace', 'skills'),
1075
- ];
1076
-
1077
- let scriptPath = '';
1078
- let skillName = providerSkill;
1079
-
1080
- // Helper: search a specific skill directory for matching scripts
1081
- const findScript = async (skillDir: string): Promise<string> => {
1082
- for (const c of scriptCandidates) {
1083
- const p = path.join(skillDir, c);
1084
- try { await fs.access(p); return p; } catch { /* skip */ }
1085
- }
1086
- return '';
1087
- };
1088
-
1089
- // 1) Try the explicitly specified provider skill first
1090
- if (providerSkill) {
1091
- for (const root of skillRoots) {
1092
- scriptPath = await findScript(path.join(root, providerSkill));
1093
- if (scriptPath) break;
1094
- }
1095
- }
1119
+ // ── Step 2: Invoke the media driver to generate actual media ─────
1120
+ const providerSlug = provider.startsWith('skill-') ? provider.replace(/^skill-/, '') : provider;
1121
+ const configEnv = await loadConfigEnv();
1122
+ const mergedEnv = { ...process.env, ...configEnv } as Record<string, string>;
1096
1123
 
1097
- // 2) If not found, auto-discover any skill that has the right script
1098
- if (!scriptPath) {
1099
- for (const root of skillRoots) {
1100
- try {
1101
- const entries = await fs.readdir(root, { withFileTypes: true });
1102
- for (const entry of entries) {
1103
- if (!entry.isDirectory()) continue;
1104
- const found = await findScript(path.join(root, entry.name));
1105
- if (found) {
1106
- scriptPath = found;
1107
- skillName = entry.name;
1108
- break;
1109
- }
1110
- }
1111
- } catch { /* root dir doesn't exist */ }
1112
- if (scriptPath) break;
1113
- }
1124
+ // Find a registered driver, or fall back to auto-discovered generic driver
1125
+ let driver = getDriver(providerSlug);
1126
+ if (!driver) {
1127
+ const discovered = await GenericDriver.createFromSkill(providerSlug);
1128
+ if (discovered) driver = discovered;
1114
1129
  }
1115
1130
 
1116
- const skillSearchDirs = providerSkill
1117
- ? skillRoots.map(r => path.join(r, providerSkill))
1118
- : skillRoots;
1119
-
1120
1131
  let payload: Record<string, unknown>;
1121
- if (scriptPath) {
1122
- // Run the skill script with the refined prompt
1123
- // Inject env vars from OpenClaw config (gateway doesn't expose them to process.env)
1124
- let configEnv: Record<string, string> = {};
1125
- try {
1126
- const cfgRaw = await fs.readFile(path.join(homedir, '.openclaw', 'openclaw.json'), 'utf8');
1127
- const cfgParsed = JSON.parse(cfgRaw);
1128
-
1129
- // openclaw.json supports multiple shapes historically:
1130
- // - { env: { KEY: "..." } }
1131
- // - { env: { vars: { KEY: "..." } } } (current)
1132
- const envBlock = (cfgParsed as any)?.env;
1133
- const maybeVars = envBlock && typeof envBlock === 'object' ? (envBlock as any).vars : null;
1134
- const rawVars = (maybeVars && typeof maybeVars === 'object') ? maybeVars : envBlock;
1135
-
1136
- if (rawVars && typeof rawVars === 'object') {
1137
- configEnv = Object.fromEntries(
1138
- Object.entries(rawVars).filter(([, v]) => typeof v === 'string')
1139
- ) as Record<string, string>;
1140
- }
1141
- } catch { /* config read failed — proceed with process.env only */ }
1142
-
1143
- // If the .py script has a venv alongside it, use that Python; otherwise system python3.
1144
- let runner = 'bash';
1145
- if (scriptPath.endsWith('.py')) {
1146
- const scriptDir = path.dirname(scriptPath);
1147
- const venvPython = path.join(scriptDir, '.venv', 'bin', 'python');
1148
- try {
1149
- await fs.access(venvPython);
1150
- runner = venvPython;
1151
- } catch {
1152
- runner = 'python3';
1153
- }
1154
- }
1155
-
1156
- let scriptOutput = '';
1157
- try {
1158
- scriptOutput = execSync(
1159
- `${runner} ${JSON.stringify(scriptPath)}`,
1160
- {
1161
- cwd: mediaDir,
1162
- timeout: timeoutMs,
1163
- encoding: 'utf8',
1164
- input: refinedPrompt,
1165
- env: {
1166
- ...process.env,
1167
- ...configEnv,
1168
- HOME: homedir,
1169
- MEDIA_OUTPUT_DIR: mediaDir,
1170
- },
1171
- }
1172
- ).trim();
1173
- } catch (err) {
1174
- // Surface stderr/stdout to make debugging skill scripts possible.
1175
- // execSync throws an Error with extra fields: stdout/stderr (Buffer|string)
1176
- const e = err as any;
1177
- const stdout = typeof e?.stdout === 'string' ? e.stdout : (Buffer.isBuffer(e?.stdout) ? e.stdout.toString('utf8') : '');
1178
- const stderr = typeof e?.stderr === 'string' ? e.stderr : (Buffer.isBuffer(e?.stderr) ? e.stderr.toString('utf8') : '');
1179
- const msg = [
1180
- e?.message ? String(e.message) : 'Skill script failed',
1181
- stdout ? `\n--- stdout ---\n${stdout.trim()}` : '',
1182
- stderr ? `\n--- stderr ---\n${stderr.trim()}` : '',
1183
- ].filter(Boolean).join('');
1184
- throw new Error(msg);
1185
- }
1186
-
1187
- // Parse the output — skill scripts print "MEDIA:/path/to/file"
1188
- const mediaMatch = scriptOutput.match(/MEDIA:(.+)$/m);
1189
- const filePath = mediaMatch ? mediaMatch[1].trim() : '';
1132
+ if (driver) {
1133
+ const result = await driver.invoke({
1134
+ prompt: refinedPrompt,
1135
+ outputDir: mediaDir,
1136
+ env: mergedEnv,
1137
+ timeout: timeoutMs,
1138
+ config: node.config as Record<string, unknown> | undefined,
1139
+ });
1190
1140
 
1191
1141
  payload = {
1192
1142
  [promptKey]: refinedPrompt,
1193
- file_path: filePath,
1194
- status: filePath ? 'success' : 'error',
1195
- skill: skillName,
1196
- script_output: scriptOutput,
1197
- error: filePath ? null : 'No MEDIA: path in script output',
1143
+ file_path: result.filePath,
1144
+ status: result.filePath ? 'success' : 'error',
1145
+ skill: driver.slug,
1146
+ script_output: (result.metadata as any)?.script_output ?? '',
1147
+ error: result.filePath ? null : 'No file path returned from driver',
1198
1148
  };
1199
1149
  } else {
1200
- // No skill script found — fall back to prompt-only output
1201
1150
  payload = {
1202
1151
  [promptKey]: refinedPrompt,
1203
1152
  file_path: '',
1204
- status: 'no_skill_script',
1205
- skill: skillName,
1206
- error: `No executable script found for skill "${skillName}" in ${skillSearchDirs.join(', ')}`,
1153
+ status: 'no_driver',
1154
+ skill: providerSlug,
1155
+ error: `No media driver found for provider "${providerSlug}"`,
1207
1156
  };
1208
1157
  }
1209
1158
  text = JSON.stringify(payload, null, 2);