@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.
- package/docs/ARCHITECTURE.md +66 -1
- package/docs/COMMANDS.md +12 -0
- package/docs/MEDIA_DRIVERS.md +175 -0
- package/docs/MEDIA_GENERATION.md +553 -0
- package/docs/TEMPLATE_VARIABLES.md +196 -0
- package/docs/WORKFLOW_APPROVALS.md +334 -0
- package/docs/WORKFLOW_NODES.md +147 -0
- package/docs/WORKFLOW_RUNS_FILE_FIRST.md +2 -0
- package/index.ts +9 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/handlers/media-drivers.ts +49 -0
- package/src/lib/workflows/media-drivers/generic.driver.ts +128 -0
- package/src/lib/workflows/media-drivers/index.ts +22 -0
- package/src/lib/workflows/media-drivers/kling-video.driver.ts +110 -0
- package/src/lib/workflows/media-drivers/luma-video.driver.ts +59 -0
- package/src/lib/workflows/media-drivers/nano-banana-pro.driver.ts +70 -0
- package/src/lib/workflows/media-drivers/openai-image-gen.driver.ts +60 -0
- package/src/lib/workflows/media-drivers/registry.ts +96 -0
- package/src/lib/workflows/media-drivers/runway-video.driver.ts +59 -0
- package/src/lib/workflows/media-drivers/types.ts +50 -0
- package/src/lib/workflows/media-drivers/utils.ts +149 -0
- package/src/lib/workflows/workflow-worker.ts +119 -170
|
@@ -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(
|
|
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
|
|
223
|
-
|
|
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(
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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 (
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
const
|
|
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 (
|
|
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
|
|
1065
|
-
const
|
|
1066
|
-
const
|
|
1067
|
-
|
|
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
|
-
//
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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 (
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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:
|
|
1196
|
-
script_output:
|
|
1197
|
-
error: filePath ? null : 'No
|
|
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: '
|
|
1205
|
-
skill:
|
|
1206
|
-
error: `No
|
|
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);
|