@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
@@ -1,4 +1,4 @@
1
- import { createSdkMcpServer, query } from '@anthropic-ai/claude-agent-sdk';
1
+ import { loadAgentSdk } from '../agent-sdk-loader.mjs';
2
2
 
3
3
  import { formatClaudeBackgroundCommandLimitLabel } from '../../shared/claude-background-command-limit.mjs';
4
4
  import { parseThinkingDisplay } from '../../shared/claude-thinking.mjs';
@@ -9,6 +9,9 @@ import { createPreviewToolDefinition } from './claude-preview-tool.mjs';
9
9
  import { createRemoteRelayToolDefinition } from './claude-remote-relay-tool.mjs';
10
10
  import { CLAUDE_LIVE_FLAG_SETTINGS_CODE, CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE } from './claude-turn-failure.mjs';
11
11
 
12
+ // The SDK the relay installed for this worker (agent-sdk-loader.mjs).
13
+ const { createSdkMcpServer, query } = await loadAgentSdk('claude');
14
+
12
15
  export { CLAUDE_LIVE_FLAG_SETTINGS_CODE };
13
16
 
14
17
  // The name the in-process MCP server is registered under; it prefixes every
@@ -555,6 +555,11 @@ export function createSdkMessageNormalizer() {
555
555
  const thinking = state.thinking.get(index);
556
556
  if (thinking) {
557
557
  thinking.text = capThought(thinking.text + String(event?.delta?.thinking || ''));
558
+ // Gated as stream text: a delta can be one token, and every update
559
+ // carries the whole thought. Past the cap the text stops changing,
560
+ // so nothing more goes out until the block's final frame.
561
+ if (!shouldEmitStreamUpdate(thinking.text, thinking.lastEmittedText)) return actions;
562
+ thinking.lastEmittedText = thinking.text;
558
563
  actions.push({
559
564
  channel: 'thought',
560
565
  payload: {
@@ -715,7 +720,7 @@ export function createSdkMessageNormalizer() {
715
720
  const assistantError = lastAssistantError;
716
721
  lastAssistantError = '';
717
722
  const apiErrorStatus = Number(sdkMessage.api_error_status ?? NaN);
718
- return [{
723
+ return [...flushOpenThoughts(), {
719
724
  channel: 'result',
720
725
  payload: {
721
726
  text: String(sdkMessage.result || '').trim(),
@@ -745,6 +750,29 @@ export function createSdkMessageNormalizer() {
745
750
  return [];
746
751
  }
747
752
 
753
+ // A thinking block the turn left open (aborted, failed) never gets its
754
+ // final frame: what the delta gating held back goes out with the result,
755
+ // so the stored thought ends with the last delta.
756
+ function flushOpenThoughts() {
757
+ const actions = [];
758
+ for (const state of threads.values()) {
759
+ for (const thinking of state.thinking.values()) {
760
+ if (!thinking.text.trim() || thinking.text === thinking.lastEmittedText) continue;
761
+ thinking.lastEmittedText = thinking.text;
762
+ actions.push({
763
+ channel: 'thought',
764
+ payload: {
765
+ reasoningId: thinking.reasoningId,
766
+ text: thinking.text,
767
+ done: false,
768
+ subagentRunId: subagentRunIdFor(state.key === 'main' ? null : state.key),
769
+ },
770
+ });
771
+ }
772
+ }
773
+ return actions;
774
+ }
775
+
748
776
  function finalStreamText() {
749
777
  return threadState(null).streamText;
750
778
  }
@@ -5005,10 +5005,12 @@ export function createCopilotSdkSessionRunner({
5005
5005
 
5006
5006
  /**
5007
5007
  * One image-model row (shared/worker-runtime/direct-openai-image.mjs). With
5008
- * an image operation the relay's execute route publishes the reply, the
5009
- * failure or the requeue itself; without one the shared helper publishes
5010
- * the reply, and a refusal by the provider is published here as the row's
5011
- * failure. Anything else requeues the row, as on the extension engine.
5008
+ * an image operation the relay's execute route publishes the reply or the
5009
+ * failure itself; without one the shared helper publishes the reply, and
5010
+ * any failure of the request is published here as the row's failure, never
5011
+ * requeued: the relay already retried it once, and a second run is billed
5012
+ * again. Only an execute call that did not get through requeues the row
5013
+ * (the route refuses to send an operation's request twice).
5012
5014
  */
5013
5015
  async function runDirectImageTurn(message) {
5014
5016
  const id = String(message.id || '');
@@ -5025,15 +5027,18 @@ export function createCopilotSdkSessionRunner({
5025
5027
  dbg('image turn', message.id, result.via, result.outcome);
5026
5028
  } catch (error) {
5027
5029
  if (isOpenAIImageTerminalError(error)) {
5028
- dbg('image turn refused by OpenAI', message.id, error.message);
5030
+ // The relay's note and code (the ones its image operation uses for
5031
+ // the same cases); an older relay names no code.
5032
+ const code = error.imageCode
5033
+ || (error.relayProcessing === true ? 'image-reply-unprocessed' : 'image-provider-rejected');
5034
+ dbg('image turn failed', message.id, code, error.message);
5029
5035
  await publishResponse(message, {
5030
5036
  text: error.message,
5031
5037
  model: null,
5032
5038
  terminalError: {
5033
5039
  kind: 'openai-image-failed',
5034
- // The code the relay's image operation uses for the same refusal.
5035
- code: 'image-provider-rejected',
5036
- stableCode: 'image-provider-rejected',
5040
+ code,
5041
+ stableCode: code,
5037
5042
  message: error.message,
5038
5043
  ...(error.detail ? { detail: String(error.detail) } : {}),
5039
5044
  failedAt: new Date().toISOString(),
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs';
3
3
 
4
+ import { loadAgentSdk } from '../agent-sdk-loader.mjs';
5
+
4
6
  import {
5
7
  createTurnLiveness,
6
8
  createTurnStalledError,
@@ -18,7 +20,7 @@ import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '
18
20
  */
19
21
 
20
22
  async function defaultAgentFactory() {
21
- const { Agent } = await import('@cursor/sdk');
23
+ const { Agent } = await loadAgentSdk('cursor');
22
24
  return {
23
25
  create: (options) => Agent.create(options),
24
26
  resume: (agentId, options) => Agent.resume(agentId, options),
@@ -30,7 +32,7 @@ async function defaultAgentFactory() {
30
32
  // `storeFactoryImpl(storePath, { cwd })` with the real store rooted in the
31
33
  // storePath's directory.
32
34
  async function defaultStoreFactory() {
33
- const { SqliteLocalAgentStore } = await import('@cursor/sdk/sqlite');
35
+ const { SqliteLocalAgentStore } = await loadAgentSdk('cursor', { entry: 'sqlite' });
34
36
  return (storePath, { cwd } = {}) =>
35
37
  SqliteLocalAgentStore.open({
36
38
  workspaceRef: cwd || path.dirname(storePath),
@@ -497,7 +499,7 @@ async function defaultModelsList({ apiKey }) {
497
499
  // The namespace form is the only working call shape: `Cursor` has a private
498
500
  // constructor and `models` is a static member, so the old client-instance
499
501
  // fallback could only ever throw a TypeError that masked the real error.
500
- const sdk = await import('@cursor/sdk');
502
+ const sdk = await loadAgentSdk('cursor');
501
503
  return sdk.Cursor.models.list({ apiKey });
502
504
  }
503
505
 
@@ -170,6 +170,9 @@ export function createSdkMessageNormalizer() {
170
170
  deltaOwnsThinking = true;
171
171
  if (!openThought) openThought = { reasoningId: allocateThoughtId(), text: '' };
172
172
  openThought.text = capThought(openThought.text + String(update?.text || ''));
173
+ // Gated as stream text; the thought's close sends the whole text.
174
+ if (!shouldEmitStreamUpdate(openThought.text, openThought.lastEmittedText)) return actions;
175
+ openThought.lastEmittedText = openThought.text;
173
176
  actions.push({
174
177
  channel: 'thought',
175
178
  payload: {
@@ -201,6 +204,14 @@ export function createSdkMessageNormalizer() {
201
204
 
202
205
  if (type === 'turn-ended') {
203
206
  if (update?.usage && typeof update.usage === 'object') lastUsage = update.usage;
207
+ // A thought the turn left open: what the delta gating held back.
208
+ if (openThought?.text.trim() && openThought.text !== openThought.lastEmittedText) {
209
+ openThought.lastEmittedText = openThought.text;
210
+ actions.push({
211
+ channel: 'thought',
212
+ payload: { reasoningId: openThought.reasoningId, text: openThought.text, done: false, subagentRunId: null },
213
+ });
214
+ }
204
215
  return actions;
205
216
  }
206
217
 
@@ -231,6 +242,8 @@ export function createSdkMessageNormalizer() {
231
242
  state.openThought = { reasoningId: `cursor-thought-${runId}-${state.thoughtIndex++}`, text: '' };
232
243
  }
233
244
  state.openThought.text = capThought(state.openThought.text + String(nested.text || ''));
245
+ if (!shouldEmitStreamUpdate(state.openThought.text, state.openThought.lastEmittedText)) return actions;
246
+ state.openThought.lastEmittedText = state.openThought.text;
234
247
  actions.push({
235
248
  channel: 'thought',
236
249
  payload: {
@@ -44,6 +44,8 @@ import { migrateConversationPlanOnly } from './migrations/0035-conversation-plan
44
44
  import { migrateTokenUsage } from './migrations/0036-token-usage.mjs';
45
45
  import { migrateRelayAsksQueueId } from './migrations/0037-relay-asks-queue-id.mjs';
46
46
  import { migrateTurnSeq } from './migrations/0038-turn-seq.mjs';
47
+ import { migrateMediaIndex } from './migrations/0039-media-index.mjs';
48
+ import { migrateImageSessionDefaults } from './migrations/0040-image-session-defaults.mjs';
47
49
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
48
50
 
49
51
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -1096,6 +1098,23 @@ try {
1096
1098
  console.warn(`[turn-seq] migration 0038 failed; retrying on next boot: ${error?.message || error}`);
1097
1099
  }
1098
1100
 
1101
+ // The media gallery index: tables, the two queue triggers on messages and a
1102
+ // one-time seed of the queue. The gallery works without it (empty), so a
1103
+ // failure is logged and retried next boot.
1104
+ try {
1105
+ migrateMediaIndex(db);
1106
+ } catch (error) {
1107
+ console.warn(`[media-index] migration 0039 failed; retrying on next boot: ${error?.message || error}`);
1108
+ }
1109
+
1110
+ // An image session's own size/quality/count defaults. Image turns run on the
1111
+ // built-in defaults without it, so a failure is logged and retried next boot.
1112
+ try {
1113
+ migrateImageSessionDefaults(db);
1114
+ } catch (error) {
1115
+ console.warn(`[image-sessions] migration 0040 failed; retrying on next boot: ${error?.message || error}`);
1116
+ }
1117
+
1099
1118
  const conversationColumns = db.prepare(`PRAGMA table_info(conversations)`).all().map((c) => c.name);
1100
1119
  if (!conversationColumns.includes('archived')) {
1101
1120
  db.exec(`ALTER TABLE conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`);
@@ -148,6 +148,9 @@ export function createSdkMessageNormalizer() {
148
148
  openThought = { reasoningId: allocateThoughtId(), text: '' };
149
149
  }
150
150
  openThought.text = capThought(openThought.text + chunk);
151
+ // Gated as stream text; the thought's close sends the whole text.
152
+ if (!shouldEmitStreamUpdate(openThought.text, openThought.lastEmittedText)) return actions;
153
+ openThought.lastEmittedText = openThought.text;
151
154
  actions.push({
152
155
  channel: 'thought',
153
156
  payload: {
@@ -0,0 +1,167 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: the media gallery index.
5
+ *
6
+ * `media_items`: one row per picture, video or attached file of a message
7
+ * (services/media-extract.mjs decides what counts). Rows go with their
8
+ * message or conversation (foreign keys, ON DELETE CASCADE). `message_ts`
9
+ * copies the message time so the gallery pages by an index.
10
+ *
11
+ * `media_index_queue`: the ids of messages to (re)index. Two triggers on
12
+ * `messages` put a row id here on insert and when a column the index reads
13
+ * changes; they never parse anything, so the write path pays one small
14
+ * insert. The indexer (services/media-index-service.mjs) drains the queue in
15
+ * batches. `seq` grows on every re-queue, so a drain only removes the queue
16
+ * row it actually indexed.
17
+ *
18
+ * `media_index_meta`: the extractor version the queue was seeded for. When
19
+ * it is missing (first run) or older than MEDIA_EXTRACTOR_VERSION, every
20
+ * message is queued once and the version is stored; later boots seed nothing.
21
+ *
22
+ * Not in SCHEMA_SQL (db-schema.mjs): the triggers name columns that older
23
+ * migrations add. The triggers are checked on every boot and re-created when
24
+ * missing or different, like the FTS triggers.
25
+ *
26
+ * Additive and idempotent. Rolling back: drop the triggers first, then the
27
+ * tables (a trigger left pointing at a dropped table breaks message inserts):
28
+ * DROP TRIGGER IF EXISTS media_index_on_insert;
29
+ * DROP TRIGGER IF EXISTS media_index_on_update;
30
+ * DROP TABLE IF EXISTS media_index_queue; DROP TABLE IF EXISTS media_items;
31
+ * DROP TABLE IF EXISTS media_index_meta;
32
+ *
33
+ * Usage: node server/migrations/0039-media-index.mjs [path/to/oar.db]
34
+ */
35
+
36
+ import { fileURLToPath } from 'url';
37
+ import path from 'path';
38
+ import process from 'process';
39
+ import Database from 'better-sqlite3';
40
+ import { cliDbPath } from '../services/database-file.mjs';
41
+ import { MEDIA_EXTRACTOR_VERSION } from '../services/media-extract.mjs';
42
+
43
+ export const MEDIA_INDEX_TRIGGERS = Object.freeze(['media_index_on_insert', 'media_index_on_update']);
44
+ // Columns the index reads; a change to one re-queues the message.
45
+ const WATCHED_COLUMNS = Object.freeze(['text', 'attachments', 'timestamp', 'role', 'kind', 'origin_json', 'conversation_id']);
46
+
47
+ const QUEUE_UPSERT = `INSERT INTO media_index_queue (message_id, seq) VALUES (NEW.id, 0)
48
+ ON CONFLICT(message_id) DO UPDATE SET seq = seq + 1;`;
49
+
50
+ function tableExists(db, name) {
51
+ return !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
52
+ }
53
+
54
+ function columnsOf(db, table) {
55
+ return db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name);
56
+ }
57
+
58
+ export function mediaIndexTriggerSql(messageColumns) {
59
+ const watched = WATCHED_COLUMNS.filter((column) => messageColumns.includes(column));
60
+ return {
61
+ media_index_on_insert: `CREATE TRIGGER media_index_on_insert AFTER INSERT ON messages BEGIN
62
+ ${QUEUE_UPSERT}
63
+ END`,
64
+ media_index_on_update: `CREATE TRIGGER media_index_on_update AFTER UPDATE OF ${watched.join(', ')} ON messages BEGIN
65
+ ${QUEUE_UPSERT}
66
+ END`,
67
+ };
68
+ }
69
+
70
+ export function migrateMediaIndex(db) {
71
+ if (!tableExists(db, 'messages') || !tableExists(db, 'conversations')) {
72
+ return { applied: false, tablesCreated: 0, triggersCreated: 0, seeded: 0 };
73
+ }
74
+ let tablesCreated = 0;
75
+ let triggersCreated = 0;
76
+ let seeded = 0;
77
+ const run = db.transaction(() => {
78
+ if (!tableExists(db, 'media_items')) {
79
+ db.exec(`
80
+ CREATE TABLE media_items (
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
83
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
84
+ ordinal INTEGER NOT NULL,
85
+ role TEXT NOT NULL,
86
+ sender TEXT NOT NULL,
87
+ origin TEXT NOT NULL,
88
+ locator_kind TEXT NOT NULL,
89
+ locator TEXT NOT NULL,
90
+ identity_key TEXT NOT NULL,
91
+ category TEXT NOT NULL,
92
+ media_kind TEXT NOT NULL,
93
+ mime TEXT,
94
+ name TEXT,
95
+ alt TEXT,
96
+ size_bytes INTEGER,
97
+ message_ts TEXT NOT NULL,
98
+ UNIQUE (message_id, ordinal)
99
+ );
100
+ CREATE INDEX idx_media_items_conv ON media_items(conversation_id, category, message_ts DESC, id DESC);
101
+ CREATE INDEX idx_media_items_all ON media_items(category, message_ts DESC, id DESC);
102
+ CREATE INDEX idx_media_items_identity ON media_items(identity_key);
103
+ `);
104
+ tablesCreated += 1;
105
+ }
106
+ if (!tableExists(db, 'media_index_queue')) {
107
+ db.exec(`
108
+ CREATE TABLE media_index_queue (
109
+ message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
110
+ seq INTEGER NOT NULL DEFAULT 0
111
+ )
112
+ `);
113
+ tablesCreated += 1;
114
+ }
115
+ if (!tableExists(db, 'media_index_meta')) {
116
+ db.exec(`CREATE TABLE media_index_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
117
+ tablesCreated += 1;
118
+ }
119
+
120
+ const expected = mediaIndexTriggerSql(columnsOf(db, 'messages'));
121
+ for (const name of MEDIA_INDEX_TRIGGERS) {
122
+ const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?`).get(name);
123
+ if (row?.sql === expected[name]) continue;
124
+ if (row) db.exec(`DROP TRIGGER ${name}`);
125
+ db.exec(expected[name]);
126
+ triggersCreated += 1;
127
+ }
128
+
129
+ const version = db.prepare(`SELECT value FROM media_index_meta WHERE key = 'extractor_version'`).get()?.value;
130
+ if (String(version || '') !== String(MEDIA_EXTRACTOR_VERSION)) {
131
+ seeded = db.prepare(`
132
+ INSERT INTO media_index_queue (message_id, seq)
133
+ SELECT id, 0 FROM messages WHERE true
134
+ ON CONFLICT(message_id) DO UPDATE SET seq = seq + 1
135
+ `).run().changes;
136
+ db.prepare(`INSERT INTO media_index_meta (key, value) VALUES ('extractor_version', ?)
137
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(MEDIA_EXTRACTOR_VERSION));
138
+ }
139
+ });
140
+ run();
141
+ return { applied: tablesCreated + triggersCreated + seeded > 0, tablesCreated, triggersCreated, seeded };
142
+ }
143
+
144
+ export function migrate(dbPath, options = {}) {
145
+ const db = new Database(dbPath, options);
146
+ try {
147
+ return migrateMediaIndex(db);
148
+ } finally {
149
+ db.close();
150
+ }
151
+ }
152
+
153
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
154
+ if (isMain) {
155
+ try {
156
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
157
+ const result = migrate(dbPath);
158
+ console.log(`Migration 0039 on ${dbPath}`);
159
+ console.log(result.applied
160
+ ? ` media index: tables created ${result.tablesCreated}, triggers created ${result.triggersCreated}, messages queued ${result.seeded}`
161
+ : ' skipped: nothing to change');
162
+ process.exit(0);
163
+ } catch (err) {
164
+ console.error(`Migration 0039 failed: ${err?.message || err}`);
165
+ process.exit(1);
166
+ }
167
+ }
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: an image session's own defaults, and a turn's options.
5
+ *
6
+ * `image_sessions.defaults_json`: the size, quality, count, output format and
7
+ * background an image session's turns use unless a turn names its own
8
+ * (relay_tool update_session `image`, the conversation settings route).
9
+ * JSON as shared/openai-image-options.mjs validates it; NULL means none.
10
+ *
11
+ * `queue.image_options_json`: the options of one image turn as the send
12
+ * settled them, for a turn without an image operation (the worker sends them
13
+ * with its direct request). NULL for every other turn.
14
+ *
15
+ * The image tables are created by migrateImageConversationSchema
16
+ * (repositories/image-conversation-repository.mjs), which this migration
17
+ * calls first, additively, so a database whose tables are missing gets them
18
+ * before the column. A database without the queue table (no relay's) is
19
+ * left alone.
20
+ *
21
+ * Additive and idempotent. Safe to run on every boot. Rolling back needs no
22
+ * data change: older code ignores the column.
23
+ *
24
+ * Usage: node server/migrations/0040-image-session-defaults.mjs [path/to/oar.db]
25
+ */
26
+
27
+ import { fileURLToPath } from 'url';
28
+ import path from 'path';
29
+ import process from 'process';
30
+ import Database from 'better-sqlite3';
31
+ import { cliDbPath } from '../services/database-file.mjs';
32
+ import { migrateImageConversationSchema } from '../repositories/image-conversation-repository.mjs';
33
+
34
+ export function hasImageSessionDefaultsColumn(db) {
35
+ try {
36
+ return db.prepare(`PRAGMA table_info(image_sessions)`).all().some((column) => column.name === 'defaults_json');
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function tableExists(db, name) {
43
+ return !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
44
+ }
45
+
46
+ export function hasQueueImageOptionsColumn(db) {
47
+ try {
48
+ return db.prepare(`PRAGMA table_info(queue)`).all().some((column) => column.name === 'image_options_json');
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ export function migrateImageSessionDefaults(db) {
55
+ // The image schema reads the queue; a database without one is no relay's.
56
+ if (!tableExists(db, 'queue')) return { applied: false, columnsAdded: 0 };
57
+ migrateImageConversationSchema(db);
58
+ let columnsAdded = 0;
59
+ if (!hasImageSessionDefaultsColumn(db)) {
60
+ db.exec(`ALTER TABLE image_sessions ADD COLUMN defaults_json TEXT`);
61
+ columnsAdded += 1;
62
+ }
63
+ if (!hasQueueImageOptionsColumn(db)) {
64
+ db.exec(`ALTER TABLE queue ADD COLUMN image_options_json TEXT`);
65
+ columnsAdded += 1;
66
+ }
67
+ return { applied: columnsAdded > 0, columnsAdded };
68
+ }
69
+
70
+ export function migrate(dbPath, options = {}) {
71
+ const db = new Database(dbPath, options);
72
+ try {
73
+ return migrateImageSessionDefaults(db);
74
+ } finally {
75
+ db.close();
76
+ }
77
+ }
78
+
79
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
80
+ if (isMain) {
81
+ try {
82
+ // The given path, else the database in server/data (oar.db, else the
83
+ // name before 0.9.20); never a new one.
84
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
85
+ const result = migrate(dbPath);
86
+ console.log(`Migration 0040 on ${dbPath}`);
87
+ console.log(result.applied ? ` image session defaults, turn options: columns added ${result.columnsAdded}` : ' skipped: nothing to change');
88
+ process.exit(0);
89
+ } catch (err) {
90
+ console.error(`Migration 0040 failed: ${err?.message || err}`);
91
+ process.exit(1);
92
+ }
93
+ }