@goodandready/dsh-image-gen 0.11.16 → 0.11.18

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.
@@ -1,651 +1,496 @@
1
- import { registerGenerationPackTool } from './generation-pack.js'
2
- import { defineTool } from '@deepseek-ai/dsh-tools'
3
- import path from 'node:path'
4
- import { mkdir, writeFile } from 'node:fs/promises'
5
- import {
6
- computeGenerationHash,
7
- IMAGE_SIZES,
8
- OUTPUT_FORMATS,
9
- PROVIDER_KEYS,
10
- buildSidecar,
11
- makeProviders,
12
- ASPECT_RATIOS,
13
- normalizeCount,
14
- embedPngMetadata,
15
- resolveStylePreset,
16
- toLosslessJson,
17
- saveAttachmentSafe,
18
- } from '../providers.js'
19
- import { buildDualOutputMarkdown } from '../resolve-image.js'
20
- import { sanitizeNegativePrompt, supportsNegativePrompt } from '../negative-sanitizer.js'
21
- import { executeWithQualityGate } from '../quality-gate.js'
22
- import { calculateGenerationCost, recordSpend, assertBudgetAvailable } from '../cost-meter.js'
23
- import { trackAndAssertLoopGuard } from '../loop-guard.js'
24
- import { getCachedGeneration, setCachedGeneration } from '../generation-cache.js'
25
- import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
26
- import { getSessionAnchor, applyAnchorPromptHints, buildFaceConsistencyPrompt } from '../anchor-helpers.js'
27
- import { enhancePrompt } from '../prompt-enhancer.js'
28
- import {
29
- readHistory,
30
- writeHistory,
31
- pruneHistory,
32
- findCachedGeneration,
33
- findCached,
34
- findCachedByPrompt,
35
- cachedResult,
36
- } from '../history.js'
37
-
38
- // generation — image-gen tools (generate_image, generate_image_pack). Extracted from apply() (#216).
39
-
40
- /** Run an array of async task functions with a concurrency cap */
41
- async function asyncPool(tasks, concurrency = 3) {
42
- const results = new Array(tasks.length)
43
- let nextIdx = 0
44
- async function worker() {
45
- while (nextIdx < tasks.length) {
46
- const idx = nextIdx++
47
- results[idx] = await tasks[idx]()
48
- }
49
- }
50
- const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker())
51
- await Promise.all(workers)
52
- return results
53
- }
54
-
55
- export function registerGenerationTools(ctx, deps, options = {}) {
56
- const {
57
- config,
58
- live,
59
- resolveSource,
60
- slugify,
61
- resolveApiKey,
62
- } = deps
63
- ctx.effect(() => {
64
- ctx.tools.register(
65
- defineTool({
66
- name: 'generate_image',
67
- description: 'Generate an image from prompt. Saves the image to workspace and conversation. Pass count (1-4) for variations.',
68
- parameters: {
69
- prompt: {
70
- type: 'string',
71
- required: true,
72
- description: 'Detailed description of the image to generate.',
73
- },
74
- image_size: {
75
- type: 'string',
76
- description: `One of: ${IMAGE_SIZES.join(', ')}. Default: ${config?.defaultSize || 'square_hd'}.`,
77
- },
78
- aspect_ratio: {
79
- type: 'string',
80
- enum: ['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3'],
81
- description: 'Optional aspect ratio (e.g. 16:9, 1:1), overrides image_size.',
82
- },
83
- seed: {
84
- type: 'integer',
85
- description: 'Optional seed for reproducible output.',
86
- },
87
- output_format: {
88
- type: 'string',
89
- enum: OUTPUT_FORMATS,
90
- description: `Output format. Default: ${config?.defaultFormat || 'png'}.`,
91
- },
92
- output_name: {
93
- type: 'string',
94
- description: 'Optional file name stem for the saved image.',
95
- },
96
- output_dir: {
97
- type: 'string',
98
- description: 'Optional custom directory to write images into.',
99
- },
100
- style_preset: {
101
- type: 'string',
102
- description: 'Optional style preset: cinematic, photorealistic, anime, minimalist_vector, isometric_3d, analog_film, cyberpunk, claymation.',
103
- },
104
- palette_colors: {
105
- type: 'array',
106
- items: { type: 'string' },
107
- description: 'Optional hex color codes (#1e1b4b) to harmonize palette.',
108
- },
109
- count: {
110
- type: 'integer',
111
- description: 'Variations count (1-4, default 1).',
112
- },
113
- prompts: {
114
- type: 'array',
115
- items: {
116
- oneOf: [
117
- { type: 'string' },
118
- { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } } },
119
- ],
120
- },
121
- description: 'Optional list of prompts for batch generation.',
122
- },
123
- negative_prompt: {
124
- type: 'string',
125
- description: 'Optional text describing what NOT to draw.',
126
- },
127
- guidance_scale: {
128
- type: 'number',
129
- description: 'Optional prompt adherence (1-20).',
130
- },
131
- quality: {
132
- type: 'string',
133
- enum: ['auto', 'low', 'medium', 'high'],
134
- description: 'Optional quality for supported providers.',
135
- },
136
- style: {
137
- type: 'string',
138
- description: 'Optional style preset for supported providers.',
139
- },
140
- source_image: {
141
- type: 'string',
142
- description: 'Optional source image path or attachment ID for img2img.',
143
- },
144
- mask: {
145
- type: 'string',
146
- description: 'Optional inpaint mask path or attachment ID.',
147
- },
148
- strength: {
149
- type: 'number',
150
- description: 'Optional edit strength (0-1).',
151
- },
152
- quality_gate: {
153
- type: 'boolean',
154
- description: 'Optional automated quality gate override.',
155
- },
156
- force: {
157
- type: 'boolean',
158
- description: 'Bypass cache and force new generation.',
159
- },
160
- reference_image: {
161
- type: 'string',
162
- description: 'Optional reference image path or URL for consistency.',
163
- },
164
- reference_strength: {
165
- type: 'number',
166
- description: 'Strength of reference influence (0.1-1.0).',
167
- },
168
- face_reference: {
169
- type: 'string',
170
- description: 'Portrait reference image for facial identity preservation.',
171
- },
172
- face_strength: {
173
- type: 'number',
174
- description: 'Facial identity similarity weight (0.1-1.0).',
175
- },
176
- },
177
- output: {
178
- schema: {
179
- type: 'object',
180
- additionalProperties: true,
181
- properties: {
182
- summary: { type: 'string' },
183
- channel: { type: 'string' },
184
- provider: { type: 'string' },
185
- model: { type: 'string' },
186
- _fallback: {
187
- type: 'object',
188
- additionalProperties: true,
189
- properties: {
190
- triggered: { type: 'boolean' },
191
- primaryProvider: { type: 'string' },
192
- providerUsed: { type: 'string' },
193
- attempts: {
194
- type: 'array',
195
- items: {
196
- type: 'object',
197
- additionalProperties: true,
198
- properties: {
199
- provider: { type: 'string' },
200
- durationMs: { type: 'number' },
201
- success: { type: 'boolean' },
202
- error: { type: 'string' },
203
- },
204
- },
205
- },
206
- },
207
- },
208
- path: { type: 'string' },
209
- url: { type: 'string' },
210
- width: { type: 'integer' },
211
- height: { type: 'integer' },
212
- seed: { type: 'integer' },
213
- prompt: { type: 'string' },
214
- originalPrompt: { type: 'string' },
215
- format: { type: 'string' },
216
- cost: { type: 'number' },
217
- fromCache: { type: 'boolean' },
218
- qualityReport: { type: 'object', additionalProperties: true },
219
- anchor: {
220
- type: 'object',
221
- additionalProperties: true,
222
- properties: {
223
- label: { type: 'string' },
224
- mode: { type: 'string' },
225
- },
226
- },
227
- attachment: {
228
- type: 'object',
229
- additionalProperties: true,
230
- properties: {
231
- attachmentId: { type: 'string' },
232
- mediaType: { type: 'string' },
233
- bytes: { type: 'integer' },
234
- width: { type: 'integer' },
235
- height: { type: 'integer' },
236
- name: { type: 'string' },
237
- },
238
- },
239
- images: {
240
- type: 'array',
241
- items: {
242
- type: 'object',
243
- additionalProperties: true,
244
- properties: {
245
- path: { type: 'string' },
246
- url: { type: 'string' },
247
- width: { type: 'integer' },
248
- height: { type: 'integer' },
249
- seed: { type: 'integer' },
250
- prompt: { type: 'string' },
251
- originalPrompt: { type: 'string' },
252
- format: { type: 'string' },
253
- cost: { type: 'number' },
254
- fromCache: { type: 'boolean' },
255
- qualityReport: { type: 'object', additionalProperties: true },
256
- _fallback: {
257
- type: 'object',
258
- additionalProperties: true,
259
- properties: {
260
- triggered: { type: 'boolean' },
261
- primaryProvider: { type: 'string' },
262
- providerUsed: { type: 'string' },
263
- attempts: {
264
- type: 'array',
265
- items: {
266
- type: 'object',
267
- additionalProperties: true,
268
- properties: {
269
- provider: { type: 'string' },
270
- durationMs: { type: 'number' },
271
- success: { type: 'boolean' },
272
- error: { type: 'string' },
273
- },
274
- },
275
- },
276
- },
277
- },
278
- provider: { type: 'string' },
279
- model: { type: 'string' },
280
- attachment: {
281
- type: 'object',
282
- additionalProperties: true,
283
- properties: {
284
- attachmentId: { type: 'string' },
285
- mediaType: { type: 'string' },
286
- bytes: { type: 'integer' },
287
- width: { type: 'integer' },
288
- height: { type: 'integer' },
289
- name: { type: 'string' },
290
- },
291
- },
292
- },
293
- },
294
- },
295
- },
296
- },
297
- render(args, value) {
298
- const list = value.images || [value]
299
- const lines = list.map((img) => `Generated image (${img.width}×${img.height}, ${img.format}, seed ${img.seed}) via ${value.provider || 'image-gen'}/${value.model || 'default'}: ${img.path}`)
300
- const summary = lines.join('\n')
301
- if (live().deliverAs !== 'image') {
302
- const urls = list.map((img) => img.url).filter(Boolean)
303
- return [{ type: 'text', text: urls.length ? `${summary}\n${urls.join('\n')}` : summary }]
304
- }
305
- const blocks = [{ type: 'text', text: summary }]
306
- for (const img of list) if (img.attachment) blocks.push({ type: 'image', attachment: img.attachment })
307
- return blocks
308
- },
309
- },
310
- isConcurrencySafe: () => false,
311
- timeoutMs: (config?.timeoutMs || 180000) + 30000,
312
- async execute(args, exec) {
313
- const cfg = live()
314
-
315
- // #168: Fail-fast loop guard protecting against runaway agent loops
316
- const sessionId = exec.agent?.session?.id || exec.agent?.session?.header?.id || 'session'
317
- if (cfg.loopGuardLimit > 0) {
318
- trackAndAssertLoopGuard(sessionId, {
319
- limit: cfg.loopGuardLimit,
320
- prompt: args.prompt,
321
- })
322
- }
323
- if (cfg.enabled === false) {
324
- throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
325
- }
326
- const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
327
- const deliverAs = cfg.deliverAs
328
- const size = args.aspect_ratio ? 'custom' : (args.image_size ?? cfg.defaultSize)
329
- if (!args.aspect_ratio && !IMAGE_SIZES.includes(size)) {
330
- throw new Error(`Invalid image_size "${size}". One of: ${IMAGE_SIZES.join(', ')}`)
331
- }
332
- const aspectPixels = args.aspect_ratio ? ASPECT_RATIOS[args.aspect_ratio] : undefined
333
- const format = args.output_format ?? cfg.defaultFormat
334
- if (!OUTPUT_FORMATS.includes(format)) {
335
- throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
336
- }
337
- // Subscriptions service is optional: without it, subscription providers
338
- // gracefully decline while other providers continue functioning.
339
- let subscriptionImages
340
- try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_err) { subscriptionImages = undefined }
341
-
342
- const source = args.source_image ? await resolveSource(ctx, exec, args.source_image) : undefined
343
- const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
344
- const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
345
- const effectiveStyle = args.style || cfg.stylePreset
346
- const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
347
- let effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
348
- const activeAnchor = getSessionAnchor(sessionId)
349
- let resolvedFaceRef
350
- const faceRefInput = args.face_reference || (activeAnchor && (activeAnchor.mode === 'face' || activeAnchor.mode === 'character') ? activeAnchor.image : undefined)
351
- if (faceRefInput) {
352
- try {
353
- resolvedFaceRef = await resolveSource(ctx, exec, faceRefInput)
354
- } catch (_err) {
355
- /* non-fatal: face_reference resolve fallback */
356
- resolvedFaceRef = { url: faceRefInput }
357
- }
358
- const facePlan = buildFaceConsistencyPrompt({
359
- prompt: effectivePrompt,
360
- faceStrength: args.face_strength || activeAnchor?.strength,
361
- characterName: activeAnchor?.label,
362
- })
363
- effectivePrompt = facePlan.prompt
364
- if (facePlan.negativePrompt) {
365
- effectiveNegative = effectiveNegative ? `${effectiveNegative}, ${facePlan.negativePrompt}` : facePlan.negativePrompt
366
- }
367
- } else if (args.reference_image) {
368
- effectivePrompt = `${effectivePrompt}, visual reference anchor`
369
- } else if (activeAnchor) {
370
- effectivePrompt = applyAnchorPromptHints(activeAnchor, effectivePrompt)
371
- }
372
-
373
- // #165: Negative prompt sanitizer for diffusion models
374
- let effectiveNegative = styleInfo.negativePrompt
375
- if (supportsNegativePrompt(provider, cfg.model || cfg.customModel)) {
376
- effectiveNegative = sanitizeNegativePrompt({
377
- positivePrompt: effectivePrompt,
378
- userNegative: effectiveNegative,
379
- autoInjectDefects: true,
380
- })
381
- }
382
- const effectiveGuidance = styleInfo.guidanceScale
383
-
384
- // #167: Assert daily budget availability before calling API
385
- const totalCount = Array.isArray(args.prompts) && args.prompts.length ? args.prompts.length : normalizeCount(args.count)
386
- const estimatedCost = calculateGenerationCost({
387
- provider,
388
- model: cfg.model || cfg.customModel,
389
- size,
390
- count: totalCount,
391
- })
392
- assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
393
- const providers = makeProviders(
394
- { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
395
- { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio, faceReference: resolvedFaceRef },
396
- )
397
- // Subscription providers return {ok:false, reason} instead of throwing:
398
- // rejection reaches the model as text. Without this guard,
399
- // execution would proceed with empty bytes.
400
- const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
401
- const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
402
- const gen = await providers[providerKey](jobSeed, promptArg)
403
- guard(gen)
404
- const mediaType = gen.mediaType || 'image/png'
405
- let bytes = gen.bytes
406
- if (mediaType === 'image/png') {
407
- bytes = embedPngMetadata(bytes, {
408
- prompt: promptArg,
409
- seed: gen.seed,
410
- provider,
411
- model: cfg.model || cfg.customModel,
412
- negative_prompt: effectiveNegative,
413
- guidance_scale: effectiveGuidance,
414
- width: gen.width,
415
- height: gen.height,
416
- })
417
- }
418
- const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
419
- const stem = `${slugify(args.output_name || promptArg)}-${Date.now().toString(36)}-${jobSeed}`
420
- const name = `${stem}.${extension}`
421
-
422
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes, mediaType, name })
423
-
424
- const sessionCwd = exec.agent?.session?.header?.cwd
425
- const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
426
- const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
427
- await mkdir(outDir, { recursive: true })
428
- const filePath = path.join(outDir, name)
429
- await writeFile(filePath, bytes)
430
-
431
- // Sidecar: metadata written alongside image file, making the output
432
- // directory self-documenting without external database dependencies.
433
- await writeFile(
434
- path.join(outDir, `${stem}.json`),
435
- JSON.stringify(buildSidecar({
436
- prompt: promptArg,
437
- size,
438
- format,
439
- seed: gen.seed,
440
- provider,
441
- deliverAs,
442
- width: gen.width || attachment.width,
443
- height: gen.height || attachment.height,
444
- mediaType,
445
- attachmentId: attachment.attachmentId,
446
- url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
447
- cost: gen.cost,
448
- }), null, 2),
449
- )
450
-
451
- const cacheHash = computeGenerationHash({
452
- provider,
453
- model: cfg.model || cfg.customModel,
454
- prompt: promptArg,
455
- seed: gen.seed,
456
- size: args.image_size || cfg.defaultSize,
457
- style: args.style || cfg.stylePreset,
458
- })
459
-
460
- // #170: Store in content-addressed disk cache
461
- if (cfg.diskCache !== false) {
462
- setCachedGeneration(cacheHash, {
463
- bytes,
464
- mediaType,
465
- width: gen.width || attachment.width,
466
- height: gen.height || attachment.height,
467
- seed: gen.seed,
468
- meta: { prompt: promptArg, provider, model: cfg.model || cfg.customModel, cost: gen.cost },
469
- })
470
- }
471
- const entry = {
472
- path: filePath,
473
- prompt: promptArg,
474
- provider,
475
- model: cfg.model,
476
- size,
477
- format,
478
- seed: gen.seed,
479
- cacheHash,
480
- thumbnailUrl: localUrl,
481
- width: gen.width || attachment.width,
482
- height: gen.height || attachment.height,
483
- mediaType,
484
- bytes: attachment.bytes,
485
- createdAt: new Date().toISOString(),
486
- attachmentId: attachment.attachmentId,
487
- }
488
- let current = await readHistory()
489
- current = await pruneHistory(current, cfg.pruneDays)
490
- current.unshift(entry)
491
- if (current.length > (cfg.historyLimit || 50)) current.length = cfg.historyLimit || 50
492
- await writeHistory(current)
493
-
494
- // #167: Record spend in cost meter
495
- recordSpend(gen.cost || (estimatedCost / totalCount), {
496
- ctx,
497
- meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
498
- })
499
-
500
- return {
501
- path: filePath,
502
- url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
503
- width: gen.width || attachment.width,
504
- height: gen.height || attachment.height,
505
- seed: gen.seed,
506
- prompt: promptArg,
507
- ...(enhanced.enhanced ? { originalPrompt: args.prompt } : {}),
508
- cost: gen.cost,
509
- format: mediaType.replace('image/', ''),
510
- attachment: {
511
- attachmentId: attachment.attachmentId,
512
- mediaType: attachment.mediaType,
513
- bytes: attachment.bytes,
514
- width: attachment.width,
515
- height: attachment.height,
516
- name: attachment.name,
517
- },
518
- }
519
- }
520
-
521
- const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
522
- // Fallback cascade: evaluate configured provider, falling back to alternates
523
- // (fal -> custom -> codex -> grok), accumulating failure diagnostics.
524
- const order = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
525
- const qgEnabled = args.quality_gate ?? cfg.qualityGate
526
- const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
527
- k,
528
- async (s, p) => {
529
- const { generated, qualityReport } = await executeWithQualityGate(
530
- (seedVal) => one(seedVal, k, p),
531
- {
532
- initialSeed: s,
533
- enabled: qgEnabled,
534
- ctx,
535
- prompt: p,
536
- }
537
- )
538
- return { ...generated, qualityReport }
539
- }
540
- ]))
541
- const images = []
542
- const historyEntries = (cfg.cacheBySeed || cfg.cacheByPrompt) ? await readHistory() : []
543
- const checkCache = async (seedVal, promptVal) => {
544
- const h = computeGenerationHash({
545
- provider,
546
- model: cfg.model || cfg.customModel,
547
- prompt: promptVal,
548
- seed: seedVal,
549
- size: args.image_size || cfg.defaultSize,
550
- style: args.style || cfg.stylePreset,
551
- })
552
-
553
- // #170: Content-addressed disk cache check (<50ms return)
554
- if (cfg.diskCache !== false && !args.force) {
555
- const diskHit = getCachedGeneration(h, { force: args.force })
556
- if (diskHit) {
557
- const extension = diskHit.mediaType === 'image/jpeg' ? 'jpg' : diskHit.mediaType === 'image/webp' ? 'webp' : 'png'
558
- const stem = `${slugify(args.output_name || promptVal)}-${Date.now().toString(36)}-${seedVal}`
559
- const name = `${stem}.${extension}`
560
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: diskHit.bytes, mediaType: diskHit.mediaType, name })
561
- const sessionCwd = exec.agent?.session?.header?.cwd
562
- const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
563
- const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
564
- await mkdir(outDir, { recursive: true })
565
- const filePath = path.join(outDir, name)
566
- await writeFile(filePath, diskHit.bytes)
567
-
568
- return {
569
- path: filePath,
570
- url: localUrl,
571
- width: diskHit.width || attachment.width,
572
- height: diskHit.height || attachment.height,
573
- seed: seedVal,
574
- prompt: promptVal,
575
- cost: 0,
576
- fromCache: true,
577
- format: diskHit.mediaType.replace('image/', ''),
578
- attachment: {
579
- attachmentId: attachment.attachmentId,
580
- mediaType: attachment.mediaType,
581
- bytes: attachment.bytes,
582
- width: attachment.width,
583
- height: attachment.height,
584
- name: attachment.name,
585
- },
586
- }
587
- }
588
- }
589
-
590
- if (!cfg.cacheBySeed && !cfg.cacheByPrompt) return undefined
591
- const hPrompt = computeGenerationHash({
592
- provider,
593
- model: cfg.model,
594
- prompt: promptVal,
595
- seed: seedVal,
596
- size: args.image_size || cfg.defaultSize,
597
- style: args.style || cfg.stylePreset,
598
- })
599
- const found = findCachedGeneration(historyEntries, hPrompt)
600
- if (found) return await cachedResult(found)
601
- if (cfg.cacheBySeed) {
602
- const bySeed = await findCached(historyEntries, seedVal, promptVal)
603
- if (bySeed) return await cachedResult(bySeed)
604
- }
605
- if (cfg.cacheByPrompt) {
606
- const byPrompt = await findCachedByPrompt(historyEntries, promptVal)
607
- if (byPrompt) return await cachedResult(byPrompt)
608
- }
609
- return undefined
610
- }
611
-
612
- const batchPrompts = Array.isArray(args.prompts) && args.prompts.length ? args.prompts : null
613
- if (batchPrompts) {
614
- const tasks = batchPrompts.map((item, i) => async () => {
615
- const text = typeof item === 'string' ? item : (item && item.text) || ''
616
- const cached = await checkCache(seedBase + i, text)
617
- return cached || await executeWithFallback(generators, order, seedBase + i, text, { logger: ctx.logger })
618
- })
619
- const parallelResults = await asyncPool(tasks, 3)
620
- images.push(...parallelResults)
621
- } else {
622
- const count = normalizeCount(args.count)
623
- const tasks = Array.from({ length: count }, (_, i) => async () => {
624
- const cached = await checkCache(seedBase + i, effectivePrompt)
625
- return cached || await executeWithFallback(generators, order, seedBase + i, effectivePrompt, { logger: ctx.logger })
626
- })
627
- const parallelResults = await asyncPool(tasks, 3)
628
- images.push(...parallelResults)
629
- }
630
- const first = images[0]
631
- const dualOutputSummary = buildDualOutputMarkdown({
632
- action: 'generated',
633
- filePath: first.path,
634
- width: first.width,
635
- height: first.height,
636
- mediaType: first.mediaType || ('image/' + (first.format || 'png')),
637
- seed: first.seed,
638
- provider,
639
- model: cfg.model || cfg.customModel || 'default',
640
- cost: first.cost,
641
- attachmentId: first.attachment?.attachmentId || 'N/A',
642
- })
643
- return toLosslessJson({ summary: dualOutputSummary, channel: provider, provider, model: cfg.model || cfg.customModel, _fallback: first._fallback, anchor: activeAnchor ? { label: activeAnchor.label, mode: activeAnchor.mode } : undefined, ...first, images })
644
- },
645
- }),
646
- )
647
- }, 'dsh-image-gen: tool generate_image')
648
- if (!options.coreOnly) {
649
- registerGenerationPackTool(ctx, deps)
650
- }
651
- }
1
+ import { registerGenerationPackTool } from './generation-pack.js'
2
+ import { defineTool } from '@deepseek-ai/dsh-tools'
3
+ import path from 'node:path'
4
+ import { mkdir, writeFile } from 'node:fs/promises'
5
+ import {
6
+ computeGenerationHash,
7
+ IMAGE_SIZES,
8
+ OUTPUT_FORMATS,
9
+ PROVIDER_KEYS,
10
+ makeProviders,
11
+ normalizeCount,
12
+ toLosslessJson,
13
+ saveAttachmentSafe,
14
+ } from '../providers.js'
15
+ import { buildDualOutputMarkdown } from '../resolve-image.js'
16
+ import { executeWithQualityGate } from '../quality-gate.js'
17
+ import { calculateGenerationCost, assertBudgetAvailable } from '../cost-meter.js'
18
+ import { trackAndAssertLoopGuard } from '../loop-guard.js'
19
+ import { getCachedGeneration } from '../generation-cache.js'
20
+ import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
21
+ import {
22
+ readHistory,
23
+ findCachedGeneration,
24
+ findCached,
25
+ findCachedByPrompt,
26
+ cachedResult,
27
+ } from '../history.js'
28
+ import {
29
+ asyncPool,
30
+ validateGenerationParams,
31
+ prepareGenerationPrompt,
32
+ saveGeneratedImageArtifact,
33
+ } from '../generation-helpers.js'
34
+
35
+ // generation — image-gen tools (generate_image, generate_image_pack). Extracted from apply() (#216).
36
+
37
+ export function registerGenerationTools(ctx, deps, options = {}) {
38
+ const {
39
+ config,
40
+ live,
41
+ resolveSource,
42
+ slugify,
43
+ resolveApiKey,
44
+ } = deps
45
+ ctx.effect(() => {
46
+ ctx.tools.register(
47
+ defineTool({
48
+ name: 'generate_image',
49
+ description: 'Generate an image from prompt. Saves the image to workspace and conversation. Pass count (1-4) for variations.',
50
+ parameters: {
51
+ prompt: {
52
+ type: 'string',
53
+ required: true,
54
+ description: 'Detailed description of the image to generate.',
55
+ },
56
+ image_size: {
57
+ type: 'string',
58
+ description: `One of: ${IMAGE_SIZES.join(', ')}. Default: ${config?.defaultSize || 'square_hd'}.`,
59
+ },
60
+ aspect_ratio: {
61
+ type: 'string',
62
+ enum: ['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3'],
63
+ description: 'Optional aspect ratio (e.g. 16:9, 1:1), overrides image_size.',
64
+ },
65
+ seed: {
66
+ type: 'integer',
67
+ description: 'Optional seed for reproducible output.',
68
+ },
69
+ output_format: {
70
+ type: 'string',
71
+ enum: OUTPUT_FORMATS,
72
+ description: `Output format. Default: ${config?.defaultFormat || 'png'}.`,
73
+ },
74
+ output_name: {
75
+ type: 'string',
76
+ description: 'Optional file name stem for the saved image.',
77
+ },
78
+ output_dir: {
79
+ type: 'string',
80
+ description: 'Optional custom directory to write images into.',
81
+ },
82
+ style_preset: {
83
+ type: 'string',
84
+ description: 'Optional style preset: cinematic, photorealistic, anime, minimalist_vector, isometric_3d, analog_film, cyberpunk, claymation.',
85
+ },
86
+ palette_colors: {
87
+ type: 'array',
88
+ items: { type: 'string' },
89
+ description: 'Optional hex color codes (#1e1b4b) to harmonize palette.',
90
+ },
91
+ count: {
92
+ type: 'integer',
93
+ description: 'Variations count (1-4, default 1).',
94
+ },
95
+ prompts: {
96
+ type: 'array',
97
+ items: {
98
+ oneOf: [
99
+ { type: 'string' },
100
+ { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } } },
101
+ ],
102
+ },
103
+ description: 'Optional list of prompts for batch generation.',
104
+ },
105
+ negative_prompt: {
106
+ type: 'string',
107
+ description: 'Optional text describing what NOT to draw.',
108
+ },
109
+ guidance_scale: {
110
+ type: 'number',
111
+ description: 'Optional prompt adherence (1-20).',
112
+ },
113
+ quality: {
114
+ type: 'string',
115
+ enum: ['auto', 'low', 'medium', 'high'],
116
+ description: 'Optional quality for supported providers.',
117
+ },
118
+ style: {
119
+ type: 'string',
120
+ description: 'Optional style preset for supported providers.',
121
+ },
122
+ source_image: {
123
+ type: 'string',
124
+ description: 'Optional source image path or attachment ID for img2img.',
125
+ },
126
+ mask: {
127
+ type: 'string',
128
+ description: 'Optional inpaint mask path or attachment ID.',
129
+ },
130
+ strength: {
131
+ type: 'number',
132
+ description: 'Optional edit strength (0-1).',
133
+ },
134
+ quality_gate: {
135
+ type: 'boolean',
136
+ description: 'Optional automated quality gate override.',
137
+ },
138
+ force: {
139
+ type: 'boolean',
140
+ description: 'Bypass cache and force new generation.',
141
+ },
142
+ reference_image: {
143
+ type: 'string',
144
+ description: 'Optional reference image path or URL for consistency.',
145
+ },
146
+ reference_strength: {
147
+ type: 'number',
148
+ description: 'Strength of reference influence (0.1-1.0).',
149
+ },
150
+ face_reference: {
151
+ type: 'string',
152
+ description: 'Portrait reference image for facial identity preservation.',
153
+ },
154
+ face_strength: {
155
+ type: 'number',
156
+ description: 'Facial identity similarity weight (0.1-1.0).',
157
+ },
158
+ },
159
+ output: {
160
+ schema: {
161
+ type: 'object',
162
+ additionalProperties: true,
163
+ properties: {
164
+ summary: { type: 'string' },
165
+ channel: { type: 'string' },
166
+ provider: { type: 'string' },
167
+ model: { type: 'string' },
168
+ _fallback: {
169
+ type: 'object',
170
+ additionalProperties: true,
171
+ properties: {
172
+ triggered: { type: 'boolean' },
173
+ primaryProvider: { type: 'string' },
174
+ providerUsed: { type: 'string' },
175
+ attempts: {
176
+ type: 'array',
177
+ items: {
178
+ type: 'object',
179
+ additionalProperties: true,
180
+ properties: {
181
+ provider: { type: 'string' },
182
+ durationMs: { type: 'number' },
183
+ success: { type: 'boolean' },
184
+ error: { type: 'string' },
185
+ },
186
+ },
187
+ },
188
+ },
189
+ },
190
+ path: { type: 'string' },
191
+ url: { type: 'string' },
192
+ width: { type: 'integer' },
193
+ height: { type: 'integer' },
194
+ seed: { type: 'integer' },
195
+ prompt: { type: 'string' },
196
+ originalPrompt: { type: 'string' },
197
+ format: { type: 'string' },
198
+ cost: { type: 'number' },
199
+ fromCache: { type: 'boolean' },
200
+ qualityReport: { type: 'object', additionalProperties: true },
201
+ anchor: {
202
+ type: 'object',
203
+ additionalProperties: true,
204
+ properties: {
205
+ label: { type: 'string' },
206
+ mode: { type: 'string' },
207
+ },
208
+ },
209
+ attachment: {
210
+ type: 'object',
211
+ additionalProperties: true,
212
+ properties: {
213
+ attachmentId: { type: 'string' },
214
+ mediaType: { type: 'string' },
215
+ bytes: { type: 'integer' },
216
+ width: { type: 'integer' },
217
+ height: { type: 'integer' },
218
+ name: { type: 'string' },
219
+ },
220
+ },
221
+ images: {
222
+ type: 'array',
223
+ items: {
224
+ type: 'object',
225
+ additionalProperties: true,
226
+ properties: {
227
+ path: { type: 'string' },
228
+ url: { type: 'string' },
229
+ width: { type: 'integer' },
230
+ height: { type: 'integer' },
231
+ seed: { type: 'integer' },
232
+ prompt: { type: 'string' },
233
+ originalPrompt: { type: 'string' },
234
+ format: { type: 'string' },
235
+ cost: { type: 'number' },
236
+ fromCache: { type: 'boolean' },
237
+ qualityReport: { type: 'object', additionalProperties: true },
238
+ _fallback: {
239
+ type: 'object',
240
+ additionalProperties: true,
241
+ properties: {
242
+ triggered: { type: 'boolean' },
243
+ primaryProvider: { type: 'string' },
244
+ providerUsed: { type: 'string' },
245
+ attempts: {
246
+ type: 'array',
247
+ items: {
248
+ type: 'object',
249
+ additionalProperties: true,
250
+ properties: {
251
+ provider: { type: 'string' },
252
+ durationMs: { type: 'number' },
253
+ success: { type: 'boolean' },
254
+ error: { type: 'string' },
255
+ },
256
+ },
257
+ },
258
+ },
259
+ },
260
+ provider: { type: 'string' },
261
+ model: { type: 'string' },
262
+ attachment: {
263
+ type: 'object',
264
+ additionalProperties: true,
265
+ properties: {
266
+ attachmentId: { type: 'string' },
267
+ mediaType: { type: 'string' },
268
+ bytes: { type: 'integer' },
269
+ width: { type: 'integer' },
270
+ height: { type: 'integer' },
271
+ name: { type: 'string' },
272
+ },
273
+ },
274
+ },
275
+ },
276
+ },
277
+ },
278
+ },
279
+ render(args, value) {
280
+ const list = value.images || [value]
281
+ const lines = list.map((img) => `Generated image (${img.width}×${img.height}, ${img.format}, seed ${img.seed}) via ${value.provider || 'image-gen'}/${value.model || 'default'}: ${img.path}`)
282
+ const summary = lines.join('\n')
283
+ if (live().deliverAs !== 'image') {
284
+ const urls = list.map((img) => img.url).filter(Boolean)
285
+ return [{ type: 'text', text: urls.length ? `${summary}\n${urls.join('\n')}` : summary }]
286
+ }
287
+ const blocks = [{ type: 'text', text: summary }]
288
+ for (const img of list) if (img.attachment) blocks.push({ type: 'image', attachment: img.attachment })
289
+ return blocks
290
+ },
291
+ },
292
+ isConcurrencySafe: () => false,
293
+ timeoutMs: (config?.timeoutMs || 180000) + 30000,
294
+ async execute(args, exec) {
295
+ const cfg = live()
296
+
297
+ // #168: Fail-fast loop guard protecting against runaway agent loops
298
+ const sessionId = exec.agent?.session?.id || exec.agent?.session?.header?.id || 'session'
299
+ if (cfg.loopGuardLimit > 0) {
300
+ trackAndAssertLoopGuard(sessionId, {
301
+ limit: cfg.loopGuardLimit,
302
+ prompt: args.prompt,
303
+ })
304
+ }
305
+
306
+ const { provider, deliverAs, size, aspectPixels, format } = validateGenerationParams(cfg, args)
307
+
308
+ // Subscriptions service is optional: without it, subscription providers
309
+ // gracefully decline while other providers continue functioning.
310
+ let subscriptionImages
311
+ try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_err) { subscriptionImages = undefined }
312
+
313
+ const source = args.source_image ? await resolveSource(ctx, exec, args.source_image) : undefined
314
+ const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
315
+
316
+ const {
317
+ enhanced,
318
+ effectivePrompt,
319
+ effectiveNegative,
320
+ effectiveGuidance,
321
+ activeAnchor,
322
+ resolvedFaceRef,
323
+ } = await prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId, provider, resolveSource })
324
+
325
+ // #167: Assert daily budget availability before calling API
326
+ const totalCount = Array.isArray(args.prompts) && args.prompts.length ? args.prompts.length : normalizeCount(args.count)
327
+ const estimatedCost = calculateGenerationCost({
328
+ provider,
329
+ model: cfg.model || cfg.customModel,
330
+ size,
331
+ count: totalCount,
332
+ })
333
+ assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
334
+ const providers = makeProviders(
335
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
336
+ { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio, faceReference: resolvedFaceRef },
337
+ )
338
+ // Subscription providers return {ok:false, reason} instead of throwing:
339
+ // rejection reaches the model as text. Without this guard,
340
+ // execution would proceed with empty bytes.
341
+ const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
342
+ const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
343
+ const gen = await providers[providerKey](jobSeed, promptArg)
344
+ guard(gen)
345
+ return await saveGeneratedImageArtifact({
346
+ ctx,
347
+ exec,
348
+ cfg,
349
+ args,
350
+ gen,
351
+ promptArg,
352
+ jobSeed,
353
+ provider,
354
+ deliverAs,
355
+ size,
356
+ format,
357
+ effectiveNegative,
358
+ effectiveGuidance,
359
+ enhanced,
360
+ slugify,
361
+ estimatedCost,
362
+ totalCount,
363
+ })
364
+ }
365
+
366
+ const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
367
+ // Fallback cascade: evaluate configured provider, falling back to alternates
368
+ // (fal -> custom -> codex -> grok), accumulating failure diagnostics.
369
+ const order = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
370
+ const qgEnabled = args.quality_gate ?? cfg.qualityGate
371
+ const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
372
+ k,
373
+ async (s, p) => {
374
+ const { generated, qualityReport } = await executeWithQualityGate(
375
+ (seedVal) => one(seedVal, k, p),
376
+ {
377
+ initialSeed: s,
378
+ enabled: qgEnabled,
379
+ ctx,
380
+ prompt: p,
381
+ }
382
+ )
383
+ return { ...generated, qualityReport }
384
+ }
385
+ ]))
386
+ const images = []
387
+ const historyEntries = (cfg.cacheBySeed || cfg.cacheByPrompt) ? await readHistory() : []
388
+ const checkCache = async (seedVal, promptVal) => {
389
+ const h = computeGenerationHash({
390
+ provider,
391
+ model: cfg.model || cfg.customModel,
392
+ prompt: promptVal,
393
+ seed: seedVal,
394
+ size: args.image_size || cfg.defaultSize,
395
+ style: args.style || cfg.stylePreset,
396
+ })
397
+
398
+ // #170: Content-addressed disk cache check (<50ms return)
399
+ if (cfg.diskCache !== false && !args.force) {
400
+ const diskHit = getCachedGeneration(h, { force: args.force })
401
+ if (diskHit) {
402
+ const extension = diskHit.mediaType === 'image/jpeg' ? 'jpg' : diskHit.mediaType === 'image/webp' ? 'webp' : 'png'
403
+ const stem = `${slugify(args.output_name || promptVal)}-${Date.now().toString(36)}-${seedVal}`
404
+ const name = `${stem}.${extension}`
405
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: diskHit.bytes, mediaType: diskHit.mediaType, name })
406
+ const sessionCwd = exec.agent?.session?.header?.cwd
407
+ const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
408
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
409
+ await mkdir(outDir, { recursive: true })
410
+ const filePath = path.join(outDir, name)
411
+ await writeFile(filePath, diskHit.bytes)
412
+
413
+ return {
414
+ path: filePath,
415
+ url: localUrl,
416
+ width: diskHit.width || attachment.width,
417
+ height: diskHit.height || attachment.height,
418
+ seed: seedVal,
419
+ prompt: promptVal,
420
+ cost: 0,
421
+ fromCache: true,
422
+ format: diskHit.mediaType.replace('image/', ''),
423
+ attachment: {
424
+ attachmentId: attachment.attachmentId,
425
+ mediaType: attachment.mediaType,
426
+ bytes: attachment.bytes,
427
+ width: attachment.width,
428
+ height: attachment.height,
429
+ name: attachment.name,
430
+ },
431
+ }
432
+ }
433
+ }
434
+
435
+ if (!cfg.cacheBySeed && !cfg.cacheByPrompt) return undefined
436
+ const hPrompt = computeGenerationHash({
437
+ provider,
438
+ model: cfg.model,
439
+ prompt: promptVal,
440
+ seed: seedVal,
441
+ size: args.image_size || cfg.defaultSize,
442
+ style: args.style || cfg.stylePreset,
443
+ })
444
+ const found = findCachedGeneration(historyEntries, hPrompt)
445
+ if (found) return await cachedResult(found)
446
+ if (cfg.cacheBySeed) {
447
+ const bySeed = await findCached(historyEntries, seedVal, promptVal)
448
+ if (bySeed) return await cachedResult(bySeed)
449
+ }
450
+ if (cfg.cacheByPrompt) {
451
+ const byPrompt = await findCachedByPrompt(historyEntries, promptVal)
452
+ if (byPrompt) return await cachedResult(byPrompt)
453
+ }
454
+ return undefined
455
+ }
456
+
457
+ const batchPrompts = Array.isArray(args.prompts) && args.prompts.length ? args.prompts : null
458
+ if (batchPrompts) {
459
+ const tasks = batchPrompts.map((item, i) => async () => {
460
+ const text = typeof item === 'string' ? item : (item && item.text) || ''
461
+ const cached = await checkCache(seedBase + i, text)
462
+ return cached || await executeWithFallback(generators, order, seedBase + i, text, { logger: ctx.logger })
463
+ })
464
+ const parallelResults = await asyncPool(tasks, 3)
465
+ images.push(...parallelResults)
466
+ } else {
467
+ const count = normalizeCount(args.count)
468
+ const tasks = Array.from({ length: count }, (_, i) => async () => {
469
+ const cached = await checkCache(seedBase + i, effectivePrompt)
470
+ return cached || await executeWithFallback(generators, order, seedBase + i, effectivePrompt, { logger: ctx.logger })
471
+ })
472
+ const parallelResults = await asyncPool(tasks, 3)
473
+ images.push(...parallelResults)
474
+ }
475
+ const first = images[0]
476
+ const dualOutputSummary = buildDualOutputMarkdown({
477
+ action: 'generated',
478
+ filePath: first.path,
479
+ width: first.width,
480
+ height: first.height,
481
+ mediaType: first.mediaType || ('image/' + (first.format || 'png')),
482
+ seed: first.seed,
483
+ provider,
484
+ model: cfg.model || cfg.customModel || 'default',
485
+ cost: first.cost,
486
+ attachmentId: first.attachment?.attachmentId || 'N/A',
487
+ })
488
+ return toLosslessJson({ summary: dualOutputSummary, channel: provider, provider, model: cfg.model || cfg.customModel, _fallback: first._fallback, anchor: activeAnchor ? { label: activeAnchor.label, mode: activeAnchor.mode } : undefined, ...first, images })
489
+ },
490
+ }),
491
+ )
492
+ }, 'dsh-image-gen: tool generate_image')
493
+ if (!options.coreOnly) {
494
+ registerGenerationPackTool(ctx, deps)
495
+ }
496
+ }