@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
@@ -47,6 +47,7 @@ This relay itself is listed with "self": true when its owner switched agent sess
47
47
  - "planOnly": true makes a plan-only session: agents can't take it out of plan mode. Set it only when the user asks; plan mode alone never sets it. `create_session` with "planOnly": true and no "mode" starts in plan; with another mode named it is refused and nothing is created. A target relay too old for planOnly creates the session without the lock and says so in "planOnlyWarning". `update_session` with "planOnly": true sets it on an existing session (with "mode": "plan" unless it is in plan already). While it is set, a `send` or `update_session` that names another mode is refused with 409, code "plan_only" ("This session is plan-only; agents can't change its mode. Clear planOnly first, or ask the user."); a `send` without "mode" runs in the session's saved mode. To clear it, pass "planOnly": false in the same `update_session` that names the new mode (`{"planOnly":false,"mode":"agent"}`); alone, or with "mode": "plan", it is ignored, and on a plan-only session the answer notes "planOnly unchanged: clear it in the same call as the new mode." Use `update_session` for all this, not the preferences route (it refuses an agent's mode change with 409 and any planOnly change with 403). `list_sessions` and `read_session` show "planOnly": true. In a plan-only conversation you count as a plan-mode caller, also in the one agent turn after "Approve plan": your `create_session` defaults to plan and your write actions ask the user first.
48
48
  - Only a session your conversation started may be updated freely; any other (the user's, another agent's, the one that started you) needs the user's approval card once per conversation and relay (refused: `REMOTE_RELAY_PERMISSION`); never your own conversation.
49
49
  - A "model" for `create_session` or `send` must be an id `relay_info` lists for the provider (Claude also takes "opus", "sonnet", "haiku"). An unknown id is refused with the close matches and the valid ids (`Unknown model 'gpt-5-4' for Copilot on relay "…"; did you mean 'gpt-5.4'? Valid ids: … Nothing was sent.`), and nothing is created or sent: pick from the list instead of guessing. Copilot ids use dots (`claude-opus-5.5`, not the Claude form `claude-opus-5-5`); a hyphen form is refused with the close match named, and a Copilot session never moves to another provider because of its model name. If the answer has a "modelNote" saying the model could not be checked, the relay had no model list yet.
50
+ - Image sessions (provider "openai" with a "gpt-image-*" or "dall-e-*" model; `create_session` with provider "openai", "images" or "image" and no "model" gets the relay's image default): `send` and `create_session` take "images" (up to 4 absolute paths of PNG, JPEG or WebP files on the target relay, each at most 20 MB: inside the session's working directory, or images any session there generated) and "image" ({"size":"1536x1024","quality":"high","count":2,"output_format":"webp","background":"transparent"}, for that turn). `update_session` with "image" sets the session's defaults. The replies list "images": [{"path","width","height","mime"}] with the files on that relay, which a later `send` can attach, and a text line such as "1 image · 1536x1024 · gpt-image-2". Sizes are "auto" or W×H with both edges multiples of 16, the longer at most 3840, at most 3840×2160 pixels, aspect 1:3 to 3:1 (dall-e keep their fixed sizes). A refused path, a file that is no PNG/JPEG/WebP, or a session whose model makes no images is refused with the reason and nothing is sent; a paired relay must run an OAR that takes images ("imageInputs" in `relay_info`). A failed image call is tried at most twice: the note says whether it may have been billed, so retry only when the user wants the cost. "image" may name a preset ("preset", from relay_info's "imagePresets"); its own fields win over it. Image turns count toward the relay's daily cap of OpenAI image spend; once reached, image sends are refused with OPENAI_IMAGE_DAILY_CAP_REACHED and nothing is sent: tell the user, who can allow more for the day (agents cannot). `usage` shows the spend.
50
51
  - A new session knows nothing of this conversation: write a self-contained prompt. A Claude Cloud session works on a fresh clone in a sandbox at Anthropic, so name the branch it should work on and push.
51
52
  - Follow each session with `wait` (the message id comes back from `create_session` and `send`) and `read_session`; "wait_seconds" is limited to the maximum set on the relay (default 600).
52
53
  - Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ import crypto from 'crypto';
4
+
3
5
  const IMAGE_SCHEMA_VERSION = 1;
4
6
 
5
7
  export function migrateImageConversationSchema(db) {
@@ -200,6 +202,10 @@ export function createImageConversationRepository(db) {
200
202
  WHERE operation.queue_message_id = ?
201
203
  `),
202
204
  linkQueueOperation: db.prepare(`UPDATE queue SET image_operation_id = ? WHERE id = ? AND image_operation_id IS NULL`),
205
+ // queue.image_options_json comes with migration 0040: prepared on first
206
+ // use, and without the column (a failed migration) the options are not
207
+ // kept and the worker sends the composer's quality and size as before.
208
+ setQueueImageOptions: { run: (json, id) => queueImageOptionsStatement()?.run(json, id) },
203
209
  insertAsset: db.prepare(`
204
210
  INSERT INTO image_operation_assets (
205
211
  operation_id, ordinal, role, upload_id, content_sha256, media_type, created_at
@@ -309,9 +315,72 @@ export function createImageConversationRepository(db) {
309
315
  return statements.insertEdge.run(edge);
310
316
  }
311
317
 
318
+ // A conversation's image defaults (migration 0040) live on its image
319
+ // sessions: every one of them carries the same copy, and a conversation
320
+ // without one gets a session for them. Prepared on first use: without the
321
+ // column (a failed migration) a read finds none and a write throws.
322
+ let queueOptionsStatement;
323
+ function queueImageOptionsStatement() {
324
+ if (queueOptionsStatement !== undefined) return queueOptionsStatement;
325
+ const hasColumn = db.prepare(`PRAGMA table_info(queue)`).all().some((column) => column.name === 'image_options_json');
326
+ queueOptionsStatement = hasColumn ? db.prepare(`UPDATE queue SET image_options_json = ? WHERE id = ?`) : null;
327
+ return queueOptionsStatement;
328
+ }
329
+
330
+ let defaultsStatements = null;
331
+ function imageDefaultsStatements() {
332
+ if (defaultsStatements) return defaultsStatements;
333
+ const hasColumn = db.prepare(`PRAGMA table_info(image_sessions)`).all().some((column) => column.name === 'defaults_json');
334
+ if (!hasColumn) return null;
335
+ defaultsStatements = {
336
+ read: db.prepare(`
337
+ SELECT defaults_json FROM image_sessions
338
+ WHERE conversation_id = ? AND defaults_json IS NOT NULL
339
+ ORDER BY updated_at DESC
340
+ LIMIT 1
341
+ `),
342
+ write: db.prepare(`UPDATE image_sessions SET defaults_json = ? WHERE conversation_id = ?`),
343
+ };
344
+ return defaultsStatements;
345
+ }
346
+
347
+ /** A conversation's image defaults as stored (`{}` when it has none). */
348
+ function readConversationImageDefaults(conversationId) {
349
+ const row = imageDefaultsStatements()?.read.get(String(conversationId || '')) || null;
350
+ if (!row) return {};
351
+ try {
352
+ const parsed = JSON.parse(row.defaults_json);
353
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
354
+ } catch {
355
+ return {};
356
+ }
357
+ }
358
+
359
+ /** Stores a conversation's image defaults (checked by the caller). */
360
+ function writeConversationImageDefaults(conversationId, defaults, { sessionId, now = new Date().toISOString() } = {}) {
361
+ const statementsForDefaults = imageDefaultsStatements();
362
+ if (!statementsForDefaults) throw new Error('Image session defaults are unavailable until the relay restarts');
363
+ const json = JSON.stringify(defaults && typeof defaults === 'object' ? defaults : {});
364
+ const conversation = String(conversationId || '');
365
+ db.transaction(() => {
366
+ if (statementsForDefaults.write.run(json, conversation).changes > 0) return;
367
+ statements.insertSession.run({
368
+ id: sessionId || `img_session_${crypto.randomUUID()}`,
369
+ conversationId: conversation,
370
+ origin: 'native',
371
+ schemaVersion: IMAGE_SCHEMA_VERSION,
372
+ createdAt: now,
373
+ updatedAt: now,
374
+ });
375
+ statementsForDefaults.write.run(json, conversation);
376
+ })();
377
+ }
378
+
312
379
  return {
313
380
  ...statements,
314
381
  imageSchemaVersion: IMAGE_SCHEMA_VERSION,
315
382
  insertEdgeChecked,
383
+ readConversationImageDefaults,
384
+ writeConversationImageDefaults,
316
385
  };
317
386
  }
@@ -0,0 +1,305 @@
1
+ 'use strict';
2
+
3
+ // The media gallery index (migration 0039): the queue of messages to
4
+ // (re)index and the `media_items` rows made from them. Every reader tolerates
5
+ // a schema without the migration: it then sees an empty index and writes
6
+ // nothing.
7
+
8
+ export const MEDIA_PENDING_COUNT_CAP = 10_000;
9
+
10
+ function tableExists(db, name) {
11
+ return !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
12
+ }
13
+
14
+ function columnsOf(db, table) {
15
+ return db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name);
16
+ }
17
+
18
+ /** Who a message comes from, for the Source filter: the owner, an agent, or the relay itself. */
19
+ export function mediaSenderFor({ role, kind, originJson }) {
20
+ if (role === 'assistant') return 'agent';
21
+ if (role === 'user' && !kind && !originJson) return 'me';
22
+ return 'relay';
23
+ }
24
+
25
+ export function createMediaRepository(db) {
26
+ const available = tableExists(db, 'media_items')
27
+ && tableExists(db, 'media_index_queue')
28
+ && tableExists(db, 'messages');
29
+ if (!available) {
30
+ return {
31
+ available: false,
32
+ nextQueueBatch: () => [],
33
+ loadQueuedMessage: () => null,
34
+ replaceMessageItems: () => 0,
35
+ pendingCount: () => 0,
36
+ hasPending: () => false,
37
+ removeOrphanQueueRows: () => 0,
38
+ inTransaction: (fn) => fn(),
39
+ listMedia: () => [],
40
+ countMedia: () => ({ media: 0, file: 0 }),
41
+ mediaFacets: () => ({ sessions: [], providers: [] }),
42
+ getMediaItem: () => null,
43
+ };
44
+ }
45
+ const messageColumns = columnsOf(db, 'messages');
46
+ // octet_length reads a value's size without reading the value (SQLite 3.43+).
47
+ let hasOctetLength = true;
48
+ try {
49
+ db.prepare(`SELECT octet_length('x')`).get();
50
+ } catch {
51
+ hasOctetLength = false;
52
+ }
53
+ const sizeOf = (column) => (hasOctetLength ? `octet_length(${column})` : `length(${column})`);
54
+ const conversationColumns = columnsOf(db, 'conversations');
55
+ const optional = (columns, column, alias) => (columns.includes(column) ? `${alias}.${column}` : 'NULL');
56
+ const workspaceRoot = `COALESCE(${optional(conversationColumns, 'runtime_workspace_root_path', 'c')}, ${optional(conversationColumns, 'configured_workspace_root_path', 'c')})`;
57
+
58
+ const statements = {
59
+ // Queue order is the rowid (insertion order); no sort over the queue.
60
+ // Ids and body sizes only: a batch never loads message bodies, which can be
61
+ // megabytes (a pasted data image); the indexer loads them one by one.
62
+ nextBatch: db.prepare(`
63
+ SELECT q.message_id AS messageId, q.seq AS seq,
64
+ ${sizeOf('m.text')} + COALESCE(${sizeOf('m.attachments')}, 0) AS bytes
65
+ FROM media_index_queue q
66
+ JOIN messages m ON m.id = q.message_id
67
+ ORDER BY q.rowid
68
+ LIMIT ?
69
+ `),
70
+ loadMessage: db.prepare(`
71
+ SELECT m.id AS messageId,
72
+ m.conversation_id AS conversationId, m.role AS role, m.text AS text,
73
+ m.attachments AS attachments, m.timestamp AS timestamp,
74
+ ${optional(messageColumns, 'kind', 'm')} AS kind,
75
+ ${optional(messageColumns, 'origin_json', 'm')} AS originJson,
76
+ ${workspaceRoot} AS workspaceRoot
77
+ FROM messages m
78
+ LEFT JOIN conversations c ON c.id = m.conversation_id
79
+ WHERE m.id = ?
80
+ `),
81
+ deleteItems: db.prepare(`DELETE FROM media_items WHERE message_id = ?`),
82
+ insertItem: db.prepare(`
83
+ INSERT INTO media_items (message_id, conversation_id, ordinal, role, sender, origin, locator_kind, locator,
84
+ identity_key, category, media_kind, mime, name, alt, size_bytes, message_ts)
85
+ VALUES (@messageId, @conversationId, @ordinal, @role, @sender, @origin, @locatorKind, @locator,
86
+ @identityKey, @category, @mediaKind, @mime, @name, @alt, @sizeBytes, @messageTs)
87
+ `),
88
+ // Only the queue row that was read: a re-queue meanwhile bumped `seq`
89
+ // and keeps the row for the next batch.
90
+ deleteQueueRow: db.prepare(`DELETE FROM media_index_queue WHERE message_id = ? AND seq = ?`),
91
+ // Capped: a full count over a large backfill queue costs tens of ms.
92
+ pending: db.prepare(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM media_index_queue LIMIT ${MEDIA_PENDING_COUNT_CAP + 1})`),
93
+ hasPending: db.prepare(`SELECT EXISTS (SELECT 1 FROM media_index_queue) AS present`),
94
+ // Rows whose message is gone while foreign keys were off (an outside tool).
95
+ removeOrphans: db.prepare(`DELETE FROM media_index_queue WHERE message_id NOT IN (SELECT id FROM messages)`),
96
+ };
97
+
98
+ const replace = db.transaction((row, items) => {
99
+ statements.deleteItems.run(row.messageId);
100
+ const sender = mediaSenderFor(row);
101
+ for (const item of items) {
102
+ statements.insertItem.run({
103
+ messageId: row.messageId,
104
+ conversationId: row.conversationId,
105
+ ordinal: item.ordinal,
106
+ role: String(row.role || ''),
107
+ sender,
108
+ origin: item.origin,
109
+ locatorKind: item.locatorKind,
110
+ locator: item.locator,
111
+ identityKey: item.identityKey,
112
+ category: item.category,
113
+ mediaKind: item.mediaKind,
114
+ mime: item.mime ?? null,
115
+ name: item.name ?? null,
116
+ alt: item.alt ?? null,
117
+ sizeBytes: Number.isFinite(item.sizeBytes) ? item.sizeBytes : null,
118
+ messageTs: String(row.timestamp || ''),
119
+ });
120
+ }
121
+ statements.deleteQueueRow.run(row.messageId, row.seq);
122
+ return items.length;
123
+ });
124
+
125
+ return {
126
+ available: true,
127
+ /** Queued messages in queue order: `{ messageId, seq, bytes }`, no bodies. */
128
+ nextQueueBatch: (limit = 200) => statements.nextBatch.all(Math.max(1, Math.min(1000, Number(limit) || 200))),
129
+ /** A queued message with what the indexer needs; null when it is gone. */
130
+ loadQueuedMessage: (entry) => {
131
+ const row = statements.loadMessage.get(entry.messageId);
132
+ return row ? { ...row, seq: entry.seq } : null;
133
+ },
134
+ /** Replaces the rows of one queued message and removes its queue row, in one transaction. */
135
+ replaceMessageItems: (row, items) => replace(row, items),
136
+ /** Queued messages, counted up to MEDIA_PENDING_COUNT_CAP + 1 (that value means "more"). */
137
+ pendingCount: () => statements.pending.get().n,
138
+ hasPending: () => statements.hasPending.get().present === 1,
139
+ removeOrphanQueueRows: () => statements.removeOrphans.run().changes,
140
+ /** Runs `fn` in one transaction (one commit for a whole slice of messages). */
141
+ inTransaction: (fn) => db.transaction(fn)(),
142
+ ...createMediaQueries(db),
143
+ };
144
+ }
145
+
146
+ // --- reading the gallery ---------------------------------------------------
147
+
148
+ export const MEDIA_SOURCES = Object.freeze(['all', 'mine', 'agent', 'generated']);
149
+ export const MEDIA_PAGE_MAX = 120;
150
+
151
+ function escapeLike(value) {
152
+ return String(value).replace(/[\\%_]/g, (ch) => `\\${ch}`);
153
+ }
154
+
155
+ /** Opaque page cursor: the (message time, id) of the last item of a page. */
156
+ export function encodeMediaCursor(item) {
157
+ return Buffer.from(JSON.stringify([item.timestamp, item.id]), 'utf8').toString('base64url');
158
+ }
159
+
160
+ export function decodeMediaCursor(raw) {
161
+ if (!raw) return null;
162
+ try {
163
+ const [timestamp, id] = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'));
164
+ if (typeof timestamp !== 'string' || !Number.isSafeInteger(id)) return null;
165
+ return { timestamp, id };
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ function createMediaQueries(db) {
172
+ const runtimeColumns = tableExists(db, 'runtime_sessions') ? columnsOf(db, 'runtime_sessions') : [];
173
+ // The session's provider, as the sidebar shows it (github when unknown).
174
+ const providerExpr = runtimeColumns.includes('provider_type')
175
+ ? `COALESCE((SELECT rs.provider_type FROM runtime_sessions rs WHERE rs.conversation_id = c.id AND rs.provider_type IS NOT NULL ORDER BY rs.last_used_at DESC LIMIT 1), 'github')`
176
+ : `'github'`;
177
+ const cache = new Map();
178
+ const prepared = (key, sql) => {
179
+ if (!cache.has(key)) cache.set(key, db.prepare(sql));
180
+ return cache.get(key);
181
+ };
182
+
183
+ const providerFor = (c) => providerExpr.replace('rs.conversation_id = c.id', `rs.conversation_id = ${c}.id`);
184
+
185
+ // The scope's conditions for a media row `mi` of a session `c` (aliases
186
+ // given, so the dedupe check can ask them of another row).
187
+ function scopeClauses({ conversationId, source, provider, archived, q }, params, mi = 'mi', c = 'c') {
188
+ const where = [`${c}.status != 'deleted'`];
189
+ const key = [];
190
+ if (conversationId) {
191
+ where.push(`${mi}.conversation_id = @conversationId`);
192
+ params.conversationId = String(conversationId);
193
+ key.push('conv');
194
+ }
195
+ if (source === 'mine') { where.push(`${mi}.sender = 'me' AND ${mi}.origin != 'generated'`); key.push('mine'); }
196
+ if (source === 'agent') { where.push(`${mi}.sender != 'me' AND ${mi}.origin != 'generated'`); key.push('agent'); }
197
+ if (source === 'generated') { where.push(`${mi}.origin = 'generated'`); key.push('generated'); }
198
+ if (provider) {
199
+ where.push(`${providerFor(c)} = @provider`);
200
+ params.provider = String(provider);
201
+ key.push('provider');
202
+ }
203
+ if (archived === 'hide') { where.push(`${c}.archived = 0`); key.push('live'); }
204
+ const text = String(q || '').trim().slice(0, 200);
205
+ if (text) {
206
+ where.push(`(${mi}.name LIKE @q ESCAPE '\\' OR ${mi}.alt LIKE @q ESCAPE '\\' OR ${c}.title LIKE @q ESCAPE '\\')`);
207
+ params.q = `%${escapeLike(text)}%`;
208
+ key.push('q');
209
+ }
210
+ return { where: where.join(' AND '), key: key.join(',') };
211
+ }
212
+
213
+ /**
214
+ * One page of a gallery: each picture once (its newest occurrence in the
215
+ * scope), newest first. The rows are walked newest first along the time
216
+ * index and a row is kept when no newer row of the scope has its identity,
217
+ * so a page stops after `limit` hits instead of sorting the whole scope,
218
+ * and an older copy of a picture never comes back on a later page. The
219
+ * occurrences, the session's title and its provider are read for the
220
+ * page's rows alone.
221
+ */
222
+ function listMedia({ category = 'media', cursor = null, limit = 60, ...filters } = {}) {
223
+ const params = { category: category === 'file' ? 'file' : 'media', limit: Math.max(1, Math.min(MEDIA_PAGE_MAX, Number(limit) || 60)) };
224
+ const scope = scopeClauses(filters, params);
225
+ // For the other rows of an identity (n: a newer copy, o: every copy) the
226
+ // columns are written as +col: a unary plus keeps a column out of index
227
+ // lookups, so identity_key = ? is the only one left and the planner takes
228
+ // idx_media_items_identity by itself (an index hint would fail outright on
229
+ // a database that lacks the index; this falls back to a slower scan).
230
+ const newer = scopeClauses(filters, {}, '+n', 'cn');
231
+ const same = scopeClauses(filters, {}, '+o', 'co');
232
+ let after = '';
233
+ if (cursor) {
234
+ after = 'AND (mi.message_ts < @cursorTs OR (mi.message_ts = @cursorTs AND mi.id < @cursorId))';
235
+ params.cursorTs = cursor.timestamp;
236
+ params.cursorId = cursor.id;
237
+ }
238
+ const sql = `
239
+ SELECT mi.id, mi.conversation_id AS conversationId, mi.message_id AS messageId, mi.role, mi.sender, mi.origin,
240
+ mi.locator_kind AS locatorKind, mi.locator, mi.identity_key AS identityKey, mi.category,
241
+ mi.media_kind AS mediaKind, mi.mime, mi.name, mi.alt, mi.size_bytes AS sizeBytes,
242
+ mi.message_ts AS message_ts, c.title AS conversationTitle, c.archived AS archived,
243
+ ${providerFor('c')} AS provider,
244
+ (SELECT COUNT(*) FROM media_items o JOIN conversations co ON co.id = o.conversation_id
245
+ WHERE o.identity_key = mi.identity_key AND +o.category = mi.category AND ${same.where}) AS occurrences
246
+ FROM media_items mi
247
+ JOIN conversations c ON c.id = mi.conversation_id
248
+ WHERE mi.category = @category AND ${scope.where} ${after}
249
+ AND EXISTS (SELECT 1 FROM messages m WHERE m.id = mi.message_id)
250
+ AND NOT EXISTS (
251
+ SELECT 1 FROM media_items n JOIN conversations cn ON cn.id = n.conversation_id
252
+ WHERE n.identity_key = mi.identity_key AND +n.category = mi.category
253
+ AND (+n.message_ts, +n.id) > (mi.message_ts, mi.id)
254
+ AND ${newer.where})
255
+ ORDER BY mi.message_ts DESC, mi.id DESC
256
+ LIMIT @limit`;
257
+ const rows = prepared(`list:${scope.key}:${cursor ? 'c' : ''}`, sql).all(params);
258
+ return rows.map(({ message_ts: timestamp, archived, ...row }) => ({ ...row, timestamp, archived: archived === 1 }));
259
+ }
260
+ /** How many distinct pictures and files a scope has. */
261
+ function countMedia(filters = {}) {
262
+ const params = {};
263
+ const scope = scopeClauses(filters, params);
264
+ const sql = `
265
+ SELECT mi.category AS category, COUNT(DISTINCT mi.identity_key) AS n
266
+ FROM media_items mi
267
+ JOIN messages m ON m.id = mi.message_id
268
+ JOIN conversations c ON c.id = mi.conversation_id
269
+ WHERE ${scope.where}
270
+ GROUP BY mi.category`;
271
+ const counts = { media: 0, file: 0 };
272
+ for (const row of prepared(`count:${scope.key}`, sql).all(params)) counts[row.category] = row.n;
273
+ return counts;
274
+ }
275
+
276
+ /** Sessions and providers that have media, for the global filters. */
277
+ function mediaFacets() {
278
+ const sessions = prepared('facets:sessions', `
279
+ SELECT c.id AS id, c.title AS title, c.archived AS archived, ${providerExpr} AS provider,
280
+ COUNT(DISTINCT mi.identity_key) AS count, MAX(mi.message_ts) AS lastAt
281
+ FROM media_items mi
282
+ JOIN conversations c ON c.id = mi.conversation_id
283
+ WHERE c.status != 'deleted'
284
+ GROUP BY c.id
285
+ ORDER BY lastAt DESC
286
+ LIMIT 1000`).all().map((row) => ({ ...row, archived: row.archived === 1 }));
287
+ const providers = [...new Set(sessions.map((row) => row.provider).filter(Boolean))].sort();
288
+ return { sessions, providers };
289
+ }
290
+
291
+ /** One indexed item with what serving it needs; null when gone or its session is deleted. */
292
+ function getMediaItem(id) {
293
+ if (!Number.isSafeInteger(id) || id <= 0) return null;
294
+ return prepared('item', `
295
+ SELECT mi.id, mi.conversation_id AS conversationId, mi.message_id AS messageId, mi.locator_kind AS locatorKind,
296
+ mi.locator, mi.identity_key AS identityKey, mi.category, mi.media_kind AS mediaKind, mi.mime, mi.name,
297
+ m.text AS text, m.attachments AS attachments
298
+ FROM media_items mi
299
+ JOIN messages m ON m.id = mi.message_id
300
+ JOIN conversations c ON c.id = mi.conversation_id
301
+ WHERE mi.id = ? AND c.status != 'deleted'`).get(id) || null;
302
+ }
303
+
304
+ return { listMedia, countMedia, mediaFacets, getMediaItem };
305
+ }
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ // The agent SDKs the relay installs on demand (agent-sdk-store.mjs): their
4
+ // status, and Install / Reinstall / Cancel from Settings. A request body only
5
+ // ever carries an SDK id, looked up through the store's frozen table; an
6
+ // unknown id is a 400 before anything runs. The store itself refuses a spawn
7
+ // while COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN is set (503), and that answer is
8
+ // passed through as it is.
9
+
10
+ const UNKNOWN_SDK_ERROR = 'Unknown agent SDK';
11
+
12
+ export function registerAgentSdkRoutes(app, { auth, io = null, agentSdkStore = null } = {}) {
13
+ const store = agentSdkStore;
14
+
15
+ // Every transition and log flush (four a second at most, the store
16
+ // coalesces them) goes to every client, as cli_install_state does.
17
+ if (store && io && typeof store.subscribe === 'function') {
18
+ store.subscribe((snapshot) => {
19
+ io.emit('agent_sdk_state', snapshot);
20
+ });
21
+ }
22
+
23
+ const unavailable = (res) => res.status(503).json({ error: 'Agent SDKs are unavailable on this relay' });
24
+
25
+ // A string the store knows, or null. Only a string: String(['claude'])
26
+ // would otherwise read as a valid id.
27
+ const knownId = (raw) => (typeof raw === 'string' && store.status(raw) ? raw : null);
28
+
29
+ app.get('/api/agent-sdks/status', auth, (_req, res) => {
30
+ if (!store) return unavailable(res);
31
+ return res.json(store.snapshot());
32
+ });
33
+
34
+ app.post('/api/agent-sdks/install', auth, (req, res) => {
35
+ if (!store) return unavailable(res);
36
+ const sdk = knownId(req.body?.sdk);
37
+ if (!sdk) return res.status(400).json({ error: UNKNOWN_SDK_ERROR, ...store.snapshot() });
38
+ let result;
39
+ try {
40
+ result = store.install(sdk, { force: req.body?.reinstall === true });
41
+ } catch (error) {
42
+ return res.status(500).json({ error: `The install could not start: ${String(error?.message || error)}`, ...store.snapshot() });
43
+ }
44
+ if (!result?.ok) {
45
+ return res.status(Number(result?.statusCode) || 400).json({
46
+ error: result?.error || 'Failed to start the agent SDK install',
47
+ ...store.snapshot(),
48
+ });
49
+ }
50
+ // The rows ride along so the caller renders without a second round trip;
51
+ // the install itself finishes over the socket.
52
+ return res.json({
53
+ ok: true,
54
+ reused: result.reused === true,
55
+ ready: result.ready === true,
56
+ ...store.snapshot(),
57
+ });
58
+ });
59
+
60
+ app.post('/api/agent-sdks/install/cancel', auth, (req, res) => {
61
+ if (!store) return unavailable(res);
62
+ const sdk = knownId(req.body?.sdk);
63
+ if (!sdk) return res.status(400).json({ error: UNKNOWN_SDK_ERROR, ...store.snapshot() });
64
+ const result = store.cancel(sdk);
65
+ if (!result?.ok) {
66
+ return res.status(Number(result?.statusCode) || 400).json({
67
+ error: result?.error || 'Failed to cancel the agent SDK install',
68
+ ...store.snapshot(),
69
+ });
70
+ }
71
+ return res.json({ ok: true, ...store.snapshot() });
72
+ });
73
+ }