@oar-sh/oar 0.9.23 → 0.9.25

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.
Files changed (110) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +307 -0
  3. package/README.md +12 -11
  4. package/bin/oar.js +54 -4
  5. package/package.json +11 -7
  6. package/server/agent-sdk-loader.mjs +116 -0
  7. package/server/agent-sdks/claude/entry.mjs +4 -0
  8. package/server/agent-sdks/claude/package-lock.json +1438 -0
  9. package/server/agent-sdks/claude/package.json +13 -0
  10. package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
  11. package/server/agent-sdks/cursor/entry.mjs +3 -0
  12. package/server/agent-sdks/cursor/package-lock.json +171 -0
  13. package/server/agent-sdks/cursor/package.json +11 -0
  14. package/server/agent-sdks/koffi/entry.cjs +2 -0
  15. package/server/agent-sdks/koffi/package-lock.json +368 -0
  16. package/server/agent-sdks/koffi/package.json +10 -0
  17. package/server/claude-worker/claude-preview-tool.mjs +5 -2
  18. package/server/claude-worker/claude-remote-relay-tool.mjs +20 -2
  19. package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
  20. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +13 -8
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
  23. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  24. package/server/db-schema.mjs +19 -0
  25. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  26. package/server/migrations/0039-media-index.mjs +167 -0
  27. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  28. package/server/public/app/agent-sdk-ui.js +349 -0
  29. package/server/public/app/api-client.js +149 -8
  30. package/server/public/app/ask-user-view.js +2 -1
  31. package/server/public/app/attachments-view.js +66 -9
  32. package/server/public/app/bootstrap.js +289 -94
  33. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  34. package/server/public/app/context-media-tab.js +143 -0
  35. package/server/public/app/conversation-view.js +90 -5
  36. package/server/public/app/image-composer-ui.js +518 -0
  37. package/server/public/app/image-turn-placeholder.mjs +233 -0
  38. package/server/public/app/journal-view.js +12 -2
  39. package/server/public/app/media-gallery-model.mjs +202 -0
  40. package/server/public/app/media-gallery-ui.js +524 -0
  41. package/server/public/app/media-view.js +79 -0
  42. package/server/public/app/model-refresh-ui.mjs +413 -0
  43. package/server/public/app/no-conversation-state.mjs +5 -2
  44. package/server/public/app/openai-image-composer.mjs +492 -0
  45. package/server/public/app/openai-image-cost-ui.js +426 -0
  46. package/server/public/app/openai-image-spend.mjs +443 -0
  47. package/server/public/app/plan-usage-view.mjs +38 -6
  48. package/server/public/app/provider-readiness.mjs +67 -4
  49. package/server/public/app/relay-error-ctas.mjs +5 -0
  50. package/server/public/app/session-history-ui.js +2 -0
  51. package/server/public/app/settings-modal.js +171 -0
  52. package/server/public/app/socket-handlers.js +47 -0
  53. package/server/public/app/tab-strip-fade.mjs +1 -1
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +478 -9
  56. package/server/relay-tools.md +1 -0
  57. package/server/repositories/image-conversation-repository.mjs +69 -0
  58. package/server/repositories/media-repository.mjs +305 -0
  59. package/server/routes/agent-sdk-routes.mjs +73 -0
  60. package/server/routes/media-routes.mjs +361 -0
  61. package/server/routes/messages-routes.mjs +1467 -343
  62. package/server/routes/sessions-routes.mjs +596 -123
  63. package/server/server-runtime.mjs +414 -81
  64. package/server/services/about-service.mjs +26 -6
  65. package/server/services/agent-sdk-launch-gate.mjs +124 -0
  66. package/server/services/agent-sdk-paths.mjs +307 -0
  67. package/server/services/agent-sdk-store.mjs +1211 -0
  68. package/server/services/claude-api-settings-service.mjs +54 -17
  69. package/server/services/cli-install-service.mjs +3 -2
  70. package/server/services/copilot-model-discovery-service.mjs +7 -1
  71. package/server/services/database-file.mjs +23 -5
  72. package/server/services/deepseek-settings-service.mjs +58 -20
  73. package/server/services/earlier-database-service.mjs +4 -2
  74. package/server/services/generated-image-cleanup-service.mjs +2 -1
  75. package/server/services/generated-image-files.mjs +110 -0
  76. package/server/services/image-input-paths.mjs +389 -0
  77. package/server/services/image-operation-service.mjs +6 -2
  78. package/server/services/media-extract.mjs +653 -0
  79. package/server/services/media-index-service.mjs +221 -0
  80. package/server/services/model-refresh-scheduler.mjs +153 -0
  81. package/server/services/model-variant-catalog-service.mjs +141 -9
  82. package/server/services/oar-cli-helpers.mjs +72 -9
  83. package/server/services/oar-state-migration-service.mjs +2 -12
  84. package/server/services/oar-state-root.mjs +17 -0
  85. package/server/services/openai-image-preview-turns.mjs +330 -0
  86. package/server/services/openai-image-spend-service.mjs +516 -0
  87. package/server/services/openai-image-stream.mjs +170 -0
  88. package/server/services/plan-usage-openai-image.mjs +148 -0
  89. package/server/services/provider-model-refresh.mjs +326 -0
  90. package/server/services/provider-model-state.mjs +395 -0
  91. package/server/services/relay-singleton-guard.mjs +72 -10
  92. package/server/services/remote-relay-caller-context.mjs +6 -0
  93. package/server/services/remote-relay-dispatcher.mjs +386 -22
  94. package/server/services/remote-relay-registry-service.mjs +3 -1
  95. package/server/services/session-worker-launch-service.mjs +13 -0
  96. package/server/services/session-worker-supervisor-service.mjs +19 -1
  97. package/server/services/session-worker-websocket-service.mjs +15 -0
  98. package/server/services/token-usage-service.mjs +15 -0
  99. package/server/services/update-install-service.mjs +2 -2
  100. package/server/services/worker-launch-note-service.mjs +14 -2
  101. package/server/services/worker-recycle-service.mjs +145 -11
  102. package/shared/image-header.mjs +90 -0
  103. package/shared/model-refresh-schedule.mjs +120 -0
  104. package/shared/openai-image-options.mjs +299 -0
  105. package/shared/openai-image-presets.mjs +175 -0
  106. package/shared/openai-image-previews.mjs +40 -0
  107. package/shared/openai-image-prices.mjs +291 -0
  108. package/shared/openai-image-spend.mjs +90 -0
  109. package/shared/remote-relay-contract.mjs +126 -4
  110. package/shared/worker-runtime/direct-openai-image.mjs +98 -48
@@ -7,8 +7,8 @@
7
7
  //
8
8
  // One key per relay, stored like the DeepSeek and Cursor keys in the settings
9
9
  // table and never sent to a browser. Next to it: the switch, the default
10
- // model, the discovered models and the ones enabled for the composer, and an
11
- // optional monthly spend cap. Commit attribution, the background command
10
+ // model, the discovered models and which of them are on for the composer
11
+ // (provider-model-state.mjs), and an optional monthly spend cap. Commit attribution, the background command
12
12
  // limit, the effort levels and the compaction window are the Claude tab's, so
13
13
  // they are not stored here.
14
14
  //
@@ -25,6 +25,7 @@ import {
25
25
  isClaudeLongContextModelId,
26
26
  isSafeClaudeModelId,
27
27
  } from '../../shared/model-id.mjs';
28
+ import { createProviderModelStateStore } from './provider-model-state.mjs';
28
29
  import { DEFAULT_CLAUDE_REASONING_EFFORTS, withClaudeUltracodeTier } from './provider-reasoning-effort.mjs';
29
30
 
30
31
  export const CLAUDE_API_PROVIDER_TYPE = 'claude-api';
@@ -34,6 +35,8 @@ export const CLAUDE_API_KEY_SETTING_KEY = 'claude_api_key';
34
35
  export const CLAUDE_API_ENABLED_SETTING_KEY = 'claude_api_enabled';
35
36
  export const CLAUDE_API_MODEL_SETTING_KEY = 'claude_api_model';
36
37
  export const CLAUDE_API_MODELS_SETTING_KEY = 'claude_api_models';
38
+ // The allowlist before 0.9.24: read once to build the model map, then left
39
+ // as it is so an older version still finds it.
37
40
  export const CLAUDE_API_ENABLED_MODELS_SETTING_KEY = 'claude_api_enabled_models';
38
41
  export const CLAUDE_API_MONTHLY_CAP_SETTING_KEY = 'claude_api_monthly_cap_usd';
39
42
  // The derived default a new key replaced, kept until that key's models are
@@ -180,8 +183,8 @@ function normalizeStoredModel(entry) {
180
183
  }
181
184
 
182
185
  /**
183
- * Reads `{ apiKey?, enabled?, model?, enabledModels?, monthlyCapUsd?,
184
- * remove? }` from a request body. The key is never echoed in an error, and a
186
+ * Reads `{ apiKey?, enabled?, model?, enabledModels?, autoEnableNew?,
187
+ * monthlyCapUsd?, remove? }` from a request body. The key is never echoed in an error, and a
185
188
  * Claude login token or an Admin key is refused by its prefix.
186
189
  */
187
190
  export function parseClaudeApiSettingsUpdateRequest(body = {}) {
@@ -212,6 +215,14 @@ export function parseClaudeApiSettingsUpdateRequest(body = {}) {
212
215
  if (!Array.isArray(payload.enabledModels)) return { ok: false, error: 'enabledModels must be a list' };
213
216
  update.enabledModels = uniqueModels(payload.enabledModels);
214
217
  }
218
+ if (hasOwn(payload, 'shownModels')) {
219
+ if (!Array.isArray(payload.shownModels)) return { ok: false, error: 'shownModels must be a list' };
220
+ update.shownModels = uniqueModels(payload.shownModels);
221
+ }
222
+ if (hasOwn(payload, 'autoEnableNew')) {
223
+ if (typeof payload.autoEnableNew !== 'boolean') return { ok: false, error: 'autoEnableNew must be true or false' };
224
+ update.autoEnableNew = payload.autoEnableNew;
225
+ }
215
226
  if (hasOwn(payload, 'monthlyCapUsd')) {
216
227
  const cap = parseClaudeApiMonthlyCap(payload.monthlyCapUsd);
217
228
  if (!cap.ok) return { ok: false, error: cap.error };
@@ -240,7 +251,11 @@ export function createClaudeApiSettingsService({
240
251
  // Where OAR_ANTHROPIC_API_BASE_URL is read (a loopback fake API in tests).
241
252
  env = process.env,
242
253
  now = () => new Date(),
254
+ // (result) after each discovery: the model refresh ledger's clock.
255
+ onModelsRefreshed = () => {},
243
256
  } = {}) {
257
+ const modelState = createProviderModelStateStore({ readSetting, writeSetting, now });
258
+
244
259
  function readClaudeSettings() {
245
260
  try {
246
261
  const settings = getClaudeProviderSettings();
@@ -321,10 +336,13 @@ export function createClaudeApiSettingsService({
321
336
  const model = stored && isUsableModelId(stored) ? stored : defaultModelFor(offered, claudeSettings, carried);
322
337
  const availableModels = uniqueModels([model, ...offered]);
323
338
  const availableKeys = new Set(availableModels.map((id) => id.toLowerCase()));
324
- const storedEnabled = uniqueModels(readJsonList(CLAUDE_API_ENABLED_MODELS_SETTING_KEY))
325
- .filter((entry) => availableKeys.has(entry.toLowerCase()));
326
- // The default model always stays enabled; with no choice stored, all are.
327
- const models = storedEnabled.length ? uniqueModels([model, ...storedEnabled]) : availableModels;
339
+ // The default model always stays on; the others as the model map says.
340
+ const enabled = modelState.resolve(CLAUDE_API_PROVIDER_TYPE, {
341
+ availableModels,
342
+ defaultModel: model,
343
+ legacyAllowlist: () => uniqueModels(readJsonList(CLAUDE_API_ENABLED_MODELS_SETTING_KEY)),
344
+ });
345
+ const models = uniqueModels(enabled.models);
328
346
 
329
347
  const detailsByBase = new Map(discovered.map((entry) => [entry.id.toLowerCase(), entry]));
330
348
  const claudeEfforts = claudeSettings?.effortsByModel && typeof claudeSettings.effortsByModel === 'object'
@@ -381,6 +399,9 @@ export function createClaudeApiSettingsService({
381
399
  models,
382
400
  availableModels,
383
401
  enabledModels: models,
402
+ autoEnableNew: enabled.autoEnableNew,
403
+ fallback: enabled.fallback,
404
+ unseenModels: enabled.unseen,
384
405
  discovered: discovered.length > 0,
385
406
  effortsByModel,
386
407
  contextWindowsByModel,
@@ -463,9 +484,9 @@ export function createClaudeApiSettingsService({
463
484
  activeQueueConversationCount: counts.queued,
464
485
  };
465
486
  }
487
+ // The model map stays: a key added again finds its models as they were.
466
488
  deleteSetting(CLAUDE_API_KEY_SETTING_KEY);
467
489
  deleteSetting(CLAUDE_API_MODELS_SETTING_KEY);
468
- deleteSetting(CLAUDE_API_ENABLED_MODELS_SETTING_KEY);
469
490
  deleteSetting(CLAUDE_API_CARRIED_MODEL_SETTING_KEY);
470
491
  writeSetting(CLAUDE_API_ENABLED_SETTING_KEY, 'false', stamp);
471
492
  return {
@@ -498,12 +519,16 @@ export function createClaudeApiSettingsService({
498
519
  if (parsed.model) writeSetting(CLAUDE_API_MODEL_SETTING_KEY, parsed.model, stamp);
499
520
  else deleteSetting(CLAUDE_API_MODEL_SETTING_KEY);
500
521
  }
501
- if (hasOwn(parsed, 'enabledModels')) {
502
- if (parsed.enabledModels.length) {
503
- writeSetting(CLAUDE_API_ENABLED_MODELS_SETTING_KEY, JSON.stringify(parsed.enabledModels), stamp);
504
- } else {
505
- deleteSetting(CLAUDE_API_ENABLED_MODELS_SETTING_KEY);
506
- }
522
+ if (hasOwn(parsed, 'enabledModels') || hasOwn(parsed, 'autoEnableNew')) {
523
+ // The models Select Models showed are set as ticked; an empty list
524
+ // leaves the default model alone, never all.
525
+ modelState.select(CLAUDE_API_PROVIDER_TYPE, {
526
+ enabledModels: parsed.enabledModels,
527
+ // The rows the browser drew; an older page sends none.
528
+ shownModels: parsed.shownModels || previous.availableModels,
529
+ defaultModel: getSettings().model,
530
+ autoEnableNew: parsed.autoEnableNew,
531
+ });
507
532
  }
508
533
  if (hasOwn(parsed, 'monthlyCapUsd')) {
509
534
  if (parsed.monthlyCapUsd === null) deleteSetting(CLAUDE_API_MONTHLY_CAP_SETTING_KEY);
@@ -530,6 +555,12 @@ export function createClaudeApiSettingsService({
530
555
  * new key's.
531
556
  */
532
557
  async function refreshModels() {
558
+ const result = await discoverModels();
559
+ try { onModelsRefreshed(result); } catch { /* the clock is best-effort */ }
560
+ return result;
561
+ }
562
+
563
+ async function discoverModels() {
533
564
  const settings = getSettings();
534
565
  if (!settings.configured) return { ok: false, models: settings.availableModels, error: 'Claude API key is not configured' };
535
566
  const redact = (text) => String(text ?? '').split(settings.apiKey).join('[redacted]');
@@ -560,7 +591,9 @@ export function createClaudeApiSettingsService({
560
591
  writeSetting?.(CLAUDE_API_MODELS_SETTING_KEY, JSON.stringify(models), now().toISOString());
561
592
  // The key's own list now decides the default.
562
593
  deleteSetting?.(CLAUDE_API_CARRIED_MODEL_SETTING_KEY);
563
- return { ok: true, models: getSettings().availableModels, error: null };
594
+ const availableModels = getSettings().availableModels;
595
+ const { added } = modelState.reconcile(CLAUDE_API_PROVIDER_TYPE, { availableModels });
596
+ return { ok: true, models: availableModels, newModels: added, error: null };
564
597
  } catch (error) {
565
598
  return {
566
599
  ok: false,
@@ -571,5 +604,9 @@ export function createClaudeApiSettingsService({
571
604
  }
572
605
  }
573
606
 
574
- return { getSettings, describe, update, refreshModels, contextWindowFor };
607
+ function markModelsSeen() {
608
+ return modelState.markSeen(CLAUDE_API_PROVIDER_TYPE);
609
+ }
610
+
611
+ return { getSettings, describe, update, refreshModels, contextWindowFor, markModelsSeen };
575
612
  }
@@ -70,8 +70,9 @@ const GENERIC_VERSION_PATTERN = /(\d+\.\d+(?:\.\d+)*)/;
70
70
  /**
71
71
  * The frozen descriptor table: the only place an install command exists.
72
72
  *
73
- * Cursor is deliberately absent — `@cursor/sdk` is a pure npm package and the
74
- * relay never invokes a `cursor-agent` binary, so an install button there would
73
+ * Cursor is deliberately absent — `@cursor/sdk` is an npm package the relay
74
+ * installs on demand (agent-sdk-store.mjs) and the relay never invokes a
75
+ * `cursor-agent` binary, so an install button there would
75
76
  * install a CLI nothing runs (plan §2.4). Adding it back later is a data change
76
77
  * here, not a code change.
77
78
  *
@@ -44,6 +44,9 @@ export function createCopilotModelDiscoveryService({
44
44
  updateModelCatalog,
45
45
  timeoutMs = DEFAULT_COPILOT_MODEL_DISCOVERY_TIMEOUT_MS,
46
46
  bootDelayMs = DEFAULT_COPILOT_MODEL_DISCOVERY_BOOT_DELAY_MS,
47
+ // (result, reason) after each refresh that got past the CLI check: the
48
+ // model refresh ledger's clock (the boot refresh included).
49
+ onRefreshed = () => {},
47
50
  logger = console,
48
51
  } = {}) {
49
52
  if (typeof createClient !== 'function' || typeof updateModelCatalog !== 'function') {
@@ -151,7 +154,10 @@ export function createCopilotModelDiscoveryService({
151
154
  }
152
155
  activeRefresh = null;
153
156
  }
154
- })();
157
+ })().then((result) => {
158
+ try { onRefreshed(result, reason); } catch { /* the clock is best-effort */ }
159
+ return result;
160
+ });
155
161
  return activeRefresh;
156
162
  }
157
163
 
@@ -27,12 +27,30 @@ const CHECK_DIR_PREFIX = '.database-file-check-';
27
27
  // one's work, not no rows: every start writes these settings (seen on a
28
28
  // real start of 0.9.19 on an empty folder). A later boot-time key goes here
29
29
  // deliberately; any other key makes the start refuse.
30
- export const STARTUP_SETTING_KEYS = Object.freeze(['push_vapid_public_key', 'push_vapid_private_key', 'relay_last_exit']);
30
+ // Since 0.9.24 a start also writes each provider's model map as it finds
31
+ // it (provider-model-state.mjs) and the last model refresh. A version that
32
+ // runs on copilot.db writes none of them; a start of this one, which the
33
+ // browser test uses as its stand-in, writes several.
34
+ export const STARTUP_SETTING_KEYS = Object.freeze([
35
+ 'push_vapid_public_key',
36
+ 'push_vapid_private_key',
37
+ 'relay_last_exit',
38
+ 'copilot_model_state',
39
+ 'openai_model_state',
40
+ 'claude_model_state',
41
+ 'cursor_model_state',
42
+ 'grok_model_state',
43
+ 'deepseek_model_state',
44
+ 'claude_api_model_state',
45
+ 'model_refresh_last',
46
+ ]);
31
47
  // And the tables such a start fills that hold no one's work: the model
32
- // catalog and its refresh state, the restart orchestrator's one row. (The
33
- // shadow tables of the message search are left out too: they follow
34
- // messages.) Any other table with a row makes the start refuse.
35
- export const STARTUP_TABLES = Object.freeze(['model_variants', 'model_selector_state', 'relay_restart_orchestrator']);
48
+ // catalog and its refresh state, the restart orchestrator's one row, the
49
+ // media index's extractor version (migration 0039; its items and queue
50
+ // follow messages and stay empty without them). (The shadow tables of the
51
+ // message search are left out too: they follow messages.) Any other table
52
+ // with a row makes the start refuse.
53
+ export const STARTUP_TABLES = Object.freeze(['model_variants', 'model_selector_state', 'relay_restart_orchestrator', 'media_index_meta']);
36
54
 
37
55
  /** A database's files: the main file, its -wal and its -shm. */
38
56
  export function sideFiles(name) {
@@ -4,10 +4,11 @@
4
4
  //
5
5
  // One API key per relay, stored like the OpenAI and Cursor keys in the
6
6
  // settings table and never sent to the browser. Next to it: the switch, the
7
- // default model, the discovered models and the ones enabled for the
8
- // composer, an optional base URL, and the commit attribution of DeepSeek
9
- // chats. Discovery asks DeepSeek's `GET /models` (shared/deepseek-api.mjs);
10
- // when it has not run or failed, the built-in list stands in.
7
+ // default model, the discovered models and which of them are on for the
8
+ // composer (provider-model-state.mjs), an optional base URL, and the commit
9
+ // attribution of DeepSeek chats. Discovery asks DeepSeek's `GET /models`
10
+ // (shared/deepseek-api.mjs); when it has not run or failed, the built-in list
11
+ // stands in.
11
12
 
12
13
  import { createDeepSeekApiClient, normalizeDeepSeekBaseUrl } from '../../shared/deepseek-api.mjs';
13
14
  import {
@@ -22,11 +23,14 @@ import {
22
23
  } from '../../shared/deepseek-models.mjs';
23
24
  import { DEEPSEEK_PRICE_CURRENCIES } from '../../shared/deepseek-pricing.mjs';
24
25
  import { isSafeDeepSeekModelId } from '../../shared/model-id.mjs';
26
+ import { createProviderModelStateStore } from './provider-model-state.mjs';
25
27
 
26
28
  export const DEEPSEEK_API_KEY_SETTING_KEY = 'deepseek_api_key';
27
29
  export const DEEPSEEK_ENABLED_SETTING_KEY = 'deepseek_enabled';
28
30
  export const DEEPSEEK_MODEL_SETTING_KEY = 'deepseek_model';
29
31
  export const DEEPSEEK_MODELS_SETTING_KEY = 'deepseek_models';
32
+ // The allowlist before 0.9.24: read once to build the model map, then left
33
+ // as it is so an older version still finds it.
30
34
  export const DEEPSEEK_ENABLED_MODELS_SETTING_KEY = 'deepseek_enabled_models';
31
35
  export const DEEPSEEK_BASE_URL_SETTING_KEY = 'deepseek_base_url';
32
36
  export const DEEPSEEK_ATTRIBUTION_SETTING_KEY = 'deepseek_attribution';
@@ -95,9 +99,9 @@ function parseMonthlyBudgetCurrency(value) {
95
99
  }
96
100
 
97
101
  /**
98
- * Reads `{ apiKey?, enabled?, model?, enabledModels?, baseUrl?, attribution?,
99
- * monthlyBudget?, monthlyBudgetCurrency?, startingBalance?,
100
- * startingBalanceEdited?, remove? }` from a
102
+ * Reads `{ apiKey?, enabled?, model?, enabledModels?, autoEnableNew?,
103
+ * baseUrl?, attribution?, monthlyBudget?, monthlyBudgetCurrency?,
104
+ * startingBalance?, startingBalanceEdited?, remove? }` from a
101
105
  * request body. The
102
106
  * key is never echoed in an error.
103
107
  */
@@ -124,6 +128,14 @@ export function parseDeepSeekSettingsUpdateRequest(body = {}) {
124
128
  if (!Array.isArray(payload.enabledModels)) return { ok: false, error: 'enabledModels must be a list' };
125
129
  update.enabledModels = uniqueModels(payload.enabledModels);
126
130
  }
131
+ if (hasOwn(payload, 'shownModels')) {
132
+ if (!Array.isArray(payload.shownModels)) return { ok: false, error: 'shownModels must be a list' };
133
+ update.shownModels = uniqueModels(payload.shownModels);
134
+ }
135
+ if (hasOwn(payload, 'autoEnableNew')) {
136
+ if (typeof payload.autoEnableNew !== 'boolean') return { ok: false, error: 'autoEnableNew must be true or false' };
137
+ update.autoEnableNew = payload.autoEnableNew;
138
+ }
127
139
  if (hasOwn(payload, 'baseUrl')) {
128
140
  const normalized = normalizeDeepSeekBaseUrl(payload.baseUrl === null ? '' : payload.baseUrl);
129
141
  if (!normalized.ok) return { ok: false, error: normalized.error };
@@ -168,7 +180,11 @@ export function createDeepSeekSettingsService({
168
180
  listProviderCandidates = () => [],
169
181
  fetchImpl = globalThis.fetch,
170
182
  now = () => new Date(),
183
+ // (result) after each discovery: the model refresh ledger's clock.
184
+ onModelsRefreshed = () => {},
171
185
  } = {}) {
186
+ const modelState = createProviderModelStateStore({ readSetting, writeSetting, now });
187
+
172
188
  function readList(settingKey) {
173
189
  try {
174
190
  const parsed = JSON.parse(readSetting(settingKey) || '[]');
@@ -220,10 +236,13 @@ export function createDeepSeekSettingsService({
220
236
  const model = readModel();
221
237
  const discovered = readList(DEEPSEEK_MODELS_SETTING_KEY);
222
238
  const availableModels = uniqueModels([model, ...(discovered.length ? discovered : DEFAULT_DEEPSEEK_MODELS)]);
223
- const storedEnabled = readList(DEEPSEEK_ENABLED_MODELS_SETTING_KEY)
224
- .filter((entry) => availableModels.some((candidate) => candidate.toLowerCase() === entry.toLowerCase()));
225
- // The default model always stays enabled; with no choice stored, all are.
226
- const models = storedEnabled.length ? uniqueModels([model, ...storedEnabled]) : availableModels;
239
+ // The default model always stays on; the others as the model map says.
240
+ const enabled = modelState.resolve(DEEPSEEK_PROVIDER_TYPE, {
241
+ availableModels,
242
+ defaultModel: model,
243
+ legacyAllowlist: () => readList(DEEPSEEK_ENABLED_MODELS_SETTING_KEY),
244
+ });
245
+ const models = uniqueModels(enabled.models);
227
246
  return {
228
247
  configured: !!apiKey,
229
248
  enabled: !!apiKey && (enabledSetting === '' || enabledSetting === 'true'),
@@ -231,6 +250,9 @@ export function createDeepSeekSettingsService({
231
250
  model,
232
251
  models,
233
252
  availableModels,
253
+ autoEnableNew: enabled.autoEnableNew,
254
+ fallback: enabled.fallback,
255
+ unseenModels: enabled.unseen,
234
256
  discovered: discovered.length > 0,
235
257
  baseUrl: readBaseUrl(),
236
258
  attribution: readAttribution(),
@@ -291,9 +313,9 @@ export function createDeepSeekSettingsService({
291
313
  activeQueueConversationCount: counts.queued,
292
314
  };
293
315
  }
316
+ // The model map stays: a key added again finds its models as they were.
294
317
  deleteSetting(DEEPSEEK_API_KEY_SETTING_KEY);
295
318
  deleteSetting(DEEPSEEK_MODELS_SETTING_KEY);
296
- deleteSetting(DEEPSEEK_ENABLED_MODELS_SETTING_KEY);
297
319
  writeSetting(DEEPSEEK_ENABLED_SETTING_KEY, 'false', stamp);
298
320
  return { ok: true, removed: true, keyChanged: previous.configured, discover: false, settings: describe() };
299
321
  }
@@ -315,12 +337,16 @@ export function createDeepSeekSettingsService({
315
337
  }
316
338
  if (hasOwn(parsed, 'enabled') || keyChanged) writeSetting(DEEPSEEK_ENABLED_SETTING_KEY, nextEnabled ? 'true' : 'false', stamp);
317
339
  if (hasOwn(parsed, 'model')) writeSetting(DEEPSEEK_MODEL_SETTING_KEY, parsed.model, stamp);
318
- if (hasOwn(parsed, 'enabledModels')) {
319
- if (parsed.enabledModels.length) {
320
- writeSetting(DEEPSEEK_ENABLED_MODELS_SETTING_KEY, JSON.stringify(parsed.enabledModels), stamp);
321
- } else {
322
- deleteSetting(DEEPSEEK_ENABLED_MODELS_SETTING_KEY);
323
- }
340
+ if (hasOwn(parsed, 'enabledModels') || hasOwn(parsed, 'autoEnableNew')) {
341
+ // The models Select Models showed are set as ticked; an empty list
342
+ // leaves the default model alone, never all.
343
+ modelState.select(DEEPSEEK_PROVIDER_TYPE, {
344
+ enabledModels: parsed.enabledModels,
345
+ // The rows the browser drew; an older page sends none.
346
+ shownModels: parsed.shownModels || previous.availableModels,
347
+ defaultModel: readModel(),
348
+ autoEnableNew: parsed.autoEnableNew,
349
+ });
324
350
  }
325
351
  if (hasOwn(parsed, 'attribution')) writeSetting(DEEPSEEK_ATTRIBUTION_SETTING_KEY, parsed.attribution, stamp);
326
352
  if (hasOwn(parsed, 'monthlyBudget')) {
@@ -361,6 +387,12 @@ export function createDeepSeekSettingsService({
361
387
  * failure keeps what was stored, so the pickers never empty out.
362
388
  */
363
389
  async function refreshModels() {
390
+ const result = await discoverModels();
391
+ try { onModelsRefreshed(result); } catch { /* the clock is best-effort */ }
392
+ return result;
393
+ }
394
+
395
+ async function discoverModels() {
364
396
  const settings = getSettings();
365
397
  if (!settings.configured) return { ok: false, models: settings.availableModels, error: 'DeepSeek API key is not configured' };
366
398
  try {
@@ -370,11 +402,17 @@ export function createDeepSeekSettingsService({
370
402
  return { ok: false, models: settings.availableModels, error: 'DeepSeek listed no DeepSeek models' };
371
403
  }
372
404
  writeSetting?.(DEEPSEEK_MODELS_SETTING_KEY, JSON.stringify(models), now().toISOString());
373
- return { ok: true, models: getSettings().availableModels, error: null };
405
+ const availableModels = getSettings().availableModels;
406
+ const { added } = modelState.reconcile(DEEPSEEK_PROVIDER_TYPE, { availableModels });
407
+ return { ok: true, models: availableModels, newModels: added, error: null };
374
408
  } catch (error) {
375
409
  return { ok: false, models: settings.availableModels, error: error?.message || 'DeepSeek model discovery failed', code: error?.code || null };
376
410
  }
377
411
  }
378
412
 
379
- return { getSettings, describe, update, refreshModels };
413
+ function markModelsSeen() {
414
+ return modelState.markSeen(DEEPSEEK_PROVIDER_TYPE);
415
+ }
416
+
417
+ return { getSettings, describe, update, refreshModels, markModelsSeen };
380
418
  }
@@ -9,7 +9,7 @@ import {
9
9
  } from './database-file.mjs';
10
10
  import { resolveOarRoot } from './oar-state-migration-service.mjs';
11
11
  import { resolveStateLayout } from './oar-state-layout.mjs';
12
- import { defaultIsProcessAlive, lockHolderMayRun, processStartedAt as defaultProcessStartedAt } from './relay-singleton-guard.mjs';
12
+ import { defaultIsProcessAlive, lockHolderMayRun, processImageName, processStartedAt as defaultProcessStartedAt } from './relay-singleton-guard.mjs';
13
13
 
14
14
  const SHA256_NAME = /^[a-f0-9]{64}$/;
15
15
  // The copies the install-time rescue of 0.9.14 to 0.9.22 made, in the data folder.
@@ -94,6 +94,8 @@ function adopt({
94
94
  platform = process.platform,
95
95
  isProcessAlive = defaultIsProcessAlive,
96
96
  processStartedAt = (pid) => defaultProcessStartedAt(pid, { platform }),
97
+ // What a pid runs, when its start cannot be told (Windows without koffi).
98
+ imageNameOf = processImageName,
97
99
  bootTime = Date.now() - os.uptime() * 1000,
98
100
  now = () => new Date(),
99
101
  clock = Date.now,
@@ -234,7 +236,7 @@ function adopt({
234
236
  fsImpl,
235
237
  rename,
236
238
  exists,
237
- lockMayRun: (lock) => lockHolderMayRun({ lock, isProcessAlive, startedAtOf: processStartedAt, bootTime }),
239
+ lockMayRun: (lock) => lockHolderMayRun({ lock, isProcessAlive, startedAtOf: processStartedAt, bootTime, platform, imageNameOf }),
238
240
  normalize,
239
241
  progress,
240
242
  });
@@ -25,7 +25,8 @@ function parseMessageAttachments(rawAttachments, { parseAttachments, hydrateAtta
25
25
  return attachments.map((entry) => hydrateAttachment(entry)).filter(Boolean);
26
26
  }
27
27
 
28
- function resolveGeneratedImagePath({
28
+ /** The file of a generated-image attachment under `root`, or null for one that would leave its folder. */
29
+ export function resolveGeneratedImagePath({
29
30
  root,
30
31
  sessionId,
31
32
  relativePath,
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ import fs from 'fs';
4
+
5
+ import { IMAGE_HEADER_BYTES, readImageHeader } from '../../shared/image-header.mjs';
6
+ import { resolveGeneratedImagePath } from './generated-image-cleanup-service.mjs';
7
+
8
+ // A generated image's attachment as GET /api/conversation/:id serves it: its
9
+ // absolute file on this relay (`generatedImage.path`) and its pixel size, so
10
+ // another relay's agent can name the file in a later send. The path is worked
11
+ // out on every read, never stored: checkouts and state folders move.
12
+
13
+ // PNG and WebP headers sit in the first bytes; a JPEG's frame header comes
14
+ // after its EXIF/ICC blocks, so only a JPEG that needs more reads further.
15
+ const FIRST_READ_BYTES = 4096;
16
+
17
+ // The conversation page is polled: a file read once is not read again while
18
+ // its size and time stay the same.
19
+ const HEADER_CACHE_MAX = 500;
20
+ const headerCache = new Map();
21
+
22
+ function readFirstBytes(filePath, length) {
23
+ let fd = null;
24
+ try {
25
+ fd = fs.openSync(filePath, 'r');
26
+ const buffer = Buffer.alloc(length);
27
+ const read = fs.readSync(fd, buffer, 0, length, 0);
28
+ return buffer.subarray(0, read);
29
+ } catch {
30
+ return null;
31
+ } finally {
32
+ if (fd !== null) {
33
+ try { fs.closeSync(fd); } catch {}
34
+ }
35
+ }
36
+ }
37
+
38
+ function imageHeaderOf(filePath, stat) {
39
+ const cached = headerCache.get(filePath);
40
+ if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.header;
41
+ const first = readFirstBytes(filePath, Math.min(FIRST_READ_BYTES, stat.size));
42
+ let header = first ? readImageHeader(first) : null;
43
+ if (!header && first && first[0] === 0xff && first[1] === 0xd8 && stat.size > first.length) {
44
+ const more = readFirstBytes(filePath, Math.min(IMAGE_HEADER_BYTES, stat.size));
45
+ header = more ? readImageHeader(more) : null;
46
+ }
47
+ if (headerCache.size >= HEADER_CACHE_MAX) headerCache.delete(headerCache.keys().next().value);
48
+ headerCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, header });
49
+ return header;
50
+ }
51
+
52
+ function positiveInt(value) {
53
+ return Number.isInteger(value) && value > 0 ? value : null;
54
+ }
55
+
56
+ function withFile(attachment, root) {
57
+ const { path: _stored, ...generatedImage } = attachment.generatedImage;
58
+ const bare = { ...attachment, generatedImage };
59
+ const filePath = resolveGeneratedImagePath({
60
+ root,
61
+ sessionId: generatedImage.sessionId,
62
+ relativePath: generatedImage.relativePath,
63
+ });
64
+ if (!filePath) return bare;
65
+ let stat = null;
66
+ try {
67
+ stat = fs.statSync(filePath, { throwIfNoEntry: false }) || null;
68
+ } catch {}
69
+ if (!stat?.isFile()) return bare;
70
+ const width = positiveInt(attachment.width);
71
+ const height = positiveInt(attachment.height);
72
+ // New attachments carry their size; older ones are read.
73
+ const header = width && height ? null : imageHeaderOf(filePath, stat);
74
+ return {
75
+ ...bare,
76
+ ...(header ? { width: header.width, height: header.height } : {}),
77
+ ...(!attachment.type && header?.mime ? { type: header.mime } : {}),
78
+ generatedImage: { ...generatedImage, path: filePath },
79
+ };
80
+ }
81
+
82
+ /**
83
+ * `messages` with each generated-image attachment given its file path and
84
+ * pixel size, when the file is there. Messages without one come back as they
85
+ * are; a missing or unreadable file leaves the attachment without a path.
86
+ * Never throws.
87
+ */
88
+ export function withGeneratedImageFiles(messages, { resolveSessionStateRoot } = {}) {
89
+ if (!Array.isArray(messages) || typeof resolveSessionStateRoot !== 'function') return messages;
90
+ let root = '';
91
+ try {
92
+ root = String(resolveSessionStateRoot() || '').trim();
93
+ } catch {}
94
+ if (!root) return messages;
95
+ return messages.map((message) => {
96
+ const attachments = Array.isArray(message?.attachments) ? message.attachments : null;
97
+ if (!attachments || !attachments.some((entry) => entry?.generatedImage && typeof entry.generatedImage === 'object')) return message;
98
+ return {
99
+ ...message,
100
+ attachments: attachments.map((entry) => {
101
+ if (!entry?.generatedImage || typeof entry.generatedImage !== 'object') return entry;
102
+ try {
103
+ return withFile(entry, root);
104
+ } catch {
105
+ return entry;
106
+ }
107
+ }),
108
+ };
109
+ });
110
+ }