@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.
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jiggai/recipes",
|
|
3
|
-
"version": "0.4.
|
|
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": {
|
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
|
|
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
226
|
-
const
|
|
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: {
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
}
|