@goodandready/dsh-image-gen 0.10.13 → 0.10.15

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.
package/lib/cost-meter.js CHANGED
@@ -135,11 +135,12 @@ export function assertBudgetAvailable(estimatedCost, dailyBudgetUsd) {
135
135
  const budget = Number(dailyBudgetUsd) || 0
136
136
  if (budget <= 0) return true // Budget 0 = unlimited
137
137
 
138
+ const cost = Number(estimatedCost) || 0
138
139
  const current = loadDailySpend()
139
- const projected = +(current.totalSpendUsd + estimatedCost).toFixed(4)
140
+ const projected = +(current.totalSpendUsd + cost).toFixed(4)
140
141
  if (projected > budget) {
141
142
  throw new Error(
142
- `Daily image generation budget exceeded: current spend $${current.totalSpendUsd.toFixed(2)} + estimated $${estimatedCost.toFixed(2)} exceeds limit $${budget.toFixed(2)} (Settings → Image generation → dailyBudgetUsd)`
143
+ `Daily image generation budget exceeded: current spend $${current.totalSpendUsd.toFixed(2)} + estimated $${cost.toFixed(2)} exceeds limit $${budget.toFixed(2)} (Settings → Image generation → dailyBudgetUsd)`
143
144
  )
144
145
  }
145
146
  return true
@@ -306,6 +306,9 @@ export function optimizeSvgContent(svgString, options = {}) {
306
306
  }
307
307
 
308
308
  let cleaned = svgString.trim()
309
+ if (!cleaned.includes('<svg')) {
310
+ throw new Error('Content does not contain valid SVG markup (<svg ...>)')
311
+ }
309
312
 
310
313
  cleaned = cleaned.replace(/<\?xml[\s\S]*?\?>/gi, '')
311
314
  cleaned = cleaned.replace(/<!DOCTYPE[\s\S]*?>/gi, '')
@@ -33,9 +33,13 @@ export function getCachedGeneration(hash, { force = false } = {}) {
33
33
  const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
34
34
  const bytes = readFileSync(dataPath)
35
35
 
36
- // Touch meta file for LRU update
37
- meta.lastAccessed = Date.now()
38
- writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8')
36
+ // Touch meta file for LRU update in isolated block
37
+ try {
38
+ meta.lastAccessed = Date.now()
39
+ writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8')
40
+ } catch {
41
+ // Non-fatal touch failure: still return valid cache entry
42
+ }
39
43
 
40
44
  return {
41
45
  bytes,
package/lib/index.js CHANGED
@@ -274,6 +274,10 @@ export const Config = z.object({
274
274
  .number()
275
275
  .description('provider=local: CFG scale.')
276
276
  .default(7),
277
+ seedreamBaseURL: z
278
+ .string()
279
+ .description('provider=seedream: base URL, e.g. https://api.bytedanceapi.com/v1 or Volcengine Ark.')
280
+ .default('https://api.bytedanceapi.com/v1'),
277
281
  seedreamKeyEnv: z
278
282
  .string()
279
283
  .role('credential-ref')
@@ -1197,8 +1201,9 @@ export function apply(ctx, config) {
1197
1201
  },
1198
1202
  },
1199
1203
  async execute(args, exec) {
1200
- const source = await resolveSource(ctx, exec, args.image)
1201
- if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
1204
+ try {
1205
+ const source = await resolveSource(ctx, exec, args.image)
1206
+ if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
1202
1207
  const cfg = live()
1203
1208
  const res = await removeBackgroundFal(
1204
1209
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
@@ -1236,6 +1241,9 @@ export function apply(ctx, config) {
1236
1241
  format: 'png',
1237
1242
  attachment,
1238
1243
  })
1244
+ } catch (err) {
1245
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
1246
+ }
1239
1247
  },
1240
1248
  }),
1241
1249
  )
@@ -1266,8 +1274,9 @@ export function apply(ctx, config) {
1266
1274
  },
1267
1275
  },
1268
1276
  async execute(args, exec) {
1269
- const source = await resolveSource(ctx, exec, args.image)
1270
- if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
1277
+ try {
1278
+ const source = await resolveSource(ctx, exec, args.image)
1279
+ if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
1271
1280
  const cfg = live()
1272
1281
  const res = await upscaleImageFal(
1273
1282
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
@@ -1305,6 +1314,9 @@ export function apply(ctx, config) {
1305
1314
  height: res.height || attachment.height,
1306
1315
  attachment,
1307
1316
  })
1317
+ } catch (err) {
1318
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
1319
+ }
1308
1320
  },
1309
1321
  }),
1310
1322
  )
@@ -1331,24 +1343,42 @@ export function apply(ctx, config) {
1331
1343
  },
1332
1344
  },
1333
1345
  async execute(args, exec) {
1334
- const source = await resolveSource(ctx, exec, args.image)
1335
- if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
1336
- const cfg = live()
1337
- const res = traceToSvg(source.bytes, { colorMode: args.color_mode })
1338
- const stem = `${slugify(args.output_name || 'vector')}-${Date.now().toString(36)}`
1339
- const name = `${stem}.svg`
1340
- const sessionCwd = exec.agent?.session?.header?.cwd
1341
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
1342
- await mkdir(outDir, { recursive: true })
1343
- const filePath = path.join(outDir, name)
1344
- await writeFile(filePath, res.bytes)
1345
- 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`
1346
- return toLosslessJson({
1347
- summary,
1348
- path: filePath,
1349
- svg: res.svg,
1350
- format: 'svg',
1351
- })
1346
+ try {
1347
+ const source = await resolveSource(ctx, exec, args.image)
1348
+ if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
1349
+ const cfg = live()
1350
+ const res = traceToSvg(source.bytes, { colorMode: args.color_mode })
1351
+ const stem = `${slugify(args.output_name || 'vector')}-${Date.now().toString(36)}`
1352
+ const name = `${stem}.svg`
1353
+ const sessionCwd = exec.agent?.session?.header?.cwd
1354
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
1355
+ await mkdir(outDir, { recursive: true })
1356
+ const filePath = path.join(outDir, name)
1357
+ await writeFile(filePath, res.bytes)
1358
+
1359
+ let attachment = null
1360
+ let localUrl = ''
1361
+ if (ctx.attachments?.saveImage) {
1362
+ attachment = await ctx.attachments.saveImage({
1363
+ data: new Uint8Array(res.bytes),
1364
+ mediaType: 'image/svg+xml',
1365
+ name,
1366
+ })
1367
+ localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
1368
+ }
1369
+
1370
+ 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`
1371
+ return toLosslessJson({
1372
+ summary,
1373
+ path: filePath,
1374
+ url: localUrl,
1375
+ svg: res.svg,
1376
+ format: 'svg',
1377
+ attachment,
1378
+ })
1379
+ } catch (err) {
1380
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
1381
+ }
1352
1382
  },
1353
1383
  }),
1354
1384
  )
@@ -1378,14 +1408,17 @@ export function apply(ctx, config) {
1378
1408
  },
1379
1409
  },
1380
1410
  async execute(args, exec) {
1381
- const refs = Array.isArray(args.images) ? args.images : [args.images].filter(Boolean)
1411
+ try {
1412
+ const cfg = live()
1413
+ trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
1414
+ assertBudgetAvailable(0.05, cfg.dailyBudgetUsd)
1415
+ const refs = Array.isArray(args.images) ? args.images : [args.images].filter(Boolean)
1382
1416
  const sources = []
1383
1417
  for (const r of refs) {
1384
1418
  const s = await resolveSource(ctx, exec, r)
1385
1419
  if (s && s.bytes) sources.push(s)
1386
1420
  }
1387
1421
  if (sources.length === 0) throw new Error('No valid images found to blend')
1388
- const cfg = live()
1389
1422
  const res = await blendImagesFal(
1390
1423
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
1391
1424
  { images: sources, weights: args.weights, prompt: args.prompt, signal: exec.signal }
@@ -1421,6 +1454,9 @@ export function apply(ctx, config) {
1421
1454
  height: res.height || attachment.height,
1422
1455
  attachment,
1423
1456
  })
1457
+ } catch (err) {
1458
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
1459
+ }
1424
1460
  },
1425
1461
  }),
1426
1462
  )
@@ -1542,7 +1578,16 @@ export function apply(ctx, config) {
1542
1578
  },
1543
1579
  },
1544
1580
  async execute(args, exec) {
1545
- const source = await resolveConversationImage(ctx, exec, args.image)
1581
+ try {
1582
+ trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
1583
+ const estimatedCostEdit = calculateGenerationCost({
1584
+ provider: cfg.provider || 'fal',
1585
+ model: cfg.model,
1586
+ size: cfg.defaultSize || 'square_hd',
1587
+ count: 1,
1588
+ })
1589
+ assertBudgetAvailable(estimatedCostEdit, cfg.dailyBudgetUsd)
1590
+ const source = await resolveConversationImage(ctx, exec, args.image)
1546
1591
  const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
1547
1592
  const vision = await analyzeImageWithVision(ctx, exec, source)
1548
1593
 
@@ -1603,6 +1648,9 @@ export function apply(ctx, config) {
1603
1648
  result.visionAnalysis = vision.summary
1604
1649
  }
1605
1650
  return toLosslessJson(result)
1651
+ } catch (err) {
1652
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
1653
+ }
1606
1654
  },
1607
1655
  }),
1608
1656
  )
@@ -1662,7 +1710,17 @@ export function apply(ctx, config) {
1662
1710
  },
1663
1711
  },
1664
1712
  async execute(args, exec) {
1665
- const source = await resolveConversationImage(ctx, exec, args.image)
1713
+ try {
1714
+ trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
1715
+ const count = normalizeCount(args.count || 1)
1716
+ const estimatedCostVary = calculateGenerationCost({
1717
+ provider: cfg.provider || 'fal',
1718
+ model: cfg.model,
1719
+ size: cfg.defaultSize || 'square_hd',
1720
+ count,
1721
+ })
1722
+ assertBudgetAvailable(estimatedCostVary, cfg.dailyBudgetUsd)
1723
+ const source = await resolveConversationImage(ctx, exec, args.image)
1666
1724
  const vision = await analyzeImageWithVision(ctx, exec, source)
1667
1725
 
1668
1726
  const provider = cfg.provider || 'fal'
@@ -1671,8 +1729,6 @@ export function apply(ctx, config) {
1671
1729
  resolveKey: (ref) => resolveApiKey(ctx, ref),
1672
1730
  cfg,
1673
1731
  }
1674
-
1675
- const count = normalizeCount(args.count || 1)
1676
1732
  const variationStrength = Math.max(0.05, Math.min(0.95, args.variation_strength ?? 0.35))
1677
1733
  const baseSeed = Math.floor(Math.random() * 100000)
1678
1734
  const format = cfg.defaultFormat || 'png'
@@ -1727,6 +1783,9 @@ export function apply(ctx, config) {
1727
1783
  ...first,
1728
1784
  variations: results.map((r) => ({ path: r.path, attachmentId: r.attachment?.attachmentId, seed: r.seed })),
1729
1785
  })
1786
+ } catch (err) {
1787
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
1788
+ }
1730
1789
  },
1731
1790
  }),
1732
1791
  )
package/lib/loop-guard.js CHANGED
@@ -29,8 +29,13 @@ export function trackAndAssertLoopGuard(sessionId, {
29
29
  return { count: 0, allowed: true }
30
30
  }
31
31
 
32
+ // A limit of 0 explicitly disables loop guard protection
33
+ if (limit === 0 || limit === '0') {
34
+ return { count: 0, remaining: Infinity, allowed: true }
35
+ }
36
+
32
37
  const current = sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0 }
33
- const maxLimit = Math.max(1, limit)
38
+ const maxLimit = Math.max(1, Number(limit) || DEFAULT_MAX_CONSECUTIVE_GENERATIONS)
34
39
 
35
40
  if (current.count >= maxLimit) {
36
41
  throw new Error(
package/lib/providers.js CHANGED
@@ -749,7 +749,7 @@ export function makeProviders(deps, job) {
749
749
  // Seedream (ByteDance): OpenAI-совместимый API картинок.
750
750
  async function seedream(seedArg = seed, promptArg = prompt) {
751
751
  const key = await resolveKey(cfg.seedreamKeyEnv)
752
- const base = 'https://api.bytedanceapi.com/v1'
752
+ const base = (cfg.seedreamBaseURL || 'https://api.bytedanceapi.com/v1').replace(/\/+$/, '')
753
753
  const res = await fetchImpl(`${base}/images/generations`, {
754
754
  method: 'POST',
755
755
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
@@ -1,4 +1,4 @@
1
- import { readFile } from 'node:fs/promises'
1
+ import { readFile, stat } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import { estimateSharpnessAndVariance } from './providers.js'
4
4
 
@@ -158,6 +158,17 @@ export async function resolveConversationImage(ctx, exec, targetRef) {
158
158
  ? effectiveRef
159
159
  : path.resolve(sessionCwd, effectiveRef)
160
160
 
161
+ const fileStat = await stat(absPath).catch(() => null)
162
+ if (!fileStat) {
163
+ throw new Error(`Image file not found: ${effectiveRef}`)
164
+ }
165
+ if (fileStat.size === 0) {
166
+ throw new Error(`Image file is empty (0 bytes): ${effectiveRef}`)
167
+ }
168
+ if (fileStat.size > 50 * 1024 * 1024) {
169
+ throw new Error(`Image file too large (${(fileStat.size / (1024 * 1024)).toFixed(1)} MB). Maximum allowed size is 50 MB.`)
170
+ }
171
+
161
172
  const bytes = await readFile(absPath)
162
173
  const mediaType = detectMediaType(absPath, bytes)
163
174
  return {
package/lib/security.js CHANGED
@@ -42,6 +42,16 @@ export function sanitizeErrorAndLogs(input) {
42
42
  return maskApiKey(token)
43
43
  })
44
44
 
45
+ // Mask Replicate tokens r8_...
46
+ text = text.replace(/(r8_[a-zA-Z0-9_\-]{8,})/gi, (match, token) => {
47
+ return maskApiKey(token)
48
+ })
49
+
50
+ // Mask Google Gemini API keys (starts with AIza and ~35-45 chars)
51
+ text = text.replace(/(AIza[0-9A-Za-z\-_]{30,45})/g, (match, token) => {
52
+ return maskApiKey(token)
53
+ })
54
+
45
55
  return text
46
56
  }
47
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.10.13",
3
+ "version": "0.10.15",
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",