@goodandready/dsh-image-gen 0.10.35 → 0.11.1

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