@goodandready/dsh-image-gen 0.10.30 → 0.10.32

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,163 @@
1
+ // fallback-router.js — Smart Provider Fallback Chain for image generation (#282).
2
+ // Automatically cascades across configured providers on 429, 5xx, timeouts,
3
+ // quota depletion, and network issues without failing the user's turn.
4
+
5
+ import { formatErrorMessage } from './provider-utils.js'
6
+
7
+ /**
8
+ * Checks if an error is strictly a prompt-level safety or client syntax violation
9
+ * that cannot be fixed by switching to a different provider.
10
+ *
11
+ * @param {Error|any} error
12
+ * @returns {boolean} true if fatal prompt/content violation, false if provider-level retryable
13
+ */
14
+ export function isFatalPromptError(error) {
15
+ const msg = (error?.message || String(error || '')).toLowerCase()
16
+ return (
17
+ msg.includes('content policy') ||
18
+ msg.includes('safety system') ||
19
+ msg.includes('nsfw') ||
20
+ msg.includes('moderation') ||
21
+ msg.includes('prompt is required') ||
22
+ msg.includes('empty prompt') ||
23
+ msg.includes('invalid_prompt') ||
24
+ msg.includes('unsupported image format')
25
+ )
26
+ }
27
+
28
+ /**
29
+ * Checks whether an error is retryable by switching to another provider.
30
+ * Covers 429 (rate limit), 5xx (server error), timeout, payment/quota depletion, and network dropouts.
31
+ *
32
+ * @param {Error|any} error
33
+ * @returns {boolean}
34
+ */
35
+ export function isRetryableProviderError(error) {
36
+ if (isFatalPromptError(error)) return false
37
+ const msg = (error?.message || String(error || '')).toLowerCase()
38
+ return (
39
+ msg.includes('429') ||
40
+ msg.includes('rate limit') ||
41
+ msg.includes('too many requests') ||
42
+ msg.includes('500') ||
43
+ msg.includes('502') ||
44
+ msg.includes('503') ||
45
+ msg.includes('504') ||
46
+ msg.includes('bad gateway') ||
47
+ msg.includes('service unavailable') ||
48
+ msg.includes('gateway timeout') ||
49
+ msg.includes('timeout') ||
50
+ msg.includes('timed out') ||
51
+ msg.includes('aborted') ||
52
+ msg.includes('402') ||
53
+ msg.includes('payment required') ||
54
+ msg.includes('insufficient_quota') ||
55
+ msg.includes('insufficient credits') ||
56
+ msg.includes('exceeded your current quota') ||
57
+ msg.includes('balance is insufficient') ||
58
+ msg.includes('quota') ||
59
+ msg.includes('credit') ||
60
+ msg.includes('unauthorized') ||
61
+ msg.includes('401') ||
62
+ msg.includes('invalid api key') ||
63
+ msg.includes('econnrefused') ||
64
+ msg.includes('enotfound') ||
65
+ msg.includes('fetch failed') ||
66
+ msg.includes('network')
67
+ )
68
+ }
69
+
70
+ /**
71
+ * Resolves the candidate evaluation order:
72
+ * Primary provider first, followed by explicit fallbackProviders, then remaining known providers.
73
+ *
74
+ * @param {string} primary
75
+ * @param {string[]} [configuredFallbacks=[]]
76
+ * @param {string[]} [allProviders=[]]
77
+ * @returns {string[]}
78
+ */
79
+ export function resolveFallbackChain(primary, configuredFallbacks = [], allProviders = []) {
80
+ const chain = [primary]
81
+ if (Array.isArray(configuredFallbacks)) {
82
+ for (const p of configuredFallbacks) {
83
+ if (typeof p === 'string' && p.trim() && !chain.includes(p.trim())) {
84
+ chain.push(p.trim())
85
+ }
86
+ }
87
+ }
88
+ if (Array.isArray(allProviders)) {
89
+ for (const p of allProviders) {
90
+ if (typeof p === 'string' && p.trim() && !chain.includes(p.trim())) {
91
+ chain.push(p.trim())
92
+ }
93
+ }
94
+ }
95
+ return chain
96
+ }
97
+
98
+ /**
99
+ * Executes image generation across a cascade of providers with full attempt diagnostics.
100
+ *
101
+ * @param {Record<string, (seed: number, prompt: string) => Promise<any>>} generators
102
+ * @param {string[]} chain Ordered list of providers to try
103
+ * @param {number} seed
104
+ * @param {string} prompt
105
+ * @param {object} [options]
106
+ * @returns {Promise<any>} Produced image with attached _fallback metadata
107
+ */
108
+ export async function executeWithFallback(generators, chain, seed, prompt, options = {}) {
109
+ const attempts = []
110
+ const primaryProvider = chain[0] || 'unknown'
111
+ const logger = options.logger || null
112
+
113
+ for (let i = 0; i < chain.length; i++) {
114
+ const providerKey = chain[i]
115
+ const generator = generators[providerKey]
116
+ if (typeof generator !== 'function') {
117
+ continue
118
+ }
119
+
120
+ const startTime = Date.now()
121
+ try {
122
+ const produced = await generator(seed, prompt)
123
+ if (produced) {
124
+ const isFallback = providerKey !== primaryProvider
125
+ produced._fallback = {
126
+ triggered: isFallback,
127
+ primaryProvider,
128
+ providerUsed: providerKey,
129
+ attempts: [
130
+ ...attempts,
131
+ { provider: providerKey, durationMs: Date.now() - startTime, success: true },
132
+ ],
133
+ }
134
+ if (isFallback && logger && typeof logger.info === 'function') {
135
+ logger.info(`[dsh-image-gen] Fallback triggered: ${primaryProvider} -> ${providerKey}`)
136
+ }
137
+ return produced
138
+ }
139
+ } catch (err) {
140
+ const durationMs = Date.now() - startTime
141
+ const formatted = formatErrorMessage(err, providerKey)
142
+ attempts.push({
143
+ provider: providerKey,
144
+ error: formatted,
145
+ durationMs,
146
+ success: false,
147
+ })
148
+
149
+ if (isFatalPromptError(err)) {
150
+ throw new Error(`Content or policy error on ${providerKey} (not cascading): ${formatted}`)
151
+ }
152
+
153
+ if (logger && typeof logger.warn === 'function') {
154
+ logger.warn(`[dsh-image-gen] Provider ${providerKey} failed: ${formatted}. Trying next candidate...`)
155
+ }
156
+ }
157
+ }
158
+
159
+ const failureSummary = attempts
160
+ .map((a) => `${a.provider} (${a.durationMs}ms): ${a.error}`)
161
+ .join('; ')
162
+ throw new Error(`Image generation cascade failed on all ${attempts.length} attempted providers: ${failureSummary}`)
163
+ }
package/lib/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  import z from '@deepseek-ai/schemastery'
16
16
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
17
17
  import { mkdir, readFile, writeFile } from 'node:fs/promises'
18
- import { enforceSecurePermissions } from './security.js'
18
+ import { enforceSecurePermissions, isTrustedLocalRequest } from './security.js'
19
19
  import { saveAndAttachResult } from './attachment-helper.js'
20
20
  import {
21
21
  historyFile,
@@ -264,6 +264,10 @@ export const Config = z.object({
264
264
  .boolean()
265
265
  .description('Content-addressed disk caching for identical generations (<50ms retrieval, zero API cost). On by default.')
266
266
  .default(true),
267
+ fallbackProviders: z
268
+ .array(z.string())
269
+ .description('Ordered list of fallback providers to cascade to if the primary encounters 429, 5xx, or quota limits.')
270
+ .default([]),
267
271
  })
268
272
 
269
273
  /** Keep a file stem safe for the filesystem. */
@@ -431,6 +435,11 @@ export function apply(ctx, config) {
431
435
  res.end(JSON.stringify({ error: 'GET only' }))
432
436
  return
433
437
  }
438
+ if (!isTrustedLocalRequest(req)) {
439
+ res.writeHead(403, { 'Content-Type': 'application/json' })
440
+ res.end(JSON.stringify({ error: 'forbidden' }))
441
+ return
442
+ }
434
443
  const query = new URL(req.url ?? '/', 'http://x').searchParams
435
444
  const id = query.get('id') ?? ''
436
445
  if (!/^sha256:[0-9a-f]{64}$/.test(id)) {
@@ -504,6 +513,11 @@ export function apply(ctx, config) {
504
513
  res.end(JSON.stringify({ error: 'GET only' }))
505
514
  return
506
515
  }
516
+ if (!isTrustedLocalRequest(req)) {
517
+ res.writeHead(403, { 'Content-Type': 'application/json' })
518
+ res.end(JSON.stringify({ error: 'forbidden' }))
519
+ return
520
+ }
507
521
  try {
508
522
  const u = new URL(req.url, 'http://localhost')
509
523
  const provider = u.searchParams.get('provider') || config.provider || 'fal'
@@ -533,12 +547,20 @@ export function apply(ctx, config) {
533
547
  res.end(JSON.stringify({ error: 'GET only' }))
534
548
  return
535
549
  }
550
+ if (!isTrustedLocalRequest(req)) {
551
+ res.writeHead(403, { 'Content-Type': 'application/json' })
552
+ res.end(JSON.stringify({ error: 'forbidden' }))
553
+ return
554
+ }
536
555
  const exists = (p) => existsSync(p)
537
556
  const entries = await readHistory()
538
- const withThumbs = filterHistory(entries, exists).map((e) => ({
539
- ...e,
540
- thumbnailUrl: e.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(e.attachmentId)}` : '',
541
- }))
557
+ const withThumbs = filterHistory(entries, exists).map((e) => {
558
+ const { path: _discardPath, ...safeEntry } = e
559
+ return {
560
+ ...safeEntry,
561
+ thumbnailUrl: e.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(e.attachmentId)}` : '',
562
+ }
563
+ })
542
564
  res.writeHead(200, { 'Content-Type': 'application/json' })
543
565
  res.end(JSON.stringify(withThumbs))
544
566
  },
@@ -11,6 +11,9 @@ import { registerSketchTools } from './tools/sketch.js'
11
11
  import { registerSpritesheetTools } from './tools/spritesheet.js'
12
12
  import { registerPatternTools } from './tools/pattern.js'
13
13
  import { registerResponsiveTools } from './tools/responsive.js'
14
+ import { registerAnchorTools } from './tools/anchor.js'
15
+ import { registerUiAssetTools } from './tools/ui-asset.js'
16
+ import { registerStyleMatrixTools } from './tools/style-matrix.js'
14
17
 
15
18
  /**
16
19
  * Registers every image-gen tool on the host tools service.
@@ -29,4 +32,7 @@ export function registerAllTools(ctx, deps) {
29
32
  registerSpritesheetTools(ctx, deps)
30
33
  registerPatternTools(ctx, deps)
31
34
  registerResponsiveTools(ctx, deps)
35
+ registerAnchorTools(ctx, deps)
36
+ registerUiAssetTools(ctx, deps)
37
+ registerStyleMatrixTools(ctx, deps)
32
38
  }
package/lib/security.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // lib/security.js
2
2
  // Secure credential handling, 0600 file permissions, and token masking (#169)
3
+ // Address validation and trusted request guards against DNS rebinding & cross-site leaks (Refs: GitHub #1, #280, #276, #277)
3
4
 
4
5
  import { chmodSync, existsSync } from 'node:fs'
5
6
 
@@ -67,3 +68,168 @@ export function enforceSecurePermissions(filePath) {
67
68
  return false
68
69
  }
69
70
  }
71
+
72
+ /**
73
+ * Strict loopback address verification.
74
+ * Supports:
75
+ * - 'localhost' and RFC 6761 reserved '.localhost' TLD (e.g. 'sub.localhost')
76
+ * - IPv6 loopback '::1'
77
+ * - Strict IPv4 loopback 127.0.0.0/8 (anchored ^ and $, valid octets 0..255)
78
+ * - IPv6-mapped IPv4 loopback '::ffff:127.x.x.x'
79
+ *
80
+ * Explicitly rejects prefix-matched hostnames such as 127.0.0.1.evil.com.
81
+ */
82
+ export function isLoopbackAddress(value) {
83
+ if (!value || typeof value !== 'string') return false
84
+ const address = value.trim().toLowerCase().replace(/^\[|\]$/g, '')
85
+ if (
86
+ address === 'localhost' ||
87
+ address === 'localhost.' ||
88
+ address === '::1' ||
89
+ address.endsWith('.localhost') ||
90
+ address.endsWith('.localhost.')
91
+ ) {
92
+ return true
93
+ }
94
+ const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address
95
+ const m = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ipv4)
96
+ if (!m) return false
97
+ const o = [Number(m[1]), Number(m[2]), Number(m[3])]
98
+ return o.every((n) => n >= 0 && n <= 255)
99
+ }
100
+
101
+ /**
102
+ * Strict private LAN address verification.
103
+ * Supports:
104
+ * - 10.0.0.0/8 (10.0.0.0 - 10.255.255.255)
105
+ * - 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
106
+ * - 192.168.0.0/16 (192.168.0.0 - 192.168.255.255)
107
+ * - 169.254.0.0/16 (link-local)
108
+ * - IPv6-mapped IPv4 equivalents (::ffff:...)
109
+ * - IPv6 ULA (fc00::/7) and link-local (fe80::/10)
110
+ *
111
+ * Explicitly rejects prefix-matched hostnames such as 10.evil.com,
112
+ * 192.168.evil.com, 172.16.evil.com, 10.0.0.1.nip.io.
113
+ */
114
+ export function isPrivateLanAddress(value) {
115
+ if (!value || typeof value !== 'string') return false
116
+ const address = value.trim().toLowerCase().replace(/^\[|\]$/g, '')
117
+ const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address
118
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ipv4)
119
+ if (m) {
120
+ const o = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]
121
+ if (o.some((n) => n < 0 || n > 255)) return false
122
+ if (o[0] === 10) return true
123
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true
124
+ if (o[0] === 192 && o[1] === 168) return true
125
+ if (o[0] === 169 && o[1] === 254) return true
126
+ return false
127
+ }
128
+
129
+ // IPv6 ULA (fc00::/7) or link-local (fe80::/10)
130
+ if (/^(fc|fd)[0-9a-f]{2}:/i.test(address) || /^fe80:/i.test(address)) {
131
+ return true
132
+ }
133
+
134
+ return false
135
+ }
136
+
137
+ /**
138
+ * Safely extracts hostname without port or IPv6 brackets from Host header or authority string.
139
+ */
140
+ export function extractHostName(hostHeader) {
141
+ if (!hostHeader || typeof hostHeader !== 'string') return ''
142
+ const trimmed = hostHeader.trim()
143
+ try {
144
+ const url = new URL(`http://${trimmed}`)
145
+ return url.hostname.replace(/^\[|\]$/g, '')
146
+ } catch {
147
+ return trimmed.replace(/^\[|\]$/g, '').split(':')[0] || ''
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Helper to get header value in case-insensitive manner.
153
+ */
154
+ function getHeader(req, name) {
155
+ if (!req?.headers) return undefined
156
+ const direct = req.headers[name]
157
+ if (direct !== undefined) return Array.isArray(direct) ? direct[0] : direct
158
+ const lower = req.headers[name.toLowerCase()]
159
+ return Array.isArray(lower) ? lower[0] : lower
160
+ }
161
+
162
+ /**
163
+ * Verifies that an incoming HTTP request originates from and targets a trusted local or private LAN authority.
164
+ * Guards against DNS rebinding, cross-site leaks, and unauthorized external probing.
165
+ *
166
+ * Rules:
167
+ * 1. Client remote address (if present on socket) must be loopback or private LAN.
168
+ * 2. Sec-Fetch-Site (if present) must NOT be 'cross-site'.
169
+ * 3. Host header must be present and its hostname must be loopback or private LAN.
170
+ * 4. Origin header (if present) must have valid http/https protocol, its hostname must be loopback/LAN,
171
+ * and its host authority must match the request's Host header.
172
+ * 5. Referer header (if present) must have valid http/https protocol and its hostname must be loopback/LAN.
173
+ */
174
+ export function isTrustedLocalRequest(req) {
175
+ if (!req) return false
176
+
177
+ const remote = req.socket?.remoteAddress
178
+ if (remote && !isLoopbackAddress(remote) && !isPrivateLanAddress(remote)) {
179
+ return false
180
+ }
181
+
182
+ const site = getHeader(req, 'sec-fetch-site')
183
+ if (site === 'cross-site') {
184
+ return false
185
+ }
186
+
187
+ const host = getHeader(req, 'host')
188
+ if (!host || typeof host !== 'string') {
189
+ return false
190
+ }
191
+ const hostName = extractHostName(host)
192
+ if (!isLoopbackAddress(hostName) && !isPrivateLanAddress(hostName)) {
193
+ return false
194
+ }
195
+
196
+ const origin = getHeader(req, 'origin')
197
+ if (origin !== undefined) {
198
+ if (typeof origin !== 'string' || origin === '' || origin === 'null') {
199
+ return false
200
+ }
201
+ try {
202
+ const url = new URL(origin)
203
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
204
+ return false
205
+ }
206
+ const originHostName = url.hostname.replace(/^\[|\]$/g, '')
207
+ if (!isLoopbackAddress(originHostName) && !isPrivateLanAddress(originHostName)) {
208
+ return false
209
+ }
210
+ if (url.host.toLowerCase() !== host.toLowerCase()) {
211
+ return false
212
+ }
213
+ } catch {
214
+ return false
215
+ }
216
+ }
217
+
218
+ const referer = getHeader(req, 'referer')
219
+ if (referer !== undefined && typeof referer === 'string' && referer !== '') {
220
+ try {
221
+ const url = new URL(referer)
222
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
223
+ return false
224
+ }
225
+ const refererHostName = url.hostname.replace(/^\[|\]$/g, '')
226
+ if (!isLoopbackAddress(refererHostName) && !isPrivateLanAddress(refererHostName)) {
227
+ return false
228
+ }
229
+ } catch {
230
+ return false
231
+ }
232
+ }
233
+
234
+ return true
235
+ }
@@ -0,0 +1,85 @@
1
+ // style-matrix-helpers.js — Helpers for generate_style_matrix tool (#286).
2
+
3
+ export const DEFAULT_MATRIX_STYLES = [
4
+ 'editorial_photo',
5
+ 'flat_vector',
6
+ 'clay_3d',
7
+ 'cyberpunk',
8
+ ]
9
+
10
+ export const MATRIX_PRESET_PROMPTS = {
11
+ editorial_photo: 'editorial magazine photograph, 35mm film, soft diffused cinematic lighting, award-winning shot, ultra-detailed',
12
+ flat_vector: 'minimal flat vector art, clean sharp lines, bold solid colors, geometric modern graphic illustration',
13
+ clay_3d: 'charming 3D claymation plasticine sculpt, handcrafted clay texture, stop-motion animation aesthetic, miniature depth of field',
14
+ cyberpunk: 'gritty cyberpunk scene, neon reflections, rain-slicked chrome, dark synthwave palette, high-tech dystopian atmosphere',
15
+ cinematic: 'cinematic still frame, 70mm Panavision anamorphic lens, dramatic moody lighting, atmospheric haze',
16
+ anime: 'vibrant modern anime keyframe, Makoto Shinkai aesthetic, luminous skies, crisp cel shading',
17
+ isometric_3d: 'isometric 3D diorama render, Blender 3D, orthographic perspective, cute stylized textures',
18
+ analog_film: 'vintage 1970s Kodachrome analog film print, authentic grain, faded warm tones, nostalgic vignette',
19
+ }
20
+
21
+ /**
22
+ * Normalizes input styles into exactly 4 distinct style strings.
23
+ *
24
+ * @param {string[]|undefined} inputStyles
25
+ * @returns {string[]}
26
+ */
27
+ export function normalizeMatrixStyles(inputStyles) {
28
+ const result = []
29
+ if (Array.isArray(inputStyles)) {
30
+ for (const s of inputStyles) {
31
+ if (typeof s === 'string' && s.trim()) {
32
+ const clean = s.trim().toLowerCase()
33
+ if (!result.includes(clean)) result.push(clean)
34
+ }
35
+ if (result.length === 4) break
36
+ }
37
+ }
38
+
39
+ // Backfill up to 4 using defaults
40
+ for (const def of DEFAULT_MATRIX_STYLES) {
41
+ if (result.length >= 4) break
42
+ if (!result.includes(def)) result.push(def)
43
+ }
44
+
45
+ return result.slice(0, 4)
46
+ }
47
+
48
+ /**
49
+ * Builds the full prompt for a matrix quadrant cell.
50
+ *
51
+ * @param {string} basePrompt
52
+ * @param {string} styleName
53
+ * @returns {string}
54
+ */
55
+ export function buildMatrixCellPrompt(basePrompt, styleName) {
56
+ const cleanBase = String(basePrompt || '').trim()
57
+ const presetDirective = MATRIX_PRESET_PROMPTS[styleName] || (styleName + ' style, aesthetic rendering')
58
+ return cleanBase + ', ' + presetDirective
59
+ }
60
+
61
+ /**
62
+ * Formats a 2x2 markdown presentation grid for chat models.
63
+ *
64
+ * @param {object} params
65
+ * @param {string} params.basePrompt
66
+ * @param {boolean} params.blindMode
67
+ * @param {Array<{id: string, style: string, path: string, url: string, seed: number}>} params.cells
68
+ * @returns {string}
69
+ */
70
+ export function formatMatrixMarkdown({ basePrompt, blindMode, cells }) {
71
+ const lines = [
72
+ '### Style Matrix (2×2 Benchmark): "' + basePrompt + '"',
73
+ blindMode ? '_Blind Comparison Mode: styles masked until evaluated_' : '',
74
+ '',
75
+ '| Cell | Style Preset | Seed | Path |',
76
+ '| :---: | :--- | :---: | :--- |',
77
+ ]
78
+
79
+ for (const c of cells) {
80
+ const styleLabel = blindMode ? ('Option ' + c.id) : ('**' + c.style + '**')
81
+ lines.push('| **' + c.id + '** | ' + styleLabel + ' | `' + c.seed + '` | `' + c.path + '` |')
82
+ }
83
+
84
+ return lines.filter(Boolean).join('\n')
85
+ }
@@ -0,0 +1,129 @@
1
+ // anchor.js — set_style_anchor tool (#283).
2
+ // Manages persistent visual style & character reference anchors in session context.
3
+
4
+ import { defineTool } from '@deepseek-ai/dsh-tools'
5
+ import {
6
+ setSessionAnchor,
7
+ getSessionAnchor,
8
+ clearSessionAnchor,
9
+ normalizeAnchorStrength,
10
+ } from '../anchor-helpers.js'
11
+ import { toLosslessJson } from '../providers.js'
12
+
13
+ export function registerAnchorTools(ctx, deps) {
14
+ const { live, resolveSource } = deps
15
+
16
+ ctx.effect(() => {
17
+ ctx.tools.register(
18
+ defineTool({
19
+ name: 'set_style_anchor',
20
+ description:
21
+ 'Set or clear a persistent visual anchor (character identity or artstyle) for this session. '
22
+ + 'Subsequent image generations automatically adhere to this reference to maintain visual continuity.',
23
+ parameters: {
24
+ image: {
25
+ type: 'string',
26
+ description: 'URL, local file path, or attachment id (sha256:...) to use as the visual anchor. Required unless clear is true.',
27
+ },
28
+ label: {
29
+ type: 'string',
30
+ description: 'Human-readable descriptor, e.g. "Protagonist Alex" or "Dark Synthwave 3D".',
31
+ },
32
+ mode: {
33
+ type: 'string',
34
+ enum: ['style', 'character'],
35
+ description: 'Anchor mode: "character" preserves facial/physical identity; "style" preserves artistic rendering, lighting, and palette. Default: style.',
36
+ },
37
+ strength: {
38
+ type: 'number',
39
+ description: 'Reference influence strength between 0.1 and 1.0 (default 0.65).',
40
+ },
41
+ clear: {
42
+ type: 'boolean',
43
+ description: 'When true, clears the active anchor for this session instead of setting a new one.',
44
+ },
45
+ },
46
+ output: {
47
+ schema: {
48
+ type: 'object',
49
+ additionalProperties: true,
50
+ properties: {
51
+ ok: { type: 'boolean' },
52
+ action: { type: 'string' },
53
+ anchor: {
54
+ type: 'object',
55
+ additionalProperties: true,
56
+ properties: {
57
+ image: { type: 'string' },
58
+ label: { type: 'string' },
59
+ mode: { type: 'string' },
60
+ strength: { type: 'number' },
61
+ updatedAt: { type: 'string' },
62
+ },
63
+ },
64
+ summary: { type: 'string' },
65
+ },
66
+ },
67
+ render(args, value) {
68
+ return [{ type: 'text', text: value.summary || 'Style anchor updated.' }]
69
+ },
70
+ },
71
+ isConcurrencySafe: () => true,
72
+ async execute(args, exec) {
73
+ const cfg = live ? live() : {}
74
+ if (cfg.enabled === false) {
75
+ throw new Error('Image generation plugin is disabled in settings.')
76
+ }
77
+
78
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
79
+
80
+ if (args.clear) {
81
+ const existed = clearSessionAnchor(sessionId)
82
+ const summary = existed
83
+ ? 'Active visual style anchor was cleared for this session.'
84
+ : 'No active visual style anchor was set for this session.'
85
+ return toLosslessJson({
86
+ ok: true,
87
+ action: 'cleared',
88
+ summary,
89
+ })
90
+ }
91
+
92
+ if (!args.image || typeof args.image !== 'string' || !args.image.trim()) {
93
+ throw new Error('Parameter "image" (URL, file path, or sha256 attachment ID) is required to set an anchor.')
94
+ }
95
+
96
+ const imageRef = args.image.trim()
97
+ const mode = args.mode === 'character' ? 'character' : 'style'
98
+ const strength = normalizeAnchorStrength(args.strength)
99
+ const label = args.label ? String(args.label).trim() : (mode === 'character' ? 'Character Anchor' : 'Style Anchor')
100
+
101
+ // Validate source readability if it is a local path or attachment
102
+ if (resolveSource && (imageRef.startsWith('sha256:') || !imageRef.startsWith('http'))) {
103
+ try {
104
+ await resolveSource(ctx, exec, imageRef)
105
+ } catch (err) {
106
+ // Best effort check; non-blocking if remote URL or future attachment
107
+ }
108
+ }
109
+
110
+ const anchor = setSessionAnchor(sessionId, {
111
+ image: imageRef,
112
+ label,
113
+ mode,
114
+ strength,
115
+ })
116
+
117
+ const summary = `Visual anchor "${label}" (${mode} mode, strength: ${strength}) is now active for this session. Subsequent generations will align with this reference.`
118
+
119
+ return toLosslessJson({
120
+ ok: true,
121
+ action: 'set',
122
+ anchor,
123
+ summary,
124
+ })
125
+ },
126
+ }),
127
+ )
128
+ }, 'dsh-image-gen: tool set_style_anchor')
129
+ }