@goodandready/dsh-image-gen 0.11.16 → 0.11.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ // lib/generation-helpers.js
2
+ // Extracted execution and prompt/caching helpers for generate_image tool (#349).
3
+
4
+ import path from 'node:path'
5
+ import { mkdir, writeFile } from 'node:fs/promises'
6
+ import {
7
+ computeGenerationHash,
8
+ IMAGE_SIZES,
9
+ OUTPUT_FORMATS,
10
+ PROVIDER_KEYS,
11
+ buildSidecar,
12
+ ASPECT_RATIOS,
13
+ embedPngMetadata,
14
+ resolveStylePreset,
15
+ saveAttachmentSafe,
16
+ } from './providers.js'
17
+ import { enhancePrompt } from './prompt-enhancer.js'
18
+ import { buildFaceConsistencyPrompt, getSessionAnchor, applyAnchorPromptHints } from './anchor-helpers.js'
19
+ import { sanitizeNegativePrompt, supportsNegativePrompt } from './negative-sanitizer.js'
20
+ import { recordSpend } from './cost-meter.js'
21
+ import { setCachedGeneration } from './generation-cache.js'
22
+ import {
23
+ readHistory,
24
+ writeHistory,
25
+ pruneHistory,
26
+ } from './history.js'
27
+
28
+ /** Run an array of async task functions with a concurrency cap */
29
+ export async function asyncPool(tasks, concurrency = 3) {
30
+ const results = new Array(tasks.length)
31
+ let nextIdx = 0
32
+ async function worker() {
33
+ while (nextIdx < tasks.length) {
34
+ const idx = nextIdx++
35
+ results[idx] = await tasks[idx]()
36
+ }
37
+ }
38
+ const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker())
39
+ await Promise.all(workers)
40
+ return results
41
+ }
42
+
43
+ /** Validates generation configuration and arguments */
44
+ export function validateGenerationParams(cfg, args) {
45
+ if (cfg.enabled === false) {
46
+ throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
47
+ }
48
+ const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
49
+ const deliverAs = cfg.deliverAs
50
+ const size = args.aspect_ratio ? 'custom' : (args.image_size ?? cfg.defaultSize)
51
+ if (!args.aspect_ratio && !IMAGE_SIZES.includes(size)) {
52
+ throw new Error(`Invalid image_size "${size}". One of: ${IMAGE_SIZES.join(', ')}`)
53
+ }
54
+ const aspectPixels = args.aspect_ratio ? ASPECT_RATIOS[args.aspect_ratio] : undefined
55
+ const format = args.output_format ?? cfg.defaultFormat
56
+ if (!OUTPUT_FORMATS.includes(format)) {
57
+ throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
58
+ }
59
+ return { provider, deliverAs, size, aspectPixels, format }
60
+ }
61
+
62
+ /** Prepares enhanced prompt, style hints, facial identity, anchors, and sanitized negative prompt */
63
+ export async function prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId, provider, resolveSource }) {
64
+ const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
65
+ const effectiveStyle = args.style || cfg.stylePreset
66
+ const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
67
+ let effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
68
+ let effectiveNegative = styleInfo.negativePrompt
69
+ const activeAnchor = getSessionAnchor(sessionId)
70
+ let resolvedFaceRef
71
+
72
+ const faceRefInput = args.face_reference || (activeAnchor && (activeAnchor.mode === 'face' || activeAnchor.mode === 'character') ? activeAnchor.image : undefined)
73
+ if (faceRefInput) {
74
+ try {
75
+ resolvedFaceRef = await resolveSource(ctx, exec, faceRefInput)
76
+ } catch (_err) {
77
+ /* non-fatal: face_reference resolve fallback */
78
+ resolvedFaceRef = { url: faceRefInput }
79
+ }
80
+ const facePlan = buildFaceConsistencyPrompt({
81
+ prompt: effectivePrompt,
82
+ faceStrength: args.face_strength || activeAnchor?.strength,
83
+ characterName: activeAnchor?.label,
84
+ })
85
+ effectivePrompt = facePlan.prompt
86
+ if (facePlan.negativePrompt) {
87
+ effectiveNegative = effectiveNegative ? `${effectiveNegative}, ${facePlan.negativePrompt}` : facePlan.negativePrompt
88
+ }
89
+ } else if (args.reference_image) {
90
+ effectivePrompt = `${effectivePrompt}, visual reference anchor`
91
+ } else if (activeAnchor) {
92
+ effectivePrompt = applyAnchorPromptHints(activeAnchor, effectivePrompt)
93
+ }
94
+
95
+ // #165: Negative prompt sanitizer for diffusion models
96
+ if (supportsNegativePrompt(provider, cfg.model || cfg.customModel)) {
97
+ effectiveNegative = sanitizeNegativePrompt({
98
+ positivePrompt: effectivePrompt,
99
+ userNegative: effectiveNegative,
100
+ autoInjectDefects: true,
101
+ })
102
+ }
103
+
104
+ return {
105
+ enhanced,
106
+ effectivePrompt,
107
+ effectiveNegative,
108
+ effectiveGuidance: styleInfo.guidanceScale,
109
+ styleInfo,
110
+ activeAnchor,
111
+ resolvedFaceRef,
112
+ }
113
+ }
114
+
115
+ /** Saves generated image to disk, sidecar, history, records spend, and builds return structure */
116
+ export async function saveGeneratedImageArtifact({
117
+ ctx,
118
+ exec,
119
+ cfg,
120
+ args,
121
+ gen,
122
+ promptArg,
123
+ jobSeed,
124
+ provider,
125
+ deliverAs,
126
+ size,
127
+ format,
128
+ effectiveNegative,
129
+ effectiveGuidance,
130
+ enhanced,
131
+ slugify,
132
+ estimatedCost,
133
+ totalCount,
134
+ }) {
135
+ const mediaType = gen.mediaType || 'image/png'
136
+ let bytes = gen.bytes
137
+ if (mediaType === 'image/png') {
138
+ bytes = embedPngMetadata(bytes, {
139
+ prompt: promptArg,
140
+ seed: gen.seed,
141
+ provider,
142
+ model: cfg.model || cfg.customModel,
143
+ negative_prompt: effectiveNegative,
144
+ guidance_scale: effectiveGuidance,
145
+ width: gen.width,
146
+ height: gen.height,
147
+ })
148
+ }
149
+ const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
150
+ const stem = `${slugify(args.output_name || promptArg)}-${Date.now().toString(36)}-${jobSeed}`
151
+ const name = `${stem}.${extension}`
152
+
153
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes, mediaType, name })
154
+
155
+ const sessionCwd = exec?.agent?.session?.header?.cwd
156
+ const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
157
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
158
+ await mkdir(outDir, { recursive: true })
159
+ const filePath = path.join(outDir, name)
160
+ await writeFile(filePath, bytes)
161
+
162
+ // Sidecar: metadata written alongside image file, making the output
163
+ // directory self-documenting without external database dependencies.
164
+ await writeFile(
165
+ path.join(outDir, `${stem}.json`),
166
+ JSON.stringify(buildSidecar({
167
+ prompt: promptArg,
168
+ size,
169
+ format,
170
+ seed: gen.seed,
171
+ provider,
172
+ deliverAs,
173
+ width: gen.width || attachment.width,
174
+ height: gen.height || attachment.height,
175
+ mediaType,
176
+ attachmentId: attachment.attachmentId,
177
+ url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
178
+ cost: gen.cost,
179
+ }), null, 2),
180
+ )
181
+
182
+ const cacheHash = computeGenerationHash({
183
+ provider,
184
+ model: cfg.model || cfg.customModel,
185
+ prompt: promptArg,
186
+ seed: gen.seed,
187
+ size: args.image_size || cfg.defaultSize,
188
+ style: args.style || cfg.stylePreset,
189
+ })
190
+
191
+ // #170: Store in content-addressed disk cache
192
+ if (cfg.diskCache !== false) {
193
+ setCachedGeneration(cacheHash, {
194
+ bytes,
195
+ mediaType,
196
+ width: gen.width || attachment.width,
197
+ height: gen.height || attachment.height,
198
+ seed: gen.seed,
199
+ meta: { prompt: promptArg, provider, model: cfg.model || cfg.customModel, cost: gen.cost },
200
+ })
201
+ }
202
+ const entry = {
203
+ path: filePath,
204
+ prompt: promptArg,
205
+ provider,
206
+ model: cfg.model,
207
+ size,
208
+ format,
209
+ seed: gen.seed,
210
+ cacheHash,
211
+ thumbnailUrl: localUrl,
212
+ width: gen.width || attachment.width,
213
+ height: gen.height || attachment.height,
214
+ mediaType,
215
+ bytes: attachment.bytes,
216
+ createdAt: new Date().toISOString(),
217
+ attachmentId: attachment.attachmentId,
218
+ }
219
+ let current = await readHistory()
220
+ current = await pruneHistory(current, cfg.pruneDays)
221
+ current.unshift(entry)
222
+ if (current.length > (cfg.historyLimit || 50)) current.length = cfg.historyLimit || 50
223
+ await writeHistory(current)
224
+
225
+ // #167: Record spend in cost meter
226
+ recordSpend(gen.cost || (estimatedCost / totalCount), {
227
+ ctx,
228
+ meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
229
+ })
230
+
231
+ return {
232
+ path: filePath,
233
+ url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
234
+ width: gen.width || attachment.width,
235
+ height: gen.height || attachment.height,
236
+ seed: gen.seed,
237
+ prompt: promptArg,
238
+ ...(enhanced?.enhanced ? { originalPrompt: args.prompt } : {}),
239
+ cost: gen.cost,
240
+ format: mediaType.replace('image/', ''),
241
+ attachment: {
242
+ attachmentId: attachment.attachmentId,
243
+ mediaType: attachment.mediaType,
244
+ bytes: attachment.bytes,
245
+ width: attachment.width,
246
+ height: attachment.height,
247
+ name: attachment.name,
248
+ },
249
+ }
250
+ }
package/lib/index.js CHANGED
@@ -146,7 +146,7 @@ export function apply(ctx, rawConfig) {
146
146
  return {
147
147
  get: () => live(),
148
148
  replace: async (next) => {
149
- const parsed = Config(structuredClone(plainConfig(next)))
149
+ const parsed = plainConfig(Config(structuredClone(plainConfig(next))))
150
150
  currentLiveConfig = parsed
151
151
  const payload = volatileConfig(parsed)
152
152
  if (typeof svc.replace === 'function') {
@@ -157,7 +157,7 @@ export function apply(ctx, rawConfig) {
157
157
  return parsed
158
158
  },
159
159
  update: async (patch) => {
160
- const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
160
+ const merged = plainConfig(Config({ ...publicConfig(live()), ...plainConfig(patch) }))
161
161
  currentLiveConfig = merged
162
162
  const payload = volatileConfig(merged)
163
163
  if (typeof svc.update === 'function') {
@@ -1,3 +1,4 @@
1
+ import { sanitizeErrorAndLogs } from './security.js'
1
2
  // lib/provider-utils.js
2
3
  // Shared provider utilities and size/format constants extracted from providers.js (#239)
3
4
 
@@ -234,11 +235,12 @@ export function formatErrorMessage(e, providerKey = '') {
234
235
  msg = msg.replace(/\[object Object\]/g, 'unknown error object').trim()
235
236
  }
236
237
 
238
+ const sanitized = sanitizeErrorAndLogs(msg)
237
239
  if (providerKey) {
238
- return `${providerKey}: ${msg}`
240
+ return `${providerKey}: ${sanitized}`
239
241
  }
240
242
 
241
- return msg
243
+ return sanitized
242
244
  }
243
245
 
244
246
  export function resolveSubscriptionSize(size, aspectPixels, aspectRatio) {
@@ -103,7 +103,7 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, setLiveConfi
103
103
 
104
104
  try {
105
105
  const base = plainConfig(live()) || {}
106
- const merged = Config({ ...base, ...raw })
106
+ const merged = plainConfig(Config({ ...base, ...raw }))
107
107
  const settingsApi = getSettingsApi ? getSettingsApi() : null
108
108
  if (settingsApi && typeof settingsApi.replace === 'function') {
109
109
  await settingsApi.replace(merged)
@@ -1,3 +1,4 @@
1
+ import { sanitizeErrorAndLogs } from '../security.js'
1
2
  // anchor.js — set_style_anchor tool (#283).
2
3
  // Manages persistent visual style & character reference anchors in session context.
3
4
 
@@ -69,6 +70,7 @@ export function registerAnchorTools(ctx, deps) {
69
70
  },
70
71
  isConcurrencySafe: () => true,
71
72
  async execute(args, exec) {
73
+ try {
72
74
  const cfg = live ? live() : {}
73
75
  if (cfg.enabled === false) {
74
76
  throw new Error('Image generation plugin is disabled in settings.')
@@ -121,6 +123,9 @@ export function registerAnchorTools(ctx, deps) {
121
123
  anchor,
122
124
  summary,
123
125
  })
126
+ } catch (err) {
127
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
128
+ }
124
129
  },
125
130
  }),
126
131
  )
@@ -1,3 +1,4 @@
1
+ import { sanitizeErrorAndLogs } from '../security.js'
1
2
  // character-sheet.js — generate_character_sheet tool (#181).
2
3
  // Generates multi-angle character model sheets and turnarounds with visual consistency.
3
4
 
@@ -1,3 +1,4 @@
1
+ import { sanitizeErrorAndLogs } from '../security.js'
1
2
  // diagram.js — beautify_diagram tool (#185).
2
3
  // Transforms raw Mermaid / Graphviz diagrams into high-end 3D isometric architectural illustrations.
3
4
 
@@ -1,3 +1,4 @@
1
+ import { sanitizeErrorAndLogs } from '../security.js'
1
2
  import { renderToolOutput } from "../attachment-helper.js"
2
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
3
4
  import { toLosslessJson } from '../providers.js'