@jiggai/recipes 0.4.28 → 0.4.30

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.
@@ -765,7 +765,8 @@ See: [OUTBOUND_POSTING.md](OUTBOUND_POSTING.md)
765
765
  If you rely on a controller-local custom posting patch:
766
766
  - you may need to reapply that patch after install/update
767
767
  - you may need to tell your assistant to turn workflow posting back on
768
- - RJ's current public gist for the `marketing.post_all` patch is: <https://gist.github.com/rjdjohnston/7a8824ae16f347a4642fc7782fe66219>
768
+ - RJ's current public gist for the `marketing.post_all` patch is: <https://gist.github.com/rjdjohnston/2440a776529829e7d1c0d5d949f6e631>
769
+ - Note: Updated for current file structure (`workflow-worker.ts` vs old `workflow-runner.ts`)
769
770
 
770
771
  So if a workflow runs but does not actually post, check your posting path before blaming the runner.
771
772
 
@@ -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.28",
5
+ "version": "0.4.30",
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.28",
3
+ "version": "0.4.30",
4
4
  "description": "ClawRecipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
5
5
  "main": "index.ts",
6
6
  "type": "commonjs",
@@ -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
 
@@ -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}`,
@@ -207,11 +419,15 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
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;
209
421
 
422
+ // Inject team memory context for LLM nodes
423
+ const memoryContext = await buildMemoryContext(teamDir);
424
+ const promptWithMemory = memoryContext ? `${memoryContext}\n\n${taskText}` : taskText;
425
+
210
426
  const llmRes = await toolsInvoke<unknown>(api, {
211
427
  tool: 'llm-task',
212
428
  action: 'json',
213
429
  args: {
214
- prompt: taskText,
430
+ prompt: promptWithMemory,
215
431
  input: { teamId, runId, nodeId: node.id, agentId, ...priorInput },
216
432
  timeoutMs,
217
433
  },
@@ -400,6 +616,56 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
400
616
  'workflow.id': String(workflow.id ?? ''),
401
617
  'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
402
618
  };
619
+
620
+ // Load node outputs (same as fs.write)
621
+ const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
622
+ for (const nr of (runSnap.nodeResults ?? [])) {
623
+ const nid = String((nr as Record<string, unknown>).nodeId ?? '');
624
+ const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
625
+ if (nid && nrOutPath) {
626
+ try {
627
+ const outAbs = path.resolve(teamDir, nrOutPath);
628
+ const outputContent = await fs.readFile(outAbs, 'utf8');
629
+ vars[`${nid}.output`] = outputContent;
630
+
631
+ // Parse JSON outputs and make fields accessible
632
+ try {
633
+ const parsed = JSON.parse(outputContent.trim());
634
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
635
+ for (const [key, value] of Object.entries(parsed)) {
636
+ if (typeof value === 'string') {
637
+ vars[`${nid}.${key}`] = value;
638
+
639
+ // Special handling for 'text' field - try to parse as nested JSON
640
+ if (key === 'text') {
641
+ try {
642
+ const nestedParsed = JSON.parse(value);
643
+ if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
644
+ for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
645
+ if (typeof nestedValue === 'string') {
646
+ vars[`${nid}.${nestedKey}`] = nestedValue;
647
+ } else if (nestedValue !== null && nestedValue !== undefined) {
648
+ vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
649
+ }
650
+ }
651
+ }
652
+ } catch {
653
+ // If nested parsing fails, just keep the text field as is
654
+ }
655
+ }
656
+ } else if (value !== null && value !== undefined) {
657
+ // For non-string values, provide JSON representation
658
+ vars[`${nid}.${key}_json`] = JSON.stringify(value);
659
+ }
660
+ }
661
+ }
662
+ } catch {
663
+ // If output isn't valid JSON, skip parsing but keep raw output
664
+ }
665
+ } catch { /* node output may not exist */ }
666
+ }
667
+ }
668
+
403
669
  const relPath = templateReplace(relPathRaw, vars);
404
670
  const content = templateReplace(contentRaw, vars);
405
671
 
@@ -435,7 +701,43 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
435
701
  if (nid && nrOutPath) {
436
702
  try {
437
703
  const outAbs = path.resolve(teamDir, nrOutPath);
438
- vars[`${nid}.output`] = await fs.readFile(outAbs, 'utf8');
704
+ const outputContent = await fs.readFile(outAbs, 'utf8');
705
+ vars[`${nid}.output`] = outputContent;
706
+
707
+ // Parse JSON outputs and make fields accessible
708
+ try {
709
+ const parsed = JSON.parse(outputContent.trim());
710
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
711
+ for (const [key, value] of Object.entries(parsed)) {
712
+ if (typeof value === 'string') {
713
+ vars[`${nid}.${key}`] = value;
714
+
715
+ // Special handling for 'text' field - try to parse as nested JSON
716
+ if (key === 'text') {
717
+ try {
718
+ const nestedParsed = JSON.parse(value);
719
+ if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
720
+ for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
721
+ if (typeof nestedValue === 'string') {
722
+ vars[`${nid}.${nestedKey}`] = nestedValue;
723
+ } else if (nestedValue !== null && nestedValue !== undefined) {
724
+ vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
725
+ }
726
+ }
727
+ }
728
+ } catch {
729
+ // If nested parsing fails, just keep the text field as is
730
+ }
731
+ }
732
+ } else if (value !== null && value !== undefined) {
733
+ // For non-string values, provide JSON representation
734
+ vars[`${nid}.${key}_json`] = JSON.stringify(value);
735
+ }
736
+ }
737
+ }
738
+ } catch {
739
+ // If output isn't valid JSON, skip parsing but keep raw output
740
+ }
439
741
  } catch { /* node output may not exist */ }
440
742
  }
441
743
  }
@@ -453,19 +755,92 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
453
755
  const result = { writtenTo: path.relative(teamDir, abs), bytes: Buffer.byteLength(content, 'utf8') };
454
756
  await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2) + '\n', 'utf8');
455
757
 
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
758
  } else {
759
+ // Build template variables for general tool nodes (same as fs.write/fs.append)
760
+ const vars = {
761
+ date: new Date().toISOString(),
762
+ 'run.id': runId,
763
+ 'run.timestamp': runId,
764
+ 'workflow.id': String(workflow.id ?? ''),
765
+ 'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
766
+ };
767
+
768
+ // Load node outputs and make them available as template variables
769
+ const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
770
+ for (const nr of (runSnap.nodeResults ?? [])) {
771
+ const nid = String((nr as Record<string, unknown>).nodeId ?? '');
772
+ const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
773
+ if (nid && nrOutPath) {
774
+ try {
775
+ const outAbs = path.resolve(teamDir, nrOutPath);
776
+ const outputContent = await fs.readFile(outAbs, 'utf8');
777
+ vars[`${nid}.output`] = outputContent;
778
+
779
+ // Parse JSON outputs and make fields accessible
780
+ try {
781
+ const parsed = JSON.parse(outputContent.trim());
782
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
783
+ for (const [key, value] of Object.entries(parsed)) {
784
+ if (typeof value === 'string') {
785
+ vars[`${nid}.${key}`] = value;
786
+
787
+ // Special handling for 'text' field - try to parse as nested JSON
788
+ if (key === 'text') {
789
+ try {
790
+ const nestedParsed = JSON.parse(value);
791
+ if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
792
+ for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
793
+ if (typeof nestedValue === 'string') {
794
+ vars[`${nid}.${nestedKey}`] = nestedValue;
795
+ } else if (nestedValue !== null && nestedValue !== undefined) {
796
+ vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
797
+ }
798
+ }
799
+ }
800
+ } catch {
801
+ // If nested parsing fails, just keep the text field as is
802
+ }
803
+ }
804
+ } else if (value !== null && value !== undefined) {
805
+ // For non-string values, provide JSON representation
806
+ vars[`${nid}.${key}_json`] = JSON.stringify(value);
807
+ }
808
+ }
809
+ }
810
+ } catch {
811
+ // If output isn't valid JSON, skip parsing but keep raw output
812
+ }
813
+ } catch { /* node output may not exist */ }
814
+ }
815
+ }
816
+
817
+ // Apply template variable replacement to all string values in toolArgs
818
+ const processedToolArgs: Record<string, unknown> = {};
819
+ for (const [key, value] of Object.entries(toolArgs)) {
820
+ if (typeof value === 'string') {
821
+ processedToolArgs[key] = templateReplace(value, vars);
822
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
823
+ // Recursively process nested objects
824
+ const processedObject: Record<string, unknown> = {};
825
+ for (const [nestedKey, nestedValue] of Object.entries(value as Record<string, unknown>)) {
826
+ if (typeof nestedValue === 'string') {
827
+ processedObject[nestedKey] = templateReplace(nestedValue, vars);
828
+ } else {
829
+ processedObject[nestedKey] = nestedValue;
830
+ }
831
+ }
832
+ processedToolArgs[key] = processedObject;
833
+ } else {
834
+ processedToolArgs[key] = value;
835
+ }
836
+ }
837
+
463
838
  const toolRes = await toolsInvoke<unknown>(api, {
464
839
  tool: toolName,
465
- args: toolArgs,
840
+ args: processedToolArgs,
466
841
  });
467
842
 
468
- await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, result: toolRes }, null, 2) + '\n', 'utf8');
843
+ await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: processedToolArgs, result: toolRes }, null, 2) + '\n', 'utf8');
469
844
  }
470
845
 
471
846
  const defaultNodeOutputRel = path.join('node-outputs', `${String(nodeIdx).padStart(3, '0')}-${node.id}.json`);
@@ -503,6 +878,172 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
503
878
  results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', error: (e as Error).message });
504
879
  continue;
505
880
  }
881
+ } else if (kind === 'media-image' || kind === 'media-video' || kind === 'media-audio') {
882
+ // ── Media generation nodes ──────────────────────────────────────────
883
+ // Two-step process:
884
+ // 1. LLM generates a refined prompt (image_prompt / video_prompt)
885
+ // 2. Agent invokes the selected skill to produce the actual media
886
+ const config = asRecord((node as unknown as Record<string, unknown>)['config']);
887
+ const action = asRecord(node.action);
888
+
889
+ const mediaType = asString(config['mediaType'] ?? kind.replace('media-', '')).trim() || 'image';
890
+ const provider = asString(config['provider'] ?? action['provider']).trim();
891
+ const promptTemplateRaw = asString(config['promptTemplate'] ?? config['prompt'] ?? action['promptTemplate'] ?? action['prompt']).trim();
892
+ const size = asString(config['size']).trim() || '1024x1024';
893
+ const quality = asString(config['quality']).trim() || 'standard';
894
+ const style = asString(config['style']).trim() || 'natural';
895
+ const outputPathRaw = asString(config['outputPath']).trim();
896
+ const agentIdMedia = asString(config['agentId'] ?? action['agentId'] ?? '').trim();
897
+
898
+ if (!promptTemplateRaw) throw new Error(`Node ${nodeLabel(node)} missing prompt or promptTemplate for media generation`);
899
+
900
+ const vars = await buildTemplateVars(teamDir, runsDir, task.runId, workflowFile, workflow);
901
+ // Add node-level vars that templateReplace doesn't normally include
902
+ vars['node.id'] = node.id;
903
+ const prompt = templateReplace(promptTemplateRaw, vars);
904
+ const outputRelPath = templateReplace(outputPathRaw, vars);
905
+
906
+ const defaultNodeOutputRel = path.join('node-outputs', `${String(nodeIdx).padStart(3, '0')}-${node.id}.json`);
907
+ const nodeOutputRel = String(node?.output?.path ?? '').trim() || defaultNodeOutputRel;
908
+ const nodeOutputAbs = path.resolve(runDir, nodeOutputRel);
909
+ await ensureDir(path.dirname(nodeOutputAbs));
910
+
911
+ // Determine the output media directory for the skill to save into
912
+ const mediaDir = outputRelPath
913
+ ? path.resolve(runDir, path.dirname(outputRelPath))
914
+ : path.resolve(runDir, 'media');
915
+ await ensureDir(mediaDir);
916
+
917
+ const promptKey = mediaType === 'video' ? 'video_prompt' : 'image_prompt';
918
+
919
+ let text = '';
920
+ try {
921
+ // Inject team memory context
922
+ const memoryContext = await buildMemoryContext(teamDir);
923
+ const timeoutMsRaw = Number(asString(config['timeoutMs'] ?? '300000'));
924
+ const timeoutMs = Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0 ? timeoutMsRaw : 300000;
925
+
926
+ // ── Step 1: LLM refines the prompt ──────────────────────────────
927
+ const step1Text = [
928
+ `You are a media prompt engineer for teamId=${teamId}.`,
929
+ `Workflow: ${workflow.name ?? workflow.id ?? workflowFile}`,
930
+ `Node: ${nodeLabel(node)} | Media type: ${mediaType}`,
931
+ `Size: ${size} | Quality: ${quality} | Style: ${style}`,
932
+ `\n---\nINPUT PROMPT\n---\n`,
933
+ prompt.trim(),
934
+ `\n---\nINSTRUCTIONS\n---\n`,
935
+ `Refine the input into a detailed, production-ready ${mediaType} generation prompt.`,
936
+ `Return JSON with exactly one key: "${promptKey}" containing the refined prompt string.`,
937
+ `Example: {"${promptKey}": "A detailed description..."}`,
938
+ ].filter(Boolean).join('\n');
939
+
940
+ const step1Prompt = memoryContext ? `${memoryContext}\n\n${step1Text}` : step1Text;
941
+
942
+ const step1Res = await toolsInvoke<unknown>(api, {
943
+ tool: 'llm-task',
944
+ action: 'json',
945
+ args: { prompt: step1Prompt, timeoutMs: 60000 },
946
+ });
947
+
948
+ // Extract the refined prompt
949
+ const step1Rec = asRecord(step1Res);
950
+ const step1Details = asRecord(step1Rec['details']);
951
+ const step1Json = (step1Details['json'] ?? step1Details ?? step1Res) as Record<string, unknown>;
952
+ const refinedPrompt = asString(
953
+ step1Json[promptKey] ?? step1Json['image_prompt'] ?? step1Json['video_prompt'] ?? step1Json['prompt'] ?? prompt
954
+ ).trim();
955
+
956
+ if (!refinedPrompt) throw new Error('LLM returned empty refined prompt');
957
+
958
+ // ── Step 2: Invoke the skill script to generate actual media ─────
959
+ const skillName = provider.replace(/^skill-/, '');
960
+ const homedir = process.env.HOME || '/home/control';
961
+
962
+ // Search for the skill's executable script
963
+ const skillSearchDirs = [
964
+ path.join(homedir, '.openclaw', 'skills', skillName),
965
+ path.join(homedir, '.openclaw', 'workspace', 'skills', skillName),
966
+ ];
967
+ let scriptPath = '';
968
+ for (const dir of skillSearchDirs) {
969
+ const candidates = [`generate_image.sh`, `generate_video.sh`, `generate.sh`];
970
+ for (const c of candidates) {
971
+ const p = path.join(dir, c);
972
+ try { await fs.access(p); scriptPath = p; break; } catch { /* skip */ }
973
+ }
974
+ if (scriptPath) break;
975
+ }
976
+
977
+ let payload: Record<string, unknown>;
978
+ if (scriptPath) {
979
+ // Run the skill script directly with the refined prompt
980
+ const scriptOutput = execSync(
981
+ `bash ${JSON.stringify(scriptPath)} ${JSON.stringify(refinedPrompt)}`,
982
+ { cwd: mediaDir, timeout: timeoutMs, encoding: 'utf8', env: { ...process.env, HOME: homedir } }
983
+ ).trim();
984
+
985
+ // Parse the output — skill scripts print "MEDIA:/path/to/file"
986
+ const mediaMatch = scriptOutput.match(/MEDIA:(.+)$/m);
987
+ const filePath = mediaMatch ? mediaMatch[1].trim() : '';
988
+
989
+ payload = {
990
+ [promptKey]: refinedPrompt,
991
+ file_path: filePath,
992
+ status: filePath ? 'success' : 'error',
993
+ skill: skillName,
994
+ script_output: scriptOutput,
995
+ error: filePath ? null : 'No MEDIA: path in script output',
996
+ };
997
+ } else {
998
+ // No skill script found — fall back to prompt-only output
999
+ payload = {
1000
+ [promptKey]: refinedPrompt,
1001
+ file_path: '',
1002
+ status: 'no_skill_script',
1003
+ skill: skillName,
1004
+ error: `No executable script found for skill "${skillName}" in ${skillSearchDirs.join(', ')}`,
1005
+ };
1006
+ }
1007
+ text = JSON.stringify(payload, null, 2);
1008
+ } catch (e) {
1009
+ const errMsg = `Media generation failed for node ${nodeLabel(node)}: ${e instanceof Error ? e.message : String(e)}`;
1010
+ const errorTs = new Date().toISOString();
1011
+ await appendRunLog(runPath, (cur) => ({
1012
+ ...cur,
1013
+ status: 'error',
1014
+ updatedAt: errorTs,
1015
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, error: errMsg } },
1016
+ events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg }],
1017
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdMedia || agentId, error: errMsg }],
1018
+ }));
1019
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error' });
1020
+ continue;
1021
+ }
1022
+
1023
+ // Save output
1024
+ const outputObj = {
1025
+ runId: task.runId,
1026
+ teamId,
1027
+ nodeId: node.id,
1028
+ kind: node.kind,
1029
+ mediaType,
1030
+ provider,
1031
+ agentId: agentIdMedia || agentId,
1032
+ completedAt: new Date().toISOString(),
1033
+ outputPath: outputRelPath,
1034
+ mediaDir,
1035
+ text,
1036
+ };
1037
+ await fs.writeFile(nodeOutputAbs, JSON.stringify(outputObj, null, 2) + '\n', 'utf8');
1038
+
1039
+ const completedTs = new Date().toISOString();
1040
+ await appendRunLog(runPath, (cur) => ({
1041
+ ...cur,
1042
+ nextNodeIndex: nodeIdx + 1,
1043
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
1044
+ events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind: node.kind, nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
1045
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, mediaType, agentId: agentIdMedia || agentId, nodeOutputPath: path.relative(teamDir, nodeOutputAbs), bytes: new TextEncoder().encode(text).byteLength }],
1046
+ }));
506
1047
  } else {
507
1048
  throw new Error(`Worker does not yet support node kind: ${kind}`);
508
1049
  }