@goodandready/dsh-image-gen 0.11.21 → 0.11.23
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/attachment-helper.js +14 -0
- package/lib/commands.js +16 -8
- package/lib/config-schema.js +32 -4
- package/lib/generation-helpers.js +266 -250
- package/lib/index.js +12 -2
- package/lib/loop-guard.js +32 -8
- package/lib/provider-utils.js +40 -1
- package/lib/providers/backends/custom.js +15 -4
- package/lib/providers/backends/local.js +8 -1
- package/lib/providers/shared-helpers.js +2 -1
- package/lib/register-tools.js +42 -17
- package/lib/tools/character-sheet.js +98 -47
- package/lib/tools/diagram.js +101 -40
- package/lib/tools/editing.js +9 -0
- package/lib/tools/generation.js +505 -496
- package/lib/tools/processing-basic.js +6 -1
- package/lib/tools/style-matrix.js +16 -0
- package/lib/tools/theme-pair.js +80 -40
- package/lib/tools/ui-asset.js +15 -0
- package/lib/tools/vision-ocr.js +134 -71
- package/package.json +1 -1
|
@@ -1,250 +1,266 @@
|
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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 effectiveModel = provider === 'custom'
|
|
183
|
+
? (cfg.customModel || cfg.model || 'dall-e-3')
|
|
184
|
+
: (cfg.model || cfg.customModel)
|
|
185
|
+
|
|
186
|
+
const cacheHash = computeGenerationHash({
|
|
187
|
+
provider,
|
|
188
|
+
model: effectiveModel,
|
|
189
|
+
prompt: promptArg,
|
|
190
|
+
seed: gen.seed,
|
|
191
|
+
size: args.aspect_ratio ? 'custom' : (args.image_size || cfg.defaultSize),
|
|
192
|
+
style: args.style || cfg.stylePreset,
|
|
193
|
+
format: format || args.output_format || cfg.defaultFormat || 'png',
|
|
194
|
+
aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
|
|
195
|
+
negativePrompt: args.negative_prompt || cfg.negativePrompt,
|
|
196
|
+
quality: args.quality,
|
|
197
|
+
strength: args.strength,
|
|
198
|
+
sourceImage: args.image || args.source_image,
|
|
199
|
+
maskImage: args.mask_image || args.mask,
|
|
200
|
+
reference: args.reference_images || args.reference_image,
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// #170: Store in content-addressed disk cache
|
|
204
|
+
if (cfg.diskCache !== false) {
|
|
205
|
+
setCachedGeneration(cacheHash, {
|
|
206
|
+
bytes,
|
|
207
|
+
mediaType,
|
|
208
|
+
width: gen.width || attachment.width,
|
|
209
|
+
height: gen.height || attachment.height,
|
|
210
|
+
seed: gen.seed,
|
|
211
|
+
meta: { prompt: promptArg, provider, model: cfg.model || cfg.customModel, cost: gen.cost },
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
const entry = {
|
|
215
|
+
path: filePath,
|
|
216
|
+
prompt: promptArg,
|
|
217
|
+
provider,
|
|
218
|
+
model: cfg.model,
|
|
219
|
+
size,
|
|
220
|
+
format,
|
|
221
|
+
seed: gen.seed,
|
|
222
|
+
cacheHash,
|
|
223
|
+
thumbnailUrl: localUrl,
|
|
224
|
+
width: gen.width || attachment.width,
|
|
225
|
+
height: gen.height || attachment.height,
|
|
226
|
+
mediaType,
|
|
227
|
+
bytes: attachment.bytes,
|
|
228
|
+
createdAt: new Date().toISOString(),
|
|
229
|
+
attachmentId: attachment.attachmentId,
|
|
230
|
+
}
|
|
231
|
+
// Save to history (#385: historyLimit=0 skips history retention; bounds guard against negative values)
|
|
232
|
+
const historyLimit = typeof cfg.historyLimit === 'number' ? Math.max(0, Math.floor(cfg.historyLimit)) : 50
|
|
233
|
+
if (historyLimit > 0) {
|
|
234
|
+
let current = await readHistory()
|
|
235
|
+
current = await pruneHistory(current, cfg.pruneDays)
|
|
236
|
+
current.unshift(entry)
|
|
237
|
+
if (current.length > historyLimit) current.length = historyLimit
|
|
238
|
+
await writeHistory(current)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// #167: Record spend in cost meter
|
|
242
|
+
recordSpend(gen.cost || (estimatedCost / totalCount), {
|
|
243
|
+
ctx,
|
|
244
|
+
meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
path: filePath,
|
|
249
|
+
url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
|
|
250
|
+
width: gen.width || attachment.width,
|
|
251
|
+
height: gen.height || attachment.height,
|
|
252
|
+
seed: gen.seed,
|
|
253
|
+
prompt: promptArg,
|
|
254
|
+
...(enhanced?.enhanced ? { originalPrompt: args.prompt } : {}),
|
|
255
|
+
cost: gen.cost,
|
|
256
|
+
format: mediaType.replace('image/', ''),
|
|
257
|
+
attachment: {
|
|
258
|
+
attachmentId: attachment.attachmentId,
|
|
259
|
+
mediaType: attachment.mediaType,
|
|
260
|
+
bytes: attachment.bytes,
|
|
261
|
+
width: attachment.width,
|
|
262
|
+
height: attachment.height,
|
|
263
|
+
name: attachment.name,
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -371,10 +371,20 @@ export function apply(ctx, rawConfig) {
|
|
|
371
371
|
// Vault route (#159)
|
|
372
372
|
registerVaultRoutes(ctx)
|
|
373
373
|
|
|
374
|
-
//
|
|
374
|
+
// Reset loop guard when user sends a new message (#370)
|
|
375
|
+
if (typeof ctx.on === 'function') {
|
|
376
|
+
ctx.on('chat/message', (session) => {
|
|
377
|
+
if (session?.id) resetLoopGuard(session.id)
|
|
378
|
+
})
|
|
379
|
+
ctx.on('message', (session) => {
|
|
380
|
+
if (session?.id) resetLoopGuard(session.id)
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Session and cache teardown cleanup effect (#370)
|
|
375
385
|
ctx.effect(() => () => {
|
|
376
386
|
clearAllAnchors()
|
|
377
|
-
resetLoopGuard()
|
|
387
|
+
resetLoopGuard() // clears ALL session states from memory
|
|
378
388
|
}, 'dsh-image-gen: session cleanup')
|
|
379
389
|
|
|
380
390
|
// Tool registrations live in register-tools.js; each tool is a labeled ctx.effect (#216).
|
package/lib/loop-guard.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// lib/loop-guard.js
|
|
2
|
-
// Fail-fast loop guard protecting against runaway agent retry loops (#168)
|
|
2
|
+
// Fail-fast loop guard protecting against runaway agent retry loops (#168, #370)
|
|
3
3
|
|
|
4
|
-
/** In-memory session tracking map: sessionId -> { count: number, lastPrompt: string, lastTimestamp: number } */
|
|
4
|
+
/** In-memory session tracking map: sessionId -> { count: number, lastPrompt: string, lastTimestamp: number, lastTurnId: any } */
|
|
5
5
|
const sessionStates = new Map()
|
|
6
6
|
|
|
7
7
|
/** Default maximum consecutive generations without user interaction */
|
|
@@ -15,12 +15,16 @@ export const DEFAULT_MAX_CONSECUTIVE_GENERATIONS = 3
|
|
|
15
15
|
* @param {number} [options.limit=3] - Maximum allowed calls
|
|
16
16
|
* @param {string} [options.prompt=''] - Current prompt
|
|
17
17
|
* @param {boolean} [options.isUserTurn=false] - Whether the user just submitted a turn (resets counter)
|
|
18
|
+
* @param {any} [options.turnId=null] - Canonical turn / message identifier
|
|
19
|
+
* @param {Object} [options.exec=null] - Tool execution context
|
|
18
20
|
* @throws {Error} If consecutive generation limit is exceeded.
|
|
19
21
|
*/
|
|
20
22
|
export function trackAndAssertLoopGuard(sessionId, {
|
|
21
23
|
limit = DEFAULT_MAX_CONSECUTIVE_GENERATIONS,
|
|
22
24
|
prompt = '',
|
|
23
25
|
isUserTurn = false,
|
|
26
|
+
turnId = null,
|
|
27
|
+
exec = null,
|
|
24
28
|
} = {}) {
|
|
25
29
|
const sid = String(sessionId || 'default_session')
|
|
26
30
|
|
|
@@ -34,9 +38,27 @@ export function trackAndAssertLoopGuard(sessionId, {
|
|
|
34
38
|
return { count: 0, remaining: Infinity, allowed: true }
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
const current = sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0 }
|
|
41
|
+
const current = sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0, lastTurnId: null }
|
|
38
42
|
const maxLimit = Math.max(1, Number(limit) || DEFAULT_MAX_CONSECUTIVE_GENERATIONS)
|
|
39
43
|
|
|
44
|
+
// Derive turn identifier from turnId or exec context
|
|
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)
|
|
53
|
+
|
|
54
|
+
// If turn identifier changed, reset count for the new user turn
|
|
55
|
+
if (derivedTurnId !== null && derivedTurnId !== undefined) {
|
|
56
|
+
if (current.lastTurnId !== null && current.lastTurnId !== undefined && current.lastTurnId !== derivedTurnId) {
|
|
57
|
+
current.count = 0
|
|
58
|
+
}
|
|
59
|
+
current.lastTurnId = derivedTurnId
|
|
60
|
+
}
|
|
61
|
+
|
|
40
62
|
if (current.count >= maxLimit) {
|
|
41
63
|
throw new Error(
|
|
42
64
|
`Generation loop limit reached (${current.count}/${maxLimit} consecutive generations). ` +
|
|
@@ -53,19 +75,21 @@ export function trackAndAssertLoopGuard(sessionId, {
|
|
|
53
75
|
}
|
|
54
76
|
|
|
55
77
|
/**
|
|
56
|
-
* Resets loop guard counter for a session.
|
|
78
|
+
* Resets loop guard counter for a session, or clears all session states if sessionId is omitted.
|
|
57
79
|
*/
|
|
58
|
-
/** @internal — test helper; not part of the plugin runtime API */
|
|
59
80
|
export function resetLoopGuard(sessionId) {
|
|
60
|
-
|
|
81
|
+
if (!sessionId) {
|
|
82
|
+
sessionStates.clear()
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
const sid = String(sessionId)
|
|
61
86
|
sessionStates.delete(sid)
|
|
62
87
|
}
|
|
63
88
|
|
|
64
89
|
/**
|
|
65
90
|
* Gets current state for debugging/testing.
|
|
66
91
|
*/
|
|
67
|
-
/** @internal — test helper; not part of the plugin runtime API */
|
|
68
92
|
export function getLoopGuardState(sessionId) {
|
|
69
93
|
const sid = String(sessionId || 'default_session')
|
|
70
|
-
return sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0 }
|
|
94
|
+
return sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0, lastTurnId: null }
|
|
71
95
|
}
|