@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,64 @@
1
+ const REFERENCE_SOURCE = "reference";
2
+
3
+ export function mentionOf(path) {
4
+ return /[\s"]/u.test(path) ? `@"${path}"` : `@${path}`;
5
+ }
6
+
7
+ export function appendDraftMentions(draft, paths) {
8
+ let next = String(draft ?? "");
9
+ for (const path of Array.isArray(paths) ? paths : []) {
10
+ if (!path) continue;
11
+ const ref = mentionOf(path);
12
+ if (next && !/\s$/u.test(next)) next += " ";
13
+ next += ref;
14
+ }
15
+ return next;
16
+ }
17
+
18
+ export function uploadReference(path) {
19
+ const ref = mentionOf(path);
20
+ return {
21
+ source: REFERENCE_SOURCE,
22
+ ref,
23
+ label: path.slice(path.lastIndexOf("/") + 1),
24
+ appearance: "file",
25
+ clipboardText: ref,
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Append one reference per uploaded path at the end of the draft.
31
+ * @returns the paths the input machine refused, in order.
32
+ */
33
+ export function insertUploadReferences(input, paths) {
34
+ const refused = [];
35
+ for (const path of paths) {
36
+ const current = input.state.getSnapshot();
37
+ if (current.draft !== "" && !/\s$/u.test(current.draft)) input.setDraft(`${current.draft} `);
38
+ const state = input.state.getSnapshot();
39
+ const at = state.draft.length;
40
+ const applied = input.insertReference(uploadReference(path), {
41
+ start: at,
42
+ end: at,
43
+ draftRev: state.draftRev,
44
+ });
45
+ if (!applied) refused.push(path);
46
+ }
47
+ return refused;
48
+ }
49
+
50
+ export function commitUploadPaths(input, paths, notifyUnlinked) {
51
+ if (!input) return;
52
+ for (const path of insertUploadReferences(input, paths)) {
53
+ notifyUnlinked(path);
54
+ }
55
+ }
56
+
57
+ export function createUploadCommit({ scopeSession, inputFor, unlinkedMessage }) {
58
+ return (sessionId) => (paths) => {
59
+ const sessionCtx = scopeSession(sessionId);
60
+ if (sessionCtx === undefined) return;
61
+ const input = inputFor(sessionCtx);
62
+ commitUploadPaths(input, paths, (path) => input.notify("error", unlinkedMessage(path)));
63
+ };
64
+ }
@@ -0,0 +1,100 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { runOlaresDownload } from "../drive/download.js";
5
+ import { sanitizeFilename } from "./filename.js";
6
+
7
+ const inflight = new Map();
8
+
9
+ export function previewCacheRoot(env = process.env) {
10
+ const root = env.LARES_DATA_DIR?.trim();
11
+ return join(root || "/data/lares", "preview-cache");
12
+ }
13
+
14
+ function cacheDir(cacheRoot, source) {
15
+ const id = createHash("sha256").update(source).digest("hex").slice(0, 24);
16
+ return join(cacheRoot, id);
17
+ }
18
+
19
+ async function readMeta(path) {
20
+ try {
21
+ const parsed = JSON.parse(await readFile(path, "utf8"));
22
+ if (parsed && typeof parsed === "object") return parsed;
23
+ } catch {
24
+ // Missing or corrupt meta is a cache miss.
25
+ }
26
+ return null;
27
+ }
28
+
29
+ async function localSize(path) {
30
+ try {
31
+ const info = await stat(path);
32
+ return info.isFile() ? info.size : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ async function attachLocal(file, absolutePath) {
39
+ const info = await stat(absolutePath);
40
+ return {
41
+ ...file,
42
+ absolutePath,
43
+ size: info.size,
44
+ modifiedAt: info.mtimeMs,
45
+ device: info.dev,
46
+ inode: info.ino,
47
+ };
48
+ }
49
+
50
+ async function materializeOnce(file, options) {
51
+ const download = options.download ?? runOlaresDownload;
52
+ const cacheRoot = options.cacheRoot ?? previewCacheRoot(options.env);
53
+ const dir = cacheDir(cacheRoot, file.path);
54
+ const dest = join(dir, sanitizeFilename(file.name));
55
+ const metaPath = join(dir, "meta.json");
56
+ const expected = { source: file.path, size: file.size, modifiedAt: file.modifiedAt };
57
+
58
+ const meta = await readMeta(metaPath);
59
+ const size = await localSize(dest);
60
+ const metaMatches = meta?.source === expected.source
61
+ && meta?.size === expected.size
62
+ && meta?.modifiedAt === expected.modifiedAt;
63
+
64
+ if (metaMatches && size === expected.size) return attachLocal(file, dest);
65
+
66
+ await mkdir(dir, { recursive: true });
67
+ await writeFile(metaPath, JSON.stringify(expected));
68
+ const resume = metaMatches && size !== null && size < expected.size;
69
+ await download(file.path, dest, {
70
+ signal: options.signal,
71
+ overwrite: !resume,
72
+ resume,
73
+ ...(options.spawnFn ? { spawnFn: options.spawnFn } : {}),
74
+ });
75
+ const next = await localSize(dest);
76
+ if (next !== expected.size) {
77
+ const error = new Error(
78
+ `downloaded size ${next} does not match files backend size ${expected.size}`,
79
+ );
80
+ error.code = "files_unavailable";
81
+ throw error;
82
+ }
83
+ return attachLocal(file, dest);
84
+ }
85
+
86
+ /**
87
+ * Copy one files-backend file into `$LARES_DATA_DIR/preview-cache` so range
88
+ * streaming can use a local handle. Concurrent requests for the same source
89
+ * share one download; a matching size/mtime reuses the cache.
90
+ */
91
+ export function materializeFilesFile(file, options = {}) {
92
+ const key = file.path;
93
+ const pending = inflight.get(key);
94
+ if (pending) return pending;
95
+ const done = materializeOnce(file, options).finally(() => {
96
+ if (inflight.get(key) === done) inflight.delete(key);
97
+ });
98
+ inflight.set(key, done);
99
+ return done;
100
+ }
@@ -0,0 +1,21 @@
1
+ const MEDIA_KINDS = new Set(["image", "video", "audio"]);
2
+
3
+ export function partitionPreviews(paths, previews) {
4
+ const media = [];
5
+ const files = [];
6
+ const seen = new Set();
7
+ let loading = false;
8
+ for (const original of paths) {
9
+ if (!previews.has(original)) {
10
+ loading = true;
11
+ continue;
12
+ }
13
+ const preview = previews.get(original);
14
+ const path = preview?.path ?? original;
15
+ if (seen.has(path)) continue;
16
+ seen.add(path);
17
+ if (preview !== null && MEDIA_KINDS.has(preview.kind)) media.push(preview);
18
+ else files.push(path);
19
+ }
20
+ return { media, files, loading };
21
+ }
@@ -0,0 +1,342 @@
1
+ export class NullScrollport {
2
+ offset() {
3
+ return null;
4
+ }
5
+
6
+ scrollTo() {}
7
+ }
8
+
9
+ const MAX_TABS = 8;
10
+
11
+ export function fileName(path) {
12
+ const parts = String(path).split(/[/\\]/);
13
+ return parts.at(-1) || path;
14
+ }
15
+
16
+ function initialSnapshot() {
17
+ return {
18
+ mode: "chat",
19
+ tabs: [],
20
+ activePath: null,
21
+ content: { status: "idle" },
22
+ evictedName: null,
23
+ };
24
+ }
25
+
26
+ function errorCode(payload) {
27
+ return payload?.error?.code || "file_preview_failed";
28
+ }
29
+
30
+ export async function fetchPreview(sessionId, path) {
31
+ const query = new URLSearchParams({ sessionId, path });
32
+ const response = await fetch(`/api/lares/file-preview/preview?${query}`);
33
+ const payload = await response.json().catch(() => null);
34
+ if (!response.ok) throw new Error(errorCode(payload));
35
+ return payload;
36
+ }
37
+
38
+ export async function fetchPreviewMap(sessionId, paths) {
39
+ const entries = await Promise.all(paths.map(async (path) => {
40
+ try {
41
+ return [path, await fetchPreview(sessionId, path)];
42
+ } catch {
43
+ return [path, null];
44
+ }
45
+ }));
46
+ return new Map(entries);
47
+ }
48
+
49
+ export function isPrimaryUnmodifiedClick(event) {
50
+ return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
51
+ }
52
+
53
+ export function workspaceLinkClickPath(sessionId, event) {
54
+ if (!isPrimaryUnmodifiedClick(event)) return null;
55
+ const anchor = event.target?.closest?.("a[href]");
56
+ if (!anchor) return null;
57
+ return rawUrlPath(sessionId, anchor.getAttribute("href"));
58
+ }
59
+
60
+ export async function interceptOpenPath(workspace, path, openNative) {
61
+ if (await workspace.openCurrent(path)) return;
62
+ await openNative(path);
63
+ }
64
+
65
+ const RAW_ROUTE = "/api/lares/file-preview/raw";
66
+ const DOWNLOAD_ROUTE = "/api/lares/file-preview/download";
67
+
68
+ export function previewMetaUrl(sessionId, path) {
69
+ const query = new URLSearchParams({ sessionId, path });
70
+ return `/api/lares/file-preview/preview?${query}`;
71
+ }
72
+
73
+ export function rawFileUrl(sessionId, path, modifiedAt) {
74
+ const query = new URLSearchParams({ sessionId, path });
75
+ if (modifiedAt !== undefined) query.set("v", String(modifiedAt));
76
+ return `${RAW_ROUTE}?${query}`;
77
+ }
78
+
79
+ export function downloadFileUrl(sessionId, path) {
80
+ const query = new URLSearchParams({ sessionId, path });
81
+ return `${DOWNLOAD_ROUTE}?${query}`;
82
+ }
83
+
84
+ function pageOrigin() {
85
+ return globalThis.location?.origin ?? "http://localhost";
86
+ }
87
+
88
+ /** Absolute form: markdown targets survive the renderer only as full URLs. */
89
+ export function rawFileHref(sessionId, path) {
90
+ return new URL(rawFileUrl(sessionId, path), pageOrigin()).href;
91
+ }
92
+
93
+ /**
94
+ * The workspace path behind a raw URL this session owns, or null for anything
95
+ * else — the seam that turns a rewritten markdown target back into a preview.
96
+ * The pathname may carry a host-proxy prefix (LarePass `/laresHost`).
97
+ */
98
+ function isRawFilePathname(pathname) {
99
+ return pathname === RAW_ROUTE || pathname.endsWith(RAW_ROUTE);
100
+ }
101
+
102
+ export function rawUrlPath(sessionId, href) {
103
+ let url;
104
+ try {
105
+ url = new URL(href, pageOrigin());
106
+ } catch {
107
+ return null;
108
+ }
109
+ if (url.origin !== pageOrigin() || !isRawFilePathname(url.pathname)) return null;
110
+ if (url.searchParams.get("sessionId") !== sessionId) return null;
111
+ return url.searchParams.get("path");
112
+ }
113
+
114
+ export class FilePreviewWorkspace {
115
+ constructor(scrollport = new NullScrollport()) {
116
+ this.sessions = new Map();
117
+ this.current = null;
118
+ this.scrollport = scrollport;
119
+ }
120
+
121
+ session(sessionId) {
122
+ let state = this.sessions.get(sessionId);
123
+ if (state) return state;
124
+ state = {
125
+ snapshot: initialSnapshot(),
126
+ lru: [],
127
+ contents: new Map(),
128
+ offsets: new Map(),
129
+ listeners: new Set(),
130
+ requestVersions: new Map(),
131
+ chatOffset: null,
132
+ };
133
+ this.sessions.set(sessionId, state);
134
+ return state;
135
+ }
136
+
137
+ getSnapshot(sessionId) {
138
+ return this.session(sessionId).snapshot;
139
+ }
140
+
141
+ subscribe(sessionId, listener) {
142
+ const state = this.session(sessionId);
143
+ state.listeners.add(listener);
144
+ return () => state.listeners.delete(listener);
145
+ }
146
+
147
+ bindCurrent(sessionId) {
148
+ const binding = { sessionId };
149
+ this.current = binding;
150
+ return () => {
151
+ if (this.current === binding) this.current = null;
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Claim a Host open request, or decline it. The preview serves regular files
157
+ * only, so the target is resolved before any tab exists: a directory stays
158
+ * with the Host's own opener instead of becoming a tab that can never load.
159
+ */
160
+ async openCurrent(path) {
161
+ if (!this.current) return false;
162
+ const { sessionId } = this.current;
163
+ const content = await this.fetchContent(sessionId, path);
164
+ if (content.status === "error" && content.message === "path_not_file") return false;
165
+ this.open(sessionId, path, content);
166
+ return true;
167
+ }
168
+
169
+ showChat(sessionId) {
170
+ this.emit(sessionId, { mode: "chat", evictedName: null });
171
+ }
172
+
173
+ /** @param resolved - content the caller already fetched; absent loads it here. */
174
+ open(sessionId, path, resolved) {
175
+ if (resolved?.status === "ready" && resolved.data?.path) path = resolved.data.path;
176
+ const state = this.session(sessionId);
177
+ const existing = state.snapshot.tabs.find((tab) => tab.path === path);
178
+ let tabs = state.snapshot.tabs;
179
+ let evictedName = null;
180
+ if (!existing) {
181
+ if (tabs.length >= MAX_TABS) {
182
+ const victim = state.lru[0];
183
+ const evicted = tabs.find((tab) => tab.path === victim);
184
+ tabs = tabs.filter((tab) => tab.path !== victim);
185
+ state.lru = state.lru.filter((item) => item !== victim);
186
+ state.contents.delete(victim);
187
+ state.offsets.delete(victim);
188
+ state.requestVersions.delete(victim);
189
+ evictedName = evicted?.name ?? null;
190
+ }
191
+ tabs = [...tabs, { path, name: fileName(path) }];
192
+ }
193
+ if (resolved !== undefined) {
194
+ state.contents.set(path, resolved);
195
+ state.requestVersions.set(path, (state.requestVersions.get(path) ?? 0) + 1);
196
+ }
197
+ this.touch(state, path);
198
+ this.emit(sessionId, {
199
+ mode: "preview",
200
+ tabs,
201
+ activePath: path,
202
+ content: state.contents.get(path) ?? { status: "idle" },
203
+ evictedName,
204
+ });
205
+ if (resolved === undefined) void this.load(sessionId, path, true);
206
+ }
207
+
208
+ activate(sessionId, path) {
209
+ const state = this.session(sessionId);
210
+ if (!state.snapshot.tabs.some((tab) => tab.path === path)) return;
211
+ this.touch(state, path);
212
+ this.emit(sessionId, {
213
+ mode: "preview",
214
+ activePath: path,
215
+ content: state.contents.get(path) ?? { status: "idle" },
216
+ evictedName: null,
217
+ });
218
+ void this.load(sessionId, path, true);
219
+ }
220
+
221
+ close(sessionId, path) {
222
+ const state = this.session(sessionId);
223
+ if (!state.snapshot.tabs.some((tab) => tab.path === path)) return;
224
+ const tabs = state.snapshot.tabs.filter((tab) => tab.path !== path);
225
+ state.lru = state.lru.filter((item) => item !== path);
226
+ state.contents.delete(path);
227
+ state.offsets.delete(path);
228
+ state.requestVersions.set(path, (state.requestVersions.get(path) ?? 0) + 1);
229
+ if (tabs.length === 0) {
230
+ this.emit(sessionId, {
231
+ mode: "chat",
232
+ tabs,
233
+ activePath: null,
234
+ content: { status: "idle" },
235
+ evictedName: null,
236
+ });
237
+ return;
238
+ }
239
+ if (state.snapshot.activePath !== path) {
240
+ this.emit(sessionId, { tabs, evictedName: null });
241
+ return;
242
+ }
243
+ const activePath = state.lru.at(-1) ?? tabs.at(-1).path;
244
+ this.emit(sessionId, {
245
+ tabs,
246
+ activePath,
247
+ content: state.contents.get(activePath) ?? { status: "idle" },
248
+ evictedName: null,
249
+ });
250
+ void this.load(sessionId, activePath);
251
+ }
252
+
253
+ clearEvicted(sessionId) {
254
+ this.emit(sessionId, { evictedName: null });
255
+ }
256
+
257
+ retry(sessionId, path) {
258
+ const state = this.session(sessionId);
259
+ state.contents.delete(path);
260
+ void this.load(sessionId, path);
261
+ }
262
+
263
+ touch(state, path) {
264
+ state.lru = state.lru.filter((item) => item !== path);
265
+ state.lru.push(path);
266
+ }
267
+
268
+ /**
269
+ * Where a tab was scrolled to. The scrolling element belongs to whichever kind
270
+ * is on screen, so it does not outlive the tab: the offset lives here for the
271
+ * tab's lifetime instead. It stays out of the snapshot — scrolling must not
272
+ * re-render the view.
273
+ */
274
+ scrollOffset(sessionId, path) {
275
+ return this.session(sessionId).offsets.get(path) ?? 0;
276
+ }
277
+
278
+ rememberScroll(sessionId, path, offset) {
279
+ this.session(sessionId).offsets.set(path, offset);
280
+ }
281
+
282
+ async fetchContent(sessionId, path) {
283
+ try {
284
+ return { status: "ready", data: await fetchPreview(sessionId, path) };
285
+ } catch (error) {
286
+ return {
287
+ status: "error",
288
+ message: error instanceof Error ? error.message : "file_preview_failed",
289
+ };
290
+ }
291
+ }
292
+
293
+ async load(sessionId, path, force = false) {
294
+ const state = this.session(sessionId);
295
+ const current = state.contents.get(path);
296
+ if (!force && (current?.status === "ready" || current?.status === "loading")) return;
297
+ const version = (state.requestVersions.get(path) ?? 0) + 1;
298
+ state.requestVersions.set(path, version);
299
+ if (current?.status !== "ready") {
300
+ state.contents.set(path, { status: "loading" });
301
+ if (state.snapshot.activePath === path) this.emit(sessionId, { content: { status: "loading" } });
302
+ }
303
+ const content = await this.fetchContent(sessionId, path);
304
+ if (state.requestVersions.get(path) !== version) return;
305
+ state.contents.set(path, content);
306
+ if (state.snapshot.activePath === path) this.emit(sessionId, { content });
307
+ }
308
+
309
+ /**
310
+ * Hand the chat scrollport back where the reader left it. Called once the
311
+ * overlay is off screen — earlier the flow is still collapsed and the write
312
+ * would be clamped away again.
313
+ */
314
+ restoreChatScroll(sessionId) {
315
+ const state = this.session(sessionId);
316
+ if (state.chatOffset === null) return;
317
+ const offset = state.chatOffset;
318
+ state.chatOffset = null;
319
+ this.scrollport.scrollTo(offset);
320
+ }
321
+
322
+ /**
323
+ * Drop a captured offset without writing it. Switching sessions unmounts the
324
+ * previous conversation: its scroller is gone, and writing into the new one
325
+ * would jump a different thread.
326
+ */
327
+ abandonChatScroll(sessionId) {
328
+ this.session(sessionId).chatOffset = null;
329
+ }
330
+
331
+ emit(sessionId, patch) {
332
+ const state = this.session(sessionId);
333
+ const next = { ...state.snapshot, ...patch };
334
+ // Mode transitions run ahead of the commit that mounts the overlay, so this
335
+ // is the last moment the reader's own offset is still readable.
336
+ if (state.snapshot.mode === "chat" && next.mode === "preview") {
337
+ state.chatOffset = this.scrollport.offset();
338
+ }
339
+ state.snapshot = next;
340
+ for (const listener of state.listeners) listener();
341
+ }
342
+ }