@jiggai/recipes 0.4.29 → 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.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/handlers/team.ts +6 -6
- package/src/lib/workflows/workflow-worker.ts +552 -11
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/handlers/team.ts
CHANGED
|
@@ -262,9 +262,9 @@ async function scaffoldTeamAgents(
|
|
|
262
262
|
agentName,
|
|
263
263
|
update: overwrite,
|
|
264
264
|
filesRootDir: roleDir,
|
|
265
|
-
// IMPORTANT:
|
|
266
|
-
//
|
|
267
|
-
workspaceRootDir:
|
|
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
|
|
312
|
-
//
|
|
313
|
-
if (
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
}
|