@goodandready/dsh-image-gen 0.10.22 → 0.10.24

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.
@@ -255,8 +255,8 @@ export function registerGenerationTools(ctx, deps) {
255
255
  if (!OUTPUT_FORMATS.includes(format)) {
256
256
  throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
257
257
  }
258
- // Служба подписок необязательна: без неё эти два провайдера просто
259
- // отказываются, а остальные работают как работали.
258
+ // Subscriptions service is optional: without it, subscription providers
259
+ // gracefully decline while other providers continue functioning.
260
260
  let subscriptionImages
261
261
  try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (noService) { subscriptionImages = undefined }
262
262
 
@@ -291,9 +291,9 @@ export function registerGenerationTools(ctx, deps) {
291
291
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
292
292
  { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectivePolishedNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio },
293
293
  )
294
- // Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
295
- // отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
296
- // байтами, и пользователь получал битую карточку вместо внятного отказа.
294
+ // Subscription providers return {ok:false, reason} instead of throwing:
295
+ // rejection reaches the model as text. Without this guard,
296
+ // execution would proceed with empty bytes.
297
297
  const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
298
298
  const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
299
299
  const gen = await providers[providerKey](jobSeed, promptArg)
@@ -325,8 +325,8 @@ export function registerGenerationTools(ctx, deps) {
325
325
  const filePath = path.join(outDir, name)
326
326
  await writeFile(filePath, bytes)
327
327
 
328
- // Sidecar: рядом с картинкой — метаданные генерации. Каталог становится
329
- // самодокументируемым (галерея/повтор/диагностика читают их без БД).
328
+ // Sidecar: metadata written alongside image file, making the output
329
+ // directory self-documenting without external database dependencies.
330
330
  await writeFile(
331
331
  path.join(outDir, `${stem}.json`),
332
332
  JSON.stringify(buildSidecar({
@@ -416,8 +416,8 @@ export function registerGenerationTools(ctx, deps) {
416
416
  }
417
417
 
418
418
  const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
419
- // Fallback-цепочка: пробуем текущий провайдер, при отказе — следующий
420
- // по порядку (fal → custom → codex → grok), собирая причины отказов.
419
+ // Fallback cascade: evaluate configured provider, falling back to alternates
420
+ // (fal -> custom -> codex -> grok), accumulating failure diagnostics.
421
421
  const order = fallbackOrder(provider)
422
422
  const qgEnabled = args.quality_gate ?? cfg.qualityGate
423
423
  const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
@@ -1,3 +1,5 @@
1
+ // processing-basic — core image-gen tools (remove_background, upscale_image, vectorize_image, blend_images). Split from processing.js (#239).
2
+
1
3
  // processing — image-gen tools (remove_background, upscale_image, vectorize_image, blend_images). Extracted from apply() (#216).
2
4
 
3
5
  import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -44,7 +46,8 @@ import {
44
46
  } from '../frontend-assets.js'
45
47
 
46
48
 
47
- export function registerProcessingTools(ctx, deps) {
49
+
50
+ export function registerProcessingAdvancedTools(ctx, deps) {
48
51
  const {
49
52
  config,
50
53
  live,
@@ -53,274 +56,6 @@ export function registerProcessingTools(ctx, deps) {
53
56
  slugify,
54
57
  resolveApiKey,
55
58
  } = deps
56
- ctx.effect(() => {
57
- ctx.tools.register(
58
- defineTool({
59
- name: 'remove_background',
60
- description: 'Remove the background of an image (path or attachment id) and return a transparent PNG.',
61
- parameters: {
62
- image: { type: 'string', required: true, description: 'Path or attachment id of the source image.' },
63
- model: { type: 'string', description: 'FAL model for background removal (default: fal-ai/birefnet).' },
64
- output_name: { type: 'string', description: 'Custom output file name without extension.' },
65
- },
66
- output: {
67
- schema: {
68
- type: 'object',
69
- additionalProperties: true,
70
- properties: {
71
- path: { type: 'string' },
72
- url: { type: 'string' },
73
- width: { type: 'number' },
74
- height: { type: 'number' },
75
- format: { type: 'string' },
76
- },
77
- },
78
- },
79
- async execute(args, exec) {
80
- try {
81
- const source = await resolveSource(ctx, exec, args.image)
82
- if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
83
- const cfg = live()
84
- const res = await removeBackgroundFal(
85
- { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
86
- { imageBytes: source.bytes, mediaType: source.mediaType, model: args.model, signal: exec.signal }
87
- )
88
- const stem = `${slugify(args.output_name || 'nobg')}-${Date.now().toString(36)}`
89
- const name = `${stem}.png`
90
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
91
- const sessionCwd = exec.agent?.session?.header?.cwd
92
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
93
- await mkdir(outDir, { recursive: true })
94
- const filePath = path.join(outDir, name)
95
- await writeFile(filePath, res.bytes)
96
- const summary = buildDualOutputMarkdown({
97
- action: 'background removed',
98
- filePath,
99
- width: res.width || attachment.width,
100
- height: res.height || attachment.height,
101
- mediaType: 'image/png',
102
- provider: 'fal',
103
- model: args.model || 'fal-ai/birefnet',
104
- attachmentId: attachment.attachmentId,
105
- })
106
- return toLosslessJson({
107
- summary,
108
- path: filePath,
109
- url: localUrl,
110
- width: res.width || attachment.width,
111
- height: res.height || attachment.height,
112
- format: 'png',
113
- attachment,
114
- })
115
- } catch (err) {
116
- throw new Error(sanitizeErrorAndLogs(err.message || err))
117
- }
118
- },
119
- }),
120
- )
121
- }, 'dsh-image-gen: tool remove_background')
122
- ctx.effect(() => {
123
- ctx.tools.register(
124
- defineTool({
125
- name: 'upscale_image',
126
- description: 'Upscale an image 2x or 4x with enhanced clarity and detail.',
127
- parameters: {
128
- image: { type: 'string', required: true, description: 'Path or attachment id of the source image.' },
129
- scale: { type: 'number', description: 'Upscale factor: 2 or 4 (default: 2).' },
130
- prompt: { type: 'string', description: 'Optional guiding prompt for detail reconstruction.' },
131
- creativity: { type: 'number', description: 'Creativity / hallucination level from 0 to 1.' },
132
- output_name: { type: 'string', description: 'Custom output file name.' },
133
- },
134
- output: {
135
- schema: {
136
- type: 'object',
137
- additionalProperties: true,
138
- properties: {
139
- path: { type: 'string' },
140
- url: { type: 'string' },
141
- scale: { type: 'number' },
142
- width: { type: 'number' },
143
- height: { type: 'number' },
144
- },
145
- },
146
- },
147
- async execute(args, exec) {
148
- try {
149
- const source = await resolveSource(ctx, exec, args.image)
150
- if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
151
- const cfg = live()
152
- const res = await upscaleImageFal(
153
- { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
154
- { imageBytes: source.bytes, mediaType: source.mediaType, scale: args.scale, prompt: args.prompt, creativity: args.creativity, signal: exec.signal }
155
- )
156
- const stem = `${slugify(args.output_name || 'upscaled')}-${Date.now().toString(36)}`
157
- const name = `${stem}.png`
158
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
159
- const sessionCwd = exec.agent?.session?.header?.cwd
160
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
161
- await mkdir(outDir, { recursive: true })
162
- const filePath = path.join(outDir, name)
163
- await writeFile(filePath, res.bytes)
164
- const summary = buildDualOutputMarkdown({
165
- action: `upscaled (${args.scale || 2}x)`,
166
- filePath,
167
- width: res.width || attachment.width,
168
- height: res.height || attachment.height,
169
- mediaType: 'image/png',
170
- provider: 'fal',
171
- model: 'fal-ai/clarity-upscaler',
172
- attachmentId: attachment.attachmentId,
173
- })
174
- return toLosslessJson({
175
- summary,
176
- path: filePath,
177
- url: localUrl,
178
- scale: args.scale || 2,
179
- width: res.width || attachment.width,
180
- height: res.height || attachment.height,
181
- attachment,
182
- })
183
- } catch (err) {
184
- throw new Error(sanitizeErrorAndLogs(err.message || err))
185
- }
186
- },
187
- }),
188
- )
189
- }, 'dsh-image-gen: tool upscale_image')
190
- ctx.effect(() => {
191
- ctx.tools.register(
192
- defineTool({
193
- name: 'vectorize_image',
194
- description: 'Convert a raster image or icon to SVG vector format.',
195
- parameters: {
196
- image: { type: 'string', required: true, description: 'Path or attachment id of the source image.' },
197
- color_mode: { type: 'string', description: '"color" or "binary" (default: "color").' },
198
- output_name: { type: 'string', description: 'Custom output file name.' },
199
- },
200
- output: {
201
- schema: {
202
- type: 'object',
203
- additionalProperties: true,
204
- properties: {
205
- path: { type: 'string' },
206
- svg: { type: 'string' },
207
- format: { type: 'string' },
208
- },
209
- },
210
- },
211
- async execute(args, exec) {
212
- try {
213
- const source = await resolveSource(ctx, exec, args.image)
214
- if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
215
- const cfg = live()
216
- const res = traceToSvg(source.bytes, { colorMode: args.color_mode })
217
- const stem = `${slugify(args.output_name || 'vector')}-${Date.now().toString(36)}`
218
- const name = `${stem}.svg`
219
- const sessionCwd = exec.agent?.session?.header?.cwd
220
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
221
- await mkdir(outDir, { recursive: true })
222
- const filePath = path.join(outDir, name)
223
- await writeFile(filePath, res.bytes)
224
-
225
- let attachment = null
226
- let localUrl = ''
227
- if (ctx.attachments?.saveImage) {
228
- attachment = await ctx.attachments.saveImage({
229
- data: new Uint8Array(res.bytes),
230
- mediaType: 'image/svg+xml',
231
- name,
232
- })
233
- localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
234
- }
235
-
236
- const summary = `### Vectorized SVG Export\n- **File**: ${filePath}\n- **Format**: SVG (XML vector)\n- **Color mode**: ${args.color_mode || 'color'}\n- **Palette size**: ${res.palette ? res.palette.length : 16} colors\n`
237
- return toLosslessJson({
238
- summary,
239
- path: filePath,
240
- url: localUrl,
241
- svg: res.svg,
242
- format: 'svg',
243
- attachment,
244
- })
245
- } catch (err) {
246
- throw new Error(sanitizeErrorAndLogs(err.message || err))
247
- }
248
- },
249
- }),
250
- )
251
- }, 'dsh-image-gen: tool vectorize_image')
252
- ctx.effect(() => {
253
- ctx.tools.register(
254
- defineTool({
255
- name: 'blend_images',
256
- description: 'Blend multiple images (paths or attachment ids) into a coherent new composition.',
257
- parameters: {
258
- images: { type: 'array', items: { type: 'string' }, required: true, description: 'List of image paths or attachment ids to blend.' },
259
- weights: { type: 'array', items: { type: 'number' }, description: 'Weights for each image (optional).' },
260
- prompt: { type: 'string', description: 'Guiding prompt for the blend.' },
261
- output_name: { type: 'string', description: 'Custom output file name.' },
262
- },
263
- output: {
264
- schema: {
265
- type: 'object',
266
- additionalProperties: true,
267
- properties: {
268
- path: { type: 'string' },
269
- url: { type: 'string' },
270
- width: { type: 'number' },
271
- height: { type: 'number' },
272
- },
273
- },
274
- },
275
- async execute(args, exec) {
276
- try {
277
- const cfg = live()
278
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
279
- assertBudgetAvailable(0.05, cfg.dailyBudgetUsd)
280
- const refs = Array.isArray(args.images) ? args.images : [args.images].filter(Boolean)
281
- const sources = []
282
- for (const r of refs) {
283
- const s = await resolveSource(ctx, exec, r)
284
- if (s && s.bytes) sources.push(s)
285
- }
286
- if (sources.length === 0) throw new Error('No valid images found to blend')
287
- const res = await blendImagesFal(
288
- { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
289
- { images: sources, weights: args.weights, prompt: args.prompt, signal: exec.signal }
290
- )
291
- const stem = `${slugify(args.output_name || 'blended')}-${Date.now().toString(36)}`
292
- const name = `${stem}.png`
293
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
294
- const sessionCwd = exec.agent?.session?.header?.cwd
295
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
296
- await mkdir(outDir, { recursive: true })
297
- const filePath = path.join(outDir, name)
298
- await writeFile(filePath, res.bytes)
299
- const summary = buildDualOutputMarkdown({
300
- action: 'blended composition',
301
- filePath,
302
- width: res.width || attachment.width,
303
- height: res.height || attachment.height,
304
- mediaType: 'image/png',
305
- provider: 'fal',
306
- model: 'fal-ai/image-blend',
307
- attachmentId: attachment.attachmentId,
308
- })
309
- return toLosslessJson({
310
- summary,
311
- path: filePath,
312
- url: localUrl,
313
- width: res.width || attachment.width,
314
- height: res.height || attachment.height,
315
- attachment,
316
- })
317
- } catch (err) {
318
- throw new Error(sanitizeErrorAndLogs(err.message || err))
319
- }
320
- },
321
- }),
322
- )
323
- }, 'dsh-image-gen: tool blend_images')
324
59
 
325
60
  ctx.effect(() => {
326
61
  ctx.tools.register(
@@ -0,0 +1,327 @@
1
+ // processing-basic — core image-gen tools (remove_background, upscale_image, vectorize_image, blend_images). Split from processing.js (#239).
2
+
3
+ // processing — image-gen tools (remove_background, upscale_image, vectorize_image, blend_images). Extracted from apply() (#216).
4
+
5
+ import { defineTool } from '@deepseek-ai/dsh-tools'
6
+ import path from 'node:path'
7
+ import { mkdir, writeFile } from 'node:fs/promises'
8
+ import {
9
+ computeGenerationHash,
10
+ IMAGE_SIZES,
11
+ OUTPUT_FORMATS,
12
+ PROVIDER_KEYS,
13
+ buildSidecar,
14
+ makeProviders,
15
+ ASPECT_RATIOS,
16
+ pixelDiff,
17
+ normalizeCount,
18
+ tryGenerate,
19
+ fallbackOrder,
20
+ embedPngMetadata,
21
+ removeBackgroundFal,
22
+ upscaleImageFal,
23
+ traceToSvg,
24
+ estimateCost,
25
+ estimateSharpnessAndVariance,
26
+ applyStylePreset,
27
+ resolveStylePreset,
28
+ blendImagesFal,
29
+ toLosslessJson,
30
+ saveAttachmentSafe,
31
+ } from '../providers.js'
32
+ import { resolveConversationImage, analyzeImageWithVision, buildDualOutputMarkdown } from '../resolve-image.js'
33
+ import { sanitizeNegativePrompt, supportsNegativePrompt } from '../negative-sanitizer.js'
34
+ import { executeWithQualityGate } from '../quality-gate.js'
35
+ import { calculateGenerationCost, recordSpend, assertBudgetAvailable } from '../cost-meter.js'
36
+ import { trackAndAssertLoopGuard } from '../loop-guard.js'
37
+ import { sanitizeErrorAndLogs } from '../security.js'
38
+ import { getCachedGeneration, setCachedGeneration } from '../generation-cache.js'
39
+ import {
40
+ extractDesignTokens,
41
+ generateCssGradient,
42
+ checkWcagContrast,
43
+ optimizeSvgContent,
44
+ generatePwaIconSuite,
45
+ extractSampleColorsFromBuffer,
46
+ } from '../frontend-assets.js'
47
+
48
+
49
+ export function registerProcessingTools(ctx, deps) {
50
+ const {
51
+ config,
52
+ live,
53
+ saveAndAttachResult,
54
+ resolveSource,
55
+ slugify,
56
+ resolveApiKey,
57
+ } = deps
58
+ ctx.effect(() => {
59
+ ctx.tools.register(
60
+ defineTool({
61
+ name: 'remove_background',
62
+ description: 'Remove the background of an image (path or attachment id) and return a transparent PNG.',
63
+ parameters: {
64
+ image: { type: 'string', required: true, description: 'Path or attachment id of the source image.' },
65
+ model: { type: 'string', description: 'FAL model for background removal (default: fal-ai/birefnet).' },
66
+ output_name: { type: 'string', description: 'Custom output file name without extension.' },
67
+ },
68
+ output: {
69
+ schema: {
70
+ type: 'object',
71
+ additionalProperties: true,
72
+ properties: {
73
+ path: { type: 'string' },
74
+ url: { type: 'string' },
75
+ width: { type: 'number' },
76
+ height: { type: 'number' },
77
+ format: { type: 'string' },
78
+ },
79
+ },
80
+ },
81
+ async execute(args, exec) {
82
+ try {
83
+ const source = await resolveSource(ctx, exec, args.image)
84
+ if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
85
+ const cfg = live()
86
+ const res = await removeBackgroundFal(
87
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
88
+ { imageBytes: source.bytes, mediaType: source.mediaType, model: args.model, signal: exec.signal }
89
+ )
90
+ const stem = `${slugify(args.output_name || 'nobg')}-${Date.now().toString(36)}`
91
+ const name = `${stem}.png`
92
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
93
+ const sessionCwd = exec.agent?.session?.header?.cwd
94
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
95
+ await mkdir(outDir, { recursive: true })
96
+ const filePath = path.join(outDir, name)
97
+ await writeFile(filePath, res.bytes)
98
+ const summary = buildDualOutputMarkdown({
99
+ action: 'background removed',
100
+ filePath,
101
+ width: res.width || attachment.width,
102
+ height: res.height || attachment.height,
103
+ mediaType: 'image/png',
104
+ provider: 'fal',
105
+ model: args.model || 'fal-ai/birefnet',
106
+ attachmentId: attachment.attachmentId,
107
+ })
108
+ return toLosslessJson({
109
+ summary,
110
+ path: filePath,
111
+ url: localUrl,
112
+ width: res.width || attachment.width,
113
+ height: res.height || attachment.height,
114
+ format: 'png',
115
+ attachment,
116
+ })
117
+ } catch (err) {
118
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
119
+ }
120
+ },
121
+ }),
122
+ )
123
+ }, 'dsh-image-gen: tool remove_background')
124
+ ctx.effect(() => {
125
+ ctx.tools.register(
126
+ defineTool({
127
+ name: 'upscale_image',
128
+ description: 'Upscale an image 2x or 4x with enhanced clarity and detail.',
129
+ parameters: {
130
+ image: { type: 'string', required: true, description: 'Path or attachment id of the source image.' },
131
+ scale: { type: 'number', description: 'Upscale factor: 2 or 4 (default: 2).' },
132
+ prompt: { type: 'string', description: 'Optional guiding prompt for detail reconstruction.' },
133
+ creativity: { type: 'number', description: 'Creativity / hallucination level from 0 to 1.' },
134
+ output_name: { type: 'string', description: 'Custom output file name.' },
135
+ },
136
+ output: {
137
+ schema: {
138
+ type: 'object',
139
+ additionalProperties: true,
140
+ properties: {
141
+ path: { type: 'string' },
142
+ url: { type: 'string' },
143
+ scale: { type: 'number' },
144
+ width: { type: 'number' },
145
+ height: { type: 'number' },
146
+ },
147
+ },
148
+ },
149
+ async execute(args, exec) {
150
+ try {
151
+ const source = await resolveSource(ctx, exec, args.image)
152
+ if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
153
+ const cfg = live()
154
+ const res = await upscaleImageFal(
155
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
156
+ { imageBytes: source.bytes, mediaType: source.mediaType, scale: args.scale, prompt: args.prompt, creativity: args.creativity, signal: exec.signal }
157
+ )
158
+ const stem = `${slugify(args.output_name || 'upscaled')}-${Date.now().toString(36)}`
159
+ const name = `${stem}.png`
160
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
161
+ const sessionCwd = exec.agent?.session?.header?.cwd
162
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
163
+ await mkdir(outDir, { recursive: true })
164
+ const filePath = path.join(outDir, name)
165
+ await writeFile(filePath, res.bytes)
166
+ const summary = buildDualOutputMarkdown({
167
+ action: `upscaled (${args.scale || 2}x)`,
168
+ filePath,
169
+ width: res.width || attachment.width,
170
+ height: res.height || attachment.height,
171
+ mediaType: 'image/png',
172
+ provider: 'fal',
173
+ model: 'fal-ai/clarity-upscaler',
174
+ attachmentId: attachment.attachmentId,
175
+ })
176
+ return toLosslessJson({
177
+ summary,
178
+ path: filePath,
179
+ url: localUrl,
180
+ scale: args.scale || 2,
181
+ width: res.width || attachment.width,
182
+ height: res.height || attachment.height,
183
+ attachment,
184
+ })
185
+ } catch (err) {
186
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
187
+ }
188
+ },
189
+ }),
190
+ )
191
+ }, 'dsh-image-gen: tool upscale_image')
192
+ ctx.effect(() => {
193
+ ctx.tools.register(
194
+ defineTool({
195
+ name: 'vectorize_image',
196
+ description: 'Convert a raster image or icon to SVG vector format.',
197
+ parameters: {
198
+ image: { type: 'string', required: true, description: 'Path or attachment id of the source image.' },
199
+ color_mode: { type: 'string', description: '"color" or "binary" (default: "color").' },
200
+ output_name: { type: 'string', description: 'Custom output file name.' },
201
+ },
202
+ output: {
203
+ schema: {
204
+ type: 'object',
205
+ additionalProperties: true,
206
+ properties: {
207
+ path: { type: 'string' },
208
+ svg: { type: 'string' },
209
+ format: { type: 'string' },
210
+ },
211
+ },
212
+ },
213
+ async execute(args, exec) {
214
+ try {
215
+ const source = await resolveSource(ctx, exec, args.image)
216
+ if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
217
+ const cfg = live()
218
+ const res = traceToSvg(source.bytes, { colorMode: args.color_mode })
219
+ const stem = `${slugify(args.output_name || 'vector')}-${Date.now().toString(36)}`
220
+ const name = `${stem}.svg`
221
+ const sessionCwd = exec.agent?.session?.header?.cwd
222
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
223
+ await mkdir(outDir, { recursive: true })
224
+ const filePath = path.join(outDir, name)
225
+ await writeFile(filePath, res.bytes)
226
+
227
+ let attachment = null
228
+ let localUrl = ''
229
+ if (ctx.attachments?.saveImage) {
230
+ attachment = await ctx.attachments.saveImage({
231
+ data: new Uint8Array(res.bytes),
232
+ mediaType: 'image/svg+xml',
233
+ name,
234
+ })
235
+ localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
236
+ }
237
+
238
+ const summary = `### Vectorized SVG Export\n- **File**: ${filePath}\n- **Format**: SVG (XML vector)\n- **Color mode**: ${args.color_mode || 'color'}\n- **Palette size**: ${res.palette ? res.palette.length : 16} colors\n`
239
+ return toLosslessJson({
240
+ summary,
241
+ path: filePath,
242
+ url: localUrl,
243
+ svg: res.svg,
244
+ format: 'svg',
245
+ attachment,
246
+ })
247
+ } catch (err) {
248
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
249
+ }
250
+ },
251
+ }),
252
+ )
253
+ }, 'dsh-image-gen: tool vectorize_image')
254
+ ctx.effect(() => {
255
+ ctx.tools.register(
256
+ defineTool({
257
+ name: 'blend_images',
258
+ description: 'Blend multiple images (paths or attachment ids) into a coherent new composition.',
259
+ parameters: {
260
+ images: { type: 'array', items: { type: 'string' }, required: true, description: 'List of image paths or attachment ids to blend.' },
261
+ weights: { type: 'array', items: { type: 'number' }, description: 'Weights for each image (optional).' },
262
+ prompt: { type: 'string', description: 'Guiding prompt for the blend.' },
263
+ output_name: { type: 'string', description: 'Custom output file name.' },
264
+ },
265
+ output: {
266
+ schema: {
267
+ type: 'object',
268
+ additionalProperties: true,
269
+ properties: {
270
+ path: { type: 'string' },
271
+ url: { type: 'string' },
272
+ width: { type: 'number' },
273
+ height: { type: 'number' },
274
+ },
275
+ },
276
+ },
277
+ async execute(args, exec) {
278
+ try {
279
+ const cfg = live()
280
+ trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
281
+ assertBudgetAvailable(0.05, cfg.dailyBudgetUsd)
282
+ const refs = Array.isArray(args.images) ? args.images : [args.images].filter(Boolean)
283
+ const sources = []
284
+ for (const r of refs) {
285
+ const s = await resolveSource(ctx, exec, r)
286
+ if (s && s.bytes) sources.push(s)
287
+ }
288
+ if (sources.length === 0) throw new Error('No valid images found to blend')
289
+ const res = await blendImagesFal(
290
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
291
+ { images: sources, weights: args.weights, prompt: args.prompt, signal: exec.signal }
292
+ )
293
+ const stem = `${slugify(args.output_name || 'blended')}-${Date.now().toString(36)}`
294
+ const name = `${stem}.png`
295
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
296
+ const sessionCwd = exec.agent?.session?.header?.cwd
297
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
298
+ await mkdir(outDir, { recursive: true })
299
+ const filePath = path.join(outDir, name)
300
+ await writeFile(filePath, res.bytes)
301
+ const summary = buildDualOutputMarkdown({
302
+ action: 'blended composition',
303
+ filePath,
304
+ width: res.width || attachment.width,
305
+ height: res.height || attachment.height,
306
+ mediaType: 'image/png',
307
+ provider: 'fal',
308
+ model: 'fal-ai/image-blend',
309
+ attachmentId: attachment.attachmentId,
310
+ })
311
+ return toLosslessJson({
312
+ summary,
313
+ path: filePath,
314
+ url: localUrl,
315
+ width: res.width || attachment.width,
316
+ height: res.height || attachment.height,
317
+ attachment,
318
+ })
319
+ } catch (err) {
320
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
321
+ }
322
+ },
323
+ }),
324
+ )
325
+ }, 'dsh-image-gen: tool blend_images')
326
+
327
+ }