@goodandready/dsh-image-gen 0.11.29 → 0.11.30

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.
@@ -70,7 +70,7 @@ export function getContrastRatio(hex1, hex2) {
70
70
  * @returns {Promise<string[]>} Hex colors
71
71
  */
72
72
  export async function extractSampleColorsFromBuffer(buffer) {
73
- if (!buffer || buffer.length < 8) {
73
+ if (!buffer || buffer.length === 0) {
74
74
  return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
75
75
  }
76
76
 
@@ -96,76 +96,79 @@ export async function extractSampleColorsFromBuffer(buffer) {
96
96
  }
97
97
 
98
98
  // Decode raster container to raw RGBA pixels via Sharp
99
+ let data, info
99
100
  try {
100
- const { data, info } = await sharp(buffer)
101
+ const res = await sharp(buffer)
101
102
  .raw()
102
103
  .ensureAlpha()
103
104
  .toBuffer({ resolveWithObject: true })
105
+ data = res.data
106
+ info = res.info
107
+ } catch (err) {
108
+ throw new Error(`Failed to decode image buffer for color extraction: ${err.message}`)
109
+ }
104
110
 
105
- const totalPixels = info.width * info.height
106
- const step = Math.max(1, Math.floor(totalPixels / 2048))
107
- const colorBuckets = new Map()
108
-
109
- for (let i = 0; i < totalPixels; i += step) {
110
- const off = i * 4
111
- const r = data[off]
112
- const g = data[off + 1]
113
- const b = data[off + 2]
114
- const a = data[off + 3]
115
-
116
- // Ignore fully or mostly transparent pixels
117
- if (a < 128) continue
118
-
119
- // Quantize to 16-level RGB bins to cluster nearby nuances
120
- const qr = Math.round(r / 16) * 16
121
- const qg = Math.round(g / 16) * 16
122
- const qb = Math.round(b / 16) * 16
123
- const key = `${qr},${qg},${qb}`
124
-
125
- let bucket = colorBuckets.get(key)
126
- if (!bucket) {
127
- bucket = { count: 0, rSum: 0, gSum: 0, bSum: 0 }
128
- colorBuckets.set(key, bucket)
129
- }
130
- bucket.count++
131
- bucket.rSum += r
132
- bucket.gSum += g
133
- bucket.bSum += b
111
+ const totalPixels = info.width * info.height
112
+ const step = Math.max(1, Math.floor(totalPixels / 2048))
113
+ const colorBuckets = new Map()
114
+
115
+ for (let i = 0; i < totalPixels; i += step) {
116
+ const off = i * 4
117
+ const r = data[off]
118
+ const g = data[off + 1]
119
+ const b = data[off + 2]
120
+ const a = data[off + 3]
121
+
122
+ // Ignore fully or mostly transparent pixels
123
+ if (a < 128) continue
124
+
125
+ // Quantize to 16-level RGB bins to cluster nearby nuances
126
+ const qr = Math.round(r / 16) * 16
127
+ const qg = Math.round(g / 16) * 16
128
+ const qb = Math.round(b / 16) * 16
129
+ const key = `${qr},${qg},${qb}`
130
+
131
+ let bucket = colorBuckets.get(key)
132
+ if (!bucket) {
133
+ bucket = { count: 0, rSum: 0, gSum: 0, bSum: 0 }
134
+ colorBuckets.set(key, bucket)
134
135
  }
136
+ bucket.count++
137
+ bucket.rSum += r
138
+ bucket.gSum += g
139
+ bucket.bSum += b
140
+ }
135
141
 
136
- if (colorBuckets.size > 0) {
137
- const sorted = Array.from(colorBuckets.values()).sort((a, b) => b.count - a.count)
138
- const dominant = sorted.slice(0, 6).map((b) => {
139
- return rgbToHex(b.rSum / b.count, b.gSum / b.count, b.bSum / b.count)
140
- })
142
+ if (colorBuckets.size > 0) {
143
+ const sorted = Array.from(colorBuckets.values()).sort((a, b) => b.count - a.count)
144
+ const dominant = sorted.slice(0, 6).map((b) => {
145
+ return rgbToHex(b.rSum / b.count, b.gSum / b.count, b.bSum / b.count)
146
+ })
141
147
 
142
- const result = [...dominant]
143
- if (result.length < 5) {
144
- const primaryRgb = hexToRgb(result[0])
145
- const isNearBlack = primaryRgb.r === 0 && primaryRgb.g === 0 && primaryRgb.b === 0
146
- if (isNearBlack) {
147
- const darkShades = ['#1a1a1a', '#333333', '#4d4d4d', '#666666', '#808080']
148
- for (const s of darkShades) {
149
- if (result.length >= 6) break
150
- if (!result.includes(s)) result.push(s)
151
- }
152
- } else {
153
- const factors = [0.2, 0.4, 0.6, 0.8, 1.2, 1.4]
154
- for (const factor of factors) {
155
- if (result.length >= 6) break
156
- const shade = rgbToHex(
157
- Math.min(255, Math.round(primaryRgb.r * factor)),
158
- Math.min(255, Math.round(primaryRgb.g * factor)),
159
- Math.min(255, Math.round(primaryRgb.b * factor)),
160
- )
161
- if (!result.includes(shade)) result.push(shade)
162
- }
148
+ const result = [...dominant]
149
+ if (result.length < 5) {
150
+ const primaryRgb = hexToRgb(result[0])
151
+ const isNearBlack = primaryRgb.r === 0 && primaryRgb.g === 0 && primaryRgb.b === 0
152
+ if (isNearBlack) {
153
+ const darkShades = ['#1a1a1a', '#333333', '#4d4d4d', '#666666', '#808080']
154
+ for (const s of darkShades) {
155
+ if (result.length >= 6) break
156
+ if (!result.includes(s)) result.push(s)
157
+ }
158
+ } else {
159
+ const factors = [0.2, 0.4, 0.6, 0.8, 1.2, 1.4]
160
+ for (const factor of factors) {
161
+ if (result.length >= 6) break
162
+ const shade = rgbToHex(
163
+ Math.min(255, Math.round(primaryRgb.r * factor)),
164
+ Math.min(255, Math.round(primaryRgb.g * factor)),
165
+ Math.min(255, Math.round(primaryRgb.b * factor)),
166
+ )
167
+ if (!result.includes(shade)) result.push(shade)
163
168
  }
164
169
  }
165
- return result.slice(0, 6)
166
170
  }
167
- } catch (_err) {
168
- // If sharp cannot decode or buffer is non-image, fallback
171
+ return result.slice(0, 6)
169
172
  }
170
173
 
171
174
  return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
@@ -334,6 +337,28 @@ export function checkWcagContrast(colors, textHex = '#ffffff') {
334
337
  }
335
338
  }
336
339
 
340
+ /**
341
+ * Decode common HTML numeric and named entities in SVG attribute values (#374)
342
+ * @param {string} str
343
+ * @returns {string}
344
+ */
345
+ export function decodeHtmlEntities(str) {
346
+ if (!str || typeof str !== 'string') return ''
347
+ return str
348
+ .replace(/&#x([0-9a-fA-F]+);?/gi, (_, hex) => {
349
+ try { return String.fromCharCode(parseInt(hex, 16)) } catch (_e) { return '' }
350
+ })
351
+ .replace(/&#([0-9]+);?/g, (_, dec) => {
352
+ try { return String.fromCharCode(parseInt(dec, 10)) } catch (_e) { return '' }
353
+ })
354
+ .replace(/&colon;/gi, ':')
355
+ .replace(/&tab;/gi, '\t')
356
+ .replace(/&newline;/gi, '\n')
357
+ .replace(/&quot;/gi, '"')
358
+ .replace(/&apos;/gi, "'")
359
+ .replace(/&amp;/gi, '&')
360
+ }
361
+
337
362
  /**
338
363
  * Issue #176: optimize_vector_svg
339
364
  * Cleans, sanitizes, and minifies SVG content, normalizes viewBox, and exports React TSX component.
@@ -360,14 +385,51 @@ export function optimizeSvgContent(svgString, options = {}) {
360
385
  cleaned = cleaned.replace(/<script[^>]*\/?>/gi, '')
361
386
  cleaned = cleaned.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, '')
362
387
  cleaned = cleaned.replace(/<foreignObject[^>]*\/?>/gi, '')
363
- cleaned = cleaned.replace(/<(?:iframe|embed|object|applet)[\s\S]*?<\/(?:iframe|embed|object|applet)>/gi, '')
364
- cleaned = cleaned.replace(/<(?:iframe|embed|object|applet)[^>]*\/?>/gi, '')
365
-
366
- // Strip event handler attributes (onload, onerror, onclick, etc.)
367
- cleaned = cleaned.replace(/\s+on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
388
+ cleaned = cleaned.replace(/<(?:iframe|embed|object|applet|handler|listener)[\s\S]*?<\/(?:iframe|embed|object|applet|handler|listener)>/gi, '')
389
+ cleaned = cleaned.replace(/<(?:iframe|embed|object|applet|handler|listener)[^>]*\/?>/gi, '')
390
+
391
+ // Security (#374): Strip dangerous SMIL animation elements (<animate>, <set>, etc.) targeting href/xlink:href/on* or containing script schemes
392
+ cleaned = cleaned.replace(/<(animate|set|animateTransform|animateMotion|discard)\b[^>]*>[\s\S]*?<\/\1>/gi, (block) => {
393
+ const norm = decodeHtmlEntities(block).replace(/[\s\x00-\x20]+/g, ' ').toLowerCase()
394
+ if (
395
+ /attributename\s*=\s*['"]?(?:href|xlink:href|src|action|formaction|on[a-z0-9_-]+)/.test(norm) ||
396
+ /(?:javascript|vbscript|data\s*:\s*text\/html)/.test(norm)
397
+ ) {
398
+ return ''
399
+ }
400
+ return block
401
+ })
402
+ cleaned = cleaned.replace(/<(?:animate|set|animateTransform|animateMotion|discard)\b[^>]*\/?>/gi, (tag) => {
403
+ const norm = decodeHtmlEntities(tag).replace(/[\s\x00-\x20]+/g, ' ').toLowerCase()
404
+ if (
405
+ /attributename\s*=\s*['"]?(?:href|xlink:href|src|action|formaction|on[a-z0-9_-]+)/.test(norm) ||
406
+ /(?:javascript|vbscript|data\s*:\s*text\/html)/.test(norm)
407
+ ) {
408
+ return ''
409
+ }
410
+ return tag
411
+ })
368
412
 
369
- // Strip dangerous URI schemes (javascript:, data:text/html, vbscript:) in href/xlink:href/src/action
370
- cleaned = cleaned.replace(/\s+(?:href|xlink:href|src|action)\s*=\s*(?:"\s*(?:javascript|data:text\/html|vbscript):[^"]*"|'\s*(?:javascript|data:text\/html|vbscript):[^']*')/gi, '')
413
+ // Strip event handler attributes (onload, onerror, onclick, etc., quoted or unquoted)
414
+ cleaned = cleaned.replace(/\s+on[a-z0-9_-]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
415
+
416
+ // Security (#374): Strip dangerous URI schemes (javascript:, vbscript:, data:text/html, etc.) in href/xlink:href/src/action/formaction
417
+ // Handles HTML entity encoding (&#x73;), control chars, and unquoted attributes
418
+ cleaned = cleaned.replace(/\s+(?:href|xlink:href|src|action|formaction)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi, (match, q1, q2, unquoted) => {
419
+ const val = q1 !== undefined ? q1 : (q2 !== undefined ? q2 : unquoted)
420
+ const decoded = decodeHtmlEntities(val)
421
+ const normalized = decoded.replace(/[\x00-\x20\s]+/g, '').toLowerCase()
422
+ if (
423
+ normalized.startsWith('javascript:') ||
424
+ normalized.startsWith('vbscript:') ||
425
+ normalized.startsWith('data:text/html') ||
426
+ normalized.startsWith('data:text/javascript') ||
427
+ normalized.startsWith('data:image/svg+xml')
428
+ ) {
429
+ return ''
430
+ }
431
+ return match
432
+ })
371
433
 
372
434
  cleaned = cleaned.replace(/\s{2,}/g, ' ')
373
435
  cleaned = cleaned.replace(/>\s+</g, '><')
@@ -244,6 +244,7 @@ export async function saveGeneratedImageArtifact({
244
244
  model: cfg.model,
245
245
  size,
246
246
  format,
247
+ aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
247
248
  seed: gen.seed,
248
249
  cacheHash,
249
250
  thumbnailUrl: localUrl,
package/lib/history.js CHANGED
@@ -37,13 +37,38 @@ export function findCachedGeneration(entries, hash) {
37
37
  return entries.find((e) => e.cacheHash === hash)
38
38
  }
39
39
 
40
- export async function findCachedByPrompt(entries, prompt) {
41
- return entries.find((e) => e.prompt === prompt)
40
+ export async function findCachedByPrompt(entries, prompt, criteria = {}) {
41
+ if (!prompt || !Array.isArray(entries)) return undefined
42
+ return entries.find((e) => {
43
+ if (e.prompt !== prompt) return false
44
+ if (criteria.format) {
45
+ const entryFmt = (e.format || (e.mediaType || '').replace('image/', '')).toLowerCase().replace('jpeg', 'jpg')
46
+ const targetFmt = String(criteria.format).toLowerCase().replace('jpeg', 'jpg')
47
+ if (entryFmt && targetFmt && entryFmt !== targetFmt) return false
48
+ }
49
+ if (criteria.aspectRatio && e.aspectRatio && e.aspectRatio !== criteria.aspectRatio) return false
50
+ if (criteria.width && e.width && criteria.width !== e.width) return false
51
+ if (criteria.height && e.height && criteria.height !== e.height) return false
52
+ if (criteria.size && e.size && criteria.size !== criteria.size) return false
53
+ return true
54
+ })
42
55
  }
43
56
 
44
- export async function findCached(entries, seed, prompt) {
45
- if (seed === undefined) return undefined
46
- return entries.find((e) => e.seed === seed && e.prompt === prompt)
57
+ export async function findCached(entries, seed, prompt, criteria = {}) {
58
+ if (seed === undefined || !Array.isArray(entries)) return undefined
59
+ return entries.find((e) => {
60
+ if (e.seed !== seed || e.prompt !== prompt) return false
61
+ if (criteria.format) {
62
+ const entryFmt = (e.format || (e.mediaType || '').replace('image/', '')).toLowerCase().replace('jpeg', 'jpg')
63
+ const targetFmt = String(criteria.format).toLowerCase().replace('jpeg', 'jpg')
64
+ if (entryFmt && targetFmt && entryFmt !== targetFmt) return false
65
+ }
66
+ if (criteria.aspectRatio && e.aspectRatio && e.aspectRatio !== criteria.aspectRatio) return false
67
+ if (criteria.width && e.width && criteria.width !== e.width) return false
68
+ if (criteria.height && e.height && criteria.height !== e.height) return false
69
+ if (criteria.size && e.size && criteria.size !== criteria.size) return false
70
+ return true
71
+ })
47
72
  }
48
73
 
49
74
  /** Return cached entry if file exists on disk, otherwise undefined. */
@@ -61,6 +86,7 @@ export async function cachedResult(entry) {
61
86
  cost: 0,
62
87
  format: (entry.mediaType || 'image/png').replace('image/', ''),
63
88
  cached: true,
89
+ fromCache: true,
64
90
  attachment: entry.attachmentId ? {
65
91
  attachmentId: entry.attachmentId,
66
92
  mediaType: entry.mediaType || 'image/png',
@@ -14,8 +14,10 @@ const activeSessions = new Map()
14
14
  export function createLiveProgressSession(callId, options = {}) {
15
15
  if (!callId) return null
16
16
  const existing = activeSessions.get(callId)
17
- if (existing) {
18
- existing.listeners.clear()
17
+ const listeners = existing?.listeners ? existing.listeners : new Set()
18
+
19
+ if (typeof options.onProgress === 'function') {
20
+ listeners.add(options.onProgress)
19
21
  }
20
22
 
21
23
  const session = {
@@ -27,7 +29,7 @@ export function createLiveProgressSession(callId, options = {}) {
27
29
  done: false,
28
30
  error: undefined,
29
31
  timestamp: Date.now(),
30
- listeners: new Set(),
32
+ listeners,
31
33
  }
32
34
 
33
35
  if (options.signal) {
package/lib/loop-guard.js CHANGED
@@ -41,15 +41,27 @@ export function trackAndAssertLoopGuard(sessionId, {
41
41
  const current = sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0, lastTurnId: null }
42
42
  const maxLimit = Math.max(1, Number(limit) || DEFAULT_MAX_CONSECUTIVE_GENERATIONS)
43
43
 
44
- // Derive turn identifier from turnId or exec context
44
+ // Derive turn identifier from turnId or exec context (#370)
45
45
  const session = exec?.agent?.session
46
- const derivedTurnId = turnId ??
47
- exec?.turnId ??
48
- exec?.agent?.turnId ??
49
- exec?.message?.id ??
50
- session?.lastMessageId ??
51
- session?.turnId ??
52
- (Array.isArray(session?.messages) ? session.messages.length : null)
46
+ let derivedTurnId = turnId ?? exec?.turnId ?? exec?.agent?.turnId
47
+
48
+ if (derivedTurnId === null || derivedTurnId === undefined) {
49
+ const rawMessages = typeof session?.deriveMessages === 'function'
50
+ ? session.deriveMessages()
51
+ : (Array.isArray(session?.messages) ? session.messages : null)
52
+
53
+ if (Array.isArray(rawMessages)) {
54
+ const userMessages = rawMessages.filter(m => m && (m.role === 'user' || m.type === 'user'))
55
+ if (userMessages.length > 0) {
56
+ const lastUser = userMessages[userMessages.length - 1]
57
+ derivedTurnId = lastUser.id ?? lastUser.turnId ?? `user_turn_${userMessages.length}`
58
+ }
59
+ }
60
+ }
61
+
62
+ if (derivedTurnId === null || derivedTurnId === undefined) {
63
+ derivedTurnId = session?.lastUserMessageId ?? session?.lastMessageId ?? session?.turnId ?? null
64
+ }
53
65
 
54
66
  // If turn identifier changed, reset count for the new user turn
55
67
  if (derivedTurnId !== null && derivedTurnId !== undefined) {
@@ -90,9 +90,6 @@ export async function ensureImageFormat(bytes, targetFormat, { fallbackMime } =
90
90
  const verified = detectImageMediaType(transcoded) || targetMime
91
91
  return { bytes: transcoded, mediaType: verified }
92
92
  } catch (err) {
93
- if (buf.length < 64) {
94
- return { bytes: buf, mediaType: fallbackMime || targetMime || actualMime || 'image/png' }
95
- }
96
93
  throw new Error(`Failed to encode image to ${canonical}: ${err.message}`)
97
94
  }
98
95
  }
package/lib/providers.js CHANGED
@@ -536,19 +536,7 @@ export async function blendImageBuffers(images, weights) {
536
536
  const bytes = await sharp(out, { raw: { width: targetW, height: targetH, channels: 4 } }).png().toBuffer()
537
537
  return { bytes, width: targetW, height: targetH, mediaType: 'image/png', weights: normWeights }
538
538
  } catch (err) {
539
- // Fallback for mock or synthetic test buffers that cannot be parsed by sharp
540
- const maxLen = Math.max(...images.map(img => (img.bytes?.length || 0)))
541
- const out = Buffer.alloc(maxLen)
542
- for (let p = 0; p < maxLen; p++) {
543
- let val = 0
544
- for (let i = 0; i < images.length; i++) {
545
- const b = images[i].bytes || Buffer.alloc(0)
546
- const byteVal = p < b.length ? b[p] : 0
547
- val += byteVal * normWeights[i]
548
- }
549
- out[p] = Math.round(Math.min(255, Math.max(0, val)))
550
- }
551
- return { bytes: out, width: 0, height: 0, mediaType: 'image/png', weights: normWeights }
539
+ throw new Error(`Failed to decode image buffer for blending: ${err.message}`)
552
540
  }
553
541
  }
554
542
 
@@ -87,7 +87,8 @@ export function registerEditingTools(ctx, deps, options = {}) {
87
87
  if (cfg && cfg.enabled === false) {
88
88
  throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
89
89
  }
90
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
90
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
91
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
91
92
  const estimatedCostEdit = calculateGenerationCost({
92
93
  provider: cfg.provider || 'fal',
93
94
  model: cfg.model,
@@ -242,7 +243,8 @@ export function registerEditingTools(ctx, deps, options = {}) {
242
243
  if (cfg && cfg.enabled === false) {
243
244
  throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
244
245
  }
245
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
246
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
247
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
246
248
  const count = normalizeCount(args.count || 1)
247
249
  const estimatedCostVary = calculateGenerationCost({
248
250
  provider: cfg.provider || 'fal',
@@ -383,7 +385,8 @@ export function registerEditingTools(ctx, deps, options = {}) {
383
385
  if (cfg && cfg.enabled === false) {
384
386
  throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
385
387
  }
386
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
388
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
389
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
387
390
  const count = normalizeCount(args.count || 1)
388
391
  const estimatedCost = calculateGenerationCost({
389
392
  provider: cfg.provider || 'fal',
@@ -500,12 +500,17 @@ export function registerGenerationTools(ctx, deps, options = {}) {
500
500
  const hPrompt = h
501
501
  const found = findCachedGeneration(historyEntries, hPrompt)
502
502
  if (found) return await cachedResult(found)
503
+ const requestedCriteria = {
504
+ format: args.output_format || cfg.defaultFormat || 'png',
505
+ aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
506
+ size: args.aspect_ratio ? 'custom' : (args.image_size || cfg.defaultSize),
507
+ }
503
508
  if (cfg.cacheBySeed) {
504
- const bySeed = await findCached(historyEntries, seedVal, promptVal)
509
+ const bySeed = await findCached(historyEntries, seedVal, promptVal, requestedCriteria)
505
510
  if (bySeed) return await cachedResult(bySeed)
506
511
  }
507
512
  if (cfg.cacheByPrompt) {
508
- const byPrompt = await findCachedByPrompt(historyEntries, promptVal)
513
+ const byPrompt = await findCachedByPrompt(historyEntries, promptVal, requestedCriteria)
509
514
  if (byPrompt) return await cachedResult(byPrompt)
510
515
  }
511
516
  return undefined
@@ -67,7 +67,8 @@ export function registerPatternTools(ctx, deps) {
67
67
  async execute(args, exec) {
68
68
  try {
69
69
  const cfg = live()
70
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
70
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
71
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.motif || '', exec })
71
72
  const density = normalizeDensity(args.density)
72
73
  const prompt = buildSeamlessPrompt(args.motif, args.style, density)
73
74
  const provider = cfg.provider || 'fal'
@@ -252,7 +252,8 @@ export function registerProcessingTools(ctx, deps) {
252
252
  async execute(args, exec) {
253
253
  try {
254
254
  const cfg = live()
255
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
255
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
256
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
256
257
  assertBudgetAvailable(0.05, cfg.dailyBudgetUsd)
257
258
  const refs = Array.isArray(args.images) ? args.images : [args.images].filter(Boolean)
258
259
  if (refs.length < 2) {
@@ -101,7 +101,8 @@ export function registerResponsiveTools(ctx, deps) {
101
101
  async execute(args, exec) {
102
102
  try {
103
103
  const cfg = live()
104
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
104
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
105
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
105
106
  if (!args.prompt || !String(args.prompt).trim()) {
106
107
  throw new Error('prompt is required for generate_responsive_mockups')
107
108
  }
@@ -87,7 +87,8 @@ export function registerSketchTools(ctx, deps) {
87
87
  async execute(args, exec) {
88
88
  try {
89
89
  const cfg = live()
90
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
90
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
91
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
91
92
  const estimated = calculateGenerationCost({
92
93
  provider: cfg.provider || 'fal',
93
94
  model: cfg.model,
@@ -74,7 +74,8 @@ export function registerSpritesheetTools(ctx, deps) {
74
74
  async execute(args, exec) {
75
75
  try {
76
76
  const cfg = live()
77
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
77
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
78
+ trackAndAssertLoopGuard(sessionId, { limit: cfg.loopGuardLimit, prompt: args.prompt || '', exec })
78
79
  const animation = String(args.animation || 'idle').toLowerCase()
79
80
  const animKey = ANIMATION_PRESETS[animation] ? animation : 'idle'
80
81
  const frameCount = clampFrames(args.frames, animKey)
@@ -150,8 +150,18 @@ export function registerVisionOcrTools(ctx, deps) {
150
150
  throw new Error(`Unable to resolve source image for text replacement: ${args.image}`)
151
151
  }
152
152
 
153
- const imgWidth = source.width || 1024
154
- const imgHeight = source.height || 1024
153
+ let imgWidth = 1024
154
+ let imgHeight = 1024
155
+ try {
156
+ const sharpMod = await import('sharp')
157
+ const sharp = sharpMod.default || sharpMod
158
+ const meta = await sharp(source.bytes).metadata()
159
+ imgWidth = meta.width || source.width || 1024
160
+ imgHeight = meta.height || source.height || 1024
161
+ } catch (_err) {
162
+ imgWidth = source.width || 1024
163
+ imgHeight = source.height || 1024
164
+ }
155
165
 
156
166
  // 1. Gather text items and bounding boxes
157
167
  const replacements = Array.isArray(args.replacements) ? args.replacements : []
@@ -162,13 +172,14 @@ export function registerVisionOcrTools(ctx, deps) {
162
172
  let bbox = item.bbox
163
173
  if (!bbox && item.find && ctx.tools?.get) {
164
174
  const groundTool = ctx.tools.get('vision_ground')
165
- if (groundTool && typeof groundTool.execute === 'function' && source.ref) {
175
+ if (groundTool && typeof groundTool.execute === 'function') {
166
176
  try {
167
177
  const gRes = await groundTool.execute({
168
- attachmentId: source.ref.startsWith('sha256:') ? source.ref : undefined,
178
+ image: args.image,
179
+ attachmentId: source.ref?.startsWith('sha256:') ? source.ref : (args.image?.startsWith('sha256:') ? args.image : undefined),
169
180
  target: `text "${item.find}"`,
170
181
  }, execCtx)
171
- if (gRes?.bbox && gRes.bbox.length === 4) {
182
+ if (gRes?.bbox && Array.isArray(gRes.bbox) && gRes.bbox.length === 4) {
172
183
  bbox = gRes.bbox
173
184
  }
174
185
  } catch (_err) {
@@ -258,13 +269,23 @@ export function registerVisionOcrTools(ctx, deps) {
258
269
 
259
270
  // 5. Composite typography overlay on inpainted background
260
271
  let finalBytes = inpaintedBytes
272
+ let composited = false
261
273
  try {
262
274
  const sharpMod = await import('sharp')
263
275
  const sharp = sharpMod.default || sharpMod
276
+ const inpaintMeta = await sharp(inpaintedBytes).metadata()
277
+ const compW = inpaintMeta.width || imgWidth
278
+ const compH = inpaintMeta.height || imgHeight
279
+ let compOverlay = overlayBuffer
280
+ if (compW !== imgWidth || compH !== imgHeight) {
281
+ const scaledOverlaySvg = buildTextOverlaySvg(compW, compH, overlayItems, args.font_family || 'sans-serif')
282
+ compOverlay = Buffer.from(scaledOverlaySvg, 'utf8')
283
+ }
264
284
  finalBytes = await sharp(inpaintedBytes)
265
- .composite([{ input: overlayBuffer, top: 0, left: 0 }])
285
+ .composite([{ input: compOverlay, top: 0, left: 0 }])
266
286
  .png()
267
287
  .toBuffer()
288
+ composited = true
268
289
  } catch (sharpErr) {
269
290
  if (ctx.logger?.warn) ctx.logger.warn(`Sharp compositing skipped: ${sharpErr.message}`)
270
291
  }
@@ -322,7 +343,7 @@ export function registerVisionOcrTools(ctx, deps) {
322
343
 
323
344
  const response = {
324
345
  summary: dualOutput,
325
- replacedCount: overlayItems.length,
346
+ replacedCount: composited ? overlayItems.length : 0,
326
347
  bboxes,
327
348
  inpaintPrompt,
328
349
  targetLanguage: args.target_language || '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.11.29",
3
+ "version": "0.11.30",
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",