@oar-sh/oar 0.9.24 → 0.9.26

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 (92) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +284 -0
  3. package/README.md +12 -11
  4. package/package.json +5 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
  7. package/server/claude-worker/claude-session-process.mjs +4 -0
  8. package/server/claude-worker/claude-turn-publisher.mjs +25 -11
  9. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  10. package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
  12. package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
  13. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  14. package/server/db-schema.mjs +19 -0
  15. package/server/grok-worker/grok-turn-runner.mjs +3 -1
  16. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  17. package/server/migrations/0039-media-index.mjs +167 -0
  18. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  19. package/server/public/app/api-client.js +58 -0
  20. package/server/public/app/ask-user-view.js +2 -1
  21. package/server/public/app/attachments-view.js +66 -9
  22. package/server/public/app/bootstrap.js +85 -25
  23. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  24. package/server/public/app/context-media-tab.js +143 -0
  25. package/server/public/app/context-usage-view.mjs +2 -2
  26. package/server/public/app/conversation-view.js +90 -5
  27. package/server/public/app/image-composer-ui.js +518 -0
  28. package/server/public/app/image-turn-placeholder.mjs +233 -0
  29. package/server/public/app/journal-view.js +12 -2
  30. package/server/public/app/live-activity-cap.mjs +3 -3
  31. package/server/public/app/markdown-math.mjs +471 -0
  32. package/server/public/app/media-gallery-model.mjs +202 -0
  33. package/server/public/app/media-gallery-ui.js +524 -0
  34. package/server/public/app/media-view.js +79 -0
  35. package/server/public/app/openai-image-composer.mjs +492 -0
  36. package/server/public/app/openai-image-cost-ui.js +426 -0
  37. package/server/public/app/openai-image-spend.mjs +443 -0
  38. package/server/public/app/plan-usage-view.mjs +38 -6
  39. package/server/public/app/pwa-install.js +25 -4
  40. package/server/public/app/relay-error-ctas.mjs +5 -0
  41. package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
  42. package/server/public/app/relay-restart-ui.js +2 -2
  43. package/server/public/app/remote-relays-settings.js +33 -6
  44. package/server/public/app/router.js +49 -237
  45. package/server/public/app/session-history-ui.js +2 -0
  46. package/server/public/app/settings-modal.js +4 -0
  47. package/server/public/app/settings-tabs.js +10 -1
  48. package/server/public/app/socket-handlers.js +26 -0
  49. package/server/public/app/store.js +2 -0
  50. package/server/public/app/stream-view-preference.mjs +1 -1
  51. package/server/public/app/tab-strip-fade.mjs +1 -1
  52. package/server/public/app/thoughts-preference.mjs +1 -1
  53. package/server/public/app/tmux-inspector-view.js +29 -16
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +517 -123
  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/media-routes.mjs +361 -0
  60. package/server/routes/messages-routes.mjs +1410 -360
  61. package/server/routes/relay-board-routes.mjs +46 -1
  62. package/server/routes/remote-relay-routes.mjs +1 -1
  63. package/server/routes/sessions-routes.mjs +361 -14
  64. package/server/server-runtime.mjs +21 -0
  65. package/server/services/database-file.mjs +6 -4
  66. package/server/services/generated-image-cleanup-service.mjs +2 -1
  67. package/server/services/generated-image-files.mjs +110 -0
  68. package/server/services/image-input-paths.mjs +389 -0
  69. package/server/services/image-operation-service.mjs +6 -2
  70. package/server/services/media-extract.mjs +653 -0
  71. package/server/services/media-index-service.mjs +221 -0
  72. package/server/services/openai-image-preview-turns.mjs +330 -0
  73. package/server/services/openai-image-spend-service.mjs +516 -0
  74. package/server/services/openai-image-stream.mjs +170 -0
  75. package/server/services/plan-usage-openai-image.mjs +148 -0
  76. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  77. package/server/services/relay-restart-queue.mjs +1 -1
  78. package/server/services/remote-relay-caller-context.mjs +6 -0
  79. package/server/services/remote-relay-dispatcher.mjs +409 -23
  80. package/server/services/remote-relay-registry-service.mjs +10 -2
  81. package/server/services/session-worker-stop-service.mjs +50 -18
  82. package/server/services/token-usage-service.mjs +15 -0
  83. package/shared/image-header.mjs +90 -0
  84. package/shared/math-instructions.mjs +30 -0
  85. package/shared/openai-image-options.mjs +299 -0
  86. package/shared/openai-image-presets.mjs +175 -0
  87. package/shared/openai-image-previews.mjs +40 -0
  88. package/shared/openai-image-prices.mjs +291 -0
  89. package/shared/openai-image-spend.mjs +90 -0
  90. package/shared/remote-relay-contract.mjs +126 -4
  91. package/shared/worker-runtime/direct-openai-image.mjs +96 -50
  92. package/server/public/app/math-dollars.mjs +0 -89
@@ -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
  }
@@ -25,6 +25,7 @@ import {
25
25
  applyPreviewInstructions,
26
26
  createPreviewInstructionsProvider,
27
27
  } from '../../shared/preview-instructions.mjs';
28
+ import { applyMathInstructions } from '../../shared/math-instructions.mjs';
28
29
  import { applyMediaEmbedInstructions } from '../../shared/media-embed-instructions.mjs';
29
30
  import { buildModePrompt } from '../../shared/worker-runtime/prompt-context.mjs';
30
31
  import { loadRelayInstructionsFromFile } from '../../shared/worker-runtime/config-loader.mjs';
@@ -84,7 +85,7 @@ export function createCopilotPromptContextBuilder({
84
85
  .then(() => getPreviewInstructions({ conversationId: String(message?.conversationId || '') }))
85
86
  .catch(() => '')
86
87
  : '';
87
- instructions = applyMediaEmbedInstructions(applyPreviewInstructions(toolInstructions, previewBlock));
88
+ instructions = applyMathInstructions(applyMediaEmbedInstructions(applyPreviewInstructions(toolInstructions, previewBlock)));
88
89
  }
89
90
  const prefix = buildModePrompt(relayMode, instructions, { includeInstructions });
90
91
  return {
@@ -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,11 +5027,11 @@ export function createCopilotSdkSessionRunner({
5025
5027
  dbg('image turn', message.id, result.via, result.outcome);
5026
5028
  } catch (error) {
5027
5029
  if (isOpenAIImageTerminalError(error)) {
5028
- // A refusal by OpenAI, or a reply the relay could not process after
5029
- // OpenAI answered 2xx; the codes are the ones the relay's image
5030
- // operation uses for the same two cases.
5031
- const code = error.relayProcessing === true ? 'image-reply-unprocessed' : 'image-provider-rejected';
5032
- dbg(error.relayProcessing === true ? 'image reply not processed' : '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);
5033
5035
  await publishResponse(message, {
5034
5036
  text: error.message,
5035
5037
  model: null,
@@ -20,6 +20,7 @@ import {
20
20
  cursorSubagentRosterFingerprint,
21
21
  } from './cursor-subagent-roster.mjs';
22
22
  import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
23
+ import { renderMathInstructionBlock } from '../../shared/math-instructions.mjs';
23
24
  import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
24
25
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
25
26
  import { resolveFallbackContextLimitTokens } from '../../shared/context-window-fallbacks.mjs';
@@ -586,7 +587,8 @@ export function createCursorTurnRunner({
586
587
  await ensureAgentHandle(message, model);
587
588
  const userMessage = buildUserMessageImpl(message);
588
589
  const modeNudge = cursorModeNudge(message.relayMode, lastNudgedRelayMode);
589
- const mediaGuidance = mediaGuidanceSent ? '' : renderMediaEmbedInstructionBlock();
590
+ // The formula notation rides with it, for the same reason.
591
+ const mediaGuidance = mediaGuidanceSent ? '' : `${renderMediaEmbedInstructionBlock()}\n\n${renderMathInstructionBlock()}`;
590
592
  if (modeNudge || mediaGuidance) {
591
593
  userMessage.text = [mediaGuidance, modeNudge, userMessage.text].filter(Boolean).join('\n\n');
592
594
  }
@@ -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`);
@@ -10,6 +10,7 @@ import { extractGrokUsageFromPromptResult, normalizeGrokTurnUsage } from '../ser
10
10
  import { OAR_MCP_SERVER_NAME, buildOarMcpServerLaunch, isOarMcpServerDisabled } from '../mcp/oar-mcp-server.mjs';
11
11
  import { createPreviewInstructionsProvider } from '../../shared/preview-instructions.mjs';
12
12
  import { fetchRemoteRelayInflight } from '../../shared/remote-relay-tool-core.mjs';
13
+ import { renderMathInstructionBlock } from '../../shared/math-instructions.mjs';
13
14
  import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
14
15
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
15
16
  import { PLAN_BOARD_ACTIONS } from '../../shared/plan-board-actions.mjs';
@@ -476,7 +477,8 @@ export function createGrokTurnRunner({
476
477
  : await Promise.resolve()
477
478
  .then(() => getPreviewInstructions?.({ conversationId: message.conversationId || sdkSessionId || '' }))
478
479
  .catch(() => '');
479
- const mediaBlock = mediaInstructionsSent ? '' : renderMediaEmbedInstructionBlock();
480
+ // The formula notation rides with the media block, once per worker.
481
+ const mediaBlock = mediaInstructionsSent ? '' : `${renderMediaEmbedInstructionBlock()}\n\n${renderMathInstructionBlock()}`;
480
482
  const promptText = [previewBlock, mediaBlock, modeNudge, userText].filter(Boolean).join('\n\n');
481
483
  const pendingNudgedRelayMode = String(message.relayMode || 'agent').trim().toLowerCase();
482
484
 
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { BASE, TOKEN, CLIENT_ID, IS_SHARE_PREVIEW, SHARE_PREVIEW_CONVERSATION_ID, SHARED_CONVERSATION_TOKEN, authHeaders, updateWorkspaceRootHints, applyContextUsageBar, readContextUsageRatio, currentConvId, conversations, setCliOnline, setCloudflaredTunnelState, setActiveRuntimeSessionCount, setRuntimeSessionBindingCount, setContextIndicatorMode, setServerPlatform, setUpdateState } from './store.js';
2
2
  import { liveActivityCap } from './live-activity-cap.mjs';
3
+ import { OPENAI_IMAGE_PRESETS_PATH, OPENAI_IMAGE_SETTINGS_FIELDS, OPENAI_IMAGE_USAGE_PATH } from './openai-image-spend.mjs';
3
4
 
4
5
  // fetch() has no default timeout. Without one, a request issued while the app is
5
6
  // being backgrounded can hang indefinitely on a half-open mobile connection and
@@ -543,6 +544,63 @@ export async function updateOpenAISettings({
543
544
  return settingsRequest('/api/settings/openai', payload, 'Failed to update OpenAI settings');
544
545
  }
545
546
 
547
+ /**
548
+ * The daily image cap and today's override (POST /api/settings/openai with
549
+ * only these fields). Throws with the relay's words: a paired relay's
550
+ * request is refused (403).
551
+ */
552
+ export async function updateOpenAIImageSpendSettings({ imageDailyCapUsd = undefined, allowImagesToday = undefined } = {}) {
553
+ const payload = {};
554
+ if (imageDailyCapUsd !== undefined) payload[OPENAI_IMAGE_SETTINGS_FIELDS.dailyCapUsd] = Number(imageDailyCapUsd);
555
+ if (typeof allowImagesToday === 'boolean') payload[OPENAI_IMAGE_SETTINGS_FIELDS.allowToday] = allowImagesToday;
556
+ return settingsRequest('/api/settings/openai', payload, 'Failed to update the image cap');
557
+ }
558
+
559
+ /** Today's image spend; with a conversation, that chat's cost too. Null on any failure. */
560
+ export async function loadOpenAIImageUsage({ conversationId = '' } = {}) {
561
+ const id = String(conversationId || '').trim();
562
+ return quietGet(id ? `${OPENAI_IMAGE_USAGE_PATH}?conversationId=${encodeURIComponent(id)}` : OPENAI_IMAGE_USAGE_PATH);
563
+ }
564
+
565
+ // The presets routes: the relay's `error` and `code` ride on the thrown Error.
566
+ async function imagePresetsRequest(method, path, body = null) {
567
+ if (!networkRequestsEnabled) return null;
568
+ let response;
569
+ try {
570
+ response = await fetch(`${BASE}${path}`, {
571
+ signal: requestTimeoutSignal(),
572
+ method,
573
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
574
+ ...(body ? { body: JSON.stringify(body) } : {}),
575
+ });
576
+ } catch (error) {
577
+ noteFetchFailure(path, error);
578
+ throw Object.assign(new Error(`The relay could not be reached (${readableServerError(toErrorMessage(error))}).`), { status: 0, code: null });
579
+ }
580
+ noteFetchSuccess();
581
+ const result = await response.json().catch(() => null);
582
+ if (!response.ok) {
583
+ throw Object.assign(
584
+ new Error(readableServerError(result?.error || '') || `The relay answered HTTP ${response.status}.`),
585
+ { status: response.status, code: result?.code || null },
586
+ );
587
+ }
588
+ return result;
589
+ }
590
+
591
+ /** `{ presets }` (built-ins and own), or null when the relay has none to give. */
592
+ export async function loadOpenAIImagePresets() {
593
+ return quietGet(OPENAI_IMAGE_PRESETS_PATH);
594
+ }
595
+
596
+ export async function saveOpenAIImagePreset(preset, { replace = false } = {}) {
597
+ return imagePresetsRequest('PUT', OPENAI_IMAGE_PRESETS_PATH, { preset, ...(replace ? { replace: true } : {}) });
598
+ }
599
+
600
+ export async function deleteOpenAIImagePreset(name) {
601
+ return imagePresetsRequest('DELETE', `${OPENAI_IMAGE_PRESETS_PATH}/${encodeURIComponent(String(name || ''))}`);
602
+ }
603
+
546
604
  export async function loadCursorSettings() {
547
605
  return apiFetch('/api/settings/cursor');
548
606
  }
@@ -131,7 +131,8 @@ export async function loadRelayQuestions(conversationId) {
131
131
  // after a link the relay did not know: its notice keeps the screen empty
132
132
  // until the reader opens something.
133
133
  const readingAView = document.body?.classList?.contains('inbox-view-active')
134
- || document.body?.classList?.contains('status-view-active');
134
+ || document.body?.classList?.contains('status-view-active')
135
+ || document.body?.classList?.contains('media-view-active');
135
136
  if (!conversationId && pending.length && !readingAView && !isConversationAutoOpenHeld()) {
136
137
  const latest = pending
137
138
  .slice()