@goodandready/dsh-image-gen 0.10.8 → 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 CHANGED
@@ -83,6 +83,21 @@
83
83
  * **FAL Credential Aliasing**: seamless fallback between `FAL_API_KEY` and `FAL_KEY` in credentials and environment.
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
+
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
+
95
+ ### 🚀 What's New in v0.10.10 (#201, #203)
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.
97
+ * **Streamlined Settings UI (#203)**: Removed the bulky in-settings history gallery to keep the configuration panel focused, fast, and organized.
98
+ * **Comprehensive Localization (#201)**: Added 100% dictionary coverage for all provider credentials, endpoints, and field hints in English and Russian.
99
+ * **Robust Slot Mounting**: Implemented `registerFirst` helper with graceful fallback between `settings.plugin.item` and `settings.section`.
100
+
86
101
  ## 🎨 Supported Generation Backends
87
102
 
88
103
  * **`fal`** (Default): FAL.ai queue for FLUX.1, SDXL, Clarity Upscaler, and BiRefNet.
package/README.ru.md CHANGED
@@ -129,6 +129,21 @@ graph LR
129
129
  - **Оптимизация компонентов (#139)**: из `FalImageCard` удалён неиспользуемый мёртвый стейт.
130
130
  - **100% интернационализация (#140)**: устранены захардкоженные русские строки, добавлены переводы на русский, английский и китайский языки.
131
131
 
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
+
141
+ ### 🚀 Что нового в v0.10.10 (#201, #203)
142
+ * **Стабилизация окна настроек GUI (#201)**: Исправлена спецификация `booleanField` в клиентском коде, вызывавшая падение рендера карточки настроек плагина в DSH Web UI. Форма настроек теперь открывается штатно со всеми полями, селекторами провайдеров, моделями, ссылками на ключи и пресетами.
143
+ * **Очистка интерфейса настроек (#203)**: Из окна настроек убрана встроенная галерея истории генераций, загромождавшая карточку настроек.
144
+ * **Полная локализация (#201)**: Добавлены все недостающие строки переводов и подсказок на русском и английском языках для всех поддерживаемых провайдеров.
145
+ * **Надёжная регистрация слотов**: Реализован `registerFirst` с плавным откатом между `settings.plugin.item` и `settings.section`.
146
+
132
147
  ## 🎨 Поддерживаемые провайдеры генерации
133
148
 
134
149
  * **`fal`** *(дефолт)*: Сверхбыстрая очередь FAL.ai (`FLUX.1-schnell`, `FLUX-dev`, `SDXL`, BiRefNet, Clarity Upscaler).
package/lib/client.js CHANGED
@@ -82,12 +82,7 @@ window.__ModuleLoader__.load({
82
82
  '.fal_input:disabled,.fal_select:disabled{opacity:.6}' +
83
83
  '.fal_hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px}' +
84
84
  '.fal_invalid{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px}' +
85
- '.fal_sep{height:1px;background:var(--dsw-alias-border-l2);margin:2px 0 0}' +
86
- '.fal_gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin-top:12px}' +
87
- '.fal_galleryItem{display:flex;flex-direction:column;gap:4px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;overflow:hidden}' +
88
- '.fal_galleryImg{width:100%;height:90px;object-fit:cover;display:block}' +
89
- '.fal_galleryMeta{font-size:11px;color:var(--dsw-alias-label-secondary);padding:4px 6px}' +
90
- '.fal_galleryEmpty{color:var(--dsw-alias-label-secondary);font-size:12px;margin-top:12px}'
85
+ '.fal_sep{height:1px;background:var(--dsw-alias-border-l2);margin:2px 0 0}'
91
86
  const tagId = 'dsh-image-gen/settings-card.module.css'
92
87
  if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {
93
88
  const tag = document.createElement('style')
@@ -124,11 +119,6 @@ window.__ModuleLoader__.load({
124
119
  save: 'fal_save',
125
120
  select: 'fal_select',
126
121
  sep: 'fal_sep',
127
- gallery: 'fal_gallery',
128
- galleryItem: 'fal_galleryItem',
129
- galleryImg: 'fal_galleryImg',
130
- galleryMeta: 'fal_galleryMeta',
131
- galleryEmpty: 'fal_galleryEmpty',
132
122
  }
133
123
 
134
124
  // -------------------------------------------------------------- chrome
@@ -136,7 +126,7 @@ window.__ModuleLoader__.load({
136
126
  function PluginSettingsCard(props) {
137
127
  // Карточка живёт в собственном разделе настроек и на странице одна, так
138
128
  // что открыта сразу: свёрнутая выглядела бы как пустая страница.
139
- const [open, setOpen] = react.useState(false)
129
+ const [open, setOpen] = react.useState(props.defaultOpen ?? true)
140
130
  const { state } = props
141
131
  // Раньше здесь было `return null`, и раздел оказывался пустым, если хост
142
132
  // ещё не объявил настройки. Пустая страница ничего не объясняет — лучше
@@ -464,7 +454,17 @@ window.__ModuleLoader__.load({
464
454
  const PROVIDERS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini', 'replicate']
465
455
 
466
456
  /** Field specs in display order. */
467
- const booleanField = (name) => ({ name, type: 'boolean', parse: (v) => v === 'true' || v === true, format: (v) => String(!!v) })
457
+ const booleanField = (field) => ({
458
+ field,
459
+ type: 'boolean',
460
+ format: (value) => (typeof value === 'boolean' ? String(value) : (value === 'true' || value === 'false' ? value : '')),
461
+ parse: (text) => {
462
+ if (text === '') return { kind: 'clear' }
463
+ if (text === 'true') return { kind: 'set', value: true }
464
+ if (text === 'false') return { kind: 'set', value: false }
465
+ return void 0
466
+ },
467
+ })
468
468
  const STYLE_PRESET_OPTIONS = ['', 'cinematic', 'anime', 'digital_art', 'photographic', 'fantasy', 'isometric', 'cyberpunk', 'watercolor', 'origami', 'oil_painting', 'pixel_art', 'logo']
469
469
 
470
470
  const FIELDS = [
@@ -511,7 +511,6 @@ window.__ModuleLoader__.load({
511
511
  { field: 'deliverAs', spec: selectField('deliverAs', DELIVERY_MODES), kind: 'select', options: DELIVERY_MODES, labelKey: 'f.deliverAs', hintKey: 'f.deliverAsHint' },
512
512
  { field: 'outputDir', spec: textField('outputDir'), kind: 'text', labelKey: 'f.outputDir', hintKey: 'f.outputDirHint', placeholderKey: 'p.outputDir' },
513
513
  { field: 'cacheTtlDays', spec: numberField('cacheTtlDays'), kind: 'number', labelKey: 'f.cacheTtlDays', hintKey: 'f.cacheTtlDaysHint', placeholderKey: 'p.cacheTtlDays' },
514
- { field: 'maxHistoryEntries', spec: numberField('maxHistoryEntries'), kind: 'number', labelKey: 'f.maxHistoryEntries', hintKey: 'f.maxHistoryEntriesHint', placeholderKey: 'p.maxHistoryEntries' },
515
514
  ]
516
515
 
517
516
  function Field(props) {
@@ -539,66 +538,11 @@ window.__ModuleLoader__.load({
539
538
  return react_jsx_runtime.jsx(ValueField, { ...base, numeric: entry.kind === 'number', placeholder: entry.placeholderKey ? t(entry.placeholderKey) : '' })
540
539
  }
541
540
 
542
- /** Render the plugin settings card. */
543
- /** Галерея истории генераций: тянет /history и рендерит миниатюры. */
544
- function HistoryGallery(props) {
545
- const [entries, setEntries] = react.useState([])
546
- const [loading, setLoading] = react.useState(true)
547
- const [filter, setFilter] = react.useState('')
548
- react.useEffect(() => {
549
- let cancelled = false
550
- fetch('/dsh-image-gen/history')
551
- .then((r) => r.json())
552
- .then((data) => { if (!cancelled) { setEntries(Array.isArray(data) ? data : []); setLoading(false) } })
553
- .catch(() => { if (!cancelled) setLoading(false) })
554
- return () => { cancelled = true }
555
- }, [])
556
- const t = props.t
557
- if (loading) return react_jsx_runtime.jsx('p', { className: cssDefault.galleryEmpty, children: t('history.loading') })
558
- if (!entries.length) return react_jsx_runtime.jsx('p', { className: cssDefault.galleryEmpty, children: t('history.empty') })
559
- const providers = [...new Set(entries.map((e) => e.provider).filter(Boolean))]
560
- const filtered = filter ? entries.filter((e) => e.provider === filter) : entries
561
- return react_jsx_runtime.jsxs('div', {
562
- className: cssDefault.gallery,
563
- children: [
564
- react_jsx_runtime.jsx('select', {
565
- className: cssDefault.select,
566
- value: filter,
567
- onChange: (e) => setFilter(e.target.value),
568
- children: [
569
- react_jsx_runtime.jsx('option', { value: '', children: t('history.all') }),
570
- providers.map((p) => react_jsx_runtime.jsx('option', { value: p, children: p }, p)),
571
- ],
572
- }),
573
- filtered.map((e) =>
574
- react_jsx_runtime.jsxs('a', {
575
- className: cssDefault.galleryItem,
576
- key: e.path,
577
- href: e.thumbnailUrl || '#',
578
- target: '_blank',
579
- rel: 'noreferrer',
580
- children: [
581
- e.thumbnailUrl ? react_jsx_runtime.jsx('img', { className: cssDefault.galleryImg, src: e.thumbnailUrl, alt: e.prompt || '', loading: 'lazy' }) : null,
582
- react_jsx_runtime.jsx('div', { className: cssDefault.galleryMeta, children: `${e.provider || ''} · ${e.size || ''}` }),
583
- e.seed !== undefined ? react_jsx_runtime.jsx('button', {
584
- type: 'button',
585
- className: 'fal-repeat',
586
- onClick: (ev) => {
587
- ev.preventDefault()
588
- ev.stopPropagation()
589
- try { navigator.clipboard.writeText(String(e.seed)) } catch (err) { /* нет доступа к буферу */ }
590
- },
591
- }, `seed ${e.seed}`) : null,
592
- ],
593
- }),
594
- ),
595
- ],
596
- })
597
- }
598
-
599
541
  function FalSettingsCard(props) {
600
- const { t } = props
601
- const state = props.useFalSettingsCard((snapshot) => snapshot)
542
+ const t = props.t || ((k) => k)
543
+ const state = (typeof props.useFalSettingsCard === 'function'
544
+ ? props.useFalSettingsCard((snapshot) => snapshot)
545
+ : null) || props.state || { available: true, writable: true, provider: { text: 'fal' } }
602
546
  const disabled = !state.writable
603
547
  const currentProvider = (state.provider && state.provider.text) || 'fal'
604
548
  const visibleFields = FIELDS.filter((entry) => !entry.when || entry.when.includes(currentProvider))
@@ -616,7 +560,7 @@ window.__ModuleLoader__.load({
616
560
  onSave: props.save,
617
561
  onDiscard: props.discard,
618
562
  children: [
619
- react_jsx_runtime.jsx('p', { className: cssDefault.hint, children: t('settings.credentialHint') }),
563
+ react_jsx_runtime.jsx('p', { className: cssDefault.hint, key: 'credHint', children: t('settings.credentialHint') }),
620
564
  visibleFields.map((entry) =>
621
565
  react_jsx_runtime.jsx(
622
566
  Field,
@@ -625,15 +569,12 @@ window.__ModuleLoader__.load({
625
569
  state: state[entry.field] ?? { text: '', overridden: false, invalid: false },
626
570
  fieldProps,
627
571
  t,
628
- onEdit: (text) => props.edit(entry.field, text),
629
- onReset: () => props.resetField(entry.field),
572
+ onEdit: (text) => props.edit && props.edit(entry.field, text),
573
+ onReset: () => props.resetField && props.resetField(entry.field),
630
574
  },
631
575
  entry.field,
632
576
  ),
633
577
  ),
634
- react_jsx_runtime.jsx('div', { className: cssDefault.sep, key: 'hsep' }),
635
- react_jsx_runtime.jsx('p', { className: cssDefault.hint, key: 'hlabel', children: t('history.title') }),
636
- react_jsx_runtime.jsx(HistoryGallery, { t, key: 'history' }),
637
578
  ],
638
579
  })
639
580
  }
@@ -680,6 +621,53 @@ window.__ModuleLoader__.load({
680
621
  'settings.saving': 'Saving…',
681
622
  'settings.saveFailed': 'Save failed',
682
623
  'f.inherit': 'Inherit (default)',
624
+ 'f.enabled': 'Enabled',
625
+ 'f.enabledHint': 'Master switch: enable or disable the generate_image tool.',
626
+ 'f.needFal': 'requires FAL API key',
627
+ 'f.needCustom': 'requires custom endpoint URL',
628
+ 'f.needCodex': 'uses Codex subscription session',
629
+ 'f.needGrok': 'uses Grok subscription session',
630
+ 'f.needLocal': 'connects to local ComfyUI/A1111',
631
+ 'f.needSeedream': 'requires SeaDream API key',
632
+ 'f.needGemini': 'requires Gemini API key',
633
+ 'f.needReplicate': 'requires Replicate API token',
634
+ 'f.replicateModel': 'Model (Replicate)',
635
+ 'f.replicateModelHint': 'Replicate model identifier, e.g. black-forest-labs/flux-schnell.',
636
+ 'f.replicateKeyEnv': 'API key reference (Replicate)',
637
+ 'f.replicateKeyEnvHint': 'Credential reference / env var holding the Replicate API token.',
638
+ 'p.replicateModel': 'Example: black-forest-labs/flux-schnell',
639
+ 'p.replicateKeyEnv': 'Example: REPLICATE_API_TOKEN',
640
+ 'f.seedreamModel': 'Model (SeaDream)',
641
+ 'f.seedreamModelHint': 'SeaDream model identifier.',
642
+ 'f.seedreamKeyEnv': 'API key reference (SeaDream)',
643
+ 'f.seedreamKeyEnvHint': 'Credential reference / env var holding the SeaDream API key.',
644
+ 'f.seedreamBaseURL': 'Base URL (SeaDream)',
645
+ 'f.seedreamBaseURLHint': 'SeaDream endpoint URL (Volcengine Ark API).',
646
+ 'p.seedreamModel': 'Example: Doubao-SeaDream-3.0',
647
+ 'p.seedreamKeyEnv': 'Example: SEADREAM_API_KEY',
648
+ 'p.seedreamBaseURL': 'Example: https://ark.cn-beijing.volces.com/api/v3',
649
+ 'f.geminiModel': 'Model (Gemini)',
650
+ 'f.geminiModelHint': 'Gemini Imagen model identifier.',
651
+ 'f.geminiKeyEnv': 'API key reference (Gemini)',
652
+ 'f.geminiKeyEnvHint': 'Credential reference / env var holding the Gemini API key.',
653
+ 'p.geminiModel': 'Example: imagen-3.0-generate-002',
654
+ 'p.geminiKeyEnv': 'Example: GEMINI_API_KEY',
655
+ 'f.localComfyUrl': 'ComfyUI URL',
656
+ 'f.localComfyUrlHint': 'Base URL of local ComfyUI server.',
657
+ 'p.localComfyUrl': 'Example: http://127.0.0.1:8188',
658
+ 'f.localA1111Url': 'Automatic1111 URL',
659
+ 'f.localA1111UrlHint': 'Base URL of local Automatic1111 / SD WebUI.',
660
+ 'p.localA1111Url': 'Example: http://127.0.0.1:7860',
661
+ 'f.stylePreset': 'Default style preset',
662
+ 'f.stylePresetHint': 'Style preset automatically applied if the tool call does not specify one.',
663
+ 'f.enableLlmEnhancer': 'LLM prompt enhancer',
664
+ 'f.enableLlmEnhancerHint': 'Automatically refine and expand prompts before generation using session LLM.',
665
+ 'f.cacheTtlDays': 'Cache retention (days)',
666
+ 'f.cacheTtlDaysHint': 'How many days downloaded images are stored in cache before cleanup.',
667
+ 'p.cacheTtlDays': 'Example: 7',
668
+ 'f.maxHistoryEntries': 'Max history entries',
669
+ 'f.maxHistoryEntriesHint': 'Maximum number of items shown in generation history gallery.',
670
+ 'p.maxHistoryEntries': 'Example: 50',
683
671
  'f.provider': 'Provider',
684
672
  'f.providerHint': 'fal — the FAL queue configured below. custom — any OpenAI-compatible API, configured further down. '
685
673
  + 'codex and grok — through a subscription you are logged into, no key needed here.',
@@ -743,6 +731,53 @@ window.__ModuleLoader__.load({
743
731
  'settings.saving': 'Сохранение…',
744
732
  'settings.saveFailed': 'Не удалось сохранить',
745
733
  'f.inherit': 'Унаследовать (по умолчанию)',
734
+ 'f.enabled': 'Включено',
735
+ 'f.enabledHint': 'Главный переключатель: включить или отключить инструмент generate_image.',
736
+ 'f.needFal': 'требуется ключ API FAL',
737
+ 'f.needCustom': 'требуется базовый URL API',
738
+ 'f.needCodex': 'использует сессию подписки Codex',
739
+ 'f.needGrok': 'использует сессию подписки Grok',
740
+ 'f.needLocal': 'подключается к локальному ComfyUI/A1111',
741
+ 'f.needSeedream': 'требуется ключ API SeaDream',
742
+ 'f.needGemini': 'требуется ключ API Gemini',
743
+ 'f.needReplicate': 'требуется токен API Replicate',
744
+ 'f.replicateModel': 'Модель (Replicate)',
745
+ 'f.replicateModelHint': 'Идентификатор модели Replicate, например black-forest-labs/flux-schnell.',
746
+ 'f.replicateKeyEnv': 'Ссылка на ключ (Replicate)',
747
+ 'f.replicateKeyEnvHint': 'Имя credential-ссылки / переменной окружения с токеном Replicate.',
748
+ 'p.replicateModel': 'Пример: black-forest-labs/flux-schnell',
749
+ 'p.replicateKeyEnv': 'Пример: REPLICATE_API_TOKEN',
750
+ 'f.seedreamModel': 'Модель (SeaDream)',
751
+ 'f.seedreamModelHint': 'Идентификатор модели SeaDream.',
752
+ 'f.seedreamKeyEnv': 'Ссылка на ключ (SeaDream)',
753
+ 'f.seedreamKeyEnvHint': 'Имя credential-ссылки / переменной окружения с ключом SeaDream.',
754
+ 'f.seedreamBaseURL': 'Базовый URL (SeaDream)',
755
+ 'f.seedreamBaseURLHint': 'Базовый URL API SeaDream (Volcengine Ark).',
756
+ 'p.seedreamModel': 'Пример: Doubao-SeaDream-3.0',
757
+ 'p.seedreamKeyEnv': 'Пример: SEADREAM_API_KEY',
758
+ 'p.seedreamBaseURL': 'Пример: https://ark.cn-beijing.volces.com/api/v3',
759
+ 'f.geminiModel': 'Модель (Gemini)',
760
+ 'f.geminiModelHint': 'Идентификатор модели Gemini Imagen.',
761
+ 'f.geminiKeyEnv': 'Ссылка на ключ (Gemini)',
762
+ 'f.geminiKeyEnvHint': 'Имя credential-ссылки / переменной окружения с ключом Gemini.',
763
+ 'p.geminiModel': 'Пример: imagen-3.0-generate-002',
764
+ 'p.geminiKeyEnv': 'Пример: GEMINI_API_KEY',
765
+ 'f.localComfyUrl': 'URL ComfyUI',
766
+ 'f.localComfyUrlHint': 'Базовый URL локального сервера ComfyUI.',
767
+ 'p.localComfyUrl': 'Пример: http://127.0.0.1:8188',
768
+ 'f.localA1111Url': 'URL Automatic1111',
769
+ 'f.localA1111UrlHint': 'Базовый URL локального SD WebUI / Automatic1111.',
770
+ 'p.localA1111Url': 'Пример: http://127.0.0.1:7860',
771
+ 'f.stylePreset': 'Стилевой пресет по умолчанию',
772
+ 'f.stylePresetHint': 'Пресет стилизации, применяемый если инструмент вызван без указания стиля.',
773
+ 'f.enableLlmEnhancer': 'Улучшение промпта через LLM',
774
+ 'f.enableLlmEnhancerHint': 'Автоматически детализировать и улучшать промпты через чат-модель перед генерацией.',
775
+ 'f.cacheTtlDays': 'Срок хранения кэша (дней)',
776
+ 'f.cacheTtlDaysHint': 'Сколько дней скачанные картинки хранятся в кэше до очистки.',
777
+ 'p.cacheTtlDays': 'Пример: 7',
778
+ 'f.maxHistoryEntries': 'Максимум записей в истории',
779
+ 'f.maxHistoryEntriesHint': 'Максимальное количество картинок в галерее истории генераций.',
780
+ 'p.maxHistoryEntries': 'Пример: 50',
746
781
  'f.provider': 'Провайдер',
747
782
  'f.providerHint': 'fal — очередь FAL, настроенная ниже. custom — любой OpenAI-совместимый API, настраивается дальше. '
748
783
  + 'codex и grok — через подписку, в которую вы вошли; ключ здесь не нужен.',
@@ -1226,36 +1261,40 @@ function FalImageCard(props) {
1226
1261
  // Запасной путь: слот объявлен пакетом настроек ядра. Если в сборке его
1227
1262
  // нет, регистрация бросает — тогда остаётся прежний раздел в боковом
1228
1263
  // списке, чтобы настройки не пропали.
1229
- let placedInPluginsTab = false
1230
- try {
1231
- ctx.slots.register(
1232
- {
1233
- name: 'settings.plugin.item',
1234
- key: SETTINGS_NS,
1235
- locale: NS,
1236
- inject: () => cardOnce().inject(),
1237
- },
1238
- FalSettingsCard,
1239
- )
1240
- placedInPluginsTab = true
1241
- } catch (noPluginItemSlot) {
1242
- placedInPluginsTab = false
1243
- }
1244
- if (!placedInPluginsTab) {
1245
- ctx.slots.inject('settings.section', () =>
1246
- ctx.slots.register(
1247
- {
1248
- name: 'settings.section',
1249
- id: '@goodandready/dsh-image-gen',
1250
- order: 34,
1251
- locale: NS,
1252
- label: () => t('settings.navLabel'),
1253
- inject: () => cardOnce().inject(),
1254
- },
1255
- FalSettingsCard,
1256
- ),
1257
- )
1264
+ function registerFirst(ctx, candidates, component) {
1265
+ if (!ctx || !ctx.slots) return undefined
1266
+ for (const entry of candidates) {
1267
+ try {
1268
+ if (typeof ctx.slots?.has === 'function' && !ctx.slots.has(entry.name)) {
1269
+ continue
1270
+ }
1271
+ let ok = false
1272
+ ctx.slots.inject(entry.name, () => {
1273
+ ctx.slots.register(entry, component)
1274
+ ok = true
1275
+ })
1276
+ if (ok) return entry.name
1277
+ } catch {}
1258
1278
  }
1279
+ return undefined
1280
+ }
1281
+
1282
+ registerFirst(ctx, [
1283
+ {
1284
+ name: 'settings.plugin.item',
1285
+ key: SETTINGS_NS,
1286
+ locale: NS,
1287
+ inject: () => cardOnce().inject(),
1288
+ },
1289
+ {
1290
+ name: 'settings.section',
1291
+ id: '@goodandready/dsh-image-gen',
1292
+ order: 34,
1293
+ locale: NS,
1294
+ label: () => t('settings.navLabel'),
1295
+ inject: () => cardOnce().inject(),
1296
+ },
1297
+ ], FalSettingsCard)
1259
1298
  }
1260
1299
  exports.apply = apply
1261
1300
  exports.inject = inject
@@ -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
- const effectiveNegative = styleInfo.negativePrompt
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 generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, (s, p) => one(s, k, p)]))
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
+ }
@@ -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.8",
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",