@olares/lares-core 0.15.0

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 (82) hide show
  1. package/README.md +23 -0
  2. package/brand/identity.d.ts +9 -0
  3. package/brand/identity.js +82 -0
  4. package/brand/manifest.js +15 -0
  5. package/drive/download.js +40 -0
  6. package/drive/execute.js +143 -0
  7. package/drive/files-path.js +55 -0
  8. package/drive/ls.js +126 -0
  9. package/drive/paths.js +320 -0
  10. package/drive/tools.js +206 -0
  11. package/drive/url-download.js +314 -0
  12. package/files/deliverables.js +32 -0
  13. package/files/disposition.js +20 -0
  14. package/files/filename.js +22 -0
  15. package/files/intake.js +217 -0
  16. package/files/limits.js +1 -0
  17. package/files/markdown.js +88 -0
  18. package/files/mention.js +64 -0
  19. package/files/preview-cache.js +100 -0
  20. package/files/preview-groups.js +21 -0
  21. package/files/preview-workspace.js +342 -0
  22. package/files/preview.js +305 -0
  23. package/files/upload-client.js +40 -0
  24. package/files/upload-http.js +22 -0
  25. package/files/upload.js +104 -0
  26. package/i18n/chat-model-switch.js +41 -0
  27. package/i18n/chat-model.js +40 -0
  28. package/i18n/files.js +39 -0
  29. package/i18n/mobile.js +115 -0
  30. package/i18n/preview.js +57 -0
  31. package/i18n/search.js +33 -0
  32. package/i18n/t.js +22 -0
  33. package/i18n/voice.js +70 -0
  34. package/icons/mark.js +22 -0
  35. package/larepass/chat.js +134 -0
  36. package/larepass/context-provenance.js +65 -0
  37. package/larepass/host.js +80 -0
  38. package/larepass/mux.js +194 -0
  39. package/larepass/questions.js +16 -0
  40. package/larepass/rpc.js +60 -0
  41. package/larepass/runtime.js +395 -0
  42. package/larepass/session-cache.js +104 -0
  43. package/larepass/settings.js +100 -0
  44. package/larepass/stage-copy.js +16 -0
  45. package/larepass/tool-row.js +132 -0
  46. package/larepass/transcript.js +332 -0
  47. package/media/ffmpeg-run.js +117 -0
  48. package/media/router-images.js +63 -0
  49. package/olares/entrance.js +45 -0
  50. package/olares/identity.js +190 -0
  51. package/olares/session-identity.js +48 -0
  52. package/olares/trusted-host.js +37 -0
  53. package/package.json +111 -0
  54. package/router/catalog-sync.js +87 -0
  55. package/router/catalog.d.ts +15 -0
  56. package/router/catalog.js +73 -0
  57. package/router/gateway.d.ts +4 -0
  58. package/router/gateway.js +21 -0
  59. package/router/models-client.js +39 -0
  60. package/router/models.d.ts +16 -0
  61. package/router/models.js +133 -0
  62. package/router/search.js +142 -0
  63. package/router/session-model.js +143 -0
  64. package/router/shim.js +134 -0
  65. package/router/stt.js +200 -0
  66. package/search/client.js +38 -0
  67. package/search/config.js +98 -0
  68. package/search/menu.js +22 -0
  69. package/tools/async.js +34 -0
  70. package/tools/crypto-uuid.js +26 -0
  71. package/tools/http.js +123 -0
  72. package/tools/json-file.js +18 -0
  73. package/voice/client.js +90 -0
  74. package/voice/config.js +59 -0
  75. package/voice/languages.js +29 -0
  76. package/voice/recorder.js +43 -0
  77. package/voice/service.js +83 -0
  78. package/workspace/env.js +13 -0
  79. package/workspace/home.js +9 -0
  80. package/workspace/path.js +80 -0
  81. package/workspace/seed.js +13 -0
  82. package/workspace/session.js +42 -0
@@ -0,0 +1,314 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lookup } from "node:dns/promises";
3
+ import { constants } from "node:fs";
4
+ import { link, open, rename, rm } from "node:fs/promises";
5
+ import http from "node:http";
6
+ import https from "node:https";
7
+ import { BlockList, isIP } from "node:net";
8
+ import { Readable } from "node:stream";
9
+
10
+ export const MAX_URL_DOWNLOAD_BYTES = 200 * 1024 * 1024;
11
+ const MAX_REDIRECTS = 5;
12
+ const MAX_ATTEMPTS = 3;
13
+ const ATTEMPT_TIMEOUT_MS = 2 * 60 * 1000;
14
+ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
15
+
16
+ const blocked4 = new BlockList();
17
+ for (const [network, prefix] of [
18
+ ["0.0.0.0", 8],
19
+ ["10.0.0.0", 8],
20
+ ["100.64.0.0", 10],
21
+ ["127.0.0.0", 8],
22
+ ["169.254.0.0", 16],
23
+ ["172.16.0.0", 12],
24
+ ["192.0.0.0", 24],
25
+ ["192.0.2.0", 24],
26
+ ["192.168.0.0", 16],
27
+ ["198.18.0.0", 15],
28
+ ["198.51.100.0", 24],
29
+ ["203.0.113.0", 24],
30
+ ["224.0.0.0", 4],
31
+ ["240.0.0.0", 4],
32
+ ]) {
33
+ blocked4.addSubnet(network, prefix, "ipv4");
34
+ }
35
+ const blocked6 = new BlockList();
36
+ for (const [network, prefix] of [
37
+ ["::", 128],
38
+ ["::1", 128],
39
+ ["::ffff:0:0", 96],
40
+ ["64:ff9b:1::", 48],
41
+ ["100::", 64],
42
+ ["2001:db8::", 32],
43
+ ["fc00::", 7],
44
+ ["fe80::", 10],
45
+ ["ff00::", 8],
46
+ ]) {
47
+ blocked6.addSubnet(network, prefix, "ipv6");
48
+ }
49
+
50
+ function downloadError(message, retryable = false) {
51
+ return Object.assign(new Error(message), { retryable });
52
+ }
53
+
54
+ export function isPublicAddress(address) {
55
+ const family = isIP(address);
56
+ if (family === 0) return false;
57
+ return family === 4
58
+ ? !blocked4.check(address, "ipv4")
59
+ : !blocked6.check(address, "ipv6");
60
+ }
61
+
62
+ export async function assertPublicUrl(value, lookupFn = lookup) {
63
+ const url = value instanceof URL ? value : new URL(value);
64
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || !url.hostname) {
65
+ throw downloadError("url_fetch accepts only public HTTP(S) URLs without embedded credentials");
66
+ }
67
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
68
+ const literalFamily = isIP(hostname);
69
+ const addresses = literalFamily
70
+ ? [{ address: hostname, family: literalFamily }]
71
+ : await lookupFn(hostname, { all: true, verbatim: true });
72
+ if (addresses.length === 0 || addresses.some(({ address }) => !isPublicAddress(address))) {
73
+ throw downloadError(`url_fetch refused non-public host ${url.hostname}`);
74
+ }
75
+ return { url, addresses };
76
+ }
77
+
78
+ /**
79
+ * The addresses that already passed {@link assertPublicUrl}. Connection uses
80
+ * this lookup rather than asking DNS again, so a later private answer cannot
81
+ * replace the public one (DNS rebinding).
82
+ */
83
+ export function pinLookup(addresses) {
84
+ const records = addresses.map(({ address, family }) => ({
85
+ address,
86
+ family: family === 6 || family === "IPv6" ? 6 : 4,
87
+ }));
88
+ return (hostname, options, callback) => {
89
+ if (typeof options === "function") {
90
+ callback = options;
91
+ options = {};
92
+ }
93
+ if (options?.all) {
94
+ callback(null, records);
95
+ return;
96
+ }
97
+ callback(null, records[0].address, records[0].family);
98
+ };
99
+ }
100
+
101
+ export function nodeFetch(url, { signal, headers, lookup: lookupOption } = {}) {
102
+ return new Promise((resolve, reject) => {
103
+ const target = url instanceof URL ? url : new URL(url);
104
+ const lib = target.protocol === "https:" ? https : http;
105
+ const req = lib.request(target, {
106
+ method: "GET",
107
+ headers,
108
+ lookup: lookupOption,
109
+ signal,
110
+ }, (incoming) => {
111
+ const headerBag = new Headers();
112
+ for (const [name, value] of Object.entries(incoming.headers)) {
113
+ if (value === undefined) continue;
114
+ if (Array.isArray(value)) {
115
+ for (const item of value) headerBag.append(name, item);
116
+ } else {
117
+ headerBag.set(name, value);
118
+ }
119
+ }
120
+ const status = incoming.statusCode ?? 500;
121
+ const noBody = [101, 204, 205, 304].includes(status);
122
+ resolve(new Response(noBody ? null : Readable.toWeb(incoming), {
123
+ status,
124
+ headers: headerBag,
125
+ }));
126
+ });
127
+ req.on("error", reject);
128
+ req.end();
129
+ });
130
+ }
131
+
132
+ async function publicResponse(source, signal, fetchFn, lookupFn) {
133
+ let url = new URL(source);
134
+ for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
135
+ const resolved = await assertPublicUrl(url, lookupFn);
136
+ const response = await fetchFn(url, {
137
+ method: "GET",
138
+ redirect: "manual",
139
+ signal,
140
+ lookup: pinLookup(resolved.addresses),
141
+ headers: {
142
+ accept: "*/*",
143
+ "user-agent": "Lares/1.0 workspace-url-fetch",
144
+ },
145
+ });
146
+ if (response.status >= 300 && response.status < 400) {
147
+ const location = response.headers.get("location");
148
+ await response.body?.cancel();
149
+ if (!location) throw downloadError(`URL redirect ${response.status} has no location`);
150
+ if (redirects === MAX_REDIRECTS) throw downloadError(`URL exceeded ${MAX_REDIRECTS} redirects`);
151
+ url = new URL(location, url);
152
+ continue;
153
+ }
154
+ if (!response.ok) {
155
+ await response.body?.cancel();
156
+ throw downloadError(
157
+ `URL returned HTTP ${response.status}`,
158
+ RETRYABLE_STATUS.has(response.status),
159
+ );
160
+ }
161
+ if (response.body === null) throw downloadError("URL response has no body");
162
+ const declared = Number(response.headers.get("content-length"));
163
+ if (Number.isFinite(declared) && declared > MAX_URL_DOWNLOAD_BYTES) {
164
+ await response.body.cancel();
165
+ throw downloadError(`URL file exceeds ${MAX_URL_DOWNLOAD_BYTES} bytes`);
166
+ }
167
+ return response;
168
+ }
169
+ throw downloadError("URL redirect resolution failed");
170
+ }
171
+
172
+ async function writeResponse(response, absolutePath, overwrite) {
173
+ const temporary = `${absolutePath}.lares-download-${randomUUID()}.tmp`;
174
+ let handle;
175
+ let bytes = 0;
176
+ try {
177
+ handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
178
+ for await (const chunk of response.body) {
179
+ const body = Buffer.from(chunk);
180
+ bytes += body.length;
181
+ if (bytes > MAX_URL_DOWNLOAD_BYTES) {
182
+ throw downloadError(`URL file exceeds ${MAX_URL_DOWNLOAD_BYTES} bytes`);
183
+ }
184
+ await handle.write(body);
185
+ }
186
+ await handle.sync();
187
+ await handle.close();
188
+ handle = undefined;
189
+ if (overwrite) {
190
+ await rename(temporary, absolutePath);
191
+ } else {
192
+ await link(temporary, absolutePath);
193
+ await rm(temporary);
194
+ }
195
+ return {
196
+ bytes,
197
+ mediaType: response.headers.get("content-type")?.split(";", 1)[0] || "application/octet-stream",
198
+ };
199
+ } finally {
200
+ await handle?.close().catch(() => {});
201
+ await rm(temporary, { force: true }).catch(() => {});
202
+ }
203
+ }
204
+
205
+ function attemptSignal(parent) {
206
+ const controller = new AbortController();
207
+ const abort = () => controller.abort(parent?.reason);
208
+ if (parent?.aborted) abort();
209
+ else parent?.addEventListener("abort", abort, { once: true });
210
+ const timer = setTimeout(() => controller.abort(new Error("url_fetch timed out")), ATTEMPT_TIMEOUT_MS);
211
+ return {
212
+ signal: controller.signal,
213
+ dispose() {
214
+ clearTimeout(timer);
215
+ parent?.removeEventListener("abort", abort);
216
+ },
217
+ };
218
+ }
219
+
220
+ function wait(ms, signal) {
221
+ return new Promise((resolve, reject) => {
222
+ const timer = setTimeout(() => {
223
+ signal.removeEventListener("abort", abort);
224
+ resolve();
225
+ }, ms);
226
+ const abort = () => {
227
+ clearTimeout(timer);
228
+ reject(signal.reason ?? new Error("url_fetch aborted"));
229
+ };
230
+ if (signal.aborted) abort();
231
+ else signal.addEventListener("abort", abort, { once: true });
232
+ });
233
+ }
234
+
235
+ export async function downloadUrl(source, absolutePath, options = {}) {
236
+ const {
237
+ signal = new AbortController().signal,
238
+ overwrite = false,
239
+ fetchFn = nodeFetch,
240
+ lookupFn = lookup,
241
+ retryDelayMs = 250,
242
+ } = options;
243
+ let lastError;
244
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
245
+ const scoped = attemptSignal(signal);
246
+ try {
247
+ const response = await publicResponse(source, scoped.signal, fetchFn, lookupFn);
248
+ return await writeResponse(response, absolutePath, overwrite);
249
+ } catch (error) {
250
+ lastError = error;
251
+ if (signal.aborted) throw signal.reason ?? error;
252
+ const retryable = error?.retryable === true
253
+ || (error instanceof TypeError)
254
+ || scoped.signal.aborted;
255
+ if (!retryable || attempt === MAX_ATTEMPTS - 1) throw error;
256
+ } finally {
257
+ scoped.dispose();
258
+ }
259
+ await wait(retryDelayMs * (2 ** attempt), signal);
260
+ }
261
+ throw lastError;
262
+ }
263
+
264
+ /**
265
+ * Decode a `data:` URL without touching the network. Router / FlowStudio image
266
+ * payloads often arrive this way rather than as an HTTP URL.
267
+ */
268
+ export function decodeDataUrl(value) {
269
+ const raw = String(value ?? "").trim();
270
+ if (!raw.toLowerCase().startsWith("data:")) {
271
+ throw downloadError("url is not a data URL");
272
+ }
273
+ const comma = raw.indexOf(",");
274
+ if (comma < 5) throw downloadError("data URL is missing a payload");
275
+ const meta = raw.slice(5, comma);
276
+ const payload = raw.slice(comma + 1);
277
+ const parts = meta.split(";").map((part) => part.trim()).filter(Boolean);
278
+ const base64 = parts.some((part) => part.toLowerCase() === "base64");
279
+ const mediaType = (parts.find((part) => part.includes("/")) || "application/octet-stream")
280
+ .split(";", 1)[0]
281
+ .trim()
282
+ .toLowerCase() || "application/octet-stream";
283
+ let bytes;
284
+ try {
285
+ if (base64) {
286
+ const encoded = payload.replace(/\s+/g, "");
287
+ const maxEncoded = Math.ceil(MAX_URL_DOWNLOAD_BYTES / 3) * 4;
288
+ if (
289
+ encoded.length > maxEncoded
290
+ || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)
291
+ ) {
292
+ throw new Error("invalid base64");
293
+ }
294
+ bytes = Buffer.from(encoded, "base64");
295
+ } else {
296
+ bytes = Buffer.from(decodeURIComponent(payload));
297
+ }
298
+ } catch {
299
+ throw downloadError("data URL payload is not valid");
300
+ }
301
+ if (bytes.length === 0) throw downloadError("data URL payload is empty");
302
+ if (bytes.length > MAX_URL_DOWNLOAD_BYTES) {
303
+ throw downloadError(`URL file exceeds ${MAX_URL_DOWNLOAD_BYTES} bytes`);
304
+ }
305
+ return { bytes, mediaType };
306
+ }
307
+
308
+ export async function saveDataUrl(source, absolutePath, { overwrite = false } = {}) {
309
+ const decoded = decodeDataUrl(source);
310
+ const response = new Response(decoded.bytes, {
311
+ headers: { "content-type": decoded.mediaType, "content-length": String(decoded.bytes.length) },
312
+ });
313
+ return writeResponse(response, absolutePath, overwrite);
314
+ }
@@ -0,0 +1,32 @@
1
+ /** Same rule as dsh-client-ui-deliverables: only edit/diff call views produce files. */
2
+ export function producedPathsFromView(view) {
3
+ if (!view || !Array.isArray(view.locations)) return [];
4
+ const writable = view.card === "diff" || (view.card === "generic" && view.kind === "edit");
5
+ if (!writable) return [];
6
+ const paths = [];
7
+ const seen = new Set();
8
+ for (const location of view.locations) {
9
+ const path = location?.path;
10
+ if (!path || seen.has(path)) continue;
11
+ seen.add(path);
12
+ paths.push(path);
13
+ }
14
+ return paths;
15
+ }
16
+
17
+ export function producedForClosing(owner) {
18
+ const produced = owner.turn.data.get("deliverables")?.produced ?? [];
19
+ const paths = [];
20
+ const seen = new Set();
21
+ for (const item of produced) {
22
+ if (item.seq > owner.seq || seen.has(item.path)) continue;
23
+ seen.add(item.path);
24
+ paths.push(item.path);
25
+ }
26
+ return paths;
27
+ }
28
+
29
+ export function selectProducedFiles(owner) {
30
+ const paths = producedForClosing(owner);
31
+ return paths.length === 0 ? null : paths;
32
+ }
@@ -0,0 +1,20 @@
1
+ export function filenameFromDisposition(value) {
2
+ const encoded = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(String(value ?? ""));
3
+ if (encoded) {
4
+ try {
5
+ return decodeURIComponent(encoded[1].trim().replace(/^"(.*)"$/s, "$1"));
6
+ } catch {
7
+ // Fall through to the ASCII filename, or the generic save name.
8
+ }
9
+ }
10
+ const plain = /filename="?([^";]+)"?/i.exec(String(value ?? ""));
11
+ return plain?.[1]?.trim() ?? "";
12
+ }
13
+
14
+ export async function preflightDownload(url, fetchFn = fetch) {
15
+ const response = await fetchFn(url, { method: "HEAD" });
16
+ if (!response.ok) {
17
+ throw new Error(response.status === 404 ? "file_not_found" : "file_preview_failed");
18
+ }
19
+ return filenameFromDisposition(response.headers.get("content-disposition"));
20
+ }
@@ -0,0 +1,22 @@
1
+ export function posixBasename(path) {
2
+ const raw = String(path ?? "");
3
+ const trimmed = raw.replace(/\/+$/, "");
4
+ if (trimmed === "") return raw.includes("/") ? "/" : "";
5
+ return trimmed.slice(trimmed.lastIndexOf("/") + 1);
6
+ }
7
+
8
+ export function posixExtname(path) {
9
+ const base = posixBasename(path);
10
+ const dot = base.lastIndexOf(".");
11
+ return dot > 0 ? base.slice(dot) : "";
12
+ }
13
+
14
+ export function sanitizeFilename(value) {
15
+ const source = posixBasename(String(value || "file")).normalize("NFC");
16
+ const safe = source
17
+ .replace(/[\u0000-\u001f\u007f/\\:*?"<>|]/g, "_")
18
+ .replace(/\s+/g, "_")
19
+ .replace(/^\.+/, "")
20
+ .slice(0, 160);
21
+ return safe || "file";
22
+ }
@@ -0,0 +1,217 @@
1
+ const IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
2
+ const MAX_CONCURRENCY = 3;
3
+
4
+ function freshId() {
5
+ return globalThis.crypto?.randomUUID?.()
6
+ ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
7
+ }
8
+
9
+ function failureCode(error) {
10
+ if (error?.name === "AbortError") return "file_upload_cancelled";
11
+ return error instanceof Error ? error.message : "file_upload_failed";
12
+ }
13
+
14
+ export function splitComposerFiles(files) {
15
+ const images = [];
16
+ const documents = [];
17
+ for (const file of files) {
18
+ (IMAGE_TYPES.has(file.type) ? images : documents).push(file);
19
+ }
20
+ return { images, documents };
21
+ }
22
+
23
+ export function documentPasteFiles(clipboardData) {
24
+ const files = Array.from(clipboardData?.files ?? []);
25
+ return splitComposerFiles(files).documents.length > 0 ? files : null;
26
+ }
27
+
28
+ export function composerAttachReady({ phase, pending, addImages, sessionId }) {
29
+ return phase === "plain"
30
+ && !pending
31
+ && typeof addImages === "function"
32
+ && typeof sessionId === "string";
33
+ }
34
+
35
+ export function claimComposerBlock(registry, sessionId, reason) {
36
+ const store = registry.storeFor(sessionId);
37
+ if (store.getSnapshot() !== undefined) return () => {};
38
+ const block = { reason };
39
+ registry.set(sessionId, block);
40
+ return () => {
41
+ if (store.getSnapshot() === block) registry.set(sessionId, undefined);
42
+ };
43
+ }
44
+
45
+ export const COMPOSER_CARD = "[data-composer-card]";
46
+
47
+ export function composerPasteInCard(target) {
48
+ return Boolean(target && typeof target.closest === "function" && target.closest(COMPOSER_CARD));
49
+ }
50
+
51
+ export function composerDropHasDocuments(files) {
52
+ return files.length > 0 && splitComposerFiles(files).documents.length > 0;
53
+ }
54
+
55
+ export function draftImageFailureCode(reason) {
56
+ return reason instanceof Error ? reason.message : "file_upload_failed";
57
+ }
58
+
59
+ export function commitComposerImages(ports, files, sessionId) {
60
+ if (files.length === 0) return;
61
+ let attachments = [];
62
+ try {
63
+ attachments = ports.createDraftImages(files);
64
+ if (!ports.addImages(attachments.map((attachment) => attachment.id))) {
65
+ ports.releaseDraftImages(attachments);
66
+ for (const file of files) ports.reportFailure(sessionId, file, "file_input_blocked");
67
+ }
68
+ } catch (reason) {
69
+ if (attachments.length > 0) ports.releaseDraftImages(attachments);
70
+ const code = draftImageFailureCode(reason);
71
+ for (const file of files) ports.reportFailure(sessionId, file, code);
72
+ }
73
+ }
74
+
75
+ export function processComposerFiles(ports, incoming) {
76
+ if (!ports.ready || incoming.length === 0) return;
77
+ const files = [...incoming];
78
+ const { images, documents } = splitComposerFiles(files);
79
+ ports.addImages(images);
80
+ if (documents.length === 0) return;
81
+ const { accepted, oversized } = partitionDocumentsBySize(documents, ports.maxBytes);
82
+ for (const file of oversized) ports.reportFailure(ports.sessionId, file, "file_too_large");
83
+ return ports.uploadFiles(ports.sessionId, accepted, ports.commit);
84
+ }
85
+
86
+ export function partitionDocumentsBySize(documents, maxBytes) {
87
+ const accepted = [];
88
+ const oversized = [];
89
+ for (const file of documents) {
90
+ if (file.size > maxBytes) oversized.push(file);
91
+ else accepted.push(file);
92
+ }
93
+ return { accepted, oversized };
94
+ }
95
+
96
+ export class FileIntake {
97
+ constructor(upload) {
98
+ this.upload = upload;
99
+ this.sessions = new Map();
100
+ }
101
+
102
+ session(sessionId) {
103
+ let state = this.sessions.get(sessionId);
104
+ if (state) return state;
105
+ state = {
106
+ items: [],
107
+ listeners: new Set(),
108
+ snapshot: { pending: 0, failures: [] },
109
+ };
110
+ this.sessions.set(sessionId, state);
111
+ return state;
112
+ }
113
+
114
+ getSnapshot(sessionId) {
115
+ return this.session(sessionId).snapshot;
116
+ }
117
+
118
+ subscribe(sessionId, listener) {
119
+ const state = this.session(sessionId);
120
+ state.listeners.add(listener);
121
+ return () => state.listeners.delete(listener);
122
+ }
123
+
124
+ publish(state) {
125
+ state.snapshot = {
126
+ pending: state.items.filter((item) => item.status === "uploading").length,
127
+ failures: state.items
128
+ .filter((item) => item.status === "failed")
129
+ .map(({ id, file, code }) => ({ id, name: file.name || "file", code })),
130
+ };
131
+ for (const listener of state.listeners) listener();
132
+ }
133
+
134
+ reportFailure(sessionId, file, code) {
135
+ const state = this.session(sessionId);
136
+ state.items.push({
137
+ id: freshId(),
138
+ file,
139
+ status: "failed",
140
+ code,
141
+ controller: null,
142
+ });
143
+ this.publish(state);
144
+ }
145
+
146
+ dismiss(sessionId, itemId) {
147
+ const state = this.session(sessionId);
148
+ state.items = state.items.filter((item) => item.id !== itemId);
149
+ this.publish(state);
150
+ }
151
+
152
+ async uploadFiles(sessionId, files, commit) {
153
+ if (files.length === 0) return;
154
+ const state = this.session(sessionId);
155
+ const items = files.map((file) => ({
156
+ id: freshId(),
157
+ requestId: freshId(),
158
+ file,
159
+ status: "uploading",
160
+ code: null,
161
+ controller: new AbortController(),
162
+ }));
163
+ state.items.push(...items);
164
+ this.publish(state);
165
+
166
+ let cursor = 0;
167
+ const successful = new Array(items.length);
168
+ const worker = async () => {
169
+ while (cursor < items.length) {
170
+ const index = cursor++;
171
+ const item = items[index];
172
+ try {
173
+ const result = await this.upload(item.file, sessionId, {
174
+ signal: item.controller.signal,
175
+ requestId: item.requestId,
176
+ });
177
+ if (!item.controller.signal.aborted) successful[index] = { item, result };
178
+ } catch (error) {
179
+ if (!item.controller.signal.aborted) {
180
+ item.status = "failed";
181
+ item.code = failureCode(error);
182
+ }
183
+ }
184
+ }
185
+ };
186
+ await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENCY, items.length) }, worker));
187
+
188
+ const live = this.sessions.get(sessionId);
189
+ if (live !== state) return;
190
+ const committed = successful.filter(Boolean);
191
+ const paths = committed.map(({ result }) => result.path);
192
+ if (paths.length > 0) commit(paths);
193
+ const completed = new Set(committed.map(({ item }) => item));
194
+ state.items = state.items.filter((item) => {
195
+ if (completed.has(item)) return false;
196
+ if (item.controller?.signal.aborted) return false;
197
+ return true;
198
+ });
199
+ this.publish(state);
200
+ }
201
+
202
+ retry(sessionId, itemId, commit) {
203
+ const state = this.session(sessionId);
204
+ const item = state.items.find((candidate) => candidate.id === itemId && candidate.status === "failed");
205
+ if (!item) return Promise.resolve();
206
+ state.items = state.items.filter((candidate) => candidate !== item);
207
+ return this.uploadFiles(sessionId, [item.file], commit);
208
+ }
209
+
210
+ cancelSession(sessionId) {
211
+ const state = this.sessions.get(sessionId);
212
+ if (!state) return;
213
+ for (const item of state.items) item.controller?.abort();
214
+ state.items = [];
215
+ this.publish(state);
216
+ }
217
+ }
@@ -0,0 +1 @@
1
+ export const DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * dsh's MarkdownText keeps only http / https / mailto targets: a workspace-relative
3
+ * or Olares files-path link or image renders as inert text. Rewriting those targets
4
+ * to the plugin's own same-origin raw URL is what makes them render at all; the view
5
+ * maps the URL back to a path when a click lands on one.
6
+ */
7
+
8
+ import { isFilesPath, parseFilesPath } from "../drive/files-path.js";
9
+
10
+ const FENCE = /^[ \t]{0,3}(`{3,}|~{3,})/;
11
+ const INLINE_CODE = /(`+).*?\1/g;
12
+ const TARGET = /(!?)\[((?:[^\][\\]|\\.)*)\]\(\s*(<[^>\n]*>|[^\s()]*)([^)]*)\)/g;
13
+ const ABSOLUTE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
14
+
15
+ function directorySegments(path) {
16
+ const segments = String(path ?? "").split(/[/\\]/).filter((part) => part !== "");
17
+ segments.pop();
18
+ return segments;
19
+ }
20
+
21
+ /**
22
+ * Resolve a markdown link or image target against the previewed file.
23
+ * @returns the workspace-relative or Olares files path, or null when the target
24
+ * is external, empty, or climbs out of the workspace.
25
+ */
26
+ export function workspaceTargetPath(fromPath, target) {
27
+ const raw = String(target ?? "").trim().replace(/^<|>$/g, "");
28
+ if (raw === "" || ABSOLUTE.test(raw)) return null;
29
+ const [locator] = raw.split(/[?#]/);
30
+ if (locator === "") return null;
31
+ let decoded = locator;
32
+ try {
33
+ decoded = decodeURIComponent(locator);
34
+ } catch {
35
+ // A malformed escape is a literal path; the host validates it either way.
36
+ }
37
+ if (isFilesPath(decoded)) return parseFilesPath(decoded);
38
+ const segments = decoded.startsWith("/") ? [] : directorySegments(fromPath);
39
+ for (const part of decoded.split("/")) {
40
+ if (part === "" || part === ".") continue;
41
+ if (part !== "..") {
42
+ segments.push(part);
43
+ continue;
44
+ }
45
+ if (segments.length === 0) return null;
46
+ segments.pop();
47
+ }
48
+ return segments.length === 0 ? null : segments.join("/");
49
+ }
50
+
51
+ function rewriteSpan(span, fromPath, hrefFor) {
52
+ return span.replace(TARGET, (match, bang, label, target, tail) => {
53
+ const path = workspaceTargetPath(fromPath, target);
54
+ return path === null ? match : `${bang}[${label}](${hrefFor(path)}${tail})`;
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Rewrite every workspace-relative or Olares files-path link and image target
60
+ * in `text` to an absolute URL. Fenced blocks and inline code keep their source
61
+ * verbatim.
62
+ * @param hrefFor - path → absolute URL the renderer accepts.
63
+ */
64
+ export function rewriteWorkspaceTargets(text, fromPath, hrefFor) {
65
+ const lines = String(text ?? "").split("\n");
66
+ let fence = null;
67
+ return lines
68
+ .map((line) => {
69
+ const marker = FENCE.exec(line);
70
+ if (fence !== null) {
71
+ if (marker !== null && marker[1].startsWith(fence[0]) && marker[1].length >= fence.length) fence = null;
72
+ return line;
73
+ }
74
+ if (marker !== null) {
75
+ fence = marker[1];
76
+ return line;
77
+ }
78
+ let cursor = 0;
79
+ let out = "";
80
+ INLINE_CODE.lastIndex = 0;
81
+ for (let code = INLINE_CODE.exec(line); code !== null; code = INLINE_CODE.exec(line)) {
82
+ out += rewriteSpan(line.slice(cursor, code.index), fromPath, hrefFor) + code[0];
83
+ cursor = code.index + code[0].length;
84
+ }
85
+ return out + rewriteSpan(line.slice(cursor), fromPath, hrefFor);
86
+ })
87
+ .join("\n");
88
+ }