@jiggai/recipes 0.4.29 → 0.4.31

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.29",
5
+ "version": "0.4.31",
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.29",
3
+ "version": "0.4.31",
4
4
  "description": "ClawRecipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
5
5
  "main": "index.ts",
6
6
  "type": "commonjs",
@@ -10,6 +10,9 @@
10
10
  ],
11
11
  "compat": {
12
12
  "pluginApi": "2026.3"
13
+ },
14
+ "build": {
15
+ "openclawVersion": "2026.3.28"
13
16
  }
14
17
  },
15
18
  "publishConfig": {
@@ -262,9 +262,9 @@ async function scaffoldTeamAgents(
262
262
  agentName,
263
263
  update: overwrite,
264
264
  filesRootDir: roleDir,
265
- // IMPORTANT: non-lead roles use per-role workspaces so the Kitchen UI / agent files panel reads role files.
266
- // Lead uses the team root so the lead heartbeat reads team-root HEARTBEAT.md.
267
- workspaceRootDir: role === "lead" ? teamDir : roleDir,
265
+ // IMPORTANT: All roles (including lead) use per-role workspaces so they get role-specific bootstrap files.
266
+ // This ensures leads get their role-specific AGENTS.md, SOUL.md, MEMORY.md for proper identity context.
267
+ workspaceRootDir: roleDir,
268
268
  vars: { teamId, teamDir, role, agentId, agentName, roleDir },
269
269
  });
270
270
 
@@ -308,9 +308,9 @@ Recommended:
308
308
  }
309
309
 
310
310
 
311
- // Heartbeat scaffold (opt-in for non-lead roles): drop a minimal HEARTBEAT.md in the role workspace.
312
- // The lead uses the team-root HEARTBEAT.md.
313
- if (role !== "lead" && heartbeatEnabledRoles.has(String(role))) {
311
+ // Heartbeat scaffold (opt-in): drop a minimal HEARTBEAT.md in each role workspace.
312
+ // All roles now use role-specific workspaces for consistent bootstrap file access.
313
+ if (heartbeatEnabledRoles.has(String(role))) {
314
314
  const mode = overwrite ? "overwrite" : "createOnly";
315
315
  const hb = `# HEARTBEAT — ${teamId} (${role})
316
316
 
@@ -47,6 +47,8 @@ export function normalizeWorkflow(raw: unknown): Workflow {
47
47
  // LLM: allow either promptTemplatePath (preferred) or inline promptTemplate string
48
48
  ...(config['promptTemplate'] != null ? { promptTemplate: asString(config['promptTemplate']) } : {}),
49
49
  ...(config['promptTemplatePath'] != null ? { promptTemplatePath: asString(config['promptTemplatePath']) } : {}),
50
+ ...(config['model'] != null ? { model: asString(config['model']) } : {}),
51
+ ...(config['provider'] != null ? { provider: asString(config['provider']) } : {}),
50
52
 
51
53
  // Tool
52
54
  ...(config['tool'] != null ? { tool: asString(config['tool']) } : {}),
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { execSync } from 'node:child_process';
2
3
  import path from 'node:path';
3
4
  import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
4
5
  import type { ToolTextResult } from '../../toolsInvoke';
@@ -19,6 +20,155 @@ import {
19
20
  sanitizeDraftOnlyText, templateReplace,
20
21
  } from './workflow-utils';
21
22
 
23
+ /**
24
+ * Build memory context for LLM nodes by reading team memory files
25
+ */
26
+ async function buildMemoryContext(teamDir: string): Promise<string> {
27
+ try {
28
+ const memoryDir = path.join(teamDir, 'shared-context', 'memory');
29
+
30
+ // Check if memory directory exists
31
+ if (!await fileExists(memoryDir)) {
32
+ return '';
33
+ }
34
+
35
+ const memoryParts: string[] = [];
36
+ const maxTokens = 2000; // Rough token budget
37
+ let currentTokens = 0;
38
+
39
+ // Read pinned items first (highest priority)
40
+ const pinnedPath = path.join(memoryDir, 'pinned.jsonl');
41
+ if (await fileExists(pinnedPath)) {
42
+ const pinnedContent = await fs.readFile(pinnedPath, 'utf8');
43
+ const pinnedItems = pinnedContent.trim().split('\n').filter(Boolean);
44
+
45
+ if (pinnedItems.length > 0) {
46
+ memoryParts.push('[Team Memory — Pinned]');
47
+ for (const line of pinnedItems.slice(-5)) { // Last 5 pinned items
48
+ try {
49
+ const item = JSON.parse(line);
50
+ if (item.content || item.text) {
51
+ const summary = `- ${item.content || item.text} (${item.type || 'note'}, ${(item.ts || '').slice(0, 10)})`;
52
+ if (currentTokens + summary.length * 0.25 < maxTokens) { // Rough token estimate
53
+ memoryParts.push(summary);
54
+ currentTokens += summary.length * 0.25;
55
+ }
56
+ }
57
+ } catch {
58
+ // Skip malformed JSON lines
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ // Read recent items from other JSONL files
65
+ const files = await fs.readdir(memoryDir);
66
+ const jsonlFiles = files.filter(f => f.endsWith('.jsonl') && f !== 'pinned.jsonl');
67
+
68
+ for (const filename of jsonlFiles) {
69
+ if (currentTokens > maxTokens * 0.8) break; // Leave room for recent items
70
+
71
+ const filePath = path.join(memoryDir, filename);
72
+ const content = await fs.readFile(filePath, 'utf8');
73
+ const items = content.trim().split('\n').filter(Boolean);
74
+
75
+ if (items.length > 0) {
76
+ const recentItems = items.slice(-3); // Last 3 items from each file
77
+ for (const line of recentItems) {
78
+ try {
79
+ const item = JSON.parse(line);
80
+ if (item.content || item.text) {
81
+ const summary = `- ${item.content || item.text} (${item.type || 'note'}, ${(item.ts || '').slice(0, 10)})`;
82
+ if (currentTokens + summary.length * 0.25 < maxTokens) {
83
+ if (memoryParts.length === 1) { // Only pinned section exists
84
+ memoryParts.push('', '[Team Memory — Recent]');
85
+ }
86
+ memoryParts.push(summary);
87
+ currentTokens += summary.length * 0.25;
88
+ }
89
+ }
90
+ } catch {
91
+ // Skip malformed JSON lines
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ // If we have memory content, format it properly
98
+ if (memoryParts.length > 1) {
99
+ return memoryParts.join('\n') + '\n\n[Task]';
100
+ }
101
+
102
+ return '';
103
+ } catch (error) {
104
+ // Fail gracefully - memory injection is optional
105
+ console.warn('Memory context injection failed:', error);
106
+ return '';
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Build template variables from prior node outputs for prompt/path interpolation.
112
+ * Reused across LLM, media, tool, and fs nodes.
113
+ */
114
+ async function buildTemplateVars(
115
+ teamDir: string,
116
+ runsDir: string,
117
+ runId: string,
118
+ workflowFile: string,
119
+ workflow: { id?: string; name?: string },
120
+ ): Promise<Record<string, string>> {
121
+ const vars = {
122
+ date: new Date().toISOString(),
123
+ 'run.id': runId,
124
+ 'run.timestamp': runId,
125
+ 'workflow.id': String(workflow.id ?? ''),
126
+ 'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
127
+ } as Record<string, string>;
128
+
129
+ const { run: runSnap } = await loadRunFile(teamDir, runsDir, runId);
130
+ for (const nr of (runSnap.nodeResults ?? [])) {
131
+ const nid = String((nr as Record<string, unknown>).nodeId ?? '');
132
+ const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
133
+ if (nid && nrOutPath) {
134
+ try {
135
+ const outAbs = path.resolve(teamDir, nrOutPath);
136
+ const outputContent = await fs.readFile(outAbs, 'utf8');
137
+ vars[`${nid}.output`] = outputContent;
138
+
139
+ try {
140
+ const parsed = JSON.parse(outputContent.trim());
141
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
142
+ for (const [key, value] of Object.entries(parsed)) {
143
+ if (typeof value === 'string') {
144
+ vars[`${nid}.${key}`] = value;
145
+ if (key === 'text') {
146
+ try {
147
+ const nestedParsed = JSON.parse(value);
148
+ if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
149
+ for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
150
+ if (typeof nestedValue === 'string') {
151
+ vars[`${nid}.${nestedKey}`] = nestedValue;
152
+ } else if (nestedValue !== null && nestedValue !== undefined) {
153
+ vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
154
+ }
155
+ }
156
+ }
157
+ } catch { /* nested parse fail is fine */ }
158
+ }
159
+ } else if (value !== null && value !== undefined) {
160
+ vars[`${nid}.${key}_json`] = JSON.stringify(value);
161
+ }
162
+ }
163
+ }
164
+ } catch { /* non-JSON output is fine */ }
165
+ } catch { /* node output may not exist */ }
166
+ }
167
+ }
168
+
169
+ return vars;
170
+ }
171
+
22
172
  // eslint-disable-next-line complexity, max-lines-per-function
23
173
  export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
24
174
  teamId: string;
@@ -187,7 +337,69 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
187
337
  }
188
338
  await ensureDir(path.dirname(nodeOutputAbs));
189
339
 
190
- const prompt = promptTemplateInline ? promptTemplateInline : await readTextFile(promptPathAbs);
340
+ const promptRaw = promptTemplateInline ? promptTemplateInline : await readTextFile(promptPathAbs);
341
+
342
+ // Build template variables (same as fs.write/fs.append)
343
+ const vars = {
344
+ date: new Date().toISOString(),
345
+ 'run.id': runId,
346
+ 'run.timestamp': runId,
347
+ 'workflow.id': String(workflow.id ?? ''),
348
+ 'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
349
+ };
350
+
351
+ // Load node outputs and make them available as template variables
352
+ const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
353
+ for (const nr of (runSnap.nodeResults ?? [])) {
354
+ const nid = String((nr as Record<string, unknown>).nodeId ?? '');
355
+ const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
356
+ if (nid && nrOutPath) {
357
+ try {
358
+ const outAbs = path.resolve(teamDir, nrOutPath);
359
+ const outputContent = await fs.readFile(outAbs, 'utf8');
360
+ vars[`${nid}.output`] = outputContent;
361
+
362
+ // Parse JSON outputs and make fields accessible
363
+ try {
364
+ const parsed = JSON.parse(outputContent.trim());
365
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
366
+ for (const [key, value] of Object.entries(parsed)) {
367
+ if (typeof value === 'string') {
368
+ vars[`${nid}.${key}`] = value;
369
+
370
+ // Special handling for 'text' field - try to parse as nested JSON
371
+ if (key === 'text') {
372
+ try {
373
+ const nestedParsed = JSON.parse(value);
374
+ if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
375
+ for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
376
+ if (typeof nestedValue === 'string') {
377
+ vars[`${nid}.${nestedKey}`] = nestedValue;
378
+ } else if (nestedValue !== null && nestedValue !== undefined) {
379
+ vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
380
+ }
381
+ }
382
+ }
383
+ } catch {
384
+ // If nested parsing fails, just keep the text field as is
385
+ }
386
+ }
387
+ } else if (value !== null && value !== undefined) {
388
+ // For non-string values, provide JSON representation
389
+ vars[`${nid}.${key}_json`] = JSON.stringify(value);
390
+ }
391
+ }
392
+ }
393
+ } catch {
394
+ // If output isn't valid JSON, skip parsing but keep raw output
395
+ }
396
+ } catch { /* node output may not exist */ }
397
+ }
398
+ }
399
+
400
+ // Apply template variable replacement
401
+ const prompt = templateReplace(promptRaw, vars);
402
+
191
403
  const taskText = [
192
404
  `You are executing a workflow run for teamId=${teamId}.`,
193
405
  `Workflow: ${workflow.name ?? workflow.id ?? workflowFile}`,
@@ -206,14 +418,33 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
206
418
 
207
419
  const timeoutMsRaw = Number(asString(action['timeoutMs'] ?? (node as unknown as { config?: unknown })?.config?.['timeoutMs'] ?? '120000'));
208
420
  const timeoutMs = Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0 ? timeoutMsRaw : 120000;
421
+ const configuredModel = asString(action['model'] ?? (node as unknown as { config?: unknown })?.config?.['model']).trim();
422
+ const configuredProvider = asString(action['provider'] ?? (node as unknown as { config?: unknown })?.config?.['provider']).trim();
423
+ let provider = configuredProvider;
424
+ let model = configuredModel;
425
+ if (model) {
426
+ const slash = model.indexOf('/');
427
+ if (slash > 0 && slash < model.length - 1) {
428
+ const modelProvider = model.slice(0, slash).trim();
429
+ const bareModel = model.slice(slash + 1).trim();
430
+ if (!provider) provider = modelProvider;
431
+ if (provider === modelProvider) model = bareModel;
432
+ }
433
+ }
434
+
435
+ // Inject team memory context for LLM nodes
436
+ const memoryContext = await buildMemoryContext(teamDir);
437
+ const promptWithMemory = memoryContext ? `${memoryContext}\n\n${taskText}` : taskText;
209
438
 
210
439
  const llmRes = await toolsInvoke<unknown>(api, {
211
440
  tool: 'llm-task',
212
441
  action: 'json',
213
442
  args: {
214
- prompt: taskText,
443
+ prompt: promptWithMemory,
215
444
  input: { teamId, runId, nodeId: node.id, agentId, ...priorInput },
216
445
  timeoutMs,
446
+ ...(provider ? { provider } : {}),
447
+ ...(model ? { model } : {}),
217
448
  },
218
449
  });
219
450
 
@@ -222,16 +453,34 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
222
453
  const payload = details['json'] ?? (Object.keys(details).length ? details : llmRes) ?? null;
223
454
  text = JSON.stringify(payload, null, 2);
224
455
  } catch (e) {
225
- // Record the error on the run so it doesn't stay stuck in waiting_workers.
226
- const errMsg = `LLM execution failed for node ${nodeLabel(node)}: ${e instanceof Error ? e.message : String(e)}`;
456
+ const eRec = asRecord(e);
457
+ const errorDetails = {
458
+ message: e instanceof Error ? e.message : String(e),
459
+ name: e instanceof Error ? e.name : undefined,
460
+ stack: e instanceof Error ? e.stack : undefined,
461
+ error: eRec['error'],
462
+ details: eRec['details'],
463
+ data: eRec['data'],
464
+ cause: e instanceof Error && 'cause' in e ? (e as Error & { cause?: unknown }).cause : undefined,
465
+ };
466
+ const errMsg = `LLM execution failed for node ${nodeLabel(node)}: ${errorDetails.message}`;
227
467
  const errorTs = new Date().toISOString();
228
468
  await appendRunLog(runPath, (cur) => ({
229
469
  ...cur,
230
470
  status: 'error',
231
471
  updatedAt: errorTs,
232
- nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, error: errMsg } },
233
- events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg }],
234
- nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdExec, error: errMsg }],
472
+ nodeStates: {
473
+ ...(cur.nodeStates ?? {}),
474
+ [node.id]: { status: 'error', ts: errorTs, error: errMsg, details: errorDetails },
475
+ },
476
+ events: [
477
+ ...cur.events,
478
+ { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, details: errorDetails },
479
+ ],
480
+ nodeResults: [
481
+ ...(cur.nodeResults ?? []),
482
+ { nodeId: node.id, kind: node.kind, agentId: agentIdExec, error: errMsg, details: errorDetails },
483
+ ],
235
484
  }));
236
485
  results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error' });
237
486
  continue;
@@ -400,6 +649,56 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
400
649
  'workflow.id': String(workflow.id ?? ''),
401
650
  'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
402
651
  };
652
+
653
+ // Load node outputs (same as fs.write)
654
+ const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
655
+ for (const nr of (runSnap.nodeResults ?? [])) {
656
+ const nid = String((nr as Record<string, unknown>).nodeId ?? '');
657
+ const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
658
+ if (nid && nrOutPath) {
659
+ try {
660
+ const outAbs = path.resolve(teamDir, nrOutPath);
661
+ const outputContent = await fs.readFile(outAbs, 'utf8');
662
+ vars[`${nid}.output`] = outputContent;
663
+
664
+ // Parse JSON outputs and make fields accessible
665
+ try {
666
+ const parsed = JSON.parse(outputContent.trim());
667
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
668
+ for (const [key, value] of Object.entries(parsed)) {
669
+ if (typeof value === 'string') {
670
+ vars[`${nid}.${key}`] = value;
671
+
672
+ // Special handling for 'text' field - try to parse as nested JSON
673
+ if (key === 'text') {
674
+ try {
675
+ const nestedParsed = JSON.parse(value);
676
+ if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
677
+ for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
678
+ if (typeof nestedValue === 'string') {
679
+ vars[`${nid}.${nestedKey}`] = nestedValue;
680
+ } else if (nestedValue !== null && nestedValue !== undefined) {
681
+ vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
682
+ }
683
+ }
684
+ }
685
+ } catch {
686
+ // If nested parsing fails, just keep the text field as is
687
+ }
688
+ }
689
+ } else if (value !== null && value !== undefined) {
690
+ // For non-string values, provide JSON representation
691
+ vars[`${nid}.${key}_json`] = JSON.stringify(value);
692
+ }
693
+ }
694
+ }
695
+ } catch {
696
+ // If output isn't valid JSON, skip parsing but keep raw output
697
+ }
698
+ } catch { /* node output may not exist */ }
699
+ }
700
+ }
701
+
403
702
  const relPath = templateReplace(relPathRaw, vars);
404
703
  const content = templateReplace(contentRaw, vars);
405
704
 
@@ -435,7 +734,43 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
435
734
  if (nid && nrOutPath) {
436
735
  try {
437
736
  const outAbs = path.resolve(teamDir, nrOutPath);
438
- vars[`${nid}.output`] = await fs.readFile(outAbs, 'utf8');
737
+ const outputContent = await fs.readFile(outAbs, 'utf8');
738
+ vars[`${nid}.output`] = outputContent;
739
+
740
+ // Parse JSON outputs and make fields accessible
741
+ try {
742
+ const parsed = JSON.parse(outputContent.trim());
743
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
744
+ for (const [key, value] of Object.entries(parsed)) {
745
+ if (typeof value === 'string') {
746
+ vars[`${nid}.${key}`] = value;
747
+
748
+ // Special handling for 'text' field - try to parse as nested JSON
749
+ if (key === 'text') {
750
+ try {
751
+ const nestedParsed = JSON.parse(value);
752
+ if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
753
+ for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
754
+ if (typeof nestedValue === 'string') {
755
+ vars[`${nid}.${nestedKey}`] = nestedValue;
756
+ } else if (nestedValue !== null && nestedValue !== undefined) {
757
+ vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
758
+ }
759
+ }
760
+ }
761
+ } catch {
762
+ // If nested parsing fails, just keep the text field as is
763
+ }
764
+ }
765
+ } else if (value !== null && value !== undefined) {
766
+ // For non-string values, provide JSON representation
767
+ vars[`${nid}.${key}_json`] = JSON.stringify(value);
768
+ }
769
+ }
770
+ }
771
+ } catch {
772
+ // If output isn't valid JSON, skip parsing but keep raw output
773
+ }
439
774
  } catch { /* node output may not exist */ }
440
775
  }
441
776
  }
@@ -453,19 +788,92 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
453
788
  const result = { writtenTo: path.relative(teamDir, abs), bytes: Buffer.byteLength(content, 'utf8') };
454
789
  await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2) + '\n', 'utf8');
455
790
 
456
- } else if (toolName === 'marketing.post_all') {
457
- // Disabled by default: do not ship plugins that spawn local processes for posting.
458
- // Use an approval-gated workflow node that calls a dedicated posting tool/plugin instead.
459
- throw new Error(
460
- 'marketing.post_all is disabled in this build (install safety). Use an external posting tool/plugin (approval-gated) instead.'
461
- );
462
791
  } else {
792
+ // Build template variables for general tool nodes (same as fs.write/fs.append)
793
+ const vars = {
794
+ date: new Date().toISOString(),
795
+ 'run.id': runId,
796
+ 'run.timestamp': runId,
797
+ 'workflow.id': String(workflow.id ?? ''),
798
+ 'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
799
+ };
800
+
801
+ // Load node outputs and make them available as template variables
802
+ const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
803
+ for (const nr of (runSnap.nodeResults ?? [])) {
804
+ const nid = String((nr as Record<string, unknown>).nodeId ?? '');
805
+ const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
806
+ if (nid && nrOutPath) {
807
+ try {
808
+ const outAbs = path.resolve(teamDir, nrOutPath);
809
+ const outputContent = await fs.readFile(outAbs, 'utf8');
810
+ vars[`${nid}.output`] = outputContent;
811
+
812
+ // Parse JSON outputs and make fields accessible
813
+ try {
814
+ const parsed = JSON.parse(outputContent.trim());
815
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
816
+ for (const [key, value] of Object.entries(parsed)) {
817
+ if (typeof value === 'string') {
818
+ vars[`${nid}.${key}`] = value;
819
+
820
+ // Special handling for 'text' field - try to parse as nested JSON
821
+ if (key === 'text') {
822
+ try {
823
+ const nestedParsed = JSON.parse(value);
824
+ if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
825
+ for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
826
+ if (typeof nestedValue === 'string') {
827
+ vars[`${nid}.${nestedKey}`] = nestedValue;
828
+ } else if (nestedValue !== null && nestedValue !== undefined) {
829
+ vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
830
+ }
831
+ }
832
+ }
833
+ } catch {
834
+ // If nested parsing fails, just keep the text field as is
835
+ }
836
+ }
837
+ } else if (value !== null && value !== undefined) {
838
+ // For non-string values, provide JSON representation
839
+ vars[`${nid}.${key}_json`] = JSON.stringify(value);
840
+ }
841
+ }
842
+ }
843
+ } catch {
844
+ // If output isn't valid JSON, skip parsing but keep raw output
845
+ }
846
+ } catch { /* node output may not exist */ }
847
+ }
848
+ }
849
+
850
+ // Apply template variable replacement to all string values in toolArgs
851
+ const processedToolArgs: Record<string, unknown> = {};
852
+ for (const [key, value] of Object.entries(toolArgs)) {
853
+ if (typeof value === 'string') {
854
+ processedToolArgs[key] = templateReplace(value, vars);
855
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
856
+ // Recursively process nested objects
857
+ const processedObject: Record<string, unknown> = {};
858
+ for (const [nestedKey, nestedValue] of Object.entries(value as Record<string, unknown>)) {
859
+ if (typeof nestedValue === 'string') {
860
+ processedObject[nestedKey] = templateReplace(nestedValue, vars);
861
+ } else {
862
+ processedObject[nestedKey] = nestedValue;
863
+ }
864
+ }
865
+ processedToolArgs[key] = processedObject;
866
+ } else {
867
+ processedToolArgs[key] = value;
868
+ }
869
+ }
870
+
463
871
  const toolRes = await toolsInvoke<unknown>(api, {
464
872
  tool: toolName,
465
- args: toolArgs,
873
+ args: processedToolArgs,
466
874
  });
467
875
 
468
- await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, result: toolRes }, null, 2) + '\n', 'utf8');
876
+ await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: processedToolArgs, result: toolRes }, null, 2) + '\n', 'utf8');
469
877
  }
470
878
 
471
879
  const defaultNodeOutputRel = path.join('node-outputs', `${String(nodeIdx).padStart(3, '0')}-${node.id}.json`);
@@ -503,6 +911,172 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
503
911
  results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', error: (e as Error).message });
504
912
  continue;
505
913
  }
914
+ } else if (kind === 'media-image' || kind === 'media-video' || kind === 'media-audio') {
915
+ // ── Media generation nodes ──────────────────────────────────────────
916
+ // Two-step process:
917
+ // 1. LLM generates a refined prompt (image_prompt / video_prompt)
918
+ // 2. Agent invokes the selected skill to produce the actual media
919
+ const config = asRecord((node as unknown as Record<string, unknown>)['config']);
920
+ const action = asRecord(node.action);
921
+
922
+ const mediaType = asString(config['mediaType'] ?? kind.replace('media-', '')).trim() || 'image';
923
+ const provider = asString(config['provider'] ?? action['provider']).trim();
924
+ const promptTemplateRaw = asString(config['promptTemplate'] ?? config['prompt'] ?? action['promptTemplate'] ?? action['prompt']).trim();
925
+ const size = asString(config['size']).trim() || '1024x1024';
926
+ const quality = asString(config['quality']).trim() || 'standard';
927
+ const style = asString(config['style']).trim() || 'natural';
928
+ const outputPathRaw = asString(config['outputPath']).trim();
929
+ const agentIdMedia = asString(config['agentId'] ?? action['agentId'] ?? '').trim();
930
+
931
+ if (!promptTemplateRaw) throw new Error(`Node ${nodeLabel(node)} missing prompt or promptTemplate for media generation`);
932
+
933
+ const vars = await buildTemplateVars(teamDir, runsDir, task.runId, workflowFile, workflow);
934
+ // Add node-level vars that templateReplace doesn't normally include
935
+ vars['node.id'] = node.id;
936
+ const prompt = templateReplace(promptTemplateRaw, vars);
937
+ const outputRelPath = templateReplace(outputPathRaw, vars);
938
+
939
+ const defaultNodeOutputRel = path.join('node-outputs', `${String(nodeIdx).padStart(3, '0')}-${node.id}.json`);
940
+ const nodeOutputRel = String(node?.output?.path ?? '').trim() || defaultNodeOutputRel;
941
+ const nodeOutputAbs = path.resolve(runDir, nodeOutputRel);
942
+ await ensureDir(path.dirname(nodeOutputAbs));
943
+
944
+ // Determine the output media directory for the skill to save into
945
+ const mediaDir = outputRelPath
946
+ ? path.resolve(runDir, path.dirname(outputRelPath))
947
+ : path.resolve(runDir, 'media');
948
+ await ensureDir(mediaDir);
949
+
950
+ const promptKey = mediaType === 'video' ? 'video_prompt' : 'image_prompt';
951
+
952
+ let text = '';
953
+ try {
954
+ // Inject team memory context
955
+ const memoryContext = await buildMemoryContext(teamDir);
956
+ const timeoutMsRaw = Number(asString(config['timeoutMs'] ?? '300000'));
957
+ const timeoutMs = Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0 ? timeoutMsRaw : 300000;
958
+
959
+ // ── Step 1: LLM refines the prompt ──────────────────────────────
960
+ const step1Text = [
961
+ `You are a media prompt engineer for teamId=${teamId}.`,
962
+ `Workflow: ${workflow.name ?? workflow.id ?? workflowFile}`,
963
+ `Node: ${nodeLabel(node)} | Media type: ${mediaType}`,
964
+ `Size: ${size} | Quality: ${quality} | Style: ${style}`,
965
+ `\n---\nINPUT PROMPT\n---\n`,
966
+ prompt.trim(),
967
+ `\n---\nINSTRUCTIONS\n---\n`,
968
+ `Refine the input into a detailed, production-ready ${mediaType} generation prompt.`,
969
+ `Return JSON with exactly one key: "${promptKey}" containing the refined prompt string.`,
970
+ `Example: {"${promptKey}": "A detailed description..."}`,
971
+ ].filter(Boolean).join('\n');
972
+
973
+ const step1Prompt = memoryContext ? `${memoryContext}\n\n${step1Text}` : step1Text;
974
+
975
+ const step1Res = await toolsInvoke<unknown>(api, {
976
+ tool: 'llm-task',
977
+ action: 'json',
978
+ args: { prompt: step1Prompt, timeoutMs: 60000 },
979
+ });
980
+
981
+ // Extract the refined prompt
982
+ const step1Rec = asRecord(step1Res);
983
+ const step1Details = asRecord(step1Rec['details']);
984
+ const step1Json = (step1Details['json'] ?? step1Details ?? step1Res) as Record<string, unknown>;
985
+ const refinedPrompt = asString(
986
+ step1Json[promptKey] ?? step1Json['image_prompt'] ?? step1Json['video_prompt'] ?? step1Json['prompt'] ?? prompt
987
+ ).trim();
988
+
989
+ if (!refinedPrompt) throw new Error('LLM returned empty refined prompt');
990
+
991
+ // ── Step 2: Invoke the skill script to generate actual media ─────
992
+ const skillName = provider.replace(/^skill-/, '');
993
+ const homedir = process.env.HOME || '/home/control';
994
+
995
+ // Search for the skill's executable script
996
+ const skillSearchDirs = [
997
+ path.join(homedir, '.openclaw', 'skills', skillName),
998
+ path.join(homedir, '.openclaw', 'workspace', 'skills', skillName),
999
+ ];
1000
+ let scriptPath = '';
1001
+ for (const dir of skillSearchDirs) {
1002
+ const candidates = [`generate_image.sh`, `generate_video.sh`, `generate.sh`];
1003
+ for (const c of candidates) {
1004
+ const p = path.join(dir, c);
1005
+ try { await fs.access(p); scriptPath = p; break; } catch { /* skip */ }
1006
+ }
1007
+ if (scriptPath) break;
1008
+ }
1009
+
1010
+ let payload: Record<string, unknown>;
1011
+ if (scriptPath) {
1012
+ // Run the skill script directly with the refined prompt
1013
+ const scriptOutput = execSync(
1014
+ `bash ${JSON.stringify(scriptPath)} ${JSON.stringify(refinedPrompt)}`,
1015
+ { cwd: mediaDir, timeout: timeoutMs, encoding: 'utf8', env: { ...process.env, HOME: homedir } }
1016
+ ).trim();
1017
+
1018
+ // Parse the output — skill scripts print "MEDIA:/path/to/file"
1019
+ const mediaMatch = scriptOutput.match(/MEDIA:(.+)$/m);
1020
+ const filePath = mediaMatch ? mediaMatch[1].trim() : '';
1021
+
1022
+ payload = {
1023
+ [promptKey]: refinedPrompt,
1024
+ file_path: filePath,
1025
+ status: filePath ? 'success' : 'error',
1026
+ skill: skillName,
1027
+ script_output: scriptOutput,
1028
+ error: filePath ? null : 'No MEDIA: path in script output',
1029
+ };
1030
+ } else {
1031
+ // No skill script found — fall back to prompt-only output
1032
+ payload = {
1033
+ [promptKey]: refinedPrompt,
1034
+ file_path: '',
1035
+ status: 'no_skill_script',
1036
+ skill: skillName,
1037
+ error: `No executable script found for skill "${skillName}" in ${skillSearchDirs.join(', ')}`,
1038
+ };
1039
+ }
1040
+ text = JSON.stringify(payload, null, 2);
1041
+ } catch (e) {
1042
+ const errMsg = `Media generation failed for node ${nodeLabel(node)}: ${e instanceof Error ? e.message : String(e)}`;
1043
+ const errorTs = new Date().toISOString();
1044
+ await appendRunLog(runPath, (cur) => ({
1045
+ ...cur,
1046
+ status: 'error',
1047
+ updatedAt: errorTs,
1048
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, error: errMsg } },
1049
+ events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg }],
1050
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdMedia || agentId, error: errMsg }],
1051
+ }));
1052
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error' });
1053
+ continue;
1054
+ }
1055
+
1056
+ // Save output
1057
+ const outputObj = {
1058
+ runId: task.runId,
1059
+ teamId,
1060
+ nodeId: node.id,
1061
+ kind: node.kind,
1062
+ mediaType,
1063
+ provider,
1064
+ agentId: agentIdMedia || agentId,
1065
+ completedAt: new Date().toISOString(),
1066
+ outputPath: outputRelPath,
1067
+ mediaDir,
1068
+ text,
1069
+ };
1070
+ await fs.writeFile(nodeOutputAbs, JSON.stringify(outputObj, null, 2) + '\n', 'utf8');
1071
+
1072
+ const completedTs = new Date().toISOString();
1073
+ await appendRunLog(runPath, (cur) => ({
1074
+ ...cur,
1075
+ nextNodeIndex: nodeIdx + 1,
1076
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
1077
+ events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind: node.kind, nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
1078
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, mediaType, agentId: agentIdMedia || agentId, nodeOutputPath: path.relative(teamDir, nodeOutputAbs), bytes: new TextEncoder().encode(text).byteLength }],
1079
+ }));
506
1080
  } else {
507
1081
  throw new Error(`Worker does not yet support node kind: ${kind}`);
508
1082
  }