@goodandready/dsh-image-gen 0.10.10 → 0.10.11
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/README.md +8 -0
- package/README.ru.md +8 -0
- package/lib/cost-meter.js +146 -0
- package/lib/generation-cache.js +131 -0
- package/lib/index.js +150 -3
- package/lib/loop-guard.js +64 -0
- package/lib/negative-sanitizer.js +149 -0
- package/lib/quality-gate.js +140 -0
- package/lib/security.js +59 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,6 +84,14 @@
|
|
|
84
84
|
* **Subscription Aspect Ratio Mapping**: maps aspect ratios (`16:9`, `3:2`, `9:16`, `2:3`) to appropriate subscription dimensions (`1536x1024` / `1024x1536`) instead of falling back to default square `1024x1024`.
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
### 🚀 What's New in v0.10.11 (#165, #166, #167, #168, #169, #170)
|
|
88
|
+
* **Negative Prompt Sanitizer (#165)**: Automatic defect filtering and deduplication for diffusion models (SDXL, ComfyUI, Seedream, Local) with style-conflict protection (preserves intentional grainy, vintage, or dark aesthetics).
|
|
89
|
+
* **Automated Quality Gate & Silent Re-roll (#166)**: Heuristic variance and sharpness inspection combined with `dsh-vision-bridge` hook; performs silent re-rolls (up to 2 attempts) for blank, corrupted, or solid frames before returning results.
|
|
90
|
+
* **Cost Metering & Daily Budget (#167)**: Full rate card pricing per provider and resolution, spend tracking in `~/.dsh/storages/dsh-image-gen-spend.json`, dispatch to `dsh-cost-meter`, and hard daily budget enforcement via `dailyBudgetUsd`.
|
|
91
|
+
* **Fail-Fast Loop Guard (#168)**: Session-scoped circuit breaker prevents runaway agent retry loops (default max 3 consecutive generations without user interaction).
|
|
92
|
+
* **Secure Credential Masking (#169)**: Comprehensive masking of tokens in logs, URLs, and errors (`Bearer sk-...abcd`), with `0600` file permission enforcement.
|
|
93
|
+
* **Content-Addressed Disk Cache (#170)**: Instant retrieval (<50ms, zero API cost) for identical requests by SHA-256 hash, with automated LRU disk eviction (500 MB limit) and `force: true` bypass.
|
|
94
|
+
|
|
87
95
|
### 🚀 What's New in v0.10.10 (#201, #203)
|
|
88
96
|
* **Settings GUI Stabilization (#201)**: Fixed `booleanField` spec in client runtime that prevented the settings configuration pane from rendering in DSH Web UI. All configuration fields (providers, model identifiers, API credentials, style presets, LLM enhancer, timeouts, cache retention) render cleanly.
|
|
89
97
|
* **Streamlined Settings UI (#203)**: Removed the bulky in-settings history gallery to keep the configuration panel focused, fast, and organized.
|
package/README.ru.md
CHANGED
|
@@ -130,6 +130,14 @@ graph LR
|
|
|
130
130
|
- **100% интернационализация (#140)**: устранены захардкоженные русские строки, добавлены переводы на русский, английский и китайский языки.
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
### 🚀 Что нового в v0.10.11 (#165, #166, #167, #168, #169, #170)
|
|
134
|
+
* **Negative Prompt Sanitizer (#165)**: Автоматическая санитизация и дедупликация негативных промптов для диффузионных моделей (SDXL, ComfyUI, Seedream, Local) с защитой от конфликта стилей (сохраняет намеренно запрошенную зернистость, винтажность или тени).
|
|
135
|
+
* **Quality Gate и Silent Re-roll (#166)**: Экспресс-контроль резкости/энтропии кадра и интеграция с `dsh-vision-bridge` с автоматическим тихим ре-роллом дефектных, пустых или черных кадров (до 2 попыток).
|
|
136
|
+
* **Интеграция с dsh-cost-meter и суточный бюджет (#167)**: Тарифная сетка по провайдерам и разрешениям, фиксация расходов в `~/.dsh/storages/dsh-image-gen-spend.json` и защита от перерасхода суточного бюджета (`dailyBudgetUsd`).
|
|
137
|
+
* **Fail-Fast Loop Guard (#168)**: Защита от бесконечного зацикливания агента в рамках сессии (максимум 3 последовательные генерации подряд без ответа пользователя).
|
|
138
|
+
* **Маскирование ключей и безопасность (#169)**: Полная санитизация токенов в логах, ошибках и URL (`Bearer sk-...abcd`) и права доступа `0600` на файлы конфигурации.
|
|
139
|
+
* **Контентно-адресуемый дисковый кэш (#170)**: Мгновенная отдача (<50мс, нулевая стоимость) повторных идентичных генераций по SHA-256 хэшу параметров с автоочисткой по LRU (лимит 500 МБ) и опцией принудительного обхода `force: true`.
|
|
140
|
+
|
|
133
141
|
### 🚀 Что нового в v0.10.10 (#201, #203)
|
|
134
142
|
* **Стабилизация окна настроек GUI (#201)**: Исправлена спецификация `booleanField` в клиентском коде, вызывавшая падение рендера карточки настроек плагина в DSH Web UI. Форма настроек теперь открывается штатно со всеми полями, селекторами провайдеров, моделями, ссылками на ключи и пресетами.
|
|
135
143
|
* **Очистка интерфейса настроек (#203)**: Из окна настроек убрана встроенная галерея истории генераций, загромождавшая карточку настроек.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// lib/cost-meter.js
|
|
2
|
+
// Integration with dsh-cost-meter, rate card, and daily budget enforcer (#167)
|
|
3
|
+
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Standard pricing table per provider and resolution (USD per generation).
|
|
10
|
+
* Based on Fal.ai, OpenAI DALL-E 3/gpt-image-2, Seedream, Replicate rates.
|
|
11
|
+
*/
|
|
12
|
+
export const RATE_CARD = {
|
|
13
|
+
fal: {
|
|
14
|
+
'fal-ai/flux/schnell': 0.003,
|
|
15
|
+
'fal-ai/flux-2/klein/9b': 0.003,
|
|
16
|
+
'fal-ai/flux/dev': 0.025,
|
|
17
|
+
'fal-ai/flux-pro': 0.05,
|
|
18
|
+
'fal-ai/flux-pro/v1.1': 0.05,
|
|
19
|
+
'fal-ai/recraft-v3': 0.04,
|
|
20
|
+
'fal-ai/fast-sdxl': 0.002,
|
|
21
|
+
default: 0.025,
|
|
22
|
+
},
|
|
23
|
+
custom: {
|
|
24
|
+
'dall-e-3': 0.04,
|
|
25
|
+
'dall-e-3-hd': 0.08,
|
|
26
|
+
'dall-e-2': 0.02,
|
|
27
|
+
'gpt-image-2': 0.03,
|
|
28
|
+
default: 0.03,
|
|
29
|
+
},
|
|
30
|
+
seedream: {
|
|
31
|
+
'seedream-4.0': 0.015,
|
|
32
|
+
'seedream-3.0': 0.01,
|
|
33
|
+
default: 0.015,
|
|
34
|
+
},
|
|
35
|
+
replicate: {
|
|
36
|
+
'black-forest-labs/flux-schnell': 0.003,
|
|
37
|
+
'black-forest-labs/flux-dev': 0.025,
|
|
38
|
+
'stability-ai/sdxl': 0.01,
|
|
39
|
+
default: 0.02,
|
|
40
|
+
},
|
|
41
|
+
local: {
|
|
42
|
+
default: 0.0, // Self-hosted local ComfyUI / A1111 runs for free
|
|
43
|
+
},
|
|
44
|
+
codex: {
|
|
45
|
+
default: 0.0, // Handled via subscription
|
|
46
|
+
},
|
|
47
|
+
grok: {
|
|
48
|
+
default: 0.0, // Handled via subscription
|
|
49
|
+
},
|
|
50
|
+
gemini: {
|
|
51
|
+
default: 0.03,
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Calculates estimated USD cost for a generation request.
|
|
57
|
+
*/
|
|
58
|
+
export function calculateGenerationCost({ provider = 'fal', model = '', size = '1024x1024', count = 1 } = {}) {
|
|
59
|
+
const p = String(provider || '').toLowerCase()
|
|
60
|
+
const provCard = RATE_CARD[p] || {}
|
|
61
|
+
const baseCost = provCard[model] ?? provCard.default ?? 0.02
|
|
62
|
+
|
|
63
|
+
// High-res multiplier (if > 1024x1024 or 2K/4K)
|
|
64
|
+
let sizeMult = 1.0
|
|
65
|
+
const sizeLower = String(size || '').toLowerCase()
|
|
66
|
+
if (sizeLower.includes('2048') || sizeLower.includes('1536') || sizeLower.includes('hd')) {
|
|
67
|
+
sizeMult = 1.5
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return +(baseCost * sizeMult * Math.max(1, count)).toFixed(4)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getSpendFilePath() {
|
|
74
|
+
const base = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
75
|
+
return join(base, 'storages', 'dsh-image-gen-spend.json')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Loads daily spend record from storage.
|
|
80
|
+
*/
|
|
81
|
+
export function loadDailySpend() {
|
|
82
|
+
const filePath = getSpendFilePath()
|
|
83
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
84
|
+
try {
|
|
85
|
+
const raw = readFileSync(filePath, 'utf8')
|
|
86
|
+
const data = JSON.parse(raw)
|
|
87
|
+
if (data && data.date === today) {
|
|
88
|
+
return data
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
return { date: today, totalSpendUsd: 0, generations: 0 }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Records an image generation expense.
|
|
96
|
+
*/
|
|
97
|
+
export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
|
|
98
|
+
const amount = Number(usdAmount) || 0
|
|
99
|
+
if (amount <= 0) return loadDailySpend()
|
|
100
|
+
|
|
101
|
+
const filePath = getSpendFilePath()
|
|
102
|
+
const current = loadDailySpend()
|
|
103
|
+
current.totalSpendUsd = +(current.totalSpendUsd + amount).toFixed(4)
|
|
104
|
+
current.generations += 1
|
|
105
|
+
current.lastUpdated = new Date().toISOString()
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
mkdirSync(join(filePath, '..'), { recursive: true })
|
|
109
|
+
writeFileSync(filePath, JSON.stringify(current, null, 2), 'utf8')
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// Non-fatal write failure
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Notify dsh-cost-meter if available in cordis context
|
|
115
|
+
try {
|
|
116
|
+
const costMeter = ctx && (ctx.get?.('dsh-cost-meter') || ctx['dsh-cost-meter'])
|
|
117
|
+
if (costMeter && typeof costMeter.recordSpend === 'function') {
|
|
118
|
+
costMeter.recordSpend({
|
|
119
|
+
source: 'dsh-image-gen',
|
|
120
|
+
cost: amount,
|
|
121
|
+
currency: 'USD',
|
|
122
|
+
meta,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
} catch {}
|
|
126
|
+
|
|
127
|
+
return current
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Asserts that the generation does not exceed daily budget.
|
|
132
|
+
* @throws {Error} If daily budget is exceeded.
|
|
133
|
+
*/
|
|
134
|
+
export function assertBudgetAvailable(estimatedCost, dailyBudgetUsd) {
|
|
135
|
+
const budget = Number(dailyBudgetUsd) || 0
|
|
136
|
+
if (budget <= 0) return true // Budget 0 = unlimited
|
|
137
|
+
|
|
138
|
+
const current = loadDailySpend()
|
|
139
|
+
const projected = +(current.totalSpendUsd + estimatedCost).toFixed(4)
|
|
140
|
+
if (projected > budget) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Daily image generation budget exceeded: current spend $${current.totalSpendUsd.toFixed(2)} + estimated $${estimatedCost.toFixed(2)} exceeds limit $${budget.toFixed(2)} (Settings → Image generation → dailyBudgetUsd)`
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
return true
|
|
146
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// lib/generation-cache.js
|
|
2
|
+
// Content-addressed disk cache for image generations with LRU eviction (#170)
|
|
3
|
+
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, unlinkSync, readdirSync } from 'node:fs'
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CACHE_MAX_BYTES = 500 * 1024 * 1024 // 500 MB
|
|
9
|
+
|
|
10
|
+
function getCacheDir() {
|
|
11
|
+
const base = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
12
|
+
return join(base, 'cache', 'image-gen')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Looks up an identical previous generation in content-addressed disk cache.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} hash - Deterministic sha256 hash
|
|
19
|
+
* @param {Object} [options]
|
|
20
|
+
* @param {boolean} [options.force=false] - If true, bypass cache
|
|
21
|
+
* @returns {Object|null} Cached entry { bytes, mediaType, meta } or null
|
|
22
|
+
*/
|
|
23
|
+
export function getCachedGeneration(hash, { force = false } = {}) {
|
|
24
|
+
if (force || !hash) return null
|
|
25
|
+
|
|
26
|
+
const dir = getCacheDir()
|
|
27
|
+
const metaPath = join(dir, `${hash}.json`)
|
|
28
|
+
const dataPath = join(dir, `${hash}.bin`)
|
|
29
|
+
|
|
30
|
+
if (!existsSync(metaPath) || !existsSync(dataPath)) return null
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
34
|
+
const bytes = readFileSync(dataPath)
|
|
35
|
+
|
|
36
|
+
// Touch meta file for LRU update
|
|
37
|
+
meta.lastAccessed = Date.now()
|
|
38
|
+
writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8')
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
bytes,
|
|
42
|
+
mediaType: meta.mediaType || 'image/png',
|
|
43
|
+
width: meta.width,
|
|
44
|
+
height: meta.height,
|
|
45
|
+
seed: meta.seed,
|
|
46
|
+
meta,
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Stores a generation result in content-addressed cache and performs LRU pruning if needed.
|
|
55
|
+
*/
|
|
56
|
+
export function setCachedGeneration(hash, { bytes, mediaType = 'image/png', width, height, seed, meta = {} } = {}, maxBytes = DEFAULT_CACHE_MAX_BYTES) {
|
|
57
|
+
if (!hash || !bytes) return
|
|
58
|
+
|
|
59
|
+
const dir = getCacheDir()
|
|
60
|
+
try {
|
|
61
|
+
mkdirSync(dir, { recursive: true })
|
|
62
|
+
const metaPath = join(dir, `${hash}.json`)
|
|
63
|
+
const dataPath = join(dir, `${hash}.bin`)
|
|
64
|
+
|
|
65
|
+
const cacheMeta = {
|
|
66
|
+
...meta,
|
|
67
|
+
hash,
|
|
68
|
+
mediaType,
|
|
69
|
+
width,
|
|
70
|
+
height,
|
|
71
|
+
seed,
|
|
72
|
+
sizeBytes: bytes.length,
|
|
73
|
+
createdAt: Date.now(),
|
|
74
|
+
lastAccessed: Date.now(),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
writeFileSync(metaPath, JSON.stringify(cacheMeta, null, 2), 'utf8')
|
|
78
|
+
writeFileSync(dataPath, Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes))
|
|
79
|
+
|
|
80
|
+
pruneCacheToLimit(maxBytes)
|
|
81
|
+
} catch {
|
|
82
|
+
// Non-fatal write failure
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Prunes the cache directory down to maxBytes using LRU (least recently accessed).
|
|
88
|
+
*/
|
|
89
|
+
export function pruneCacheToLimit(maxBytes = DEFAULT_CACHE_MAX_BYTES) {
|
|
90
|
+
const dir = getCacheDir()
|
|
91
|
+
if (!existsSync(dir)) return
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.json'))
|
|
95
|
+
const entries = []
|
|
96
|
+
let totalSize = 0
|
|
97
|
+
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
const metaPath = join(dir, file)
|
|
100
|
+
const stem = file.slice(0, -5)
|
|
101
|
+
const dataPath = join(dir, `${stem}.bin`)
|
|
102
|
+
try {
|
|
103
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
104
|
+
const binStat = existsSync(dataPath) ? statSync(dataPath) : { size: 0 }
|
|
105
|
+
const size = binStat.size + statSync(metaPath).size
|
|
106
|
+
totalSize += size
|
|
107
|
+
entries.push({
|
|
108
|
+
stem,
|
|
109
|
+
metaPath,
|
|
110
|
+
dataPath,
|
|
111
|
+
size,
|
|
112
|
+
lastAccessed: meta.lastAccessed || meta.createdAt || 0,
|
|
113
|
+
})
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (totalSize <= maxBytes) return
|
|
118
|
+
|
|
119
|
+
// Sort by lastAccessed ascending (oldest first)
|
|
120
|
+
entries.sort((a, b) => a.lastAccessed - b.lastAccessed)
|
|
121
|
+
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (totalSize <= maxBytes) break
|
|
124
|
+
try {
|
|
125
|
+
if (existsSync(entry.metaPath)) unlinkSync(entry.metaPath)
|
|
126
|
+
if (existsSync(entry.dataPath)) unlinkSync(entry.dataPath)
|
|
127
|
+
totalSize -= entry.size
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -50,6 +50,12 @@ import {
|
|
|
50
50
|
} from './providers.js'
|
|
51
51
|
|
|
52
52
|
import { resolveConversationImage, analyzeImageWithVision, buildDualOutputMarkdown } from './resolve-image.js'
|
|
53
|
+
import { sanitizeNegativePrompt, supportsNegativePrompt } from './negative-sanitizer.js'
|
|
54
|
+
import { executeWithQualityGate } from './quality-gate.js'
|
|
55
|
+
import { calculateGenerationCost, recordSpend, assertBudgetAvailable } from './cost-meter.js'
|
|
56
|
+
import { trackAndAssertLoopGuard, resetLoopGuard } from './loop-guard.js'
|
|
57
|
+
import { maskApiKey, sanitizeErrorAndLogs } from './security.js'
|
|
58
|
+
import { getCachedGeneration, setCachedGeneration } from './generation-cache.js'
|
|
53
59
|
import {
|
|
54
60
|
extractDesignTokens,
|
|
55
61
|
generateCssGradient,
|
|
@@ -322,6 +328,22 @@ export const Config = z.object({
|
|
|
322
328
|
.boolean()
|
|
323
329
|
.description('If the same prompt was already generated (present in history), return the cached result instead of generating again. Off by default.')
|
|
324
330
|
.default(false),
|
|
331
|
+
qualityGate: z
|
|
332
|
+
.boolean()
|
|
333
|
+
.description('Automatic quality gate with silent re-roll for blank or defective frames. On by default.')
|
|
334
|
+
.default(true),
|
|
335
|
+
dailyBudgetUsd: z
|
|
336
|
+
.number()
|
|
337
|
+
.description('Daily image generation spending budget in USD. 0 (default) disables limit enforcement.')
|
|
338
|
+
.default(0),
|
|
339
|
+
loopGuardLimit: z
|
|
340
|
+
.number()
|
|
341
|
+
.description('Maximum consecutive image generations per session without user interaction. 0 disables protection.')
|
|
342
|
+
.default(3),
|
|
343
|
+
diskCache: z
|
|
344
|
+
.boolean()
|
|
345
|
+
.description('Content-addressed disk caching for identical generations (<50ms retrieval, zero API cost). On by default.')
|
|
346
|
+
.default(true),
|
|
325
347
|
})
|
|
326
348
|
|
|
327
349
|
/** Keep a file stem safe for the filesystem. */
|
|
@@ -729,6 +751,14 @@ export function apply(ctx, config) {
|
|
|
729
751
|
type: 'number',
|
|
730
752
|
description: 'Optional edit strength (0-1) for providers that accept it; higher keeps more of the original.',
|
|
731
753
|
},
|
|
754
|
+
quality_gate: {
|
|
755
|
+
type: 'boolean',
|
|
756
|
+
description: 'Optional override to enable/disable automated quality gate and defect re-rolling for this call.',
|
|
757
|
+
},
|
|
758
|
+
force: {
|
|
759
|
+
type: 'boolean',
|
|
760
|
+
description: 'Optional flag to bypass content-addressed cache and force a new API generation.',
|
|
761
|
+
},
|
|
732
762
|
},
|
|
733
763
|
output: {
|
|
734
764
|
schema: {
|
|
@@ -789,6 +819,15 @@ export function apply(ctx, config) {
|
|
|
789
819
|
timeoutMs: config.timeoutMs + 30000,
|
|
790
820
|
async execute(args, exec) {
|
|
791
821
|
const cfg = live()
|
|
822
|
+
|
|
823
|
+
// #168: Fail-fast loop guard protecting against runaway agent loops
|
|
824
|
+
const sessionId = exec.agent?.session?.id || exec.agent?.session?.header?.id || 'session'
|
|
825
|
+
if (cfg.loopGuardLimit > 0) {
|
|
826
|
+
trackAndAssertLoopGuard(sessionId, {
|
|
827
|
+
limit: cfg.loopGuardLimit,
|
|
828
|
+
prompt: args.prompt,
|
|
829
|
+
})
|
|
830
|
+
}
|
|
792
831
|
if (cfg.enabled === false) {
|
|
793
832
|
throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
|
|
794
833
|
}
|
|
@@ -814,8 +853,27 @@ export function apply(ctx, config) {
|
|
|
814
853
|
const effectiveStyle = args.style || cfg.stylePreset
|
|
815
854
|
const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
|
|
816
855
|
const effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
|
|
817
|
-
|
|
856
|
+
|
|
857
|
+
// #165: Negative prompt sanitizer for diffusion models
|
|
858
|
+
let effectiveNegative = styleInfo.negativePrompt
|
|
859
|
+
if (supportsNegativePrompt(provider, cfg.model || cfg.customModel)) {
|
|
860
|
+
effectiveNegative = sanitizeNegativePrompt({
|
|
861
|
+
positivePrompt: effectivePrompt,
|
|
862
|
+
userNegative: effectiveNegative,
|
|
863
|
+
autoInjectDefects: true,
|
|
864
|
+
})
|
|
865
|
+
}
|
|
818
866
|
const effectiveGuidance = styleInfo.guidanceScale
|
|
867
|
+
|
|
868
|
+
// #167: Assert daily budget availability before calling API
|
|
869
|
+
const totalCount = Array.isArray(args.prompts) && args.prompts.length ? args.prompts.length : normalizeCount(args.count)
|
|
870
|
+
const estimatedCost = calculateGenerationCost({
|
|
871
|
+
provider,
|
|
872
|
+
model: cfg.model || cfg.customModel,
|
|
873
|
+
size,
|
|
874
|
+
count: totalCount,
|
|
875
|
+
})
|
|
876
|
+
assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
|
|
819
877
|
const providers = makeProviders(
|
|
820
878
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
|
821
879
|
{ prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio },
|
|
@@ -886,12 +944,24 @@ export function apply(ctx, config) {
|
|
|
886
944
|
|
|
887
945
|
const cacheHash = computeGenerationHash({
|
|
888
946
|
provider,
|
|
889
|
-
model: cfg.model,
|
|
947
|
+
model: cfg.model || cfg.customModel,
|
|
890
948
|
prompt: promptArg,
|
|
891
949
|
seed: gen.seed,
|
|
892
950
|
size: args.image_size || cfg.defaultSize,
|
|
893
951
|
style: args.style || cfg.stylePreset,
|
|
894
952
|
})
|
|
953
|
+
|
|
954
|
+
// #170: Store in content-addressed disk cache
|
|
955
|
+
if (cfg.diskCache !== false) {
|
|
956
|
+
setCachedGeneration(cacheHash, {
|
|
957
|
+
bytes,
|
|
958
|
+
mediaType,
|
|
959
|
+
width: gen.width || attachment.width,
|
|
960
|
+
height: gen.height || attachment.height,
|
|
961
|
+
seed: gen.seed,
|
|
962
|
+
meta: { prompt: promptArg, provider, model: cfg.model || cfg.customModel, cost: gen.cost },
|
|
963
|
+
})
|
|
964
|
+
}
|
|
895
965
|
const entry = {
|
|
896
966
|
path: filePath,
|
|
897
967
|
prompt: promptArg,
|
|
@@ -915,6 +985,12 @@ export function apply(ctx, config) {
|
|
|
915
985
|
if (current.length > (cfg.historyLimit || 50)) current.length = cfg.historyLimit || 50
|
|
916
986
|
await writeHistory(current)
|
|
917
987
|
|
|
988
|
+
// #167: Record spend in cost meter
|
|
989
|
+
recordSpend(gen.cost || (estimatedCost / totalCount), {
|
|
990
|
+
ctx,
|
|
991
|
+
meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
|
|
992
|
+
})
|
|
993
|
+
|
|
918
994
|
return {
|
|
919
995
|
path: filePath,
|
|
920
996
|
url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
|
|
@@ -940,10 +1016,81 @@ export function apply(ctx, config) {
|
|
|
940
1016
|
// Fallback-цепочка: пробуем текущий провайдер, при отказе — следующий
|
|
941
1017
|
// по порядку (fal → custom → codex → grok), собирая причины отказов.
|
|
942
1018
|
const order = fallbackOrder(provider)
|
|
943
|
-
const
|
|
1019
|
+
const qgEnabled = args.quality_gate ?? cfg.qualityGate
|
|
1020
|
+
const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
|
|
1021
|
+
k,
|
|
1022
|
+
async (s, p) => {
|
|
1023
|
+
const { generated, qualityReport } = await executeWithQualityGate(
|
|
1024
|
+
(seedVal) => one(seedVal, k, p),
|
|
1025
|
+
{
|
|
1026
|
+
initialSeed: s,
|
|
1027
|
+
enabled: qgEnabled,
|
|
1028
|
+
ctx,
|
|
1029
|
+
prompt: p,
|
|
1030
|
+
}
|
|
1031
|
+
)
|
|
1032
|
+
return { ...generated, qualityReport }
|
|
1033
|
+
}
|
|
1034
|
+
]))
|
|
944
1035
|
const images = []
|
|
945
1036
|
const historyEntries = (cfg.cacheBySeed || cfg.cacheByPrompt) ? await readHistory() : []
|
|
946
1037
|
const checkCache = async (seedVal, promptVal) => {
|
|
1038
|
+
const h = computeGenerationHash({
|
|
1039
|
+
provider,
|
|
1040
|
+
model: cfg.model || cfg.customModel,
|
|
1041
|
+
prompt: promptVal,
|
|
1042
|
+
seed: seedVal,
|
|
1043
|
+
size: args.image_size || cfg.defaultSize,
|
|
1044
|
+
style: args.style || cfg.stylePreset,
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
// #170: Content-addressed disk cache check (<50ms return)
|
|
1048
|
+
if (cfg.diskCache !== false && !args.force) {
|
|
1049
|
+
const diskHit = getCachedGeneration(h, { force: args.force })
|
|
1050
|
+
if (diskHit) {
|
|
1051
|
+
const extension = diskHit.mediaType === 'image/jpeg' ? 'jpg' : diskHit.mediaType === 'image/webp' ? 'webp' : 'png'
|
|
1052
|
+
const stem = `${slugify(args.output_name || promptVal)}-${Date.now().toString(36)}-${seedVal}`
|
|
1053
|
+
const name = `${stem}.${extension}`
|
|
1054
|
+
const attachment = await ctx.attachments.saveImage({
|
|
1055
|
+
data: new Uint8Array(diskHit.bytes),
|
|
1056
|
+
mediaType: diskHit.mediaType,
|
|
1057
|
+
name,
|
|
1058
|
+
})
|
|
1059
|
+
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
1060
|
+
const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
|
|
1061
|
+
const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
|
|
1062
|
+
await mkdir(outDir, { recursive: true })
|
|
1063
|
+
const filePath = path.join(outDir, name)
|
|
1064
|
+
await writeFile(filePath, diskHit.bytes)
|
|
1065
|
+
|
|
1066
|
+
const localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
|
|
1067
|
+
+ '&mt=' + encodeURIComponent(attachment.mediaType)
|
|
1068
|
+
+ '&b=' + encodeURIComponent(String(attachment.bytes))
|
|
1069
|
+
+ '&w=' + encodeURIComponent(String(attachment.width))
|
|
1070
|
+
+ '&h=' + encodeURIComponent(String(attachment.height))
|
|
1071
|
+
|
|
1072
|
+
return {
|
|
1073
|
+
path: filePath,
|
|
1074
|
+
url: localUrl,
|
|
1075
|
+
width: diskHit.width || attachment.width,
|
|
1076
|
+
height: diskHit.height || attachment.height,
|
|
1077
|
+
seed: seedVal,
|
|
1078
|
+
prompt: promptVal,
|
|
1079
|
+
cost: 0,
|
|
1080
|
+
fromCache: true,
|
|
1081
|
+
format: diskHit.mediaType.replace('image/', ''),
|
|
1082
|
+
attachment: {
|
|
1083
|
+
attachmentId: attachment.attachmentId,
|
|
1084
|
+
mediaType: attachment.mediaType,
|
|
1085
|
+
bytes: attachment.bytes,
|
|
1086
|
+
width: attachment.width,
|
|
1087
|
+
height: attachment.height,
|
|
1088
|
+
name: attachment.name,
|
|
1089
|
+
},
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
947
1094
|
if (!cfg.cacheBySeed && !cfg.cacheByPrompt) return undefined
|
|
948
1095
|
const h = computeGenerationHash({
|
|
949
1096
|
provider,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// lib/loop-guard.js
|
|
2
|
+
// Fail-fast loop guard protecting against runaway agent retry loops (#168)
|
|
3
|
+
|
|
4
|
+
/** In-memory session tracking map: sessionId -> { count: number, lastPrompt: string, lastTimestamp: number } */
|
|
5
|
+
const sessionStates = new Map()
|
|
6
|
+
|
|
7
|
+
/** Default maximum consecutive generations without user interaction */
|
|
8
|
+
export const DEFAULT_MAX_CONSECUTIVE_GENERATIONS = 3
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Checks and records a generation turn for a session.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} sessionId - Conversation session identifier
|
|
14
|
+
* @param {Object} options
|
|
15
|
+
* @param {number} [options.limit=3] - Maximum allowed calls
|
|
16
|
+
* @param {string} [options.prompt=''] - Current prompt
|
|
17
|
+
* @param {boolean} [options.isUserTurn=false] - Whether the user just submitted a turn (resets counter)
|
|
18
|
+
* @throws {Error} If consecutive generation limit is exceeded.
|
|
19
|
+
*/
|
|
20
|
+
export function trackAndAssertLoopGuard(sessionId, {
|
|
21
|
+
limit = DEFAULT_MAX_CONSECUTIVE_GENERATIONS,
|
|
22
|
+
prompt = '',
|
|
23
|
+
isUserTurn = false,
|
|
24
|
+
} = {}) {
|
|
25
|
+
const sid = String(sessionId || 'default_session')
|
|
26
|
+
|
|
27
|
+
if (isUserTurn) {
|
|
28
|
+
sessionStates.delete(sid)
|
|
29
|
+
return { count: 0, allowed: true }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const current = sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0 }
|
|
33
|
+
const maxLimit = Math.max(1, limit)
|
|
34
|
+
|
|
35
|
+
if (current.count >= maxLimit) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Generation loop limit reached (${current.count}/${maxLimit} consecutive generations). ` +
|
|
38
|
+
`To prevent runaway API billing, please stop retrying and ask the user to clarify or confirm the image prompt.`
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
current.count += 1
|
|
43
|
+
current.lastPrompt = prompt
|
|
44
|
+
current.lastTimestamp = Date.now()
|
|
45
|
+
sessionStates.set(sid, current)
|
|
46
|
+
|
|
47
|
+
return { count: current.count, remaining: maxLimit - current.count, allowed: true }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resets loop guard counter for a session.
|
|
52
|
+
*/
|
|
53
|
+
export function resetLoopGuard(sessionId) {
|
|
54
|
+
const sid = String(sessionId || 'default_session')
|
|
55
|
+
sessionStates.delete(sid)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Gets current state for debugging/testing.
|
|
60
|
+
*/
|
|
61
|
+
export function getLoopGuardState(sessionId) {
|
|
62
|
+
const sid = String(sessionId || 'default_session')
|
|
63
|
+
return sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0 }
|
|
64
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// lib/negative-sanitizer.js
|
|
2
|
+
// Negative prompt sanitizer & smart enhancer for diffusion models (#165)
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Standard defect keywords grouped by category.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_DEFECT_TERMS = [
|
|
8
|
+
'blurry',
|
|
9
|
+
'low quality',
|
|
10
|
+
'worst quality',
|
|
11
|
+
'deformed',
|
|
12
|
+
'disfigured',
|
|
13
|
+
'bad anatomy',
|
|
14
|
+
'extra limbs',
|
|
15
|
+
'missing limbs',
|
|
16
|
+
'poorly drawn face',
|
|
17
|
+
'poorly drawn hands',
|
|
18
|
+
'missing fingers',
|
|
19
|
+
'extra fingers',
|
|
20
|
+
'watermark',
|
|
21
|
+
'signature',
|
|
22
|
+
'jpeg artifacts',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Models that explicitly benefit from negative prompts (SD 1.5, SD 2.x, SDXL, ComfyUI, etc.).
|
|
27
|
+
* FLUX, DALL-E, and Ideogram generally ignore or misinterpret negative prompts.
|
|
28
|
+
*/
|
|
29
|
+
export function supportsNegativePrompt(provider, model) {
|
|
30
|
+
const p = String(provider || '').toLowerCase()
|
|
31
|
+
const m = String(model || '').toLowerCase()
|
|
32
|
+
|
|
33
|
+
if (p === 'fal' && (m.includes('flux') || m.includes('recraft'))) return false
|
|
34
|
+
if (p === 'codex' || p === 'openai' || m.includes('dall-e') || m.includes('gpt-image')) return false
|
|
35
|
+
if (p === 'grok' || m.includes('aurora')) return false
|
|
36
|
+
|
|
37
|
+
// Diffusion / Comfy / A1111 / SDXL / Seedream / Replicate (SD variants)
|
|
38
|
+
if (p === 'local' || p === 'seedream') return true
|
|
39
|
+
if (m.includes('stable-diffusion') || m.includes('sdxl') || m.includes('sd-') || m.includes('illustrious') || m.includes('pony')) return true
|
|
40
|
+
if (p === 'custom' && (m.includes('sd') || m.includes('diffusion'))) return true
|
|
41
|
+
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Clean and split prompt into normalized lowercase terms.
|
|
47
|
+
*/
|
|
48
|
+
export function splitTerms(promptStr) {
|
|
49
|
+
if (!promptStr || typeof promptStr !== 'string') return []
|
|
50
|
+
return promptStr
|
|
51
|
+
.split(/[,;\n]+/)
|
|
52
|
+
.map((t) => t.trim())
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeWord(word) {
|
|
57
|
+
if (!word) return ''
|
|
58
|
+
return word
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.replace(/(y|ies|ing|ed|s)$/, '')
|
|
61
|
+
.replace(/(.)\1+$/, '$1') // e.g. blurr -> blur
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Checks whether a negative term contradicts a positive prompt intent.
|
|
66
|
+
* E.g. If user asks for "grainy vintage 35mm film", don't inject "grain" or "vintage".
|
|
67
|
+
*/
|
|
68
|
+
export function contradictsPositive(negativeTerm, positivePrompt) {
|
|
69
|
+
const pos = String(positivePrompt || '').toLowerCase()
|
|
70
|
+
const neg = String(negativeTerm || '').toLowerCase().trim()
|
|
71
|
+
|
|
72
|
+
if (!pos || !neg) return false
|
|
73
|
+
|
|
74
|
+
// Check direct inclusion or overlap
|
|
75
|
+
const posWords = pos.match(/[a-z0-9]+/g) || []
|
|
76
|
+
const negWords = neg.match(/[a-z0-9]+/g) || []
|
|
77
|
+
|
|
78
|
+
// Stylistic keywords where positive intent must be respected
|
|
79
|
+
const styleKeywords = [
|
|
80
|
+
'blur', 'blurry', 'grain', 'grainy', 'vintage', 'noise', 'noisy',
|
|
81
|
+
'monochrome', 'grayscale', 'sketch', 'drawing', 'dark', 'shadowy',
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
for (const nw of negWords) {
|
|
85
|
+
const nwNorm = normalizeWord(nw)
|
|
86
|
+
for (const styleKw of styleKeywords) {
|
|
87
|
+
if (nw === styleKw || nwNorm === normalizeWord(styleKw)) {
|
|
88
|
+
// Look if positive prompt contains this style word or normalized form
|
|
89
|
+
if (posWords.some((pw) => pw === styleKw || normalizeWord(pw) === nwNorm)) {
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Sanitize, merge, and deduplicate negative prompt with default safety defect terms.
|
|
101
|
+
*
|
|
102
|
+
* @param {Object} options
|
|
103
|
+
* @param {string} [options.positivePrompt]
|
|
104
|
+
* @param {string} [options.userNegative]
|
|
105
|
+
* @param {string[]} [options.additionalTerms]
|
|
106
|
+
* @param {boolean} [options.autoInjectDefects=true]
|
|
107
|
+
* @returns {string} Sanitized negative prompt
|
|
108
|
+
*/
|
|
109
|
+
export function sanitizeNegativePrompt({
|
|
110
|
+
positivePrompt = '',
|
|
111
|
+
userNegative = '',
|
|
112
|
+
additionalTerms = [],
|
|
113
|
+
autoInjectDefects = true,
|
|
114
|
+
} = {}) {
|
|
115
|
+
const seen = new Set()
|
|
116
|
+
const result = []
|
|
117
|
+
|
|
118
|
+
const addTerm = (rawTerm) => {
|
|
119
|
+
const term = String(rawTerm || '').trim()
|
|
120
|
+
if (!term) return
|
|
121
|
+
const key = term.toLowerCase()
|
|
122
|
+
if (seen.has(key)) return
|
|
123
|
+
if (contradictsPositive(term, positivePrompt)) return
|
|
124
|
+
seen.add(key)
|
|
125
|
+
result.push(term)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 1. User's explicit negative prompt comes first (preserves user intent and weights like "(bad:1.2)")
|
|
129
|
+
const userTerms = splitTerms(userNegative)
|
|
130
|
+
for (const t of userTerms) {
|
|
131
|
+
addTerm(t)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 2. Preset / additional negative terms
|
|
135
|
+
for (const t of additionalTerms) {
|
|
136
|
+
if (typeof t === 'string') {
|
|
137
|
+
for (const sub of splitTerms(t)) addTerm(sub)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 3. Built-in defect terms (if enabled)
|
|
142
|
+
if (autoInjectDefects) {
|
|
143
|
+
for (const d of DEFAULT_DEFECT_TERMS) {
|
|
144
|
+
addTerm(d)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return result.join(', ')
|
|
149
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// lib/quality-gate.js
|
|
2
|
+
// Automatic Quality Gate and Silent Re-roll Engine (#166)
|
|
3
|
+
|
|
4
|
+
import { estimateSharpnessAndVariance } from './providers.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Inspects generated image bytes for visual defects, blank frames, or low entropy.
|
|
8
|
+
*
|
|
9
|
+
* @param {Buffer|Uint8Array} bytes - Image bytes
|
|
10
|
+
* @param {Object} [options]
|
|
11
|
+
* @param {Object} [options.ctx] - Cordis context for vision bridge integration
|
|
12
|
+
* @param {string} [options.prompt] - Prompt used for generation
|
|
13
|
+
* @returns {Promise<{ passed: boolean, score: number, defect: string|null, details: Object }>}
|
|
14
|
+
*/
|
|
15
|
+
export async function evaluateImageQuality(bytes, { ctx, prompt } = {}) {
|
|
16
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes || [])
|
|
17
|
+
|
|
18
|
+
// 1. Minimum buffer check
|
|
19
|
+
if (!buf || buf.length < 512) {
|
|
20
|
+
return {
|
|
21
|
+
passed: false,
|
|
22
|
+
score: 0,
|
|
23
|
+
defect: 'corrupt_buffer',
|
|
24
|
+
details: { reason: 'Buffer size is too small or corrupt (< 512 bytes)', size: buf.length },
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 2. Laplacian-like sharpness / variance test
|
|
29
|
+
const heuristic = estimateSharpnessAndVariance(buf)
|
|
30
|
+
if (!heuristic.passed || heuristic.isBlank) {
|
|
31
|
+
return {
|
|
32
|
+
passed: false,
|
|
33
|
+
score: heuristic.score || 0.1,
|
|
34
|
+
defect: 'blank_or_solid_frame',
|
|
35
|
+
details: heuristic,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 3. Vision bridge integration (if available and loaded in ctx)
|
|
40
|
+
let visionVerdict = null
|
|
41
|
+
try {
|
|
42
|
+
const visionBridge = ctx && (ctx.get?.('dsh-vision-bridge') || ctx['dsh-vision-bridge'])
|
|
43
|
+
if (visionBridge && typeof visionBridge.inspectQuality === 'function') {
|
|
44
|
+
visionVerdict = await visionBridge.inspectQuality({ bytes: buf, prompt })
|
|
45
|
+
if (visionVerdict && visionVerdict.passed === false) {
|
|
46
|
+
return {
|
|
47
|
+
passed: false,
|
|
48
|
+
score: visionVerdict.score ?? 0.3,
|
|
49
|
+
defect: visionVerdict.defect || 'vision_rejected',
|
|
50
|
+
details: { heuristic, vision: visionVerdict },
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
// Non-fatal: if vision bridge fails or throws, fallback gracefully to heuristic result
|
|
56
|
+
visionVerdict = { error: err.message }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
passed: true,
|
|
61
|
+
score: heuristic.score,
|
|
62
|
+
defect: null,
|
|
63
|
+
details: {
|
|
64
|
+
heuristic,
|
|
65
|
+
...(visionVerdict ? { vision: visionVerdict } : {}),
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Runs a generation job with silent re-roll if quality gate fails.
|
|
72
|
+
*
|
|
73
|
+
* @param {Function} generateFn - Async function (seed) => Promise<generated>
|
|
74
|
+
* @param {Object} options
|
|
75
|
+
* @param {number} options.initialSeed - Base random seed
|
|
76
|
+
* @param {boolean} [options.enabled=true] - Whether quality gate is enabled
|
|
77
|
+
* @param {number} [options.maxRerolls=2] - Maximum silent retry attempts
|
|
78
|
+
* @param {Object} [options.ctx] - Cordis context
|
|
79
|
+
* @param {string} [options.prompt] - Text prompt
|
|
80
|
+
* @param {Function} [options.logger] - Optional logger callback
|
|
81
|
+
* @returns {Promise<{ generated: Object, qualityReport: Object }>}
|
|
82
|
+
*/
|
|
83
|
+
export async function executeWithQualityGate(generateFn, {
|
|
84
|
+
initialSeed,
|
|
85
|
+
enabled = true,
|
|
86
|
+
maxRerolls = 2,
|
|
87
|
+
ctx,
|
|
88
|
+
prompt,
|
|
89
|
+
logger = () => {},
|
|
90
|
+
}) {
|
|
91
|
+
let currentSeed = initialSeed !== undefined ? initialSeed : Math.floor(Math.random() * 1000000)
|
|
92
|
+
let rerolls = 0
|
|
93
|
+
let lastReport = null
|
|
94
|
+
let lastGenerated = null
|
|
95
|
+
|
|
96
|
+
const attempts = enabled ? Math.max(1, maxRerolls + 1) : 1
|
|
97
|
+
|
|
98
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
99
|
+
lastGenerated = await generateFn(currentSeed)
|
|
100
|
+
|
|
101
|
+
if (!enabled) {
|
|
102
|
+
return {
|
|
103
|
+
generated: lastGenerated,
|
|
104
|
+
qualityReport: { passed: true, score: 1.0, rerolls: 0, skipped: true },
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const evaluation = await evaluateImageQuality(lastGenerated.bytes, { ctx, prompt })
|
|
109
|
+
lastReport = {
|
|
110
|
+
passed: evaluation.passed,
|
|
111
|
+
score: evaluation.score,
|
|
112
|
+
defect: evaluation.defect,
|
|
113
|
+
details: evaluation.details,
|
|
114
|
+
rerolls,
|
|
115
|
+
seedUsed: currentSeed,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (evaluation.passed) {
|
|
119
|
+
return {
|
|
120
|
+
generated: lastGenerated,
|
|
121
|
+
qualityReport: lastReport,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Critical defect detected, prepare silent re-roll
|
|
126
|
+
rerolls++
|
|
127
|
+
currentSeed = (currentSeed + 1013) % 2147483647
|
|
128
|
+
logger(`Quality gate failed (${evaluation.defect}, score: ${evaluation.score}). Silent re-roll attempt ${rerolls}/${maxRerolls}...`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// If all re-rolls exhausted, return the best we have with clear defect warning in report
|
|
132
|
+
return {
|
|
133
|
+
generated: lastGenerated,
|
|
134
|
+
qualityReport: {
|
|
135
|
+
...lastReport,
|
|
136
|
+
rerolls,
|
|
137
|
+
exhausted: true,
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
package/lib/security.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// lib/security.js
|
|
2
|
+
// Secure credential handling, 0600 file permissions, and token masking (#169)
|
|
3
|
+
|
|
4
|
+
import { chmodSync, existsSync } from 'node:fs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Masks sensitive API keys, leaving only the prefix/suffix.
|
|
8
|
+
* E.g. "sk-proj-1234567890abcdef" -> "sk-p...cdef"
|
|
9
|
+
*/
|
|
10
|
+
export function maskApiKey(key) {
|
|
11
|
+
if (!key || typeof key !== 'string') return ''
|
|
12
|
+
const trimmed = key.trim()
|
|
13
|
+
if (!trimmed) return ''
|
|
14
|
+
if (trimmed.length <= 8) return '********'
|
|
15
|
+
return trimmed.slice(0, 4) + '...' + trimmed.slice(-4)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sanitizes arbitrary text, error messages, and URLs to remove authorization tokens.
|
|
20
|
+
*/
|
|
21
|
+
export function sanitizeErrorAndLogs(input) {
|
|
22
|
+
if (!input) return ''
|
|
23
|
+
let text = typeof input === 'string' ? input : (input.message || JSON.stringify(input))
|
|
24
|
+
|
|
25
|
+
// Mask Bearer tokens
|
|
26
|
+
text = text.replace(/Bearer\s+([a-zA-Z0-9_\-\.]{6,})/gi, (match, token) => {
|
|
27
|
+
return `Bearer ${maskApiKey(token)}`
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// Mask Key tokens
|
|
31
|
+
text = text.replace(/Key\s+([a-zA-Z0-9_\-\.]{6,})/gi, (match, token) => {
|
|
32
|
+
return `Key ${maskApiKey(token)}`
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Mask query params like ?key=... or &api_key=...
|
|
36
|
+
text = text.replace(/([?&](?:api_)?key=)([^&\s]+)/gi, (match, prefix, val) => {
|
|
37
|
+
return `${prefix}${maskApiKey(val)}`
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// Mask raw sk-... tokens
|
|
41
|
+
text = text.replace(/(sk-[a-zA-Z0-9_\-]{8,})/gi, (match, token) => {
|
|
42
|
+
return maskApiKey(token)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return text
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Ensures strict 0600 file permissions on sensitive config/key files.
|
|
50
|
+
*/
|
|
51
|
+
export function enforceSecurePermissions(filePath) {
|
|
52
|
+
if (!filePath || !existsSync(filePath)) return false
|
|
53
|
+
try {
|
|
54
|
+
chmodSync(filePath, 0o600)
|
|
55
|
+
return true
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-image-gen",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.11",
|
|
4
4
|
"description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|