@goodandready/dsh-image-gen 0.11.0 → 0.11.2

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/index.js CHANGED
@@ -14,8 +14,8 @@
14
14
 
15
15
  import z from '@deepseek-ai/schemastery'
16
16
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
17
- import { mkdir, readFile, writeFile } from 'node:fs/promises'
18
- import { enforceSecurePermissions, isTrustedLocalRequest } from './security.js'
17
+ import { readFile } from 'node:fs/promises'
18
+ import { isTrustedLocalRequest } from './security.js'
19
19
  import { saveAndAttachResult } from './attachment-helper.js'
20
20
  import {
21
21
  historyFile,
@@ -28,8 +28,7 @@ import {
28
28
  writeHistory,
29
29
  filterHistory,
30
30
  } from './history.js'
31
- import { existsSync, unlinkSync } from 'node:fs'
32
- import os from 'node:os'
31
+ import { existsSync } from 'node:fs'
33
32
  import path from 'node:path'
34
33
  import {
35
34
  IMAGE_SIZES,
@@ -38,16 +37,16 @@ import {
38
37
  buildSidecar,
39
38
  normalizeMediaType,
40
39
  resolveApiKeyCandidates,
41
- toLosslessJson,
42
- saveAttachmentSafe,
43
40
  testProviderConnection,
44
41
  } from './providers.js'
45
42
 
46
- import { buildDualOutputMarkdown, resolveConversationImage, analyzeImageWithVision } from './resolve-image.js'
43
+ import { resolveConversationImage, analyzeImageWithVision } from './resolve-image.js'
47
44
  import { registerAllTools } from './register-tools.js'
48
45
  import { registerPluginUpdater } from './updater.js'
49
46
  import { registerVaultRoutes } from './vault.js'
50
-
47
+ import { registerSettingsRoutes } from './settings-route.js'
48
+ import { clearAllAnchors } from './anchor-helpers.js'
49
+ import { resetLoopGuard, getLoopGuardState } from './loop-guard.js'
51
50
 
52
51
  export { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS, buildSidecar, normalizeMediaType, resolveConversationImage, analyzeImageWithVision }
53
52
  export { saveAndAttachResult }
@@ -63,13 +62,6 @@ export {
63
62
  filterHistory,
64
63
  }
65
64
 
66
-
67
- /**
68
- * Saves generated asset to workspace, registers with attachments, and returns Dual-Output (#150).
69
- */
70
-
71
-
72
-
73
65
  export const name = '@goodandready/dsh-image-gen'
74
66
 
75
67
  /** Settings namespace the Web card edits. */
@@ -80,45 +72,62 @@ const NS = 'dsh-image-gen'
80
72
  const LEGACY_NS = 'dsh-fal-image-gen'
81
73
  export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings', 'llm', 'systemPrompt']
82
74
 
75
+ // Polyfill for .volatile() schema annotation if runtime schemastery lacks it (#295)
76
+ if (typeof z.prototype?.volatile !== 'function') {
77
+ z.prototype.volatile = function volatile() {
78
+ if (this.meta && this.meta.volatile) throw new TypeError('volatile schema is already wrapped')
79
+ return typeof this.extra === 'function' ? this.extra('volatile', true) : this
80
+ }
81
+ }
82
+
83
83
  export const Config = z.object({
84
84
  enabled: z
85
85
  .boolean()
86
86
  .description('Master switch for image generation. When false, tools refuse to execute.')
87
- .default(true),
87
+ .default(true)
88
+ .volatile(),
88
89
  provider: z
89
90
  .string()
90
91
  .description(`Which provider generates the image. One of: ${PROVIDER_KEYS.join(', ')}. `
91
92
  + '"fal" uses the FAL queue below; "custom" uses the OpenAI-compatible API configured under it.')
92
- .default('fal'),
93
+ .default('fal')
94
+ .volatile(),
93
95
  model: z
94
96
  .string()
95
97
  .description('FAL model id, called as {baseURL}/{model}.')
96
- .default('fal-ai/flux-2/klein/9b'),
98
+ .default('fal-ai/flux-2/klein/9b')
99
+ .volatile(),
97
100
  apiKeyEnv: z
98
101
  .string()
99
102
  .role('credential-ref')
100
103
  .description('Credential reference / env var holding the FAL API key (the "Key " auth prefix is added automatically when missing).')
101
- .default('FAL_API_KEY'),
104
+ .default('FAL_API_KEY')
105
+ .volatile(),
102
106
  baseURL: z
103
107
  .string()
104
108
  .description('FAL queue base URL.')
105
- .default('https://queue.fal.run'),
109
+ .default('https://queue.fal.run')
110
+ .volatile(),
106
111
  defaultSize: z
107
112
  .string()
108
113
  .description(`Default image size when the tool call omits image_size. One of: ${IMAGE_SIZES.join(', ')}.`)
109
- .default('landscape_4_3'),
114
+ .default('landscape_4_3')
115
+ .volatile(),
110
116
  defaultFormat: z
111
117
  .string()
112
118
  .description(`Default output format. One of: ${OUTPUT_FORMATS.join(', ')}.`)
113
- .default('png'),
119
+ .default('png')
120
+ .volatile(),
114
121
  pollIntervalMs: z
115
122
  .number()
116
123
  .description('Status polling interval in milliseconds.')
117
- .default(2000),
124
+ .default(2000)
125
+ .volatile(),
118
126
  timeoutMs: z
119
127
  .number()
120
128
  .description('Total generation timeout in milliseconds (submit + poll + download).')
121
- .default(180000),
129
+ .default(180000)
130
+ .volatile(),
122
131
  deliverAs: z
123
132
  .string()
124
133
  .description(
@@ -126,151 +135,216 @@ export const Config = z.object({
126
135
  + '"link": the tool returns a link and the card renders the picture from it — the chat model only ever sees text, so this works with any model. '
127
136
  + '"image": the tool returns the image itself — the picture is part of the result, which a text-only chat model cannot read, so this mode needs dsh-vision-bridge (or a vision-capable chat model).'
128
137
  )
129
- .default('link'),
138
+ .default('link')
139
+ .volatile(),
130
140
  customBaseURL: z
131
141
  .string()
132
142
  .description('provider=custom: API root without a trailing slash, e.g. https://api.openai.com/v1. '
133
143
  + 'The request goes to {customBaseURL}/images/generations.')
134
- .default(''),
144
+ .default('')
145
+ .volatile(),
135
146
  customModel: z
136
147
  .string()
137
148
  .description('provider=custom: model id, e.g. gpt-image-1.')
138
- .default(''),
149
+ .default('')
150
+ .volatile(),
139
151
  customKeyEnv: z
140
152
  .string()
141
153
  .role('credential-ref')
142
154
  .description('provider=custom: credential reference / env var holding the API key. '
143
155
  + 'Empty means no authorization header, for gateways that need none.')
144
- .default('OPENAI_API_KEY'),
156
+ .default('OPENAI_API_KEY')
157
+ .volatile(),
145
158
 
146
159
  replicateModel: z
147
160
  .string()
148
161
  .description('provider=replicate: model identifier on Replicate.')
149
- .default('black-forest-labs/flux-schnell'),
162
+ .default('black-forest-labs/flux-schnell')
163
+ .volatile(),
150
164
  replicateKeyEnv: z
151
165
  .string()
152
166
  .role('credential-ref')
153
167
  .description('provider=replicate: credential reference / env var holding API token.')
154
- .default('REPLICATE_API_TOKEN'),
168
+ .default('REPLICATE_API_TOKEN')
169
+ .volatile(),
155
170
 
156
171
  subscriptionQuality: z
157
172
  .string()
158
173
  .description('provider=codex or grok: quality asked of the subscription — low, medium, high or empty '
159
174
  + 'for the provider default.')
160
- .default(''),
175
+ .default('')
176
+ .volatile(),
161
177
  customSize: z
162
178
  .string()
163
179
  .description('provider=custom: fixed size sent to the API, e.g. 1024x1024. '
164
180
  + 'Empty means the named size is translated automatically — set this only for an API picky about sizes.')
165
- .default(''),
181
+ .default('')
182
+ .volatile(),
166
183
  localKind: z
167
184
  .string()
168
185
  .description('provider=local: which local API to use — "comfyui" or "a1111".')
169
- .default('comfyui'),
186
+ .default('comfyui')
187
+ .volatile(),
170
188
  localBaseURL: z
171
189
  .string()
172
190
  .description('provider=local: server address, e.g. http://127.0.0.1:8188 (ComfyUI) or http://127.0.0.1:7860 (A1111).')
173
- .default(''),
191
+ .default('')
192
+ .volatile(),
174
193
  localModel: z
175
194
  .string()
176
195
  .description('provider=local: model id (A1111) or workflow/schema name (ComfyUI). Empty means the server default.')
177
- .default(''),
196
+ .default('')
197
+ .volatile(),
178
198
  localSteps: z
179
199
  .number()
180
200
  .description('provider=local: sampling steps.')
181
- .default(20),
201
+ .default(20)
202
+ .volatile(),
182
203
  localCfg: z
183
204
  .number()
184
205
  .description('provider=local: CFG scale.')
185
- .default(7),
206
+ .default(7)
207
+ .volatile(),
186
208
  seedreamBaseURL: z
187
209
  .string()
188
210
  .description('provider=seedream: base URL, e.g. https://api.bytedanceapi.com/v1 or Volcengine Ark.')
189
- .default('https://api.bytedanceapi.com/v1'),
211
+ .default('https://api.bytedanceapi.com/v1')
212
+ .volatile(),
190
213
  seedreamKeyEnv: z
191
214
  .string()
192
215
  .role('credential-ref')
193
216
  .description('provider=seedream: credential reference / env var holding the API key.')
194
- .default('SEEDREAM_API_KEY'),
217
+ .default('SEEDREAM_API_KEY')
218
+ .volatile(),
195
219
  seedreamModel: z
196
220
  .string()
197
221
  .description('provider=seedream: model id, e.g. seedream-4.0.')
198
- .default('seedream-4.0'),
222
+ .default('seedream-4.0')
223
+ .volatile(),
199
224
  geminiKeyEnv: z
200
225
  .string()
201
226
  .role('credential-ref')
202
227
  .description('provider=gemini: credential reference / env var holding the Google API key.')
203
- .default('GEMINI_API_KEY'),
228
+ .default('GEMINI_API_KEY')
229
+ .volatile(),
204
230
  geminiModel: z
205
231
  .string()
206
232
  .description('provider=gemini: model id, e.g. gemini-2.0-flash-exp-image-generation.')
207
- .default('gemini-2.0-flash-exp-image-generation'),
233
+ .default('gemini-2.0-flash-exp-image-generation')
234
+ .volatile(),
208
235
  outputDir: z
209
236
  .string()
210
237
  .description('Where generated images are saved. A relative path resolves against the session working directory; an absolute path is used as given.')
211
- .default('generated/images'),
238
+ .default('generated/images')
239
+ .volatile(),
212
240
  historyLimit: z
213
241
  .number()
214
242
  .description('How many recent generations to keep in the in-memory history list.')
215
- .default(50),
243
+ .default(50)
244
+ .volatile(),
216
245
  pruneDays: z
217
246
  .number()
218
247
  .description('Delete generated files and history entries older than this many days. 0 (default) disables pruning.')
219
- .default(0),
248
+ .default(0)
249
+ .volatile(),
220
250
  enhancePrompt: z
221
251
  .boolean()
222
252
  .description('Expand a short prompt into a detailed one through the chat model before generating. Off by default.')
223
- .default(false),
253
+ .default(false)
254
+ .volatile(),
224
255
  enhanceModel: z
225
256
  .string()
226
257
  .description('Model used to enhance the prompt. Empty means the same model that leads the conversation.')
227
- .default(''),
258
+ .default('')
259
+ .volatile(),
228
260
  autoEnhancePrompt: z
229
261
  .boolean()
230
262
  .description('Enrich prompts with photography, lighting, and composition quality tokens.')
231
- .default(false),
263
+ .default(false)
264
+ .volatile(),
232
265
  defaultStylePreset: z
233
266
  .string()
234
267
  .description('Default style preset applied to generations (e.g. cinematic, anime, photorealistic).')
235
- .default('none'),
268
+ .default('none')
269
+ .volatile(),
236
270
  enhanceBelowChars: z
237
271
  .number()
238
272
  .description('Only enhance prompts shorter than this many characters.')
239
- .default(200),
273
+ .default(200)
274
+ .volatile(),
240
275
  stylePreset: z
241
276
  .string()
242
277
  .description('Optional style suffix appended to the prompt before generation. Empty (default) means no style is applied and the prompt is used as-is.')
243
- .default(''),
278
+ .default('')
279
+ .volatile(),
244
280
  cacheBySeed: z
245
281
  .boolean()
246
282
  .description('If the same seed+prompt was already generated (present in history), return the cached result instead of generating again. Off by default.')
247
- .default(false),
283
+ .default(false)
284
+ .volatile(),
248
285
  cacheByPrompt: z
249
286
  .boolean()
250
287
  .description('If the same prompt was already generated (present in history), return the cached result instead of generating again. Off by default.')
251
- .default(false),
288
+ .default(false)
289
+ .volatile(),
252
290
  qualityGate: z
253
291
  .boolean()
254
292
  .description('Automatic quality gate with silent re-roll for blank or defective frames. On by default.')
255
- .default(true),
293
+ .default(true)
294
+ .volatile(),
256
295
  dailyBudgetUsd: z
257
296
  .number()
258
297
  .description('Daily image generation spending budget in USD. 0 (default) disables limit enforcement.')
259
- .default(0),
298
+ .default(0)
299
+ .volatile(),
260
300
  loopGuardLimit: z
261
301
  .number()
262
302
  .description('Maximum consecutive image generations per session without user interaction. 0 disables protection.')
263
- .default(3),
303
+ .default(3)
304
+ .volatile(),
264
305
  diskCache: z
265
306
  .boolean()
266
307
  .description('Content-addressed disk caching for identical generations (<50ms retrieval, zero API cost). On by default.')
267
- .default(true),
308
+ .default(true)
309
+ .volatile(),
268
310
  fallbackProviders: z
269
311
  .array(z.string())
270
312
  .description('Ordered list of fallback providers to cascade to if the primary encounters 429, 5xx, or quota limits.')
271
- .default([]),
313
+ .default([])
314
+ .volatile(),
272
315
  })
273
316
 
317
+ function isVolatileRef(value) {
318
+ return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
319
+ }
320
+
321
+ export function plainConfig(cfg) {
322
+ if (isVolatileRef(cfg)) return plainConfig(cfg.get())
323
+ if (!cfg || typeof cfg !== 'object') return cfg
324
+ const out = {}
325
+ for (const key of Object.keys(cfg)) {
326
+ const value = cfg[key]
327
+ out[key] = isVolatileRef(value) ? value.get() : value
328
+ }
329
+ return out
330
+ }
331
+
332
+ export function volatileConfig(cfg) {
333
+ const plain = plainConfig(cfg)
334
+ if (!plain || typeof plain !== 'object') return plain
335
+ const out = {}
336
+ for (const [key, field] of Object.entries(Config.dict || {})) {
337
+ if (field?.meta?.volatile && Object.hasOwn(plain, key)) {
338
+ out[key] = plain[key]
339
+ }
340
+ }
341
+ return out
342
+ }
343
+
344
+ export function publicConfig(cfg) {
345
+ return plainConfig(cfg)
346
+ }
347
+
274
348
  /** Keep a file stem safe for the filesystem. */
275
349
  /** Read source image for editing: filesystem path or attachment id. */
276
350
  export async function resolveSource(ctx, exec, ref) {
@@ -308,43 +382,33 @@ export async function resolveApiKey(ctx, ref) {
308
382
  try {
309
383
  const resolved = await ctx.credentials.resolve(credentialRef(candidate))
310
384
  if (resolved && resolved.value) return resolved.value
311
- } catch {
312
- // fall through to the environment
385
+ } catch (_) {
386
+ // Credential reference not found in service, try next candidate
313
387
  }
314
- const fromEnv = process.env[candidate]
315
- if (fromEnv) return fromEnv
388
+ const envVal = process.env[candidate]
389
+ if (envVal) return envVal
316
390
  }
317
- throw new Error(
318
- `API key not configured: set credential/env "${ref}" (Web: Settings → Credentials, or add "${ref}: <key>" to $DSH_HOME/.credentials.yaml)`,
319
- )
391
+ return ''
320
392
  }
321
393
 
322
394
  /**
323
- * Migrate settings from legacy plugin name.
324
- *
325
- * Uses raw user layer: default values do not need migration,
326
- * distinguishable only from user overrides. If settings are already configured
327
- * under the new namespace, preserve them as newer choices.
328
- *
329
- * The legacy block is left untouched in settings to avoid unexpected deletions.
330
- * and causes no conflict with the active namespace.
395
+ * If the user configured the plugin under its old name (dsh-fal-image-gen), copy
396
+ * those settings forward into dsh-image-gen once so existing keys and choices
397
+ * are preserved. Only touches values that the user actually modified.
331
398
  */
332
399
  function migrateLegacySettings(sctx, scope) {
333
400
  try {
334
- const readSection = sctx.settings.section
335
- if (typeof readSection !== 'function') return
336
- const legacy = readSection.call(sctx.settings, LEGACY_NS)
337
- if (!legacy || typeof legacy !== 'object' || Object.keys(legacy).length === 0) return
338
- const mine = readSection.call(sctx.settings, NS)
339
- if (mine && typeof mine === 'object' && Object.keys(mine).length > 0) return
401
+ if (!scope || typeof scope.update !== 'function') return
402
+ const legacyScope = sctx.settings.get?.(LEGACY_NS)
403
+ if (!legacyScope) return
404
+ const legacy = legacyScope.get?.()
405
+ if (!legacy || typeof legacy !== 'object') return
340
406
  scope.update(structuredClone(legacy))
341
- } catch (cannotMigrate) {
342
- // Settings migration skipped — fallback to default schema configuration,
343
- // and user may configure settings via UI card. No crash required.
407
+ } catch (_) {
408
+ // Settings migration skipped — fallback to default schema configuration
344
409
  }
345
410
  }
346
411
 
347
- /** History directory: persists across restarts. */
348
412
  /** Collect text chunks from llm.stream iterator. */
349
413
  export async function collectText(iterable) {
350
414
  let out = ''
@@ -389,12 +453,13 @@ export async function enhancePrompt(ctx, cfg, prompt, signal, provider) {
389
453
  const text = await collectText(chunks)
390
454
  if (!text) return { prompt, enhanced: false }
391
455
  return { prompt: text, enhanced: true }
392
- } catch (e) {
456
+ } catch (_) {
393
457
  return { prompt, enhanced: false }
394
458
  }
395
459
  }
396
460
 
397
- export function apply(ctx, config) {
461
+ export function apply(ctx, rawConfig) {
462
+ const config = Config(rawConfig ?? {})
398
463
  if (ctx.systemPrompt && typeof ctx.systemPrompt.section === 'function') {
399
464
  ctx.systemPrompt.section({
400
465
  name: 'tool:image-generation',
@@ -406,29 +471,79 @@ export function apply(ctx, config) {
406
471
  })
407
472
  }
408
473
 
409
-
410
- // The Web card edits this namespace; without registering it the card binds to
411
- // a namespace nobody declared, stays unready and renders nothing — which is
412
- // why the plugin's settings tab was empty. Reading through getConfig() also
413
- // means an edit applies to the next call instead of after a restart.
414
- let getConfig = () => config
415
- const live = () => Config(structuredClone(getConfig() ?? {})) ?? config
474
+ let settingsApi
475
+ let currentLiveConfig = null
476
+ let getConfig = () => currentLiveConfig || config
477
+ const live = () => Config(structuredClone(plainConfig(getConfig() ?? {}))) ?? config
478
+
479
+ const createSettingsAdapter = (svc) => {
480
+ if (!svc) return undefined
481
+ if (typeof svc.replace === 'function' || typeof svc.update === 'function' || typeof svc.mutate === 'function') {
482
+ const getRevision = () => {
483
+ try {
484
+ return svc.describe?.().find((row) => row.ns === NS)?.revision
485
+ } catch (_) {
486
+ return undefined
487
+ }
488
+ }
489
+ return {
490
+ get: () => live(),
491
+ replace: async (next) => {
492
+ const parsed = Config(structuredClone(plainConfig(next)))
493
+ currentLiveConfig = parsed
494
+ const payload = volatileConfig(parsed)
495
+ if (typeof svc.replace === 'function') {
496
+ await svc.replace(NS, payload, getRevision())
497
+ } else if (typeof svc.update === 'function') {
498
+ await svc.update(NS, payload, getRevision())
499
+ }
500
+ return parsed
501
+ },
502
+ update: async (patch) => {
503
+ const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
504
+ currentLiveConfig = merged
505
+ const payload = volatileConfig(merged)
506
+ if (typeof svc.update === 'function') {
507
+ await svc.update(NS, payload, getRevision())
508
+ } else if (typeof svc.replace === 'function') {
509
+ await svc.replace(NS, payload, getRevision())
510
+ }
511
+ return merged
512
+ },
513
+ watch: (cb) => {
514
+ if (typeof svc.watch === 'function') return svc.watch(cb)
515
+ return () => {}
516
+ },
517
+ }
518
+ }
519
+ return undefined
520
+ }
416
521
 
417
522
  ctx.inject(['settings'], (sctx) => {
418
- const scope = sctx.settings.register(NS, Config, { base: config })
419
- migrateLegacySettings(sctx, scope)
420
- getConfig = () => (scope?.get?.() ?? config) ?? config
421
- sctx.effect(() => () => {
422
- getConfig = () => config
423
- })
523
+ if (typeof sctx.settings?.register === 'function') {
524
+ const scope = sctx.settings.register(NS, Config, { base: config })
525
+ settingsApi = scope
526
+ migrateLegacySettings(sctx, scope)
527
+ getConfig = () => (scope?.get?.() ?? config) ?? config
528
+ sctx.effect(() => () => {
529
+ getConfig = () => config
530
+ settingsApi = undefined
531
+ })
532
+ } else {
533
+ settingsApi = createSettingsAdapter(sctx.settings)
534
+ if (typeof sctx.settings?.describe === 'function') {
535
+ try {
536
+ sctx.settings.describe(NS, Config)
537
+ } catch (_) { /* bestEffort */ }
538
+ }
539
+ sctx.effect(() => () => {
540
+ getConfig = () => config
541
+ settingsApi = undefined
542
+ })
543
+ }
424
544
  })
425
545
 
426
- // Serve the stored image so the tool card can show it inline. Tool cards do
427
- // not render image blocks — only assistant messages do — so the picture a
428
- // tool produces needs a URL of its own.
429
- //
430
- // Ids are content-addressed (`sha256:<hex>`), the store verifies them, and
431
- // the route is same-origin like every other plugin route.
546
+ // Serve the stored image so the tool card can show it inline.
432
547
  const imageHandler = (() => {
433
548
  return async (req, res) => {
434
549
  if (req.method !== 'GET' && req.method !== 'HEAD') {
@@ -448,47 +563,37 @@ export function apply(ctx, config) {
448
563
  res.end(JSON.stringify({ error: 'bad attachment id' }))
449
564
  return
450
565
  }
451
- // The store verifies the whole reference, not just the id: mediaType,
452
- // byte count and dimensions must match what it probes from the bytes.
453
- // The tool result carries all of them, so the card sends them back.
454
- // Nothing is gained by forging them — the bytes are still verified
455
- // against the sha256 in the id, and a mismatch is simply a 404.
456
- const rawBytes = Number(query.get('b'))
457
- const rawW = Number(query.get('w'))
458
- const rawH = Number(query.get('h'))
459
- const safeBytes = Number.isFinite(rawBytes) && rawBytes >= 0 ? rawBytes : 0
460
- const safeW = Number.isFinite(rawW) && rawW >= 0 ? rawW : 0
461
- const safeH = Number.isFinite(rawH) && rawH >= 0 ? rawH : 0
462
-
463
- const ref = {
464
- attachmentId: id,
465
- mediaType: query.get('mt') || 'image/png',
466
- bytes: safeBytes,
467
- width: safeW,
468
- height: safeH,
469
- }
566
+
567
+ const mediaType = query.get('mediaType') ?? 'image/png'
568
+ const bytes = Number(query.get('bytes') ?? 0)
569
+ const width = Number(query.get('width') ?? 0)
570
+ const height = Number(query.get('height') ?? 0)
571
+
470
572
  try {
471
- if (!ctx.attachments || typeof ctx.attachments.readImage !== 'function') {
472
- res.writeHead(404, { 'Content-Type': 'application/json' })
473
- res.end(JSON.stringify({ error: 'attachment service unavailable' }))
474
- return
475
- }
476
- const stored = await ctx.attachments.readImage(ref)
573
+ const stored = await ctx.attachments.readImage({
574
+ attachmentId: id,
575
+ mediaType,
576
+ bytes,
577
+ width,
578
+ height,
579
+ })
477
580
  res.writeHead(200, {
478
- 'Content-Type': stored.ref?.mediaType || 'image/png',
479
- // Content-addressed: the bytes behind an id never change.
581
+ 'Content-Type': stored.ref?.mediaType || mediaType,
582
+ 'Content-Length': stored.data.byteLength,
480
583
  'Cache-Control': 'public, max-age=31536000, immutable',
481
584
  })
585
+ if (req.method === 'HEAD') {
586
+ res.end()
587
+ return
588
+ }
482
589
  res.end(Buffer.isBuffer(stored.data) ? stored.data : Buffer.from(stored.data))
483
- } catch (error) {
590
+ } catch (_) {
484
591
  res.writeHead(404, { 'Content-Type': 'application/json' })
485
- res.end(JSON.stringify({ error: 'image not found or reference mismatch' }))
592
+ res.end(JSON.stringify({ error: 'attachment not found' }))
486
593
  }
487
594
  }
488
595
  })()
489
596
 
490
- // Two routes, one handler: new route for new messages, legacy route for
491
- // backward compatibility with conversation history.
492
597
  for (const path of ['/dsh-image-gen/image', '/dsh-fal-image-gen/image']) {
493
598
  ctx.effect(() => ctx.webServer.register({
494
599
  kind: 'exact',
@@ -497,7 +602,14 @@ export function apply(ctx, config) {
497
602
  }), `dsh-image-gen: image route ${path}`)
498
603
  }
499
604
 
500
- // One-click plugin updater route per DSH standard
605
+ // Settings REST routes (#295)
606
+ registerSettingsRoutes(ctx, {
607
+ live,
608
+ getSettingsApi: () => settingsApi,
609
+ setLiveConfig: (c) => { currentLiveConfig = c },
610
+ })
611
+
612
+ // One-click plugin updater route per DSH standard
501
613
  ctx.effect(() => registerPluginUpdater(ctx, {
502
614
  endpoint: '/api/dsh-image-gen/update',
503
615
  packageName: '@goodandready/dsh-image-gen',
@@ -525,12 +637,13 @@ export function apply(ctx, config) {
525
637
  const deps = {
526
638
  fetchImpl: fetch,
527
639
  resolveKey: (ref) => resolveApiKey(ctx, ref),
528
- cfg: (typeof getConfig === "function") ? getConfig() : config,
640
+ cfg: (typeof getConfig === 'function') ? getConfig() : config,
529
641
  ctx,
530
642
  }
531
643
  const result = await testProviderConnection(deps, provider)
644
+ const guardState = getLoopGuardState(provider)
532
645
  res.writeHead(200, { 'Content-Type': 'application/json' })
533
- res.end(JSON.stringify({ provider, ...result }))
646
+ res.end(JSON.stringify({ provider, loopGuard: guardState, ...result }))
534
647
  } catch (err) {
535
648
  res.writeHead(500, { 'Content-Type': 'application/json' })
536
649
  res.end(JSON.stringify({ ok: false, error: err.message }))
@@ -570,6 +683,12 @@ export function apply(ctx, config) {
570
683
  // Vault route (#159)
571
684
  registerVaultRoutes(ctx)
572
685
 
686
+ // Session and cache teardown cleanup effect
687
+ ctx.effect(() => () => {
688
+ clearAllAnchors()
689
+ resetLoopGuard()
690
+ }, 'dsh-image-gen: session cleanup')
691
+
573
692
  // Tool registrations live in register-tools.js; each tool is a labeled ctx.effect (#216).
574
693
  registerAllTools(ctx, {
575
694
  config,
@@ -579,5 +698,4 @@ export function apply(ctx, config) {
579
698
  slugify,
580
699
  resolveApiKey,
581
700
  })
582
-
583
701
  }