@jiggai/recipes 0.4.57 → 0.4.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "id": "recipes",
3
3
  "name": "Recipes",
4
4
  "description": "Markdown recipes that scaffold agents and teams (workspace-local).",
5
- "version": "0.4.57",
5
+ "version": "0.4.61",
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.57",
3
+ "version": "0.4.61",
4
4
  "description": "ClawRecipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
5
5
  "main": "index.ts",
6
6
  "type": "commonjs",
@@ -563,7 +563,7 @@ templates:
563
563
  task_id=""
564
564
  spec_text=""
565
565
  spec_file_in=""
566
- base_ref="${SWARM_BASE_REF}"
566
+ base_ref="${SWARM_BASE_REF:-}"
567
567
  branch=""
568
568
  tmux_session=""
569
569
  agent_kind="codex"
@@ -13,7 +13,7 @@ import {
13
13
  asRecord, asString,
14
14
  ensureDir, fileExists,
15
15
  moveRunTicket, appendRunLog, nodeLabel,
16
- loadNodeStatesFromRun, sanitizeDraftOnlyText, templateReplace,
16
+ loadNodeStatesFromRun, sanitizeDraftOnlyText, templateReplace, expandFileIncludes,
17
17
  } from './workflow-utils';
18
18
 
19
19
  export async function resolveApprovalBindingTarget(api: OpenClawPluginApi, bindingId: string): Promise<{ channel: string; target: string; accountId?: string }> {
@@ -180,7 +180,10 @@ export async function executeWorkflowNodes(opts: {
180
180
  }
181
181
  await ensureDir(path.dirname(nodeOutputAbs));
182
182
 
183
- const prompt = promptTemplateInline ? promptTemplateInline : await readTextFile(promptPathAbs);
183
+ const promptRaw = promptTemplateInline ? promptTemplateInline : await readTextFile(promptPathAbs);
184
+ // Inline `{{file:<relative-path>}}` contents so LLM nodes can see workspace files
185
+ // they cannot fetch themselves (llm-task is one-shot, no tool loop).
186
+ const prompt = await expandFileIncludes(promptRaw, teamDir);
184
187
  const task = [
185
188
  `You are executing a workflow run for teamId=${teamId}.`,
186
189
  `Workflow: ${workflow.name ?? workflow.id ?? workflowFile}`,
@@ -158,6 +158,81 @@ export function templateReplace(input: string, vars: Record<string, string>) {
158
158
  return out;
159
159
  }
160
160
 
161
+ export const FILE_INCLUDE_MAX_BYTES_DEFAULT = 256 * 1024;
162
+
163
+ /**
164
+ * Expand `{{file:<relative-path>}}` markers by inlining the contents of files
165
+ * under the team workspace. Intended for LLM prompts where the model cannot
166
+ * call a read_file tool itself (workflow llm-task is one-shot, no tool loop).
167
+ *
168
+ * Safety:
169
+ * - paths are treated as RELATIVE TO teamDir
170
+ * - rejects absolute paths, paths with `..`, and paths that escape teamDir (incl. via symlink)
171
+ * - enforces a per-include byte cap (default 256KB)
172
+ *
173
+ * On any rejection the marker is replaced with a short `[[file-include …]]`
174
+ * note so the LLM sees the failure context instead of the pipeline blowing up.
175
+ */
176
+ export async function expandFileIncludes(
177
+ input: string,
178
+ teamDir: string,
179
+ opts: { maxBytes?: number } = {},
180
+ ): Promise<string> {
181
+ const text = String(input ?? '');
182
+ const pattern = /\{\{\s*file:([^}]+?)\s*\}\}/g;
183
+ const matches = Array.from(text.matchAll(pattern));
184
+ if (!matches.length) return text;
185
+
186
+ const maxBytes = opts.maxBytes ?? FILE_INCLUDE_MAX_BYTES_DEFAULT;
187
+ const teamDirResolvedRaw = path.resolve(teamDir);
188
+ // Resolve symlinks on teamDir too so comparisons below are apples-to-apples.
189
+ // On macOS, os.tmpdir() returns /var/... but realpath yields /private/var/... .
190
+ let teamDirResolved = teamDirResolvedRaw;
191
+ try { teamDirResolved = await fs.realpath(teamDirResolvedRaw); } catch { /* fall back to resolved */ }
192
+ const teamDirPrefix = teamDirResolved + path.sep;
193
+
194
+ const resolved = new Map<string, string>();
195
+ for (const m of matches) {
196
+ const rawPath = m[1].trim();
197
+ if (resolved.has(rawPath)) continue;
198
+
199
+ if (!rawPath || path.isAbsolute(rawPath) || rawPath.split('/').includes('..')) {
200
+ resolved.set(rawPath, `[[file-include rejected: unsafe path "${rawPath}"]]`);
201
+ continue;
202
+ }
203
+
204
+ const candidate = path.resolve(teamDirResolved, rawPath);
205
+ if (candidate !== teamDirResolved && !candidate.startsWith(teamDirPrefix)) {
206
+ resolved.set(rawPath, `[[file-include rejected: outside team workspace "${rawPath}"]]`);
207
+ continue;
208
+ }
209
+
210
+ try {
211
+ const real = await fs.realpath(candidate);
212
+ if (real !== teamDirResolved && !real.startsWith(teamDirPrefix)) {
213
+ resolved.set(rawPath, `[[file-include rejected: symlink escapes team workspace "${rawPath}"]]`);
214
+ continue;
215
+ }
216
+ const stat = await fs.stat(real);
217
+ if (!stat.isFile()) {
218
+ resolved.set(rawPath, `[[file-include rejected: not a regular file "${rawPath}"]]`);
219
+ continue;
220
+ }
221
+ if (stat.size > maxBytes) {
222
+ resolved.set(rawPath, `[[file-include rejected: "${rawPath}" size ${stat.size}B exceeds ${maxBytes}B cap]]`);
223
+ continue;
224
+ }
225
+ const content = await fs.readFile(real, 'utf8');
226
+ resolved.set(rawPath, content);
227
+ } catch (err) {
228
+ const msg = err instanceof Error ? err.message : String(err);
229
+ resolved.set(rawPath, `[[file-include failed: "${rawPath}" — ${msg}]]`);
230
+ }
231
+ }
232
+
233
+ return text.replace(pattern, (_full, raw) => resolved.get(String(raw).trim()) ?? '');
234
+ }
235
+
161
236
  export function sanitizeDraftOnlyText(text: string): string {
162
237
  // Back-compat: older workflow nodes mention 'draft only'.
163
238
  // New canonical sanitizer also strips other internal-only disclaimer lines.
@@ -22,7 +22,7 @@ import {
22
22
  moveRunTicket, appendRunLog, writeRunFile, loadRunFile,
23
23
  runFilePathFor, nodeLabel,
24
24
  loadNodeStatesFromRun, pickNextRunnableNodeIndex,
25
- sanitizeDraftOnlyText, templateReplace,
25
+ sanitizeDraftOnlyText, templateReplace, expandFileIncludes,
26
26
  } from './workflow-utils';
27
27
 
28
28
  /**
@@ -780,7 +780,10 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
780
780
  const promptRaw = promptTemplateInline ? promptTemplateInline : await readTextFile(promptPathAbs);
781
781
 
782
782
  const vars = await buildTemplateVars(teamDir, runsDir, runId, workflowFile, workflow);
783
- const prompt = templateReplace(promptRaw, vars);
783
+ const promptVarsResolved = templateReplace(promptRaw, vars);
784
+ // Inline `{{file:<relative-path>}}` contents so LLM nodes can see workspace files
785
+ // they cannot fetch themselves (llm-task is one-shot, no tool loop).
786
+ const prompt = await expandFileIncludes(promptVarsResolved, teamDir);
784
787
 
785
788
  // Build output format instructions from outputFields when defined
786
789
  const nodeConfig = asRecord((node as unknown as Record<string, unknown>)['config']);
@@ -1240,7 +1243,8 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1240
1243
  const vars = await buildTemplateVars(teamDir, runsDir, task.runId, workflowFile, workflow);
1241
1244
  // Add node-level vars that templateReplace doesn't normally include
1242
1245
  vars['node.id'] = node.id;
1243
- const prompt = templateReplace(promptTemplateRaw, vars);
1246
+ const promptVarsResolved = templateReplace(promptTemplateRaw, vars);
1247
+ const prompt = await expandFileIncludes(promptVarsResolved, teamDir);
1244
1248
  const outputRelPath = templateReplace(outputPathRaw, vars);
1245
1249
 
1246
1250
  const defaultNodeOutputRel = path.join('node-outputs', `${String(nodeIdx).padStart(3, '0')}-${node.id}.json`);