@goodandready/dsh-image-gen 0.11.31 → 0.11.33

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.
@@ -359,6 +359,61 @@ export function decodeHtmlEntities(str) {
359
359
  .replace(/&/gi, '&')
360
360
  }
361
361
 
362
+ /**
363
+ * Escapes JSX-sensitive characters in SVG markup so text nodes and unquoted
364
+ * curly braces cannot be interpreted as JavaScript expressions in TSX export (#406).
365
+ * Replaces unquoted `{` and `}` outside attribute values with `{` and `}`.
366
+ * @param {string} content
367
+ * @returns {string}
368
+ */
369
+ export function escapeJsxText(content) {
370
+ if (!content || typeof content !== 'string') return ''
371
+ let result = ''
372
+ let inTag = false
373
+ let quote = null
374
+
375
+ for (let i = 0; i < content.length; i++) {
376
+ const ch = content[i]
377
+
378
+ if (!inTag) {
379
+ if (ch === '<') {
380
+ inTag = true
381
+ quote = null
382
+ result += ch
383
+ } else if (ch === '{') {
384
+ result += '&#123;'
385
+ } else if (ch === '}') {
386
+ result += '&#125;'
387
+ } else {
388
+ result += ch
389
+ }
390
+ } else {
391
+ if (quote) {
392
+ if (ch === quote) {
393
+ quote = null
394
+ }
395
+ result += ch
396
+ } else {
397
+ if (ch === '"' || ch === "'") {
398
+ quote = ch
399
+ result += ch
400
+ } else if (ch === '>') {
401
+ inTag = false
402
+ result += ch
403
+ } else if (ch === '{') {
404
+ result += '&#123;'
405
+ } else if (ch === '}') {
406
+ result += '&#125;'
407
+ } else {
408
+ result += ch
409
+ }
410
+ }
411
+ }
412
+ }
413
+
414
+ return result
415
+ }
416
+
362
417
  /**
363
418
  * Issue #176: optimize_vector_svg
364
419
  * Cleans, sanitizes, and minifies SVG content, normalizes viewBox, and exports React TSX component.
@@ -463,6 +518,8 @@ export function optimizeSvgContent(svgString, options = {}) {
463
518
  .replace(/stroke-linejoin=/g, 'strokeLinejoin=')
464
519
  .replace(/stroke-miterlimit=/g, 'strokeMiterlimit=')
465
520
 
521
+ jsxBody = escapeJsxText(jsxBody)
522
+
466
523
  const reactTsx = `import React from 'react';
467
524
 
468
525
  export interface ${componentName}Props extends React.SVGProps<SVGSVGElement> {
@@ -2,6 +2,7 @@
2
2
  // Extracted execution and prompt/caching helpers for generate_image tool (#349).
3
3
 
4
4
  import path from 'node:path'
5
+ import { createHash } from 'node:crypto'
5
6
  import { mkdir, writeFile } from 'node:fs/promises'
6
7
  import {
7
8
  computeGenerationHash,
@@ -153,6 +154,10 @@ export async function saveGeneratedImageArtifact({
153
154
  estimatedCost,
154
155
  totalCount,
155
156
  recordSpend: shouldRecordSpend = true,
157
+ source,
158
+ mask,
159
+ resolvedFaceRef,
160
+ resolvedRefImage,
156
161
  }) {
157
162
  const targetFormat = format || (args.output_format || cfg.defaultFormat)
158
163
  const formatted = await ensureImageFormat(gen.bytes, targetFormat, { fallbackMime: gen.mediaType })
@@ -205,7 +210,11 @@ export async function saveGeneratedImageArtifact({
205
210
 
206
211
  const effectiveModel = provider === 'custom'
207
212
  ? (cfg.customModel || cfg.model || 'dall-e-3')
208
- : (cfg.model || cfg.customModel)
213
+ : (cfg.model || cfg.customModel || 'default')
214
+
215
+ const sourceBytes = source && source.bytes ? source.bytes : (args.image || args.source_image)
216
+ const maskBytes = mask && mask.bytes ? mask.bytes : (args.mask_image || args.mask)
217
+ const refBytes = (resolvedRefImage && resolvedRefImage.bytes) || (resolvedFaceRef && resolvedFaceRef.bytes) || args.reference_images || args.reference_image
209
218
 
210
219
  const cacheHash = computeGenerationHash({
211
220
  provider,
@@ -222,9 +231,9 @@ export async function saveGeneratedImageArtifact({
222
231
  negativePrompt: args.negative_prompt || cfg.negativePrompt,
223
232
  quality: args.quality,
224
233
  strength: args.strength,
225
- sourceImage: args.image || args.source_image,
226
- maskImage: args.mask_image || args.mask,
227
- reference: args.reference_images || args.reference_image,
234
+ sourceImage: sourceBytes,
235
+ maskImage: maskBytes,
236
+ reference: refBytes,
228
237
  })
229
238
 
230
239
  // #170: Store in content-addressed disk cache
@@ -235,19 +244,32 @@ export async function saveGeneratedImageArtifact({
235
244
  width: gen.width || attachment.width,
236
245
  height: gen.height || attachment.height,
237
246
  seed: gen.seed,
238
- meta: { prompt: promptArg, provider, model: cfg.model || cfg.customModel, cost: gen.cost },
247
+ meta: { prompt: promptArg, provider, model: effectiveModel, cost: gen.cost },
239
248
  })
240
249
  }
250
+
251
+ const sourceHash = (source && source.bytes) ? createHash('sha256').update(source.bytes).digest('hex') : undefined
252
+ const maskHash = (mask && mask.bytes) ? createHash('sha256').update(mask.bytes).digest('hex') : undefined
253
+ const refHash = (resolvedRefImage && resolvedRefImage.bytes)
254
+ ? createHash('sha256').update(resolvedRefImage.bytes).digest('hex')
255
+ : ((resolvedFaceRef && resolvedFaceRef.bytes) ? createHash('sha256').update(resolvedFaceRef.bytes).digest('hex') : undefined)
256
+
241
257
  const entry = {
242
258
  path: filePath,
243
259
  prompt: promptArg,
244
260
  provider,
245
- model: cfg.model,
261
+ model: effectiveModel,
246
262
  size,
247
263
  format,
248
264
  aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
249
265
  seed: gen.seed,
250
266
  cacheHash,
267
+ sourceHash,
268
+ maskHash,
269
+ refHash,
270
+ quality: args.quality,
271
+ style: args.style,
272
+ strength: args.strength,
251
273
  thumbnailUrl: localUrl,
252
274
  width: gen.width || attachment.width,
253
275
  height: gen.height || attachment.height,
package/lib/history.js CHANGED
@@ -41,6 +41,22 @@ export async function findCachedByPrompt(entries, prompt, criteria = {}) {
41
41
  if (!prompt || !Array.isArray(entries)) return undefined
42
42
  return entries.find((e) => {
43
43
  if (e.prompt !== prompt) return false
44
+ if (criteria.cacheHash && e.cacheHash && criteria.cacheHash !== e.cacheHash) return false
45
+ if (criteria.provider && e.provider && criteria.provider.toLowerCase() !== e.provider.toLowerCase()) return false
46
+ if (criteria.model && e.model && criteria.model.toLowerCase() !== e.model.toLowerCase()) return false
47
+ if (criteria.sourceHash !== undefined && e.sourceHash !== undefined && criteria.sourceHash !== e.sourceHash) return false
48
+ if (criteria.sourceHash && !e.sourceHash) return false
49
+ if (!criteria.sourceHash && e.sourceHash) return false
50
+ if (criteria.maskHash !== undefined && e.maskHash !== undefined && criteria.maskHash !== e.maskHash) return false
51
+ if (criteria.maskHash && !e.maskHash) return false
52
+ if (!criteria.maskHash && e.maskHash) return false
53
+ if (criteria.refHash !== undefined && e.refHash !== undefined && criteria.refHash !== e.refHash) return false
54
+ if (criteria.refHash && !e.refHash) return false
55
+ if (!criteria.refHash && e.refHash) return false
56
+ if (criteria.quality !== undefined && e.quality !== undefined && criteria.quality !== e.quality) return false
57
+ if (criteria.style !== undefined && e.style !== undefined && criteria.style !== e.style) return false
58
+ if (criteria.strength !== undefined && e.strength !== undefined && criteria.strength !== e.strength) return false
59
+
44
60
  if (criteria.format) {
45
61
  const entryFmt = (e.format || (e.mediaType || '').replace('image/', '')).toLowerCase().replace('jpeg', 'jpg')
46
62
  const targetFmt = String(criteria.format).toLowerCase().replace('jpeg', 'jpg')
@@ -58,6 +74,22 @@ export async function findCached(entries, seed, prompt, criteria = {}) {
58
74
  if (seed === undefined || !Array.isArray(entries)) return undefined
59
75
  return entries.find((e) => {
60
76
  if (e.seed !== seed || e.prompt !== prompt) return false
77
+ if (criteria.cacheHash && e.cacheHash && criteria.cacheHash !== e.cacheHash) return false
78
+ if (criteria.provider && e.provider && criteria.provider.toLowerCase() !== e.provider.toLowerCase()) return false
79
+ if (criteria.model && e.model && criteria.model.toLowerCase() !== e.model.toLowerCase()) return false
80
+ if (criteria.sourceHash !== undefined && e.sourceHash !== undefined && criteria.sourceHash !== e.sourceHash) return false
81
+ if (criteria.sourceHash && !e.sourceHash) return false
82
+ if (!criteria.sourceHash && e.sourceHash) return false
83
+ if (criteria.maskHash !== undefined && e.maskHash !== undefined && criteria.maskHash !== e.maskHash) return false
84
+ if (criteria.maskHash && !e.maskHash) return false
85
+ if (!criteria.maskHash && e.maskHash) return false
86
+ if (criteria.refHash !== undefined && e.refHash !== undefined && criteria.refHash !== e.refHash) return false
87
+ if (criteria.refHash && !e.refHash) return false
88
+ if (!criteria.refHash && e.refHash) return false
89
+ if (criteria.quality !== undefined && e.quality !== undefined && criteria.quality !== e.quality) return false
90
+ if (criteria.style !== undefined && e.style !== undefined && criteria.style !== e.style) return false
91
+ if (criteria.strength !== undefined && e.strength !== undefined && criteria.strength !== e.strength) return false
92
+
61
93
  if (criteria.format) {
62
94
  const entryFmt = (e.format || (e.mediaType || '').replace('image/', '')).toLowerCase().replace('jpeg', 'jpg')
63
95
  const targetFmt = String(criteria.format).toLowerCase().replace('jpeg', 'jpg')
@@ -191,10 +191,13 @@ export function computeGenerationHash({
191
191
  } = {}) {
192
192
  const hashContent = (item) => {
193
193
  if (!item) return ''
194
- if (Buffer.isBuffer(item)) return createHash('sha256').update(item).digest('hex').slice(0, 16)
194
+ if (Buffer.isBuffer(item)) return createHash('sha256').update(item).digest('hex')
195
195
  if (typeof item === 'object' && item.bytes) {
196
196
  const b = Buffer.isBuffer(item.bytes) ? item.bytes : Buffer.from(item.bytes)
197
- return createHash('sha256').update(b).digest('hex').slice(0, 16)
197
+ return createHash('sha256').update(b).digest('hex')
198
+ }
199
+ if (Array.isArray(item)) {
200
+ return item.map(hashContent).filter(Boolean).join(',')
198
201
  }
199
202
  return String(item).trim()
200
203
  }
@@ -43,6 +43,7 @@ export function createFalGenerator(deps, job) {
43
43
  const refDataUri = job.faceReference.bytes
44
44
  ? `data:${job.faceReference.mediaType || 'image/png'};base64,${Buffer.from(job.faceReference.bytes).toString('base64')}`
45
45
  : job.faceReference.url
46
+ body.image_url = refDataUri
46
47
  body.reference_image_url = refDataUri
47
48
  body.face_image_url = refDataUri
48
49
  if (job.faceStrength !== undefined) {
@@ -58,6 +59,7 @@ export function createFalGenerator(deps, job) {
58
59
  const refDataUri = job.referenceImage.bytes
59
60
  ? `data:${job.referenceImage.mediaType || 'image/png'};base64,${Buffer.from(job.referenceImage.bytes).toString('base64')}`
60
61
  : job.referenceImage.url
62
+ body.image_url = refDataUri
61
63
  body.reference_image_url = refDataUri
62
64
  if (job.referenceStrength !== undefined) {
63
65
  body.reference_weight = job.referenceStrength
@@ -69,7 +71,7 @@ export function createFalGenerator(deps, job) {
69
71
  }
70
72
 
71
73
  const submit = await submitJob(fetchImpl, cfg.baseURL, targetModel, key, body, signal)
72
- const statusUrl = submit.status_url || `${cfg.baseURL}/${cfg.model}/requests/${submit.request_id}/status`
74
+ const statusUrl = submit.status_url || `${cfg.baseURL}/${targetModel}/requests/${submit.request_id}/status`
73
75
  const statusBody = await pollStatus(fetchImpl, statusUrl, key, signal, cfg.pollIntervalMs, cfg.timeoutMs, onProgress)
74
76
 
75
77
  const resultRes = await fetchImpl(statusBody.response_url, {
@@ -154,6 +154,7 @@ export function registerCharacterSheetTools(ctx, deps) {
154
154
  })
155
155
  const reservation = reserveSpend(estimatedCost, cfg.dailyBudgetUsd)
156
156
  let gen
157
+ let committed = false
157
158
  try {
158
159
  gen = await executeWithFallback(
159
160
  providers,
@@ -162,10 +163,6 @@ export function registerCharacterSheetTools(ctx, deps) {
162
163
  sheetPlan.prompt,
163
164
  { logger: ctx.logger },
164
165
  )
165
- } catch (err) {
166
- reservation.release()
167
- throw err
168
- }
169
166
 
170
167
  const usedProvider = gen._fallback?.providerUsed || provider
171
168
  const finalBytes = gen.bytes
@@ -205,6 +202,7 @@ export function registerCharacterSheetTools(ctx, deps) {
205
202
  ctx,
206
203
  meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: sheetPlan.prompt, seed },
207
204
  })
205
+ committed = true
208
206
 
209
207
  const dualOutput = buildDualOutputMarkdown({
210
208
  action: 'generated',
@@ -237,6 +235,11 @@ export function registerCharacterSheetTools(ctx, deps) {
237
235
  }
238
236
 
239
237
  return toLosslessJson(response)
238
+ } finally {
239
+ if (!committed) {
240
+ reservation.release()
241
+ }
242
+ }
240
243
  },
241
244
  })
242
245
  )
@@ -150,6 +150,7 @@ export function registerDiagramTools(ctx, deps) {
150
150
  })
151
151
  const reservation = reserveSpend(estimatedCost, cfg.dailyBudgetUsd)
152
152
  let gen
153
+ let committed = false
153
154
  try {
154
155
  gen = await executeWithFallback(
155
156
  providers,
@@ -158,10 +159,6 @@ export function registerDiagramTools(ctx, deps) {
158
159
  plan.prompt,
159
160
  { logger: ctx.logger },
160
161
  )
161
- } catch (err) {
162
- reservation.release()
163
- throw err
164
- }
165
162
 
166
163
  const usedProvider = gen._fallback?.providerUsed || provider
167
164
  const finalBytes = gen.bytes
@@ -201,6 +198,7 @@ export function registerDiagramTools(ctx, deps) {
201
198
  ctx,
202
199
  meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: plan.prompt, seed },
203
200
  })
201
+ committed = true
204
202
 
205
203
  const dualOutput = buildDualOutputMarkdown({
206
204
  action: 'generated',
@@ -232,6 +230,11 @@ export function registerDiagramTools(ctx, deps) {
232
230
  }
233
231
 
234
232
  return toLosslessJson(response)
233
+ } finally {
234
+ if (!committed) {
235
+ reservation.release()
236
+ }
237
+ }
235
238
  },
236
239
  })
237
240
  )
@@ -2,6 +2,7 @@ import { buildPresentationMeta } from '../attachment-helper.js'
2
2
  import { registerGenerationPackTool } from './generation-pack.js'
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
4
4
  import path from 'node:path'
5
+ import { createHash } from 'node:crypto'
5
6
  import { mkdir, writeFile } from 'node:fs/promises'
6
7
  import {
7
8
  computeGenerationHash,
@@ -316,8 +317,11 @@ export function registerGenerationTools(ctx, deps, options = {}) {
316
317
  let subscriptionImages
317
318
  try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_err) { subscriptionImages = undefined }
318
319
 
319
- const source = args.source_image ? await resolveSource(ctx, exec, args.source_image) : undefined
320
- const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
320
+ const sourceInput = args.source_image || args.image
321
+ const source = sourceInput ? await resolveSource(ctx, exec, sourceInput) : undefined
322
+
323
+ const maskInput = args.mask || args.mask_image
324
+ const mask = maskInput ? await resolveSource(ctx, exec, maskInput) : undefined
321
325
 
322
326
  const {
323
327
  enhanced,
@@ -383,6 +387,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
383
387
  const rawGenerate = async (seedVal) => {
384
388
  const reservation = reserveSpend(perAttemptCost, cfg.dailyBudgetUsd)
385
389
  totalAttempts++
390
+ let committed = false
386
391
  try {
387
392
  const gen = await providers[k](seedVal, p)
388
393
  guard(gen)
@@ -392,10 +397,12 @@ export function registerGenerationTools(ctx, deps, options = {}) {
392
397
  ctx,
393
398
  meta: { provider: k, model: cfg.model || cfg.customModel, prompt: p, seed: seedVal },
394
399
  })
400
+ committed = true
395
401
  return gen
396
- } catch (err) {
397
- reservation.release()
398
- throw err
402
+ } finally {
403
+ if (!committed) {
404
+ reservation.release()
405
+ }
399
406
  }
400
407
  }
401
408
 
@@ -429,6 +436,10 @@ export function registerGenerationTools(ctx, deps, options = {}) {
429
436
  estimatedCost: accumulatedCost || estimatedCost,
430
437
  totalCount,
431
438
  recordSpend: false,
439
+ source,
440
+ mask,
441
+ resolvedFaceRef,
442
+ resolvedRefImage,
432
443
  })
433
444
 
434
445
  return {
@@ -449,7 +460,11 @@ export function registerGenerationTools(ctx, deps, options = {}) {
449
460
 
450
461
  const effectiveModel = provider === 'custom'
451
462
  ? (cfg.customModel || cfg.model || 'dall-e-3')
452
- : (cfg.model || cfg.customModel)
463
+ : (cfg.model || cfg.customModel || 'default')
464
+
465
+ const sourceBytes = source && source.bytes ? source.bytes : (args.image || args.source_image)
466
+ const maskBytes = mask && mask.bytes ? mask.bytes : (args.mask_image || args.mask)
467
+ const refBytes = (resolvedRefImage && resolvedRefImage.bytes) || (resolvedFaceRef && resolvedFaceRef.bytes) || args.reference_images || args.reference_image
453
468
 
454
469
  const h = computeGenerationHash({
455
470
  provider,
@@ -466,9 +481,9 @@ export function registerGenerationTools(ctx, deps, options = {}) {
466
481
  negativePrompt: args.negative_prompt || cfg.negativePrompt,
467
482
  quality: args.quality,
468
483
  strength: args.strength,
469
- sourceImage: args.image || args.source_image,
470
- maskImage: args.mask_image || args.mask,
471
- reference: args.reference_images || args.reference_image,
484
+ sourceImage: sourceBytes,
485
+ maskImage: maskBytes,
486
+ reference: refBytes,
472
487
  })
473
488
 
474
489
  // #170: Content-addressed disk cache check (<50ms return)
@@ -512,10 +527,25 @@ export function registerGenerationTools(ctx, deps, options = {}) {
512
527
  const hPrompt = h
513
528
  const found = findCachedGeneration(historyEntries, hPrompt)
514
529
  if (found) return await cachedResult(found)
530
+ const srcHash = (source && source.bytes) ? createHash('sha256').update(source.bytes).digest('hex') : undefined
531
+ const mskHash = (mask && mask.bytes) ? createHash('sha256').update(mask.bytes).digest('hex') : undefined
532
+ const rfHash = (resolvedRefImage && resolvedRefImage.bytes)
533
+ ? createHash('sha256').update(resolvedRefImage.bytes).digest('hex')
534
+ : ((resolvedFaceRef && resolvedFaceRef.bytes) ? createHash('sha256').update(resolvedFaceRef.bytes).digest('hex') : undefined)
535
+
515
536
  const requestedCriteria = {
537
+ provider,
538
+ model: effectiveModel,
539
+ cacheHash: h,
516
540
  format: args.output_format || cfg.defaultFormat || 'png',
517
541
  aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
518
542
  size: args.aspect_ratio ? 'custom' : (args.image_size || cfg.defaultSize),
543
+ quality: args.quality,
544
+ style: args.style,
545
+ strength: args.strength,
546
+ sourceHash: srcHash,
547
+ maskHash: mskHash,
548
+ refHash: rfHash,
519
549
  }
520
550
  if (cfg.cacheBySeed) {
521
551
  const bySeed = await findCached(historyEntries, seedVal, promptVal, requestedCriteria)
@@ -56,38 +56,49 @@ export function registerProcessingTools(ctx, deps) {
56
56
  if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
57
57
  const cfg = live()
58
58
  const resvNobg = reserveSpend(0.02, cfg.dailyBudgetUsd)
59
- const res = await removeBackgroundFal(
60
- { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
61
- { imageBytes: source.bytes, mediaType: source.mediaType, model: args.model, signal: exec?.signal }
62
- )
63
- const stem = `${slugify(args.output_name || 'nobg')}-${Date.now().toString(36)}`
64
- const name = `${stem}.png`
65
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
66
- const sessionCwd = exec?.agent?.session?.header?.cwd
67
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
68
- await mkdir(outDir, { recursive: true })
69
- const filePath = path.join(outDir, name)
70
- await writeFile(filePath, res.bytes)
71
- resvNobg.commit(0.02, { ctx, meta: { tool: 'remove_background', model: args.model || 'fal-ai/birefnet' } })
72
- const summary = buildDualOutputMarkdown({
73
- action: 'background removed',
74
- filePath,
75
- width: res.width || attachment.width,
76
- height: res.height || attachment.height,
77
- mediaType: 'image/png',
78
- provider: 'fal',
79
- model: args.model || 'fal-ai/birefnet',
80
- attachmentId: attachment.attachmentId,
81
- })
82
- return toLosslessJson({
83
- summary,
84
- path: filePath,
85
- url: localUrl,
86
- width: res.width || attachment.width,
87
- height: res.height || attachment.height,
88
- format: 'png',
89
- attachment,
90
- })
59
+ let committed = false
60
+ try {
61
+ const res = await removeBackgroundFal(
62
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
63
+ { imageBytes: source.bytes, mediaType: source.mediaType, model: args.model, signal: exec?.signal }
64
+ )
65
+ if (!res || !res.bytes) {
66
+ throw new Error('remove_background provider returned empty or invalid response')
67
+ }
68
+ const stem = `${slugify(args.output_name || 'nobg')}-${Date.now().toString(36)}`
69
+ const name = `${stem}.png`
70
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
71
+ const sessionCwd = exec?.agent?.session?.header?.cwd
72
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
73
+ await mkdir(outDir, { recursive: true })
74
+ const filePath = path.join(outDir, name)
75
+ await writeFile(filePath, res.bytes)
76
+ resvNobg.commit(0.02, { ctx, meta: { tool: 'remove_background', model: args.model || 'fal-ai/birefnet' } })
77
+ committed = true
78
+ const summary = buildDualOutputMarkdown({
79
+ action: 'background removed',
80
+ filePath,
81
+ width: res.width || attachment.width,
82
+ height: res.height || attachment.height,
83
+ mediaType: 'image/png',
84
+ provider: 'fal',
85
+ model: args.model || 'fal-ai/birefnet',
86
+ attachmentId: attachment.attachmentId,
87
+ })
88
+ return toLosslessJson({
89
+ summary,
90
+ path: filePath,
91
+ url: localUrl,
92
+ width: res.width || attachment.width,
93
+ height: res.height || attachment.height,
94
+ format: 'png',
95
+ attachment,
96
+ })
97
+ } finally {
98
+ if (!committed) {
99
+ resvNobg.release()
100
+ }
101
+ }
91
102
  } catch (err) {
92
103
  throw new Error(sanitizeErrorAndLogs(err.message || err))
93
104
  }
@@ -127,38 +138,49 @@ export function registerProcessingTools(ctx, deps) {
127
138
  if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
128
139
  const cfg = live()
129
140
  const resvUpscale = reserveSpend(0.025, cfg.dailyBudgetUsd)
130
- const res = await upscaleImageFal(
131
- { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
132
- { imageBytes: source.bytes, mediaType: source.mediaType, scale: args.scale, prompt: args.prompt, creativity: args.creativity, signal: exec?.signal }
133
- )
134
- const stem = `${slugify(args.output_name || 'upscaled')}-${Date.now().toString(36)}`
135
- const name = `${stem}.png`
136
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
137
- const sessionCwd = exec?.agent?.session?.header?.cwd
138
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
139
- await mkdir(outDir, { recursive: true })
140
- const filePath = path.join(outDir, name)
141
- await writeFile(filePath, res.bytes)
142
- resvUpscale.commit(0.025, { ctx, meta: { tool: 'upscale_image', scale: args.scale } })
143
- const summary = buildDualOutputMarkdown({
144
- action: `upscaled (${args.scale || 2}x)`,
145
- filePath,
146
- width: res.width || attachment.width,
147
- height: res.height || attachment.height,
148
- mediaType: 'image/png',
149
- provider: 'fal',
150
- model: 'fal-ai/clarity-upscaler',
151
- attachmentId: attachment.attachmentId,
152
- })
153
- return toLosslessJson({
154
- summary,
155
- path: filePath,
156
- url: localUrl,
157
- scale: args.scale || 2,
158
- width: res.width || attachment.width,
159
- height: res.height || attachment.height,
160
- attachment,
161
- })
141
+ let committed = false
142
+ try {
143
+ const res = await upscaleImageFal(
144
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
145
+ { imageBytes: source.bytes, mediaType: source.mediaType, scale: args.scale, prompt: args.prompt, creativity: args.creativity, signal: exec?.signal }
146
+ )
147
+ if (!res || !res.bytes) {
148
+ throw new Error('upscale_image provider returned empty or invalid response')
149
+ }
150
+ const stem = `${slugify(args.output_name || 'upscaled')}-${Date.now().toString(36)}`
151
+ const name = `${stem}.png`
152
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
153
+ const sessionCwd = exec?.agent?.session?.header?.cwd
154
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
155
+ await mkdir(outDir, { recursive: true })
156
+ const filePath = path.join(outDir, name)
157
+ await writeFile(filePath, res.bytes)
158
+ resvUpscale.commit(0.025, { ctx, meta: { tool: 'upscale_image', scale: args.scale } })
159
+ committed = true
160
+ const summary = buildDualOutputMarkdown({
161
+ action: `upscaled (${args.scale || 2}x)`,
162
+ filePath,
163
+ width: res.width || attachment.width,
164
+ height: res.height || attachment.height,
165
+ mediaType: 'image/png',
166
+ provider: 'fal',
167
+ model: 'fal-ai/clarity-upscaler',
168
+ attachmentId: attachment.attachmentId,
169
+ })
170
+ return toLosslessJson({
171
+ summary,
172
+ path: filePath,
173
+ url: localUrl,
174
+ scale: args.scale || 2,
175
+ width: res.width || attachment.width,
176
+ height: res.height || attachment.height,
177
+ attachment,
178
+ })
179
+ } finally {
180
+ if (!committed) {
181
+ resvUpscale.release()
182
+ }
183
+ }
162
184
  } catch (err) {
163
185
  throw new Error(sanitizeErrorAndLogs(err.message || err))
164
186
  }
@@ -254,50 +276,61 @@ export function registerProcessingTools(ctx, deps) {
254
276
  const cfg = live()
255
277
  const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
256
278
  trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
257
- const resvBlend = reserveSpend(0.05, cfg.dailyBudgetUsd)
258
279
  const refs = Array.isArray(args.images) ? args.images : [args.images].filter(Boolean)
259
- if (refs.length < 2) {
260
- throw new Error('At least two images are required for blending')
261
- }
262
- const sources = []
263
- for (const r of refs) {
264
- const s = await resolveSource(ctx, exec, r)
265
- if (s && s.bytes) sources.push(s)
266
- }
267
- if (sources.length < 2) {
268
- throw new Error('At least two valid images are required to blend')
269
- }
270
- const res = await blendImagesFal(
271
- { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
272
- { images: sources, weights: args.weights, prompt: args.prompt, signal: exec?.signal }
273
- )
274
- const stem = `${slugify(args.output_name || 'blended')}-${Date.now().toString(36)}`
275
- const name = `${stem}.png`
276
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
277
- const sessionCwd = exec?.agent?.session?.header?.cwd
278
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
279
- await mkdir(outDir, { recursive: true })
280
- const filePath = path.join(outDir, name)
281
- await writeFile(filePath, res.bytes)
282
- resvBlend.commit(0.05, { ctx, meta: { tool: 'blend_images' } })
283
- const summary = buildDualOutputMarkdown({
284
- action: 'blended composition',
285
- filePath,
286
- width: res.width || attachment.width,
287
- height: res.height || attachment.height,
288
- mediaType: 'image/png',
289
- provider: 'fal',
290
- model: 'fal-ai/image-blend',
291
- attachmentId: attachment.attachmentId,
292
- })
293
- return toLosslessJson({
294
- summary,
295
- path: filePath,
296
- url: localUrl,
297
- width: res.width || attachment.width,
298
- height: res.height || attachment.height,
299
- attachment,
300
- })
280
+ if (refs.length < 2) {
281
+ throw new Error('At least two images are required for blending')
282
+ }
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 < 2) {
289
+ throw new Error('At least two valid images are required to blend')
290
+ }
291
+ const resvBlend = reserveSpend(0.05, cfg.dailyBudgetUsd)
292
+ let committed = false
293
+ try {
294
+ const res = await blendImagesFal(
295
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
296
+ { images: sources, weights: args.weights, prompt: args.prompt, signal: exec?.signal }
297
+ )
298
+ if (!res || !res.bytes) {
299
+ throw new Error('blend_images provider returned empty or invalid response')
300
+ }
301
+ const stem = `${slugify(args.output_name || 'blended')}-${Date.now().toString(36)}`
302
+ const name = `${stem}.png`
303
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
304
+ const sessionCwd = exec?.agent?.session?.header?.cwd
305
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
306
+ await mkdir(outDir, { recursive: true })
307
+ const filePath = path.join(outDir, name)
308
+ await writeFile(filePath, res.bytes)
309
+ resvBlend.commit(0.05, { ctx, meta: { tool: 'blend_images' } })
310
+ committed = true
311
+ const summary = buildDualOutputMarkdown({
312
+ action: 'blended composition',
313
+ filePath,
314
+ width: res.width || attachment.width,
315
+ height: res.height || attachment.height,
316
+ mediaType: 'image/png',
317
+ provider: 'fal',
318
+ model: 'fal-ai/image-blend',
319
+ attachmentId: attachment.attachmentId,
320
+ })
321
+ return toLosslessJson({
322
+ summary,
323
+ path: filePath,
324
+ url: localUrl,
325
+ width: res.width || attachment.width,
326
+ height: res.height || attachment.height,
327
+ attachment,
328
+ })
329
+ } finally {
330
+ if (!committed) {
331
+ resvBlend.release()
332
+ }
333
+ }
301
334
  } catch (err) {
302
335
  throw new Error(sanitizeErrorAndLogs(err.message || err))
303
336
  }
@@ -146,7 +146,8 @@ export function registerStyleMatrixTools(ctx, deps) {
146
146
  count: cellCount,
147
147
  })
148
148
  const batchReservation = reserveSpend(estimatedCost, cfg.dailyBudgetUsd)
149
-
149
+ let committed = false
150
+ try {
150
151
  const chain = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
151
152
  const quadrantIds = ['A', 'B', 'C', 'D']
152
153
  const matrixId = `matrix-${Date.now().toString(36)}`
@@ -236,6 +237,7 @@ export function registerStyleMatrixTools(ctx, deps) {
236
237
  cells,
237
238
  })
238
239
 
240
+ committed = true
239
241
  return toLosslessJson({
240
242
  matrix_id: matrixId,
241
243
  base_prompt: basePrompt,
@@ -244,6 +246,11 @@ export function registerStyleMatrixTools(ctx, deps) {
244
246
  cells,
245
247
  summary,
246
248
  })
249
+ } finally {
250
+ if (!committed) {
251
+ batchReservation.release()
252
+ }
253
+ }
247
254
  },
248
255
  }),
249
256
  )
@@ -154,7 +154,8 @@ export function registerThemePairTools(ctx, deps) {
154
154
  count: 2,
155
155
  })
156
156
  const batchReservation = reserveSpend(estimatedCost, cfg.dailyBudgetUsd)
157
-
157
+ let committed = false
158
+ try {
158
159
  const chain = resolveFallbackChain(primaryProvider, cfg.fallbackProviders, PROVIDER_KEYS)
159
160
  const baseSeed = typeof args.seed === 'number' ? args.seed : Math.floor(Math.random() * 1000000)
160
161
 
@@ -277,6 +278,7 @@ export function registerThemePairTools(ctx, deps) {
277
278
  + `- Dark: ${darkFilename} (seed: ${baseSeed})\n\n`
278
279
  + `\`\`\`html\n${html}\n\`\`\``
279
280
 
281
+ committed = true
280
282
  return toLosslessJson({
281
283
  summary,
282
284
  prompt,
@@ -302,6 +304,11 @@ export function registerThemePairTools(ctx, deps) {
302
304
  css_snippet: css,
303
305
  attachment: lightAttachment,
304
306
  })
307
+ } finally {
308
+ if (!committed) {
309
+ batchReservation.release()
310
+ }
311
+ }
305
312
  },
306
313
  }),
307
314
  )
@@ -173,14 +173,11 @@ export function registerUiAssetTools(ctx, deps) {
173
173
  const seedVal = args.seed ?? Math.floor(Math.random() * 100000)
174
174
 
175
175
  let gen
176
+ let committed = false
176
177
  try {
177
178
  gen = await executeWithFallback(providers, chain, seedVal, synthesizedPrompt, {
178
179
  logger: ctx.logger,
179
180
  })
180
- } catch (err) {
181
- reservation.release()
182
- throw err
183
- }
184
181
 
185
182
  let finalBytes = gen.bytes
186
183
  let mediaType = gen.mediaType || 'image/png'
@@ -220,6 +217,7 @@ export function registerUiAssetTools(ctx, deps) {
220
217
  ctx,
221
218
  meta: { provider, model: cfg.model || cfg.customModel, prompt: synthesizedPrompt, seed: seedVal },
222
219
  })
220
+ committed = true
223
221
 
224
222
  const sessionCwd = exec?.agent?.session?.header?.cwd
225
223
  const targetDir = cfg.outputDir || 'generated/images'
@@ -274,6 +272,11 @@ export function registerUiAssetTools(ctx, deps) {
274
272
  attachment,
275
273
  _fallback: gen._fallback,
276
274
  })
275
+ } finally {
276
+ if (!committed) {
277
+ reservation.release()
278
+ }
279
+ }
277
280
  },
278
281
  }),
279
282
  )
@@ -254,6 +254,7 @@ export function registerVisionOcrTools(ctx, deps) {
254
254
  })
255
255
  const reservation = reserveSpend(estimatedCost, cfg.dailyBudgetUsd)
256
256
  let gen
257
+ let committed = false
257
258
  try {
258
259
  gen = await executeWithFallback(
259
260
  providers,
@@ -262,10 +263,6 @@ export function registerVisionOcrTools(ctx, deps) {
262
263
  inpaintPrompt,
263
264
  { logger: ctx.logger },
264
265
  )
265
- } catch (err) {
266
- reservation.release()
267
- throw err
268
- }
269
266
 
270
267
  const inpaintedBytes = gen.bytes
271
268
  if (!inpaintedBytes) {
@@ -332,6 +329,7 @@ export function registerVisionOcrTools(ctx, deps) {
332
329
  ctx,
333
330
  meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: inpaintPrompt, seed },
334
331
  })
332
+ committed = true
335
333
 
336
334
  const dualOutput = buildDualOutputMarkdown({
337
335
  action: 'generated',
@@ -364,6 +362,11 @@ export function registerVisionOcrTools(ctx, deps) {
364
362
  }
365
363
 
366
364
  return toLosslessJson(response)
365
+ } finally {
366
+ if (!committed) {
367
+ reservation.release()
368
+ }
369
+ }
367
370
  },
368
371
  })
369
372
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.11.31",
3
+ "version": "0.11.33",
4
4
  "description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
5
5
  "keywords": [
6
6
  "deepseek-harness",