@goodandready/dsh-model-sync 0.3.8 → 0.3.10

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
@@ -142,6 +142,31 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
142
142
 
143
143
  ---
144
144
 
145
+ ## 🚀 Enhancements in v0.3.10
146
+
147
+ * 📦 **Compacted History Storage**:
148
+ * Stripped non-essential metadata (`description`, `tags`, `aliases`) from catalog snapshots (`before`/`after`) in `settings.yaml`, retaining only essential properties (`id`, `name`, `capabilities`, `pricing`, `contextWindow`, `maxTokens`).
149
+ * Optimized default snapshot retention (`DEFAULT_SNAPSHOT_RETENTION = 3`) to keep configuration files lean while preserving full rollback functionality.
150
+ * ⚡ **Immediate Scheduler First Tick**:
151
+ * Scheduler now triggers an initial discovery run immediately upon startup when enabled without requiring an initial 60-minute wait.
152
+ * 🛡️ **Client-side Policy Regex Validation**:
153
+ * Added instant regex pattern verification in the Web UI before submitting `/policy` requests with localized error feedback.
154
+
155
+ ---
156
+
157
+ ## 🚀 Enhancements in v0.3.9
158
+
159
+ * 🔒 **Serialized Settings Mutations (`saveConfig`)**:
160
+ * Chained internal settings persistence via an async promise mutex (`saveQueue`).
161
+ * Eliminates race conditions between simultaneous sync runs and user UI interactions, ensuring clean atomic writes to `settings.yaml`.
162
+ * 🛡️ **Hardened Cross-Origin & CSRF Protections**:
163
+ * Enhanced `trusted(req)` checking to evaluate `sec-fetch-site`, `origin`, and `referer` against the HTTP `Host`.
164
+ * Blocks unauthorized cross-origin mutations while preserving legitimate local and reverse-proxy requests.
165
+ * 🎨 **Standardized Plugin Style Discovery**:
166
+ * Added `data-dsh-plugin="dsh-model-sync"` attribute to injected UI style tags in compliance with the DeepSeek Harness plugin authoring standard.
167
+
168
+ ---
169
+
145
170
  ## 🚀 Enhancements in v0.3.5
146
171
 
147
172
  * ⏱️ **Adaptive Rate Limit & Retry-After Handling**:
package/README.ru.md CHANGED
@@ -119,6 +119,31 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
119
119
 
120
120
  ---
121
121
 
122
+ ## 🚀 Новые возможности в v0.3.10
123
+
124
+ * 📦 **Компактизация хранения истории**:
125
+ * Очистка некритичных метаданных (`description`, `tags`, `aliases`) из снимков моделей (`before`/`after`) в `settings.yaml`, с сохранением ключевых свойств (`id`, `name`, `capabilities`, `pricing`, `contextWindow`, `maxTokens`).
126
+ * Оптимизирована глубина хранения снимков (`DEFAULT_SNAPSHOT_RETENTION = 3`) для предотвращения разрастания конфигурационного файла при сохранении возможности безопасного отката.
127
+ * ⚡ **Мгновенный первый запуск планировщика**:
128
+ * Планировщик автоматически запускает первичный синк сразу при старте, если расписание активно и предыдущие запуски отсутствуют.
129
+ * 🛡️ **Клиентская валидация regex-политик**:
130
+ * Добавлена проверка синтаксиса регулярных выражений в интерфейсе настроек перед отправкой запроса на `/policy` с локализованными подсказками.
131
+
132
+ ---
133
+
134
+ ## 🚀 Новые возможности в v0.3.9
135
+
136
+ * 🔒 **Атомарная очередь сохранения настроек (`saveConfig`)**:
137
+ * Сериализация внутренних мутаций конфигурации через цепочку промисов (`saveQueue`).
138
+ * Предотвращает состояние гонки между фоновой синхронизацией и действиями пользователя в интерфейсе, гарантируя целостность `settings.yaml`.
139
+ * 🛡️ **Усиленная защита от CSRF и cross-origin атак**:
140
+ * Расширенная валидация запросов `trusted(req)` с проверкой заголовков `sec-fetch-site`, `origin` и `referer` против `Host`.
141
+ * Блокировка межсайтовых вызовов мутаций без нарушения локальных обращений и проксирования.
142
+ * 🎨 **Стандартизация инжекции стилей интерфейса**:
143
+ * Добавлен атрибут `data-dsh-plugin="dsh-model-sync"` для тега стилей в полном соответствии со стандартом разработки плагинов DeepSeek Harness.
144
+
145
+ ---
146
+
122
147
  ## 🚀 Новые возможности в v0.3.5
123
148
 
124
149
  * ⏱️ **Адаптивная обработка Rate Limit и Retry-After**:
package/lib/client.js CHANGED
@@ -33,18 +33,19 @@ window.__ModuleLoader__.load({
33
33
  function ensureStyles() {
34
34
  if (typeof document === 'undefined' || !document.head) return
35
35
  const cssId = 'dsh-model-sync/settings.module.css'
36
- if (document.querySelector('style[data-plugin-css="' + cssId + '"]')) return
36
+ if (document.querySelector('style[data-plugin-css="' + cssId + '"]') || document.querySelector('style[data-dsh-plugin="dsh-model-sync"]')) return
37
37
  const tag = document.createElement('style')
38
38
  tag.textContent = CSS
39
39
  tag.setAttribute('data-plugin', 'dsh-model-sync')
40
+ tag.setAttribute('data-dsh-plugin', 'dsh-model-sync')
40
41
  tag.setAttribute('data-plugin-css', cssId)
41
42
  document.head.appendChild(tag)
42
43
  }
43
44
  const en = {
44
- title: 'Model synchronization', showSettings: 'Show settings', hideSettings: 'Hide settings', desc: 'Refresh model catalogs for API-key providers. A dry-run is the default; applying changes is explicit.', loading: 'Loading…', refresh: 'Refresh status', refreshAll: 'Refresh all', run: 'Discover', apply: 'Apply changes', dryRun: 'Preview only (dry-run)', all: 'All API-key providers', ok: 'ready', dormant: 'not configured', running: 'running…', noProviders: 'No API-key providers found.', lastRun: 'Last run', previewTitle: 'Changes preview', previewNone: 'No changes detected.', previewAdded: 'Added', previewRemoved: 'Removed', previewChanged: 'Changed', previewApply: 'Apply preview', previewDismiss: 'Close preview', selectModels: 'Choose models', saveSelection: 'Save and apply selection', selectAtLeastOne: 'Select at least one model or use All models.', cancel: 'Cancel', selected: 'selected', allModels: 'All models', noModels: 'No models in the current catalog.', capabilityFilter: 'Filter by capabilities', capabilityCount: 'shown', noCapabilities: 'No capability metadata in this catalog.', pickerNote: 'The selection is applied to the DSH model picker immediately.', health: 'Check availability', healthOk: 'available', healthError: 'unavailable', healthDormant: 'not configured', policy: 'Manual model selection', savePolicy: 'Save policy', policyInclude: 'Include patterns (one per line)', policyExclude: 'Exclude patterns (one per line)', policyRequire: 'Required capabilities (comma-separated)', policyDeny: 'Denied capabilities (comma-separated)', policyNote: 'Patterns match model id, name, and tags. Apply changes to update the DSH picker.', scheduler: 'Scheduler', nextRun: 'next run', schedulerOff: 'off', schedulerOn: 'on', removeMissing: 'Confirm stale removal', stale: 'stale', deprecated: 'deprecated', history: 'Synchronization history', historyRun: 'Run', diffAdded: 'added', diffRemoved: 'removed', diffRenamed: 'renamed', diffChanged: 'metadata changed', rollback: 'Rollback catalog', rollbackConfirm: 'Rollback this provider catalog to the selected snapshot?', credentials: 'Credential diagnostics', checkCredentials: 'Check credentials', missingCredential: 'not configured', credentialReady: 'configured', report: 'Run report', success: 'success', partial: 'partial success', failure: 'failed', empty: 'no configured providers', notifications: 'Notifications', markRead: 'Mark read', acknowledge: 'Acknowledge', noNotifications: 'No notifications.', selectAllFiltered: 'Select all shown', deselectAllFiltered: 'Deselect all shown', invertFiltered: 'Invert selection', filterContext100k: 'Context ≥ 100k', filterCheap: 'Cheap ≤ $1/1M', showMore: 'Show more (%d)', showingCount: 'Showing %d of %d models', deprecatesIn: 'Deprecates in %dd', cacheHoursAgo: 'cache %dh ago', noHistory: 'No synchronization history.', searchPlaceholder: 'Search id/name/tag', sortName: 'Name A→Z', sortPrice: 'Price ↑', sortContext: 'Context ↓', sortDate: 'Newest ↓', schedulerSettings: 'Scheduler configuration', scheduleEnabled: 'Enable background schedule', intervalMinutes: 'Interval (minutes)', autoApplySchedule: 'Auto-apply changes', saveScheduler: 'Save scheduler', schedulerSaved: 'Saved', scopeUnavailable: 'Settings service unavailable.',
45
+ title: 'Model synchronization', showSettings: 'Show settings', hideSettings: 'Hide settings', desc: 'Refresh model catalogs for API-key providers. A dry-run is the default; applying changes is explicit.', loading: 'Loading…', refresh: 'Refresh status', refreshAll: 'Refresh all', run: 'Discover', apply: 'Apply changes', dryRun: 'Preview only (dry-run)', all: 'All API-key providers', ok: 'ready', dormant: 'not configured', running: 'running…', noProviders: 'No API-key providers found.', lastRun: 'Last run', previewTitle: 'Changes preview', previewNone: 'No changes detected.', previewAdded: 'Added', previewRemoved: 'Removed', previewChanged: 'Changed', previewApply: 'Apply preview', previewDismiss: 'Close preview', selectModels: 'Choose models', saveSelection: 'Save and apply selection', selectAtLeastOne: 'Select at least one model or use All models.', cancel: 'Cancel', selected: 'selected', allModels: 'All models', noModels: 'No models in the current catalog.', capabilityFilter: 'Filter by capabilities', capabilityCount: 'shown', noCapabilities: 'No capability metadata in this catalog.', pickerNote: 'The selection is applied to the DSH model picker immediately.', health: 'Check availability', healthOk: 'available', healthError: 'unavailable', healthDormant: 'not configured', policy: 'Manual model selection', savePolicy: 'Save policy', policyInclude: 'Include patterns (one per line)', policyExclude: 'Exclude patterns (one per line)', policyRequire: 'Required capabilities (comma-separated)', policyDeny: 'Denied capabilities (comma-separated)', policyNote: 'Patterns match model id, name, and tags. Apply changes to update the DSH picker.', scheduler: 'Scheduler', nextRun: 'next run', schedulerOff: 'off', schedulerOn: 'on', removeMissing: 'Confirm stale removal', stale: 'stale', deprecated: 'deprecated', history: 'Synchronization history', historyRun: 'Run', diffAdded: 'added', diffRemoved: 'removed', diffRenamed: 'renamed', diffChanged: 'metadata changed', rollback: 'Rollback catalog', rollbackConfirm: 'Rollback this provider catalog to the selected snapshot?', credentials: 'Credential diagnostics', checkCredentials: 'Check credentials', missingCredential: 'not configured', credentialReady: 'configured', report: 'Run report', success: 'success', partial: 'partial success', failure: 'failed', empty: 'no configured providers', notifications: 'Notifications', markRead: 'Mark read', acknowledge: 'Acknowledge', noNotifications: 'No notifications.', selectAllFiltered: 'Select all shown', deselectAllFiltered: 'Deselect all shown', invertFiltered: 'Invert selection', filterContext100k: 'Context ≥ 100k', filterCheap: 'Cheap ≤ $1/1M', showMore: 'Show more (%d)', showingCount: 'Showing %d of %d models', deprecatesIn: 'Deprecates in %dd', cacheHoursAgo: 'cache %dh ago', noHistory: 'No synchronization history.', searchPlaceholder: 'Search id/name/tag', sortName: 'Name A→Z', sortPrice: 'Price ↑', sortContext: 'Context ↓', sortDate: 'Newest ↓', schedulerSettings: 'Scheduler configuration', scheduleEnabled: 'Enable background schedule', intervalMinutes: 'Interval (minutes)', autoApplySchedule: 'Auto-apply changes', saveScheduler: 'Save scheduler', schedulerSaved: 'Saved', scopeUnavailable: 'Settings service unavailable.', invalidPattern: 'Invalid regex pattern: %s',
45
46
  }
46
47
  const ru = {
47
- title: 'Синхронизация моделей', showSettings: 'Показать настройки', hideSettings: 'Скрыть настройки', desc: 'Обновляет каталоги моделей у провайдеров с API-ключом. По умолчанию только dry-run; применение запускается явно.', loading: 'Загрузка…', refresh: 'Обновить статус', refreshAll: 'Обновить все', run: 'Проверить', apply: 'Применить изменения', dryRun: 'Только просмотр (dry-run)', all: 'Все API-key провайдеры', ok: 'готов', dormant: 'не настроен', running: 'выполняется…', noProviders: 'API-key провайдеры не найдены.', lastRun: 'Последний запуск', previewTitle: 'Предпросмотр изменений', previewNone: 'Изменений не обнаружено.', previewAdded: 'Добавлено', previewRemoved: 'Удалено', previewChanged: 'Изменено', previewApply: 'Применить изменения', previewDismiss: 'Закрыть предпросмотр', selectModels: 'Выбрать модели', saveSelection: 'Сохранить и применить выбор', selectAtLeastOne: 'Выберите хотя бы одну модель или нажмите «Все модели».', cancel: 'Отмена', selected: 'выбрано', allModels: 'Все модели', noModels: 'В текущем каталоге нет моделей.', capabilityFilter: 'Фильтр по возможностям', capabilityCount: 'показано', noCapabilities: 'В этом каталоге нет данных о возможностях.', pickerNote: 'Выбор сразу применяется к пикеру моделей DSH.', health: 'Проверить доступность', healthOk: 'доступен', healthError: 'недоступен', healthDormant: 'не настроен', policy: 'Ручной выбор моделей', savePolicy: 'Сохранить политику', policyInclude: 'Включать (по одному шаблону в строке)', policyExclude: 'Исключать (по одному шаблону в строке)', policyRequire: 'Обязательные возможности (через запятую)', policyDeny: 'Запрещённые возможности (через запятую)', policyNote: 'Шаблоны ищутся в id, названии и тегах модели. Нажмите «Применить», чтобы обновить пикер DSH.', scheduler: 'Планировщик', nextRun: 'следующий запуск', schedulerOff: 'выключен', schedulerOn: 'включён', removeMissing: 'Подтвердить удаление устаревших', stale: 'устарела', deprecated: 'deprecated', history: 'История синхронизаций', historyRun: 'Запуск', diffAdded: 'добавлено', diffRemoved: 'удалено', diffRenamed: 'переименовано', diffChanged: 'изменена метаинформация', rollback: 'Откатить каталог', rollbackConfirm: 'Откатить каталог этого провайдера к выбранному снимку?', credentials: 'Диагностика ключей', checkCredentials: 'Проверить ключи', missingCredential: 'не настроен', credentialReady: 'настроен', report: 'Итог запуска', success: 'успех', partial: 'частичный успех', failure: 'ошибка', empty: 'нет настроенных провайдеров', notifications: 'Уведомления', markRead: 'Прочитано', acknowledge: 'Подтвердить', noNotifications: 'Новых уведомлений нет.', selectAllFiltered: 'Выбрать все показанные', deselectAllFiltered: 'Снять выбор с показанных', invertFiltered: 'Инвертировать выбор', filterContext100k: 'Контекст ≥ 100k', filterCheap: 'Дешевые ≤ $1/1M', showMore: 'Показать ещё (%d)', showingCount: 'Показано %d из %d моделей', deprecatesIn: 'Устареет через %d дн.', cacheHoursAgo: 'кеш %dч назад', noHistory: 'Истории синхронизаций нет.', searchPlaceholder: 'Поиск id/name/tag', sortName: 'Имя A→Z', sortPrice: 'Цена ↑', sortContext: 'Контекст ↓', sortDate: 'Новые ↓', schedulerSettings: 'Настройка планировщика', scheduleEnabled: 'Включить фоновое расписание', intervalMinutes: 'Интервал (минуты)', autoApplySchedule: 'Автоматически применять изменения', saveScheduler: 'Сохранить расписание', schedulerSaved: 'Сохранено', scopeUnavailable: 'Служба настроек недоступна.',
48
+ title: 'Синхронизация моделей', showSettings: 'Показать настройки', hideSettings: 'Скрыть настройки', desc: 'Обновляет каталоги моделей у провайдеров с API-ключом. По умолчанию только dry-run; применение запускается явно.', loading: 'Загрузка…', refresh: 'Обновить статус', refreshAll: 'Обновить все', run: 'Проверить', apply: 'Применить изменения', dryRun: 'Только просмотр (dry-run)', all: 'Все API-key провайдеры', ok: 'готов', dormant: 'не настроен', running: 'выполняется…', noProviders: 'API-key провайдеры не найдены.', lastRun: 'Последний запуск', previewTitle: 'Предпросмотр изменений', previewNone: 'Изменений не обнаружено.', previewAdded: 'Добавлено', previewRemoved: 'Удалено', previewChanged: 'Изменено', previewApply: 'Применить изменения', previewDismiss: 'Закрыть предпросмотр', selectModels: 'Выбрать модели', saveSelection: 'Сохранить и применить выбор', selectAtLeastOne: 'Выберите хотя бы одну модель или нажмите «Все модели».', cancel: 'Отмена', selected: 'выбрано', allModels: 'Все модели', noModels: 'В текущем каталоге нет моделей.', capabilityFilter: 'Фильтр по возможностям', capabilityCount: 'показано', noCapabilities: 'В этом каталоге нет данных о возможностях.', pickerNote: 'Выбор сразу применяется к пикеру моделей DSH.', health: 'Проверить доступность', healthOk: 'доступен', healthError: 'недоступен', healthDormant: 'не настроен', policy: 'Ручной выбор моделей', savePolicy: 'Сохранить политику', policyInclude: 'Включать (по одному шаблону в строке)', policyExclude: 'Исключать (по одному шаблону в строке)', policyRequire: 'Обязательные возможности (через запятую)', policyDeny: 'Запрещённые возможности (через запятую)', policyNote: 'Шаблоны ищутся в id, названии и тегах модели. Нажмите «Применить», чтобы обновить пикер DSH.', scheduler: 'Планировщик', nextRun: 'следующий запуск', schedulerOff: 'выключен', schedulerOn: 'включён', removeMissing: 'Подтвердить удаление устаревших', stale: 'устарела', deprecated: 'deprecated', history: 'История синхронизаций', historyRun: 'Запуск', diffAdded: 'добавлено', diffRemoved: 'удалено', diffRenamed: 'переименовано', diffChanged: 'изменена метаинформация', rollback: 'Откатить каталог', rollbackConfirm: 'Откатить каталог этого провайдера к выбранному снимку?', credentials: 'Диагностика ключей', checkCredentials: 'Проверить ключи', missingCredential: 'не настроен', credentialReady: 'настроен', report: 'Итог запуска', success: 'успех', partial: 'частичный успех', failure: 'ошибка', empty: 'нет настроенных провайдеров', notifications: 'Уведомления', markRead: 'Прочитано', acknowledge: 'Подтвердить', noNotifications: 'Новых уведомлений нет.', selectAllFiltered: 'Выбрать все показанные', deselectAllFiltered: 'Снять выбор с показанных', invertFiltered: 'Инвертировать выбор', filterContext100k: 'Контекст ≥ 100k', filterCheap: 'Дешевые ≤ $1/1M', showMore: 'Показать ещё (%d)', showingCount: 'Показано %d из %d моделей', deprecatesIn: 'Устареет через %d дн.', cacheHoursAgo: 'кеш %dч назад', noHistory: 'Истории синхронизаций нет.', searchPlaceholder: 'Поиск id/name/tag', sortName: 'Имя A→Z', sortPrice: 'Цена ↑', sortContext: 'Контекст ↓', sortDate: 'Новые ↓', schedulerSettings: 'Настройка планировщика', scheduleEnabled: 'Включить фоновое расписание', intervalMinutes: 'Интервал (минуты)', autoApplySchedule: 'Автоматически применять изменения', saveScheduler: 'Сохранить расписание', schedulerSaved: 'Сохранено', scopeUnavailable: 'Служба настроек недоступна.', invalidPattern: 'Некорректный regex-шаблон: %s',
48
49
  }
49
50
  const API = '/dsh-model-sync'
50
51
  const CAPABILITY_LABELS = { vision: { en: 'Vision', ru: 'Зрение' }, tools: { en: 'Tools', ru: 'Инструменты' }, reasoning: { en: 'Reasoning', ru: 'Рассуждение' }, embeddings: { en: 'Embeddings', ru: 'Эмбеддинги' } }
@@ -96,7 +97,16 @@ window.__ModuleLoader__.load({
96
97
  const closePolicy = () => { setPolicyProvider(''); setPolicyDraft({ include: '', exclude: '', requireCapabilities: '', denyCapabilities: '' }) }
97
98
  const splitValues = (value) => [...new Set(String(value ?? '').split(/[,\n]+/).map((item) => item.trim()).filter(Boolean))]
98
99
  const capabilityValues = (value) => Object.fromEntries(splitValues(value).map((key) => [key, true]))
99
- const savePolicy = () => { setBusy(true); setError(''); fetch(`${API}/policy`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: policyProvider, include: splitValues(policyDraft.include), exclude: splitValues(policyDraft.exclude), requireCapabilities: capabilityValues(policyDraft.requireCapabilities), denyCapabilities: capabilityValues(policyDraft.denyCapabilities) }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(() => { closePolicy(); return load() }).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
100
+ const validatePatterns = (patterns) => { for (const p of patterns) { try { new RegExp(p, 'i') } catch { return p } } return null }
101
+ const savePolicy = () => {
102
+ const includeList = splitValues(policyDraft.include)
103
+ const excludeList = splitValues(policyDraft.exclude)
104
+ const badInclude = validatePatterns(includeList)
105
+ if (badInclude) { setError((t.invalidPattern || 'Invalid regex pattern: %s').replace('%s', badInclude)); return }
106
+ const badExclude = validatePatterns(excludeList)
107
+ if (badExclude) { setError((t.invalidPattern || 'Invalid regex pattern: %s').replace('%s', badExclude)); return }
108
+ setBusy(true); setError(''); fetch(`${API}/policy`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: policyProvider, include: includeList, exclude: excludeList, requireCapabilities: capabilityValues(policyDraft.requireCapabilities), denyCapabilities: capabilityValues(policyDraft.denyCapabilities) }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(() => { closePolicy(); return load() }).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false))
109
+ }
100
110
  const saveSelection = () => { const row = rows.find((item) => item.provider === selectionProvider); const available = row?.availableModels ?? row?.models ?? []; if (available.length > 0 && selectionDraft.length === 0) { setError(t.selectAtLeastOne); return } const models = selectionDraft.length === available.length ? [] : selectionDraft; setBusy(true); setError(''); fetch(`${API}/selection`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: selectionProvider, models }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then((body) => { setStatus(body); closeSelection() }).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
101
111
  const rollback = (entry, targetProvider) => { if (!window.confirm(t.rollbackConfirm)) return; setBusy(true); setError(''); fetch(`${API}/history/rollback`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ historyId: entry.id, provider: targetProvider }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(() => load()).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
102
112
  const isNewModel = (model, row) => { const added = status?.lastRun?.results?.find(r=>r.provider===row.provider)?.diff?.added||[]; const isAdded = added.some(m=>m.id===model.id); if(!isAdded) return false; const finishedAt = status?.lastRun?.finishedAt; if(!finishedAt) return false; return (Date.now()-new Date(finishedAt).getTime()) < 7*24*60*60*1000; };
package/lib/history.js CHANGED
@@ -16,11 +16,22 @@ export function historyLimit(value) {
16
16
  }
17
17
 
18
18
  function cloneModel(provider, model) {
19
- return structuredClone(normalizeModels(provider, [model])[0] ?? null)
19
+ const normalized = normalizeModels(provider, [model])[0]
20
+ if (!normalized) return null
21
+ const compact = {
22
+ provider: normalized.provider,
23
+ id: normalized.id,
24
+ name: normalized.name,
25
+ }
26
+ if (normalized.capabilities) compact.capabilities = normalized.capabilities
27
+ if (normalized.pricing) compact.pricing = normalized.pricing
28
+ if (normalized.contextWindow !== undefined) compact.contextWindow = normalized.contextWindow
29
+ if (normalized.maxTokens !== undefined) compact.maxTokens = normalized.maxTokens
30
+ return compact
20
31
  }
21
32
 
22
33
  function catalog(provider, models) {
23
- return normalizeModels(provider, models).slice(0, MAX_HISTORY_MODELS)
34
+ return (normalizeModels(provider, models) ?? []).map((model) => cloneModel(provider, model)).filter(Boolean).slice(0, MAX_HISTORY_MODELS)
24
35
  }
25
36
 
26
37
  function message(value) {
@@ -80,7 +91,7 @@ export function createHistoryEntry(result, { version = 1, maxModels = MAX_HISTOR
80
91
  }
81
92
  }
82
93
 
83
- export const DEFAULT_SNAPSHOT_RETENTION = 5
94
+ export const DEFAULT_SNAPSHOT_RETENTION = 3
84
95
 
85
96
  export function compactHistoryEntry(entry) {
86
97
  if (!entry || typeof entry !== 'object') return entry
package/lib/http.js CHANGED
@@ -8,12 +8,24 @@ function json(res, status, body) {
8
8
  }
9
9
 
10
10
  function trusted(req) {
11
- if (req.headers?.['sec-fetch-site'] === 'cross-site') return false
12
- const origin = req.headers?.origin
13
- if (!origin) return true
11
+ const fetchSite = req.headers?.['sec-fetch-site']
12
+ if (fetchSite === 'cross-site') return false
14
13
  const host = req.headers?.host
15
- if (!host) return false
16
- try { return new URL(origin).host === host } catch { return false }
14
+ const origin = req.headers?.origin
15
+ if (origin) {
16
+ if (!host) return false
17
+ try { return new URL(origin).host === host } catch { return false }
18
+ }
19
+ const referer = req.headers?.referer
20
+ if (referer) {
21
+ if (!host) return false
22
+ try { return new URL(referer).host === host } catch { return false }
23
+ }
24
+ // If sec-fetch-site is same-origin or same-site, allow
25
+ if (fetchSite === 'same-origin' || fetchSite === 'same-site') return true
26
+ // If sec-fetch-mode is present but origin/referer missing on a cross-site request, reject
27
+ if (req.headers?.['sec-fetch-mode'] && !host) return false
28
+ return true
17
29
  }
18
30
 
19
31
  function readJson(req) {
package/lib/index.js CHANGED
@@ -115,10 +115,15 @@ export function apply(ctx, config) {
115
115
  liveConfig = resolveConfig(next || baseConfig)
116
116
  reconfigure()
117
117
  })
118
- saveConfigImpl = async (patch) => {
119
- const result = await scope.update(patch)
120
- liveConfig = resolveConfig(scope.get() || baseConfig)
121
- return result
118
+ let saveQueue = Promise.resolve()
119
+ saveConfigImpl = (patch) => {
120
+ const nextTask = saveQueue.then(async () => {
121
+ const result = await scope.update(patch)
122
+ liveConfig = resolveConfig(scope.get() || baseConfig)
123
+ return result
124
+ })
125
+ saveQueue = nextTask.catch(() => {})
126
+ return nextTask
122
127
  }
123
128
  ownerCtx.effect(() => () => {
124
129
  settingsScope = undefined
package/lib/scheduler.js CHANGED
@@ -117,10 +117,16 @@ export function createSyncScheduler(sync, {
117
117
  if (config.enabled === false || config.scheduleEnabled === false) return false
118
118
  const rows = scheduleRows(config)
119
119
  if (inventory(config) !== null && rows.length === 0) return false
120
+ const isFirstRun = state.size === 0 || [...state.values()].every((row) => row.lastRunAt === null)
120
121
  resetStates(config)
121
122
  // One-minute polling is the lower safety bound. Per-provider intervals,
122
123
  // TTLs, and jitter are enforced by tick without multiplying timers.
123
124
  timer = setIntervalFn(() => tick(), 60_000)
125
+ if (isFirstRun) {
126
+ Promise.resolve().then(() => tick()).catch((err) => {
127
+ logger.warn?.("[dsh-model-sync] initial tick failed: " + errorMessage(err))
128
+ })
129
+ }
124
130
  return true
125
131
  }
126
132
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",