@goodandready/dsh-image-gen 0.11.38 → 0.11.40

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.
@@ -0,0 +1,234 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+
5
+ /**
6
+ * Visual Project & Series Profiles (#435)
7
+ * Manages persistent project visual profiles with explicit scope,
8
+ * parameter cascade (defaults -> profile -> request), and immutable history tagging.
9
+ */
10
+
11
+ export const BUILTIN_PROJECT_PROFILES = [
12
+ {
13
+ id: 'cyberpunk_neon',
14
+ name: 'Cyberpunk Neon',
15
+ version: '1.0.0',
16
+ description: 'High contrast neon lighting and dark futuristic styling.',
17
+ palette: ['#00f0ff', '#ff003c', '#ffe600', '#0a0a12'],
18
+ stylePrompt: 'cyberpunk aesthetic, volumetric atmospheric lighting, raytracing, sharp neon accents, highly detailed 8k',
19
+ negativePrompt: 'washed out, vintage, pastel, desaturated, noisy',
20
+ format: { size: '1024x1024', aspect: '1:1', format: 'webp' },
21
+ rules: {
22
+ background: 'dark cityscapes or moody interior',
23
+ typography: 'clean monospace or futuristic geometric',
24
+ },
25
+ allowedModels: ['fal-ai/flux/dev', 'sdxl_base'],
26
+ builtin: true,
27
+ },
28
+ {
29
+ id: 'minimal_nordic',
30
+ name: 'Scandinavian Minimalist',
31
+ version: '1.0.0',
32
+ description: 'Warm daylight, matte textures, and understated earthy tones.',
33
+ palette: ['#f4f1de', '#e07a5f', '#3d405b', '#81b29a'],
34
+ stylePrompt: 'scandinavian minimalism, clean composition, soft natural daylight, matte surfaces, earthy organic elegance',
35
+ negativePrompt: 'cluttered, neon, heavy shadows, oversaturated, glossy plastic',
36
+ format: { size: '1024x1024', aspect: '1:1', format: 'png' },
37
+ rules: {
38
+ background: 'plain textured warm neutral',
39
+ typography: 'light grotesque sans',
40
+ },
41
+ allowedModels: ['fal-ai/flux/dev', 'imagen-3.0-generate-002'],
42
+ builtin: true,
43
+ },
44
+ {
45
+ id: 'corporate_editorial',
46
+ name: 'Corporate Editorial',
47
+ version: '1.0.0',
48
+ description: 'High-end business magazine photography with crisp studio lighting.',
49
+ palette: ['#0f172a', '#3b82f6', '#64748b', '#f8fafc'],
50
+ stylePrompt: 'high-end corporate magazine editorial, crisp balanced studio lighting, premium commercial photography',
51
+ negativePrompt: 'anime, cartoon, oversaturated, exaggerated distortions, amateur',
52
+ format: { size: '1024x1024', aspect: '16:9', format: 'webp' },
53
+ rules: {
54
+ background: 'architectural soft blur or pristine studio',
55
+ typography: 'editorial serif / humanistic sans',
56
+ },
57
+ allowedModels: ['fal-ai/flux/dev', 'black-forest-labs/flux-1.1-pro'],
58
+ builtin: true,
59
+ },
60
+ ]
61
+
62
+ export function getProfilesStoragePath(customPath) {
63
+ if (customPath) return customPath
64
+ if (process.env.DSH_PROJECT_PROFILES_PATH) return process.env.DSH_PROJECT_PROFILES_PATH
65
+ const home = os.homedir() || '/tmp'
66
+ return path.join(home, '.dsh', 'dsh-image-gen', 'project-profiles.json')
67
+ }
68
+
69
+ export function loadCustomProfiles(storagePath) {
70
+ const filePath = getProfilesStoragePath(storagePath)
71
+ try {
72
+ if (!fs.existsSync(filePath)) return []
73
+ const content = fs.readFileSync(filePath, 'utf8')
74
+ const parsed = JSON.parse(content)
75
+ return Array.isArray(parsed) ? parsed : []
76
+ } catch (err) {
77
+ return []
78
+ }
79
+ }
80
+
81
+ export function saveCustomProfile(profile, storagePath) {
82
+ if (!profile || typeof profile !== 'object') {
83
+ throw new Error('Invalid project profile: object required')
84
+ }
85
+ if (!profile.id || typeof profile.id !== 'string') {
86
+ throw new Error('Project profile requires a string "id"')
87
+ }
88
+ if (!profile.name || typeof profile.name !== 'string') {
89
+ throw new Error('Project profile requires a string "name"')
90
+ }
91
+
92
+ const filePath = getProfilesStoragePath(storagePath)
93
+ const dir = path.dirname(filePath)
94
+ if (!fs.existsSync(dir)) {
95
+ fs.mkdirSync(dir, { recursive: true })
96
+ }
97
+
98
+ const existing = loadCustomProfiles(storagePath)
99
+ const index = existing.findIndex((p) => p.id === profile.id)
100
+ const prepared = {
101
+ ...profile,
102
+ version: profile.version || '1.0.0',
103
+ builtin: false,
104
+ updatedAt: new Date().toISOString(),
105
+ }
106
+
107
+ if (index >= 0) {
108
+ existing[index] = prepared
109
+ } else {
110
+ existing.push(prepared)
111
+ }
112
+
113
+ fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), 'utf8')
114
+ return prepared
115
+ }
116
+
117
+ export function deleteCustomProfile(id, storagePath) {
118
+ const filePath = getProfilesStoragePath(storagePath)
119
+ const existing = loadCustomProfiles(storagePath)
120
+ const filtered = existing.filter((p) => p.id !== id)
121
+ if (filtered.length === existing.length) {
122
+ return false
123
+ }
124
+ fs.writeFileSync(filePath, JSON.stringify(filtered, null, 2), 'utf8')
125
+ return true
126
+ }
127
+
128
+ export function getAllProjectProfiles(storagePath) {
129
+ const custom = loadCustomProfiles(storagePath)
130
+ const customIds = new Set(custom.map((c) => c.id))
131
+ const builtins = BUILTIN_PROJECT_PROFILES.filter((b) => !customIds.has(b.id))
132
+ return [...builtins, ...custom]
133
+ }
134
+
135
+ export function getProjectProfileById(id, storagePath) {
136
+ if (!id) return null
137
+ const all = getAllProjectProfiles(storagePath)
138
+ return all.find((p) => p.id === id) || null
139
+ }
140
+
141
+ /**
142
+ * Parameter resolution cascade (#435):
143
+ * Priority: Request parameters > Project Profile > System Defaults.
144
+ * Returns effective params, provenance mapping, and immutable profile tag for history.
145
+ */
146
+ export function resolveEffectiveParams(requestParams = {}, profileId = null, storagePath = null) {
147
+ const profile = profileId ? getProjectProfileById(profileId, storagePath) : null
148
+ const provenance = {}
149
+ const effective = {}
150
+
151
+ // 1. Defaults
152
+ const defaults = {
153
+ size: '1024x1024',
154
+ format: 'webp',
155
+ negativePrompt: '',
156
+ steps: 25,
157
+ cfg: 7,
158
+ }
159
+
160
+ for (const [key, val] of Object.entries(defaults)) {
161
+ effective[key] = val
162
+ provenance[key] = 'default'
163
+ }
164
+
165
+ // 2. Profile layer
166
+ if (profile) {
167
+ if (profile.format?.size) {
168
+ effective.size = profile.format.size
169
+ provenance.size = 'profile'
170
+ }
171
+ if (profile.format?.format) {
172
+ effective.format = profile.format.format
173
+ provenance.format = 'profile'
174
+ }
175
+ if (profile.negativePrompt) {
176
+ effective.negativePrompt = profile.negativePrompt
177
+ provenance.negativePrompt = 'profile'
178
+ }
179
+ if (profile.stylePrompt) {
180
+ effective.stylePrompt = profile.stylePrompt
181
+ provenance.stylePrompt = 'profile'
182
+ }
183
+ if (Array.isArray(profile.palette) && profile.palette.length > 0) {
184
+ effective.palette = [...profile.palette]
185
+ provenance.palette = 'profile'
186
+ }
187
+ if (profile.rules) {
188
+ effective.rules = { ...profile.rules }
189
+ provenance.rules = 'profile'
190
+ }
191
+ }
192
+
193
+ // 3. Request layer (highest precedence)
194
+ for (const [key, val] of Object.entries(requestParams)) {
195
+ if (val !== undefined && val !== null) {
196
+ if (key === 'prompt') {
197
+ // Synthesize prompt with profile style if available
198
+ if (effective.stylePrompt) {
199
+ effective.prompt = `${val}, ${effective.stylePrompt}`
200
+ provenance.prompt = 'request+profile'
201
+ } else {
202
+ effective.prompt = val
203
+ provenance.prompt = 'request'
204
+ }
205
+ } else if (key === 'negativePrompt') {
206
+ if (effective.negativePrompt && val) {
207
+ effective.negativePrompt = `${effective.negativePrompt}, ${val}`
208
+ provenance.negativePrompt = 'request+profile'
209
+ } else {
210
+ effective.negativePrompt = val || effective.negativePrompt
211
+ provenance.negativePrompt = 'request'
212
+ }
213
+ } else {
214
+ effective[key] = val
215
+ provenance[key] = 'request'
216
+ }
217
+ }
218
+ }
219
+
220
+ // Immutable applied profile metadata snapshot for history
221
+ const appliedProfile = profile
222
+ ? {
223
+ id: profile.id,
224
+ name: profile.name,
225
+ version: profile.version || '1.0.0',
226
+ }
227
+ : null
228
+
229
+ return {
230
+ effectiveParams: effective,
231
+ provenance,
232
+ appliedProfile,
233
+ }
234
+ }
@@ -9,6 +9,12 @@ import {
9
9
  interpolateComfyWorkflow,
10
10
  buildDefaultComfyWorkflow,
11
11
  } from '../../comfy-workflow-helpers.js'
12
+ import {
13
+ getComfyTemplate,
14
+ getComfyTemplates,
15
+ createComfyDependencyValidator,
16
+ prepareComfyWorkflowFromTemplate,
17
+ } from '../../comfy-catalog.js'
12
18
 
13
19
  function resolveWebSocket() {
14
20
  if (typeof window !== 'undefined' && window.WebSocket) return window.WebSocket
@@ -108,7 +114,35 @@ export function createLocalGenerator(deps, job) {
108
114
  }
109
115
  const promptId = `dsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
110
116
  let workflowGraph
111
- if (cfg.comfyWorkflowJson) {
117
+ const activeTemplateId = job.comfyTemplate || cfg.comfyTemplate
118
+ if (activeTemplateId) {
119
+ const template = getComfyTemplate(activeTemplateId)
120
+ if (!template) {
121
+ throw new Error(`Unknown ComfyUI template "${activeTemplateId}". Available templates: ${getComfyTemplates().map((t) => t.id).join(', ')}`)
122
+ }
123
+ const validator = createComfyDependencyValidator({ fetchImpl, timeoutMs: 8000 })
124
+ const depCheck = await validator(base, template)
125
+ if (!depCheck.ok) {
126
+ throw new Error(depCheck.error)
127
+ }
128
+ const faceData = job.faceReference && job.faceReference.bytes
129
+ ? Buffer.from(job.faceReference.bytes).toString('base64')
130
+ : (job.faceReference?.url || '')
131
+ workflowGraph = prepareComfyWorkflowFromTemplate(activeTemplateId, {
132
+ prompt: promptArg,
133
+ negativePrompt: negativePrompt || '',
134
+ seed: seedArg ?? 0,
135
+ width,
136
+ height,
137
+ steps: cfg.localSteps ?? 20,
138
+ cfg: cfg.localCfg ?? 7,
139
+ model: cfg.localModel,
140
+ image: source && source.bytes ? Buffer.from(source.bytes).toString('base64') : '',
141
+ face_image: faceData,
142
+ face_weight: job.faceStrength ?? 0.75,
143
+ ...(job.templateParams || {}),
144
+ })
145
+ } else if (cfg.comfyWorkflowJson) {
112
146
  const template = validateComfyWorkflow(cfg.comfyWorkflowJson)
113
147
  const faceData = job.faceReference && job.faceReference.bytes
114
148
  ? Buffer.from(job.faceReference.bytes).toString('base64')
package/lib/recipes.js ADDED
@@ -0,0 +1,410 @@
1
+ // lib/recipes.js — Sequential image recipe pipelines with step tracking & cost safety (#432)
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+
6
+ /**
7
+ * Built-in sequential recipe pipelines.
8
+ */
9
+ export const BUILTIN_RECIPES = [
10
+ {
11
+ id: 'product_card',
12
+ name: 'Product Card Suite',
13
+ description: 'Generates product photography, cleanly removes background, and upscales for print or web presentation.',
14
+ version: '1.0.0',
15
+ category: 'e-commerce',
16
+ inputs: [
17
+ { name: 'prompt', type: 'string', required: true, description: 'Product and materials description' },
18
+ { name: 'size', type: 'string', default: '1024x1024', description: 'Initial generation dimensions' },
19
+ { name: 'scale', type: 'number', default: 2, description: 'Upscaling factor (2 or 4)' },
20
+ ],
21
+ steps: [
22
+ {
23
+ id: 'generate',
24
+ tool: 'generate_image',
25
+ description: 'Generate product visual',
26
+ params: {
27
+ prompt: '{{inputs.prompt}}, studio lighting, clean product shot',
28
+ size: '{{inputs.size}}',
29
+ },
30
+ estimatedCost: 0.02,
31
+ },
32
+ {
33
+ id: 'remove_bg',
34
+ tool: 'remove_background',
35
+ description: 'Remove background to transparent alpha',
36
+ params: {
37
+ image: '{{steps.generate.path}}',
38
+ },
39
+ estimatedCost: 0.01,
40
+ },
41
+ {
42
+ id: 'upscale',
43
+ tool: 'upscale_image',
44
+ description: 'Upscale transparent asset',
45
+ params: {
46
+ image: '{{steps.remove_bg.path}}',
47
+ scale: '{{inputs.scale}}',
48
+ },
49
+ estimatedCost: 0.02,
50
+ },
51
+ ],
52
+ },
53
+ {
54
+ id: 'social_promo',
55
+ name: 'Social Media Promo Banner',
56
+ description: 'Synthesizes theme graphic and smart crops to social widescreen (16:9) format.',
57
+ version: '1.0.0',
58
+ category: 'marketing',
59
+ inputs: [
60
+ { name: 'prompt', type: 'string', required: true, description: 'Promo announcement concept' },
61
+ { name: 'aspect', type: 'string', default: '16:9', description: 'Social crop aspect ratio' },
62
+ ],
63
+ steps: [
64
+ {
65
+ id: 'generate',
66
+ tool: 'generate_image',
67
+ description: 'Synthesize promo visual',
68
+ params: {
69
+ prompt: '{{inputs.prompt}}',
70
+ size: '1024x1024',
71
+ },
72
+ estimatedCost: 0.02,
73
+ },
74
+ {
75
+ id: 'crop',
76
+ tool: 'smart_crop_image',
77
+ description: 'Smart crop to destination ratio',
78
+ params: {
79
+ image: '{{steps.generate.path}}',
80
+ aspect: '{{inputs.aspect}}',
81
+ },
82
+ estimatedCost: 0.00,
83
+ },
84
+ ],
85
+ },
86
+ {
87
+ id: 'vector_asset',
88
+ name: 'Vector Asset Pipeline',
89
+ description: 'Generates clean icon concept, traces raster to vector paths, and produces scalable SVG.',
90
+ version: '1.0.0',
91
+ category: 'graphics',
92
+ inputs: [
93
+ { name: 'prompt', type: 'string', required: true, description: 'Icon concept prompt' },
94
+ { name: 'colors', type: 'number', default: 8, description: 'Palette color count for tracing' },
95
+ ],
96
+ steps: [
97
+ {
98
+ id: 'generate',
99
+ tool: 'generate_image',
100
+ description: 'Generate minimalist graphic',
101
+ params: {
102
+ prompt: '{{inputs.prompt}}, minimalist clean vector style, solid plain background',
103
+ size: '1024x1024',
104
+ },
105
+ estimatedCost: 0.02,
106
+ },
107
+ {
108
+ id: 'vectorize',
109
+ tool: 'vectorize_image',
110
+ description: 'Trace raster into clean vector paths',
111
+ params: {
112
+ image: '{{steps.generate.path}}',
113
+ mode: 'color',
114
+ colors: '{{inputs.colors}}',
115
+ },
116
+ estimatedCost: 0.00,
117
+ },
118
+ ],
119
+ },
120
+ {
121
+ id: 'character_pipeline',
122
+ name: 'Character Turnaround Pipeline',
123
+ description: 'Synthesizes character concept and generates multi-angle model turnaround sheet.',
124
+ version: '1.0.0',
125
+ category: 'creative',
126
+ inputs: [
127
+ { name: 'character_name', type: 'string', required: true, description: 'Name of the character' },
128
+ { name: 'prompt', type: 'string', required: true, description: 'Character description and attire' },
129
+ ],
130
+ steps: [
131
+ {
132
+ id: 'concept',
133
+ tool: 'generate_image',
134
+ description: 'Generate key character portrait',
135
+ params: {
136
+ prompt: '{{inputs.prompt}}, full body concept character art',
137
+ size: '1024x1024',
138
+ },
139
+ estimatedCost: 0.02,
140
+ },
141
+ {
142
+ id: 'turnaround',
143
+ tool: 'generate_character_sheet',
144
+ description: 'Generate model turnaround sheet',
145
+ params: {
146
+ character_name: '{{inputs.character_name}}',
147
+ reference_image: '{{steps.concept.path}}',
148
+ views: 'front,side,back',
149
+ },
150
+ estimatedCost: 0.03,
151
+ },
152
+ ],
153
+ },
154
+ ]
155
+
156
+ function getRecipesFilePath() {
157
+ const base = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
158
+ return path.join(base, 'dsh-image-gen', 'recipes.json')
159
+ }
160
+
161
+ /**
162
+ * Loads custom recipes from local storage file.
163
+ */
164
+ export function loadCustomRecipes() {
165
+ const filePath = getRecipesFilePath()
166
+ try {
167
+ if (!fs.existsSync(filePath)) return []
168
+ const content = fs.readFileSync(filePath, 'utf8')
169
+ const list = JSON.parse(content)
170
+ return Array.isArray(list) ? list : []
171
+ } catch (_) {
172
+ return []
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Saves a user recipe to local storage.
178
+ */
179
+ export function saveCustomRecipe(recipe) {
180
+ if (!recipe || typeof recipe !== 'object') {
181
+ throw new Error('Recipe must be an object')
182
+ }
183
+ if (!recipe.id || typeof recipe.id !== 'string') {
184
+ throw new Error('Recipe must have a valid string "id"')
185
+ }
186
+ if (!recipe.name || typeof recipe.name !== 'string') {
187
+ throw new Error('Recipe must have a valid string "name"')
188
+ }
189
+ if (!Array.isArray(recipe.steps) || recipe.steps.length === 0) {
190
+ throw new Error('Recipe must have a non-empty array of "steps"')
191
+ }
192
+
193
+ // Validate step format
194
+ for (let i = 0; i < recipe.steps.length; i++) {
195
+ const s = recipe.steps[i]
196
+ if (!s || !s.tool || typeof s.tool !== 'string') {
197
+ throw new Error(`Recipe step #${i + 1} missing required "tool" name`)
198
+ }
199
+ }
200
+
201
+ const list = loadCustomRecipes().filter((r) => r.id !== recipe.id)
202
+ const item = {
203
+ ...recipe,
204
+ custom: true,
205
+ updatedAt: new Date().toISOString(),
206
+ }
207
+ list.push(item)
208
+
209
+ const filePath = getRecipesFilePath()
210
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
211
+ fs.writeFileSync(filePath, JSON.stringify(list, null, 2), 'utf8')
212
+ return item
213
+ }
214
+
215
+ /**
216
+ * Deletes a user recipe by ID.
217
+ */
218
+ export function deleteCustomRecipe(id) {
219
+ const list = loadCustomRecipes()
220
+ const filtered = list.filter((r) => r.id !== id)
221
+ if (filtered.length === list.length) return false
222
+ const filePath = getRecipesFilePath()
223
+ fs.writeFileSync(filePath, JSON.stringify(filtered, null, 2), 'utf8')
224
+ return true
225
+ }
226
+
227
+ /**
228
+ * Returns merged list of all available recipes (built-in + custom).
229
+ */
230
+ export function getAllRecipes() {
231
+ const custom = loadCustomRecipes()
232
+ const customIds = new Set(custom.map((c) => c.id))
233
+ const builtins = BUILTIN_RECIPES.filter((b) => !customIds.has(b.id)).map((b) => ({
234
+ ...b,
235
+ builtin: true,
236
+ }))
237
+ return [...builtins, ...custom]
238
+ }
239
+
240
+ /**
241
+ * Retrieves a recipe by ID.
242
+ */
243
+ export function getRecipe(id) {
244
+ if (!id) return null
245
+ return getAllRecipes().find((r) => r.id === id) || null
246
+ }
247
+
248
+ /**
249
+ * Interpolates recipe step parameters using context containing inputs and prior step results.
250
+ */
251
+ export function interpolateRecipeParams(rawParams, context = {}) {
252
+ const { inputs = {}, steps = {} } = context
253
+
254
+ function resolveRef(str) {
255
+ if (typeof str !== 'string') return str
256
+ const trimmed = str.trim()
257
+
258
+ // Handle exact number matches
259
+ if (/^\{\{inputs\.([a-zA-Z0-9_.-]+)\}\}$/.test(trimmed)) {
260
+ const key = trimmed.slice(9, -2)
261
+ return inputs[key] !== undefined ? inputs[key] : ''
262
+ }
263
+
264
+ // General replacements
265
+ let out = str.replace(/\{\{inputs\.([a-zA-Z0-9_.-]+)\}\}/g, (_, k) => {
266
+ return inputs[k] !== undefined ? String(inputs[k]) : ''
267
+ })
268
+
269
+ out = out.replace(/\{\{steps\.([a-zA-Z0-9_.-]+)\.([a-zA-Z0-9_.-]+)\}\}/g, (_, stepId, prop) => {
270
+ const stepData = steps[stepId]
271
+ if (!stepData) return ''
272
+ // Try direct property or nested image/attachment properties
273
+ if (stepData[prop] !== undefined) return String(stepData[prop])
274
+ if (prop === 'path' && stepData.images?.[0]?.path) return String(stepData.images[0].path)
275
+ if (prop === 'url' && stepData.images?.[0]?.url) return String(stepData.images[0].url)
276
+ if (prop === 'path' && stepData.attachment?.path) return String(stepData.attachment.path)
277
+ if (prop === 'path' && stepData.svgPath) return String(stepData.svgPath)
278
+ return ''
279
+ })
280
+
281
+ return out
282
+ }
283
+
284
+ function transform(val) {
285
+ if (typeof val === 'string') return resolveRef(val)
286
+ if (Array.isArray(val)) return val.map(transform)
287
+ if (val && typeof val === 'object') {
288
+ const res = {}
289
+ for (const [k, v] of Object.entries(val)) {
290
+ res[k] = transform(v)
291
+ }
292
+ return res
293
+ }
294
+ return val
295
+ }
296
+
297
+ return transform(rawParams)
298
+ }
299
+
300
+ /**
301
+ * Executes a sequential image recipe pipeline (#432).
302
+ *
303
+ * @param {string|object} recipeOrId Recipe ID or full recipe object
304
+ * @param {object} userInputs User provided arguments (e.g. prompt, image, aspect)
305
+ * @param {object} env Runtime tools environment
306
+ * @param {Function} env.toolDispatcher Function (toolName, params) => Promise<result>
307
+ * @param {Function} [env.assertBudget] Function (amount) => throws if insufficient budget
308
+ * @param {Function} [env.onProgress] Function ({ stage, progress })
309
+ * @returns {Promise<object>} Recipe execution summary and final artifact
310
+ */
311
+ export async function executeRecipe(recipeOrId, userInputs = {}, env = {}) {
312
+ const { toolDispatcher, assertBudget, onProgress } = env
313
+ if (typeof toolDispatcher !== 'function') {
314
+ throw new Error('Recipe runner requires a toolDispatcher function')
315
+ }
316
+
317
+ const recipe = typeof recipeOrId === 'string' ? getRecipe(recipeOrId) : recipeOrId
318
+ if (!recipe) {
319
+ throw new Error(`Recipe "${recipeOrId}" not found`)
320
+ }
321
+
322
+ // 1. Validate required inputs
323
+ const mergedInputs = {}
324
+ for (const inp of recipe.inputs || []) {
325
+ if (inp.default !== undefined) mergedInputs[inp.name] = inp.default
326
+ }
327
+ Object.assign(mergedInputs, userInputs)
328
+
329
+ for (const inp of recipe.inputs || []) {
330
+ if (inp.required && (mergedInputs[inp.name] === undefined || mergedInputs[inp.name] === null || mergedInputs[inp.name] === '')) {
331
+ throw new Error(`Recipe "${recipe.name}" missing required input: "${inp.name}"`)
332
+ }
333
+ }
334
+
335
+ // 2. Pre-flight dry run: estimate total budget and verify tools exist before executing paid requests
336
+ let totalEstimatedCost = 0
337
+ for (let i = 0; i < recipe.steps.length; i++) {
338
+ const step = recipe.steps[i]
339
+ totalEstimatedCost += Number(step.estimatedCost || 0)
340
+ }
341
+
342
+ if (typeof assertBudget === 'function' && totalEstimatedCost > 0) {
343
+ assertBudget(totalEstimatedCost)
344
+ }
345
+
346
+ // 3. Sequential step execution
347
+ const completedSteps = []
348
+ const stepContext = {
349
+ inputs: mergedInputs,
350
+ steps: {},
351
+ }
352
+
353
+ for (let i = 0; i < recipe.steps.length; i++) {
354
+ const step = recipe.steps[i]
355
+ const stepId = step.id || String(i)
356
+ const stepDescription = step.description || `Step ${i + 1}: ${step.tool}`
357
+
358
+ onProgress?.({
359
+ stage: `Step ${i + 1}/${recipe.steps.length}: ${step.tool}`,
360
+ progress: Math.round((i / recipe.steps.length) * 100),
361
+ })
362
+
363
+ const interpolated = interpolateRecipeParams(step.params || {}, stepContext)
364
+
365
+ const startTime = Date.now()
366
+ let result
367
+ try {
368
+ result = await toolDispatcher(step.tool, interpolated)
369
+ } catch (err) {
370
+ const failedErr = new Error(`Recipe "${recipe.name}" failed at step ${i + 1} (${step.tool}): ${err.message}`)
371
+ failedErr.recipeId = recipe.id
372
+ failedErr.failedStepIndex = i
373
+ failedErr.failedStepTool = step.tool
374
+ failedErr.completedSteps = completedSteps
375
+ throw failedErr
376
+ }
377
+
378
+ const durationMs = Date.now() - startTime
379
+ const record = {
380
+ stepIndex: i,
381
+ stepId,
382
+ tool: step.tool,
383
+ description: stepDescription,
384
+ params: interpolated,
385
+ durationMs,
386
+ result,
387
+ }
388
+
389
+ completedSteps.push(record)
390
+ stepContext.steps[stepId] = result
391
+ stepContext.steps[String(i)] = result
392
+ }
393
+
394
+ onProgress?.({
395
+ stage: 'Recipe completed',
396
+ progress: 100,
397
+ })
398
+
399
+ const lastResult = completedSteps[completedSteps.length - 1]?.result
400
+
401
+ return {
402
+ ok: true,
403
+ recipeId: recipe.id,
404
+ recipeName: recipe.name,
405
+ stepsCount: recipe.steps.length,
406
+ completedSteps,
407
+ finalResult: lastResult,
408
+ totalEstimatedCost: +totalEstimatedCost.toFixed(4),
409
+ }
410
+ }