@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
@@ -0,0 +1,653 @@
1
+ import { createHash } from 'node:crypto';
2
+ import path from 'node:path';
3
+
4
+ // Finds the pictures, videos and attached files of one stored message, for
5
+ // the media gallery index. Pure: no file system and no database. Sources:
6
+ // - messages.attachments JSON (uploads, @file: references, generated
7
+ // images, legacy inline data URLs);
8
+ // - markdown images `![alt](target)` in messages.text, the way agents embed
9
+ // pictures (local paths, the relay's own file routes, http(s), raster
10
+ // data:image URIs). Code spans and fenced code blocks are skipped, as the
11
+ // chat renderer shows them as code.
12
+ // Every item gets an identity key so the gallery can show a picture once even
13
+ // when many messages carry it.
14
+
15
+ const IMAGE_EXTENSIONS = new Map([
16
+ ['png', 'image/png'], ['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'], ['gif', 'image/gif'],
17
+ ['webp', 'image/webp'], ['avif', 'image/avif'], ['bmp', 'image/bmp'], ['svg', 'image/svg+xml'],
18
+ ['ico', 'image/x-icon'],
19
+ ]);
20
+ const VIDEO_EXTENSIONS = new Map([
21
+ ['mp4', 'video/mp4'], ['m4v', 'video/mp4'], ['webm', 'video/webm'], ['mov', 'video/quicktime'],
22
+ ]);
23
+ const AUDIO_EXTENSIONS = new Map([
24
+ ['mp3', 'audio/mpeg'], ['m4a', 'audio/mp4'], ['ogg', 'audio/ogg'], ['opus', 'audio/ogg'],
25
+ ['wav', 'audio/wav'], ['flac', 'audio/flac'],
26
+ ]);
27
+ const RASTER_DATA_IMAGE = /^data:(image\/(?:avif|gif|jpe?g|png|webp));base64,/i;
28
+ const MAX_TEXT_ITEMS = 200;
29
+
30
+ export const MEDIA_ORIGINS = Object.freeze(['upload', 'reference', 'generated', 'embedded']);
31
+ export const MEDIA_LOCATOR_KINDS = Object.freeze([
32
+ 'upload_sha', 'legacy_data_url', 'generated', 'local_path', 'remote_url', 'data_uri',
33
+ ]);
34
+
35
+ function extensionOf(value) {
36
+ const clean = String(value || '').split(/[?#]/, 1)[0];
37
+ const base = clean.slice(Math.max(clean.lastIndexOf('/'), clean.lastIndexOf('\\')) + 1);
38
+ const dot = base.lastIndexOf('.');
39
+ return dot > 0 ? base.slice(dot + 1).toLowerCase() : '';
40
+ }
41
+
42
+ function baseNameOf(value) {
43
+ const clean = String(value || '').split(/[?#]/, 1)[0].replace(/[\\/]+$/, '');
44
+ const base = clean.slice(Math.max(clean.lastIndexOf('/'), clean.lastIndexOf('\\')) + 1);
45
+ try {
46
+ return decodeURIComponent(base);
47
+ } catch {
48
+ return base;
49
+ }
50
+ }
51
+
52
+ export function mediaKindForMime(mime) {
53
+ const value = String(mime || '').toLowerCase().split(';', 1)[0].trim();
54
+ if (value.startsWith('image/')) return 'image';
55
+ if (value.startsWith('video/')) return 'video';
56
+ if (value.startsWith('audio/')) return 'audio';
57
+ if (value === 'application/pdf') return 'pdf';
58
+ return 'other';
59
+ }
60
+
61
+ export function mimeForExtension(ext) {
62
+ const value = String(ext || '').toLowerCase();
63
+ return IMAGE_EXTENSIONS.get(value)
64
+ || VIDEO_EXTENSIONS.get(value)
65
+ || AUDIO_EXTENSIONS.get(value)
66
+ || (value === 'pdf' ? 'application/pdf' : '');
67
+ }
68
+
69
+ export function mediaCategoryForKind(kind) {
70
+ return kind === 'image' || kind === 'video' ? 'media' : 'file';
71
+ }
72
+
73
+ // Bump when extraction rules change: the index re-reads every message.
74
+ // 2: a data image must start with the bytes of the type it declares.
75
+ export const MEDIA_EXTRACTOR_VERSION = 2;
76
+
77
+ // What a data image's bytes must start with (the type the URI declares).
78
+ export const DATA_IMAGE_MAGIC = Object.freeze({
79
+ 'image/png': (b) => b.length > 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47,
80
+ 'image/jpeg': (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
81
+ 'image/gif': (b) => b.length > 6 && b.subarray(0, 4).toString('latin1') === 'GIF8',
82
+ 'image/webp': (b) => b.length > 12 && b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP',
83
+ 'image/avif': (b) => b.length > 12 && b.subarray(4, 8).toString('latin1') === 'ftyp',
84
+ });
85
+
86
+ function sha256Hex(value) {
87
+ return createHash('sha256').update(String(value)).digest('hex');
88
+ }
89
+
90
+ /** Strict base64 (no whitespace), checked with a character loop, not a regex over the whole string. */
91
+ export function isStrictBase64(payload) {
92
+ const value = String(payload || '');
93
+ if (!value || value.length % 4 !== 0) return false;
94
+ let padding = 0;
95
+ for (let i = 0; i < value.length; i += 1) {
96
+ const code = value.charCodeAt(i);
97
+ if (code === 61) { // '='
98
+ padding += 1;
99
+ if (padding > 2 || i < value.length - 2) return false;
100
+ continue;
101
+ }
102
+ if (padding) return false;
103
+ const ok = (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57) || code === 43 || code === 47;
104
+ if (!ok) return false;
105
+ }
106
+ return true;
107
+ }
108
+
109
+ /** The identity key of a data URI payload (base64 text, no whitespace). */
110
+ export function dataPayloadIdentityKey(payload) {
111
+ return `data:${sha256Hex(payload)}`;
112
+ }
113
+
114
+ /**
115
+ * Normalizes an absolute local path for the given platform; '' when it is not
116
+ * one. Never percent-decodes: a real file may be named `100%.png`. (The drive
117
+ * helpers decode, for paths that arrive URL-encoded; callers here decode a
118
+ * markdown target once themselves.)
119
+ */
120
+ export function normalizeLocalMediaPath(rawPath, platform = process.platform) {
121
+ const value = String(rawPath || '').trim();
122
+ if (!value || value.includes('\0')) return '';
123
+ if (platform === 'win32') {
124
+ if (!/^[A-Za-z]:[\\/]/.test(value)) return '';
125
+ const normalized = path.win32.normalize(value.replace(/\//g, '\\').replace(/\\+/g, '\\'));
126
+ if (!/^[A-Za-z]:\\/.test(normalized)) return '';
127
+ const rest = normalized.slice(3).replace(/^\\+/, '').replace(/\\+$/, '');
128
+ return rest ? `${normalized[0].toUpperCase()}:\\${rest}` : '';
129
+ }
130
+ if (!value.startsWith('/')) return '';
131
+ const normalized = path.posix.normalize(value.replace(/\\/g, '/'));
132
+ if (!normalized.startsWith('/') || normalized === '/') return '';
133
+ return normalized.replace(/\/+$/, '');
134
+ }
135
+
136
+ /** Decodes a markdown target once, as the chat does after marked encoded it; the raw text when it is not valid encoding. */
137
+ function decodeTargetOnce(value) {
138
+ try {
139
+ return decodeURIComponent(value);
140
+ } catch {
141
+ return value;
142
+ }
143
+ }
144
+
145
+ export function localPathIdentityKey(normalizedPath, platform = process.platform) {
146
+ return `path:${platform === 'win32' ? normalizedPath.toLowerCase() : normalizedPath}`;
147
+ }
148
+
149
+ /** `relPath` is already decoded. */
150
+ function resolveWorkspaceRelative(relPath, workspaceRoot, platform) {
151
+ const root = normalizeLocalMediaPath(workspaceRoot, platform);
152
+ if (!root) return '';
153
+ const decoded = String(relPath || '');
154
+ if (!decoded || decoded.includes('\0')) return '';
155
+ const api = platform === 'win32' ? path.win32 : path.posix;
156
+ const joined = api.normalize(api.join(root, decoded.replace(/[\\/]+/g, api.sep)));
157
+ const rootWithSep = root.endsWith(api.sep) ? root : `${root}${api.sep}`;
158
+ const inside = platform === 'win32'
159
+ ? joined.toLowerCase().startsWith(rootWithSep.toLowerCase())
160
+ : joined.startsWith(rootWithSep);
161
+ return inside ? normalizeLocalMediaPath(joined, platform) : '';
162
+ }
163
+
164
+ /** Linux pseudo file systems are never media, even when an agent names them. */
165
+ export function isPseudoFileSystemPath(normalizedPath, platform = process.platform) {
166
+ return platform !== 'win32' && /^\/(?:proc|sys|dev)(?:\/|$)/.test(String(normalizedPath || ''));
167
+ }
168
+
169
+ function localItem({ normalizedPath, platform, origin, alt = '', name = '', mime = '', sizeBytes = null, textOnly = false }) {
170
+ if (isPseudoFileSystemPath(normalizedPath, platform)) return null;
171
+ const ext = extensionOf(normalizedPath);
172
+ const extMime = mimeForExtension(ext);
173
+ const resolvedMime = String(mime || '').trim() || extMime;
174
+ const kind = mediaKindForMime(resolvedMime);
175
+ // An embedded target must look like a picture or a video, as the chat only
176
+ // shows those as media; attachments may be any file (Files tab).
177
+ if (textOnly && kind !== 'image' && kind !== 'video') return null;
178
+ return {
179
+ origin,
180
+ locatorKind: 'local_path',
181
+ locator: normalizedPath,
182
+ identityKey: localPathIdentityKey(normalizedPath, platform),
183
+ category: mediaCategoryForKind(kind),
184
+ mediaKind: kind,
185
+ mime: resolvedMime || null,
186
+ name: name || baseNameOf(normalizedPath),
187
+ alt: alt || null,
188
+ sizeBytes: Number.isFinite(sizeBytes) ? sizeBytes : null,
189
+ };
190
+ }
191
+
192
+ function uploadShaItem({ sha, name, mime, sizeBytes, alt = '', origin = 'upload' }) {
193
+ const kind = mediaKindForMime(mime || mimeForExtension(extensionOf(name)));
194
+ return {
195
+ origin,
196
+ locatorKind: 'upload_sha',
197
+ locator: sha,
198
+ identityKey: `sha:${sha}`,
199
+ category: mediaCategoryForKind(kind),
200
+ mediaKind: kind,
201
+ mime: mime || mimeForExtension(extensionOf(name)) || null,
202
+ name: name || null,
203
+ alt: alt || null,
204
+ sizeBytes: Number.isFinite(sizeBytes) ? sizeBytes : null,
205
+ };
206
+ }
207
+
208
+ function dataUriItem(uri, { origin, locatorKind, alt = '', name = '' }) {
209
+ const match = RASTER_DATA_IMAGE.exec(uri);
210
+ if (!match) return null;
211
+ const mime = match[1].toLowerCase().replace('image/jpg', 'image/jpeg');
212
+ const payload = uri.slice(match[0].length).replace(/\s+/g, '');
213
+ if (!isStrictBase64(payload)) return null;
214
+ // The first bytes only (24 characters decode to 18): the type it declares.
215
+ const head = Buffer.from(payload.slice(0, 24), 'base64');
216
+ if (!DATA_IMAGE_MAGIC[mime.replace('image/jpg', 'image/jpeg')]?.(head)) return null;
217
+ const sub = mime.slice('image/'.length).replace('jpeg', 'jpg');
218
+ return {
219
+ origin,
220
+ locatorKind,
221
+ locator: '',
222
+ identityKey: dataPayloadIdentityKey(payload),
223
+ category: 'media',
224
+ mediaKind: 'image',
225
+ mime,
226
+ name: name || `image.${sub}`,
227
+ alt: alt || null,
228
+ sizeBytes: Math.floor((payload.length * 3) / 4) - (payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0),
229
+ };
230
+ }
231
+
232
+ function generatedItem(entry, { conversationId, messageId }) {
233
+ const info = entry.generatedImage && typeof entry.generatedImage === 'object' ? entry.generatedImage : {};
234
+ const conv = String(info.conversationId || conversationId || '');
235
+ const msg = String(info.messageId || messageId || '');
236
+ const imageId = String(info.imageId || '');
237
+ const contentUrl = typeof entry.contentUrl === 'string' ? entry.contentUrl.trim() : '';
238
+ const identity = imageId ? `gen:${conv}/${msg}/${imageId}` : (contentUrl ? `gen:${contentUrl}` : '');
239
+ if (!identity) return null;
240
+ const mime = String(entry.type || '').trim() || mimeForExtension(extensionOf(info.relativePath || entry.name));
241
+ const kind = mediaKindForMime(mime) === 'other' ? 'image' : mediaKindForMime(mime);
242
+ return {
243
+ origin: 'generated',
244
+ locatorKind: 'generated',
245
+ locator: JSON.stringify({
246
+ conversationId: conv,
247
+ messageId: msg,
248
+ imageId,
249
+ sessionId: info.sessionId ? String(info.sessionId) : '',
250
+ relativePath: info.relativePath ? String(info.relativePath) : '',
251
+ contentUrl,
252
+ }),
253
+ identityKey: identity,
254
+ category: mediaCategoryForKind(kind),
255
+ mediaKind: kind,
256
+ mime: mime || null,
257
+ name: entry.name ? String(entry.name) : null,
258
+ alt: null,
259
+ sizeBytes: Number.isFinite(entry.size) ? entry.size : null,
260
+ };
261
+ }
262
+
263
+ function parseAttachmentList(attachments) {
264
+ if (Array.isArray(attachments)) return attachments;
265
+ if (typeof attachments !== 'string' || !attachments.trim()) return [];
266
+ try {
267
+ const parsed = JSON.parse(attachments);
268
+ return Array.isArray(parsed) ? parsed : [];
269
+ } catch {
270
+ return [];
271
+ }
272
+ }
273
+
274
+ function attachmentItem(entry, context) {
275
+ if (!entry || typeof entry !== 'object') return null;
276
+ const name = entry.name ? String(entry.name) : '';
277
+ const mime = entry.type ? String(entry.type) : '';
278
+ const size = Number(entry.size);
279
+ const sizeBytes = Number.isFinite(size) && size > 0 ? size : null;
280
+ // Generated images are recognized by their shape, not by one route, so new
281
+ // kinds of generated pictures show up without gallery changes.
282
+ if (entry.generatedImage && typeof entry.generatedImage === 'object') {
283
+ return generatedItem(entry, context);
284
+ }
285
+ const sha = typeof entry.sha256 === 'string' ? entry.sha256.trim().toLowerCase() : '';
286
+ if (/^[a-f0-9]{64}$/.test(sha)) {
287
+ return uploadShaItem({ sha, name, mime, sizeBytes });
288
+ }
289
+ if (typeof entry.dataUrl === 'string' && entry.dataUrl.startsWith('data:')) {
290
+ return dataUriItem(entry.dataUrl, { origin: 'upload', locatorKind: 'legacy_data_url', name });
291
+ }
292
+ if (typeof entry.path === 'string' && entry.path.trim()) {
293
+ const normalizedPath = normalizeLocalMediaPath(entry.path, context.platform);
294
+ if (!normalizedPath) return null;
295
+ return localItem({
296
+ normalizedPath,
297
+ platform: context.platform,
298
+ origin: 'reference',
299
+ name,
300
+ mime,
301
+ sizeBytes,
302
+ });
303
+ }
304
+ return null;
305
+ }
306
+
307
+ // --- markdown scanning ------------------------------------------------------
308
+
309
+ const ESCAPABLE = /\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g;
310
+
311
+ function stripCode(text) {
312
+ const lines = String(text || '').split('\n');
313
+ const kept = [];
314
+ let fence = null;
315
+ for (const line of lines) {
316
+ const open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
317
+ if (fence) {
318
+ if (open && open[1][0] === fence[0] && open[1].length >= fence.length && !line.slice(open[0].length).trim()) {
319
+ fence = null;
320
+ }
321
+ kept.push('');
322
+ continue;
323
+ }
324
+ if (open && !(open[1][0] === '`' && line.slice(open[0].length).includes('`'))) {
325
+ fence = open[1];
326
+ kept.push('');
327
+ continue;
328
+ }
329
+ kept.push(line);
330
+ }
331
+ return blankCodeSpans(kept.join('\n'));
332
+ }
333
+
334
+ // Inline code spans: a run of N backticks up to the next run of exactly N.
335
+ // Linear: the runs are listed once and each opener looks up the next run of
336
+ // its length through a per-length cursor (a regex here backtracks badly on
337
+ // text with many unmatched backticks).
338
+ function blankCodeSpans(text) {
339
+ const runs = [];
340
+ for (let i = text.indexOf('`'); i >= 0; i = text.indexOf('`', i)) {
341
+ let j = i;
342
+ while (text[j] === '`') j += 1;
343
+ runs.push({ start: i, length: j - i });
344
+ i = j;
345
+ }
346
+ if (runs.length < 2) return text;
347
+ const byLength = new Map();
348
+ runs.forEach((run, index) => {
349
+ if (!byLength.has(run.length)) byLength.set(run.length, []);
350
+ byLength.get(run.length).push(index);
351
+ });
352
+ const cursor = new Map();
353
+ // Spans to blank, then the text put together from slices: a character
354
+ // array of a multi-megabyte message (a pasted data image) cost ~100 ms.
355
+ const spans = [];
356
+ let index = 0;
357
+ while (index < runs.length) {
358
+ const run = runs[index];
359
+ const list = byLength.get(run.length);
360
+ let pos = cursor.get(run.length) || 0;
361
+ while (pos < list.length && list[pos] <= index) pos += 1;
362
+ cursor.set(run.length, pos);
363
+ if (pos >= list.length) {
364
+ index += 1;
365
+ continue;
366
+ }
367
+ const closeIndex = list[pos];
368
+ const close = runs[closeIndex];
369
+ spans.push([run.start, close.start + close.length]);
370
+ index = closeIndex + 1;
371
+ }
372
+ if (!spans.length) return text;
373
+ const parts = [];
374
+ let at = 0;
375
+ for (const [start, end] of spans) {
376
+ parts.push(text.slice(at, start), text.slice(start, end).replace(/[^\n]/g, ' '));
377
+ at = end;
378
+ }
379
+ parts.push(text.slice(at));
380
+ return parts.join('');
381
+ }
382
+
383
+ const MAX_ALT_SCAN = 600;
384
+ const MAX_TARGET_LENGTH = 8192;
385
+ const MAX_DATA_URI_LENGTH = 28 * 1024 * 1024; // about 20 MB of image bytes
386
+ const STOP_CHARS = /[\s()\\]/g;
387
+
388
+ function findClosingBracket(text, start) {
389
+ let depth = 0;
390
+ const limit = Math.min(text.length, start + MAX_ALT_SCAN);
391
+ for (let i = start; i < limit; i += 1) {
392
+ const ch = text[i];
393
+ if (ch === '\\') { i += 1; continue; }
394
+ if (ch === '[') depth += 1;
395
+ else if (ch === ']') {
396
+ if (depth === 0) return i;
397
+ depth -= 1;
398
+ } else if (ch === '\n' && text[i + 1] === '\n') {
399
+ return -1;
400
+ }
401
+ }
402
+ return -1;
403
+ }
404
+
405
+ function parseDestination(text, start) {
406
+ let i = start;
407
+ while (i < text.length && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n')) i += 1;
408
+ if (text[i] === '<') {
409
+ const end = text.indexOf('>', i + 1);
410
+ if (end < 0) return null;
411
+ const dest = text.slice(i + 1, end);
412
+ if (dest.includes('\n') || dest.includes('<')) return null;
413
+ return { dest, end: end + 1 };
414
+ }
415
+ const maxLength = text.startsWith('data:', i) ? MAX_DATA_URI_LENGTH : MAX_TARGET_LENGTH;
416
+ // A data URI holds no space, bracket or backslash: its end is found with one
417
+ // native search instead of a step per character (a 3 MB image took ~90 ms).
418
+ if (maxLength === MAX_DATA_URI_LENGTH) {
419
+ STOP_CHARS.lastIndex = i;
420
+ const stop = STOP_CHARS.exec(text);
421
+ const end = stop ? stop.index : text.length;
422
+ if (!stop || stop[0] === ')' || /\s/.test(stop[0])) {
423
+ if (end === i) return null;
424
+ // Resume where the step-by-step scan would have stopped (i + max + 1),
425
+ // not at the next separator, which may lie inside a following image.
426
+ if (end - i > maxLength) return { overflow: true, end: Math.min(end, i + maxLength + 1) };
427
+ return { dest: text.slice(i, end), end };
428
+ }
429
+ }
430
+ let depth = 0;
431
+ let j = i;
432
+ const limit = Math.min(text.length, i + maxLength + 1);
433
+ for (; j < limit; j += 1) {
434
+ const ch = text[j];
435
+ if (ch === '\\' && j + 1 < text.length) { j += 1; continue; }
436
+ if (ch === '(') depth += 1;
437
+ else if (ch === ')') {
438
+ if (depth === 0) break;
439
+ depth -= 1;
440
+ } else if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || /\s/.test(ch)) break;
441
+ }
442
+ if (j === i) return null;
443
+ // Too long to be a target: the scan resumes after it, so text without
444
+ // whitespace is not scanned again and again from every `![`.
445
+ if (j - i > maxLength) return { overflow: true, end: j };
446
+ return { dest: text.slice(i, j), end: j };
447
+ }
448
+
449
+ function skipTitleAndClose(text, start) {
450
+ let i = start;
451
+ while (i < text.length && /\s/.test(text[i])) i += 1;
452
+ if (text[i] === ')') return i + 1;
453
+ const quote = text[i];
454
+ const closer = quote === '(' ? ')' : quote;
455
+ if (quote !== '"' && quote !== "'" && quote !== '(') return -1;
456
+ for (let j = i + 1; j < text.length; j += 1) {
457
+ if (text[j] === '\\') { j += 1; continue; }
458
+ if (text[j] === closer) {
459
+ let k = j + 1;
460
+ while (k < text.length && /\s/.test(text[k])) k += 1;
461
+ return text[k] === ')' ? k + 1 : -1;
462
+ }
463
+ }
464
+ return -1;
465
+ }
466
+
467
+ /** Inline markdown images `![alt](dest "title")` outside code, in order. */
468
+ export function findMarkdownImages(text) {
469
+ const source = stripCode(text);
470
+ const found = [];
471
+ let index = source.indexOf('![');
472
+ let nextClose = -1;
473
+ while (index >= 0 && found.length < MAX_TEXT_ITEMS) {
474
+ // Jump over openers that cannot reach a `]` within the alt scan limit.
475
+ if (nextClose < index + 2) {
476
+ nextClose = source.indexOf(']', index + 2);
477
+ if (nextClose < 0) break;
478
+ }
479
+ if (nextClose - index > MAX_ALT_SCAN) {
480
+ index = source.indexOf('![', nextClose - MAX_ALT_SCAN);
481
+ continue;
482
+ }
483
+ if (index > 0 && source[index - 1] === '\\') {
484
+ index = source.indexOf('![', index + 2);
485
+ continue;
486
+ }
487
+ const close = findClosingBracket(source, index + 2);
488
+ if (close < 0 || source[close + 1] !== '(') {
489
+ index = source.indexOf('![', index + 2);
490
+ continue;
491
+ }
492
+ const destination = parseDestination(source, close + 2);
493
+ if (destination?.overflow) {
494
+ index = source.indexOf('![', destination.end);
495
+ continue;
496
+ }
497
+ const end = destination ? skipTitleAndClose(source, destination.end) : -1;
498
+ if (!destination || end < 0) {
499
+ index = source.indexOf('![', index + 2);
500
+ continue;
501
+ }
502
+ found.push({
503
+ alt: source.slice(index + 2, close).replace(ESCAPABLE, '$1').trim(),
504
+ target: destination.dest.replace(ESCAPABLE, '$1').trim(),
505
+ });
506
+ index = source.indexOf('![', end);
507
+ }
508
+ return found;
509
+ }
510
+
511
+ function appRoutePath(target) {
512
+ if (!target.startsWith('/api/')) return null;
513
+ try {
514
+ return new URL(target, 'http://relay.invalid');
515
+ } catch {
516
+ return null;
517
+ }
518
+ }
519
+
520
+ function embeddedItem({ alt, target }, context) {
521
+ const value = String(target || '').trim();
522
+ if (!value) return null;
523
+ if (/^data:/i.test(value)) {
524
+ return dataUriItem(value, { origin: 'embedded', locatorKind: 'data_uri', alt });
525
+ }
526
+ if (/^https?:\/\//i.test(value)) {
527
+ let url;
528
+ try {
529
+ url = new URL(value);
530
+ } catch {
531
+ return null;
532
+ }
533
+ const href = url.href;
534
+ const extMime = mimeForExtension(extensionOf(url.pathname));
535
+ const kind = mediaKindForMime(extMime) === 'video' ? 'video' : 'image';
536
+ return {
537
+ origin: 'embedded',
538
+ locatorKind: 'remote_url',
539
+ locator: href,
540
+ identityKey: `url:${href}`,
541
+ category: 'media',
542
+ mediaKind: kind,
543
+ mime: extMime || null,
544
+ name: baseNameOf(url.pathname) || url.hostname,
545
+ alt: alt || null,
546
+ sizeBytes: null,
547
+ };
548
+ }
549
+ const route = appRoutePath(value);
550
+ if (route) {
551
+ if (route.pathname === '/api/drives/file') {
552
+ const normalizedPath = normalizeLocalMediaPath(route.searchParams.get('path') || '', context.platform);
553
+ return normalizedPath
554
+ ? localItem({ normalizedPath, platform: context.platform, origin: 'embedded', alt, textOnly: true })
555
+ : null;
556
+ }
557
+ if (route.pathname.startsWith('/api/files/')) {
558
+ // Each segment of the route is URL-encoded once.
559
+ const relative = route.pathname.slice('/api/files/'.length).split('/').map(decodeTargetOnce).join('/');
560
+ const normalizedPath = resolveWorkspaceRelative(relative, context.workspaceRoot, context.platform);
561
+ return normalizedPath
562
+ ? localItem({ normalizedPath, platform: context.platform, origin: 'embedded', alt, textOnly: true })
563
+ : null;
564
+ }
565
+ const upload = /^\/api\/upload\/([a-f0-9]{64})\/content$/i.exec(route.pathname);
566
+ if (upload) {
567
+ const sha = upload[1].toLowerCase();
568
+ const item = uploadShaItem({ sha, name: alt || sha.slice(0, 12), mime: '', sizeBytes: null, alt, origin: 'embedded' });
569
+ // Unknown type: the gallery shows the tile as a picture, as chat does.
570
+ if (item.mediaKind === 'other') Object.assign(item, { mediaKind: 'image', category: 'media' });
571
+ return item;
572
+ }
573
+ const generated = /^\/api\/generated-image\/([^/]+)\/([^/]+)\/([^/]+)\/content$/.exec(route.pathname);
574
+ if (generated) {
575
+ return {
576
+ ...generatedItem({ contentUrl: route.pathname, generatedImage: {
577
+ conversationId: decodeURIComponent(generated[1]),
578
+ messageId: decodeURIComponent(generated[2]),
579
+ imageId: decodeURIComponent(generated[3]),
580
+ } }, context),
581
+ origin: 'embedded',
582
+ alt: alt || null,
583
+ };
584
+ }
585
+ return null;
586
+ }
587
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value) && !/^[A-Za-z]:[\\/]/.test(value)) {
588
+ return null; // file:, javascript:, blob:, mailto: … — the chat drops them too
589
+ }
590
+ if (/^[A-Za-z]:[\\/]/.test(value) || value.startsWith('/')) {
591
+ const normalizedPath = normalizeLocalMediaPath(decodeTargetOnce(value), context.platform);
592
+ return normalizedPath
593
+ ? localItem({ normalizedPath, platform: context.platform, origin: 'embedded', alt, textOnly: true })
594
+ : null;
595
+ }
596
+ if (value.startsWith('\\') || value.startsWith('#') || value.startsWith('?')) return null;
597
+ const normalizedPath = resolveWorkspaceRelative(decodeTargetOnce(value).replace(/^\.\//, ''), context.workspaceRoot, context.platform);
598
+ return normalizedPath
599
+ ? localItem({ normalizedPath, platform: context.platform, origin: 'embedded', alt, textOnly: true })
600
+ : null;
601
+ }
602
+
603
+ /**
604
+ * The data image of a message with the given identity, for serving it:
605
+ * `{ mime, payload }` or null. Looked up by identity, not by position, so a
606
+ * message edited since it was indexed never serves a different picture.
607
+ */
608
+ export function findDataImage({ text = '', attachments = null } = {}, identityKey) {
609
+ const wanted = String(identityKey || '');
610
+ if (!wanted.startsWith('data:')) return null;
611
+ const candidates = [];
612
+ for (const entry of parseAttachmentList(attachments)) {
613
+ if (entry && typeof entry.dataUrl === 'string') candidates.push(entry.dataUrl);
614
+ }
615
+ for (const image of findMarkdownImages(text)) {
616
+ if (/^data:/i.test(image.target)) candidates.push(image.target);
617
+ }
618
+ for (const uri of candidates) {
619
+ const match = RASTER_DATA_IMAGE.exec(uri);
620
+ if (!match) continue;
621
+ const payload = uri.slice(match[0].length).replace(/\s+/g, '');
622
+ if (isStrictBase64(payload) && dataPayloadIdentityKey(payload) === wanted) {
623
+ return { mime: match[1].toLowerCase().replace('image/jpg', 'image/jpeg'), payload };
624
+ }
625
+ }
626
+ return null;
627
+ }
628
+
629
+ /**
630
+ * Items of one message, in a stable order (attachments first, then the
631
+ * markdown images of the text). `ordinal` is the position in that order.
632
+ */
633
+ export function extractMediaFromMessage({
634
+ role = '',
635
+ text = '',
636
+ attachments = null,
637
+ conversationId = '',
638
+ messageId = '',
639
+ workspaceRoot = '',
640
+ platform = process.platform,
641
+ } = {}) {
642
+ const context = { conversationId, messageId, workspaceRoot, platform, role };
643
+ const items = [];
644
+ for (const entry of parseAttachmentList(attachments)) {
645
+ const item = attachmentItem(entry, context);
646
+ if (item) items.push(item);
647
+ }
648
+ for (const image of findMarkdownImages(text)) {
649
+ const item = embeddedItem(image, context);
650
+ if (item) items.push(item);
651
+ }
652
+ return items.map((item, ordinal) => ({ ordinal, ...item }));
653
+ }