@olares/lares-core 0.15.1 → 0.15.4

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.
package/brand/identity.js CHANGED
@@ -20,7 +20,9 @@ export function identityPrompt() {
20
20
  `You are ${PRODUCT_NAME}, a helpful assistant running on ${PLATFORM_NAME}.`,
21
21
  `Prefer olares-cli for ${PLATFORM_NAME} platform tasks when skills apply.`,
22
22
  `olares-cli is on PATH; edge login materializes HOME / OLARES_CLI_* for bash when the user opens ${PRODUCT_NAME} via the ${PLATFORM_NAME} entrance.`,
23
+ "For image, video, audio, or 3D generation and creation, follow the lares-media-create skill.",
23
24
  "Use read/write/edit for files; use background jobs for long shell work.",
25
+ "User-visible files belong in the session workspace (cwd) as relative paths. Do not write them under /app: that is the application overlay, and the chat preview cannot open it.",
24
26
  "For the current date or time, run `date`; the process TZ is the user's configured Olares time zone.",
25
27
  "Images the user attaches are already in context; read_image is only for image files that exist on disk, never for an attachment.",
26
28
  `If asked who you are, answer as ${PRODUCT_NAME} on ${PLATFORM_NAME}.`,
@@ -47,7 +49,9 @@ export function agentsMarkdown() {
47
49
  You are helping inside an ${PLATFORM_NAME} workspace via ${PRODUCT_NAME}.
48
50
 
49
51
  - Prefer olares-cli skills for platform tasks (market, cluster, files, router, …).
52
+ - For image / video / audio / 3D generation, follow the lares-media-create skill.
50
53
  - Stay inside the workspace for file edits unless the user explicitly asks otherwise.
54
+ - Write files the user should open as workspace-relative paths (session cwd). \`/app\` is the running application and is not previewable.
51
55
  - Prefer structured fs tools (read / write / edit) over shell for file work.
52
56
  - Long-running shell work can use background jobs; check results with job_output.
53
57
  - \`@path\` in a user message is workspace-relative, not absolute; \`/id\` names a skill.
@@ -59,6 +63,29 @@ You are helping inside an ${PLATFORM_NAME} workspace via ${PRODUCT_NAME}.
59
63
  export const LEGACY_AGENTS_SEEDS = [
60
64
  `# AGENTS.md
61
65
 
66
+ You are helping inside an Olares workspace via Lares.
67
+
68
+ - Prefer olares-cli skills for platform tasks (market, cluster, files, router, …).
69
+ - For image / video / audio / 3D generation, follow the lares-media-create skill.
70
+ - Stay inside the workspace for file edits unless the user explicitly asks otherwise.
71
+ - Prefer structured fs tools (read / write / edit) over shell for file work.
72
+ - Long-running shell work can use background jobs; check results with job_output.
73
+ - \`@path\` in a user message is workspace-relative, not absolute; \`/id\` names a skill.
74
+ - If asked who you are, you are Lares on Olares.
75
+ `,
76
+ `# AGENTS.md
77
+
78
+ You are helping inside an Olares workspace via Lares.
79
+
80
+ - Prefer olares-cli skills for platform tasks (market, cluster, files, router, …).
81
+ - Stay inside the workspace for file edits unless the user explicitly asks otherwise.
82
+ - Prefer structured fs tools (read / write / edit) over shell for file work.
83
+ - Long-running shell work can use background jobs; check results with job_output.
84
+ - \`@path\` in a user message is workspace-relative, not absolute; \`/id\` names a skill.
85
+ - If asked who you are, you are Lares on Olares.
86
+ `,
87
+ `# AGENTS.md
88
+
62
89
  You are helping inside an Olares workspace via Dina.
63
90
 
64
91
  - Prefer olares-cli skills for platform tasks (market, cluster, files, router, …).
package/brand/manifest.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { PRODUCT_NAME, THEME_COLOR } from "./identity.js";
2
- import { MARK_PATH } from "../icons/mark.js";
2
+ import { MARK_PATH, MARK_TYPE } from "../icons/mark.js";
3
3
 
4
4
  export const MANIFEST_PATH = "/lares/manifest.webmanifest";
5
5
 
@@ -11,5 +11,5 @@ export const MANIFEST = {
11
11
  scope: "/",
12
12
  display: "fullscreen",
13
13
  theme_color: THEME_COLOR,
14
- icons: [{ src: MARK_PATH, sizes: "any", type: "image/svg+xml", purpose: "any" }],
14
+ icons: [{ src: MARK_PATH, sizes: "any", type: MARK_TYPE, purpose: "any" }],
15
15
  };
package/drive/paths.js CHANGED
@@ -29,6 +29,9 @@ const DATA_NAMES = new Map([
29
29
  ["audio/aac", "download.aac"],
30
30
  ["audio/flac", "download.flac"],
31
31
  ["application/pdf", "download.pdf"],
32
+ ["model/gltf-binary", "download.glb"],
33
+ ["model/gltf+json", "download.gltf"],
34
+ ["model/obj", "download.obj"],
32
35
  ]);
33
36
 
34
37
  function dataUrlFilename(mediaType) {
@@ -1,4 +1,4 @@
1
- const MEDIA_KINDS = new Set(["image", "video", "audio"]);
1
+ const MEDIA_KINDS = new Set(["image", "video", "audio", "model3d"]);
2
2
 
3
3
  export function partitionPreviews(paths, previews) {
4
4
  const media = [];
package/files/preview.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  resolveExistingWorkspacePath,
8
8
  resolveWorkspaceRoot,
9
9
  workspaceCandidate,
10
+ workspaceFileAlias,
10
11
  } from "../workspace/path.js";
11
12
  import { materializeFilesFile } from "./preview-cache.js";
12
13
 
@@ -36,6 +37,11 @@ const AUDIO_TYPES = new Map([
36
37
  [".aac", "audio/aac"],
37
38
  [".flac", "audio/flac"],
38
39
  ]);
40
+ const MODEL3D_TYPES = new Map([
41
+ [".glb", "model/gltf-binary"],
42
+ [".gltf", "model/gltf+json"],
43
+ [".obj", "model/obj"],
44
+ ]);
39
45
  const TEXT_EXTENSIONS = new Set([
40
46
  "",
41
47
  ".txt", ".log", ".json", ".csv", ".tsv", ".yaml", ".yml", ".toml", ".xml",
@@ -109,6 +115,7 @@ export function previewTypeForName(name) {
109
115
  if (IMAGE_TYPES.has(extension)) return { kind: "image", mediaType: IMAGE_TYPES.get(extension) };
110
116
  if (VIDEO_TYPES.has(extension)) return { kind: "video", mediaType: VIDEO_TYPES.get(extension) };
111
117
  if (AUDIO_TYPES.has(extension)) return { kind: "audio", mediaType: AUDIO_TYPES.get(extension) };
118
+ if (MODEL3D_TYPES.has(extension)) return { kind: "model3d", mediaType: MODEL3D_TYPES.get(extension) };
112
119
  if (extension === ".pdf") return { kind: "pdf", mediaType: "application/pdf" };
113
120
  if (extension === ".md" || extension === ".markdown") {
114
121
  return { kind: "markdown", mediaType: "text/markdown; charset=utf-8" };
@@ -122,8 +129,24 @@ export async function resolveWorkspaceFile(workspacePath, requestedPath) {
122
129
  throw new HttpError("path_invalid", 400, "file path is required");
123
130
  }
124
131
  const root = await resolveWorkspaceRoot(workspacePath);
125
- const candidate = workspaceCandidate(root, requestedPath);
126
- const absolutePath = await resolveExistingWorkspacePath(root, candidate);
132
+ let candidate;
133
+ try {
134
+ candidate = workspaceCandidate(root, requestedPath);
135
+ } catch (error) {
136
+ const alias = workspaceFileAlias(root, requestedPath);
137
+ if (!alias || error?.code !== "path_forbidden") throw error;
138
+ try {
139
+ candidate = workspaceCandidate(root, alias);
140
+ } catch {
141
+ throw error;
142
+ }
143
+ }
144
+ const absolutePath = await resolveExistingWorkspacePath(root, candidate).catch((error) => {
145
+ if (workspaceFileAlias(root, requestedPath) && error?.code === "file_not_found") {
146
+ throw new HttpError("path_forbidden", 403, "path leaves the session workspace");
147
+ }
148
+ throw error;
149
+ });
127
150
  const info = await stat(absolutePath).catch(() => {
128
151
  throw new HttpError("file_not_found", 404, "file was not found");
129
152
  });
@@ -284,12 +307,12 @@ async function sendFile(req, res, file, disposition) {
284
307
  }
285
308
 
286
309
  export async function sendRawFile(req, res, file, deps) {
287
- if (!["image", "video", "audio", "pdf"].includes(file.kind)) {
310
+ if (!["image", "video", "audio", "pdf", "model3d"].includes(file.kind)) {
288
311
  throw new HttpError("preview_unsupported", 415, "raw preview is not supported for this file");
289
312
  }
290
313
  // Video and audio are range-streamed, so their total size is not browser
291
- // memory pressure. Images and PDFs are consumed as whole documents.
292
- if (["image", "pdf"].includes(file.kind) && file.size > MAX_RAW_BYTES) {
314
+ // memory pressure. Images, PDFs, and 3D meshes are consumed as whole documents.
315
+ if (["image", "pdf", "model3d"].includes(file.kind) && file.size > MAX_RAW_BYTES) {
293
316
  throw new HttpError("file_too_large", 413, `file exceeds ${MAX_RAW_BYTES} bytes`);
294
317
  }
295
318
  return sendFile(req, res, await ensurePreviewBytes(file, deps), "inline");
@@ -7,6 +7,31 @@ function errorCode(payload) {
7
7
  : "file_upload_failed";
8
8
  }
9
9
 
10
+ /**
11
+ * `fetch` can only authenticate with the Olares cookie, and a host whose page is
12
+ * cross-origin to the Host — a packaged LarePass desktop build is a `file://`
13
+ * page — has none, so the upload answers 401 while every RPC call succeeds.
14
+ * When the host injects a `request` port the upload rides the same auth as the
15
+ * rest of the client; without one this stays the plain cookie fetch.
16
+ */
17
+ async function postUpload(options, url, headers, body, signal) {
18
+ if (typeof options.request === "function") {
19
+ const res = await options.request(url, { method: "POST", headers, body, signal });
20
+ return {
21
+ ok: Boolean(res?.ok),
22
+ status: Number(res?.status) || 0,
23
+ // The port already parsed the body; an empty one arrives as undefined.
24
+ payload: res?.body ?? null,
25
+ };
26
+ }
27
+ const res = await fetch(url, { method: "POST", headers, body, signal });
28
+ return {
29
+ ok: res.ok,
30
+ status: res.status,
31
+ payload: await res.json().catch(() => null),
32
+ };
33
+ }
34
+
10
35
  export async function uploadFile(file, sessionId, options = {}) {
11
36
  const controller = new AbortController();
12
37
  const timer = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
@@ -14,21 +39,21 @@ export async function uploadFile(file, sessionId, options = {}) {
14
39
  if (options.signal?.aborted) controller.abort(options.signal.reason);
15
40
  else options.signal?.addEventListener("abort", onAbort, { once: true });
16
41
  try {
17
- const response = await fetch(options.url ?? FILES_UPLOAD_PATH, {
18
- method: "POST",
19
- headers: {
42
+ const { ok, status, payload } = await postUpload(
43
+ options,
44
+ options.url ?? FILES_UPLOAD_PATH,
45
+ {
20
46
  "content-type": file.type || "application/octet-stream",
21
47
  "x-lares-file-name": encodeURIComponent(file.name || "file"),
22
48
  "x-lares-session-id": sessionId,
23
49
  "x-lares-upload-request-id": options.requestId ?? crypto.randomUUID(),
24
50
  },
25
- body: file,
26
- signal: controller.signal,
27
- });
28
- const payload = await response.json().catch(() => null);
29
- if (!response.ok) {
51
+ file,
52
+ controller.signal,
53
+ );
54
+ if (!ok) {
30
55
  const error = new Error(errorCode(payload));
31
- error.status = response.status;
56
+ error.status = status;
32
57
  throw error;
33
58
  }
34
59
  if (!payload || typeof payload.path !== "string") throw new Error("file_upload_failed");
package/i18n/preview.js CHANGED
@@ -15,7 +15,8 @@ export const ZH = {
15
15
  retry: "重试",
16
16
  truncated: "文件较大,仅显示前 1 MB 内容。",
17
17
  unsupportedTitle: "暂不支持预览这种文件",
18
- unsupported: "目前支持图片、PDF、音视频、Markdown 和文本文件。",
18
+ unsupported: "目前支持图片、PDF、音视频、3D 模型、Markdown 和文本文件。",
19
+ model3dHint: "3D 预览在电脑端打开。可先下载此文件。",
19
20
  "error.file_not_found": "文件不存在或已被移动",
20
21
  "error.file_changed": "文件在打开时发生变化,请重试",
21
22
  "error.path_not_file": "这个路径不是文件,无法预览",
@@ -44,7 +45,8 @@ export const EN = {
44
45
  retry: "Retry",
45
46
  truncated: "This file is large; only the first 1 MB is shown.",
46
47
  unsupportedTitle: "This file type cannot be previewed yet",
47
- unsupported: "Images, PDF, audio, video, Markdown, and text files are supported.",
48
+ unsupported: "Images, PDF, audio, video, 3D models, Markdown, and text files are supported.",
49
+ model3dHint: "3D preview is available on desktop. You can download this file.",
48
50
  "error.file_not_found": "The file no longer exists",
49
51
  "error.file_changed": "The file changed while it was opening; try again",
50
52
  "error.path_not_file": "This path is not a file, so it cannot be previewed",
package/icons/mark.js CHANGED
@@ -1,21 +1,38 @@
1
1
  import { PRODUCT_NAME } from "../brand/identity.js";
2
2
 
3
3
  /**
4
- * Product mark: favicon, PWA icon, CSS splash, and slot occupants.
5
- * An eave over a resident orb — two strokes, readable at 20px collapsed rail.
4
+ * Product mark: favicon, PWA icon, CSS splash, and slot occupants. Consumers
5
+ * cache this URL, so the path and the SVG encoding are fixed — publishing the
6
+ * artwork at a new path leaves stale favicons behind.
7
+ * Geometry is the design delivery verbatim (ids renamed, aria-label injected).
8
+ * Keep it a vector — favicon rasterizers drop <image> data URIs.
6
9
  */
10
+ export const MARKET_ICON_URL = "https://app.cdn.olares.com/appstore/lares/icon_new.png";
11
+
7
12
  export const MARK_PATH = "/lares/mark.svg";
13
+ export const MARK_TYPE = "image/svg+xml";
8
14
 
9
- export const MARK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="${PRODUCT_NAME}">
15
+ export const MARK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" fill="none" role="img" aria-label="${PRODUCT_NAME}">
10
16
  <defs>
11
- <linearGradient id="lares-mark" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
12
- <stop offset="0" stop-color="#6366F1"/>
13
- <stop offset="1" stop-color="#22D3EE"/>
17
+ <filter id="lares-mark-shadow" x="0" y="0" width="256" height="257.829" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
18
+ <feFlood flood-opacity="0" result="BackgroundImageFix"/>
19
+ <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
20
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
21
+ <feOffset dy="1.82857"/>
22
+ <feGaussianBlur stdDeviation="0.914286"/>
23
+ <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
24
+ <feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/>
25
+ <feBlend mode="normal" in2="shape" result="effect1_innerShadow"/>
26
+ </filter>
27
+ <linearGradient id="lares-mark" x1="128" y1="0" x2="128" y2="256" gradientUnits="userSpaceOnUse">
28
+ <stop stop-color="#FFED52"/>
29
+ <stop offset="1" stop-color="#EBD304"/>
14
30
  </linearGradient>
15
31
  </defs>
16
- <rect width="32" height="32" rx="8" fill="url(#lares-mark)"/>
17
- <path fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" d="M6.8 17.4 16 9.2l9.2 8.2"/>
18
- <circle cx="16" cy="22" r="3.2" fill="#fff"/>
32
+ <g filter="url(#lares-mark-shadow)">
33
+ <rect width="256" height="256" fill="url(#lares-mark)"/>
34
+ <path fill="#252121" d="M83.5926 125.858C85.7382 144.539 100.433 157.223 120.829 157.998L128.063 158.273H142.829C170.407 158.273 186.978 179.868 179.84 206.507H115.676C86.0222 206.506 68.2049 183.286 75.8798 154.643L83.5926 125.858ZM104.576 47.5429C133.193 47.5429 150.386 69.9505 142.98 97.5918L128.319 152.309C99.7025 152.309 82.5086 129.9 89.915 102.259L100.338 63.3618C102.534 55.1648 97.833 48.3464 89.5437 47.7079L87.3945 47.5429H104.576Z"/>
35
+ </g>
19
36
  </svg>`;
20
37
 
21
38
  /** Inline form for CSS `background-image`, which cannot reference a route that may 404 mid-boot. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-core",
3
- "version": "0.15.1",
3
+ "version": "0.15.4",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
@@ -44,6 +44,10 @@
44
44
  "./i18n/preview": "./i18n/preview.js",
45
45
  "./i18n/mobile": "./i18n/mobile.js",
46
46
  "./router/catalog": "./router/catalog.js",
47
+ "./router/catalog-cache": {
48
+ "types": "./router/catalog-cache.d.ts",
49
+ "default": "./router/catalog-cache.js"
50
+ },
47
51
  "./router/gateway": "./router/gateway.js",
48
52
  "./router/models": {
49
53
  "types": "./router/models.d.ts",
@@ -0,0 +1,29 @@
1
+ import type { RouterCatalogRow } from "./catalog.js";
2
+
3
+ export const DEFAULT_TTL_MS: number;
4
+
5
+ export function catalogSeedPath(dataDir?: string): string | null;
6
+ export function writeCatalogSeed(payload: unknown, dataDir?: string): void;
7
+
8
+ export interface CatalogSnapshot {
9
+ payload: unknown;
10
+ rows: RouterCatalogRow[];
11
+ fetchedAt: number;
12
+ }
13
+
14
+ export class CatalogCache {
15
+ constructor(options?: {
16
+ ttlMs?: number;
17
+ fetch?: typeof fetch;
18
+ now?: () => number;
19
+ dataDir?: string;
20
+ });
21
+ reset(): void;
22
+ invalidate(): void;
23
+ seed(payload: unknown): void;
24
+ snapshot(): CatalogSnapshot;
25
+ restore(snap: CatalogSnapshot | { payload: unknown; rows?: RouterCatalogRow[]; fetchedAt?: number } | null): void;
26
+ get(): Promise<CatalogSnapshot>;
27
+ }
28
+
29
+ export const catalogCache: CatalogCache;
@@ -0,0 +1,144 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { routerCatalogRows } from "./catalog.js";
4
+ import { routerGatewayUrl, routerHeaders } from "./gateway.js";
5
+
6
+ export const DEFAULT_TTL_MS = 15_000;
7
+
8
+ export function catalogSeedPath(dataDir = process.env.LARES_DATA_DIR?.trim()) {
9
+ return dataDir ? join(dataDir, "router-catalog.json") : null;
10
+ }
11
+
12
+ export function writeCatalogSeed(payload, dataDir) {
13
+ const path = catalogSeedPath(dataDir);
14
+ if (!path || payload == null || typeof payload !== "object") return;
15
+ writeFileSync(path, JSON.stringify(payload), { mode: 0o600 });
16
+ }
17
+
18
+ function readCatalogSeed(dataDir) {
19
+ const path = catalogSeedPath(dataDir);
20
+ if (!path) return null;
21
+ try {
22
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
23
+ if (parsed && typeof parsed === "object") return parsed;
24
+ } catch {
25
+ // Missing or unreadable seed is a cold start, not a failure.
26
+ }
27
+ return null;
28
+ }
29
+
30
+ /**
31
+ * In-process Router /models cache. TTL plus coalesced upstream fetches; a
32
+ * seed file lets the dsh child reuse the boot process's catalog read.
33
+ */
34
+ export class CatalogCache {
35
+ /**
36
+ * @param {{
37
+ * ttlMs?: number,
38
+ * fetch?: typeof fetch,
39
+ * now?: () => number,
40
+ * dataDir?: string,
41
+ * }} [options]
42
+ */
43
+ constructor(options = {}) {
44
+ this.ttlMs = options.ttlMs && options.ttlMs > 0 ? options.ttlMs : DEFAULT_TTL_MS;
45
+ this.fetchImpl = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
46
+ this.now = options.now ?? Date.now;
47
+ this.dataDir = options.dataDir;
48
+ this.reset();
49
+ }
50
+
51
+ reset() {
52
+ this.payload = null;
53
+ this.rows = [];
54
+ this.fetchedAt = 0;
55
+ this.inflight = null;
56
+ this.primed = false;
57
+ }
58
+
59
+ invalidate() {
60
+ this.fetchedAt = 0;
61
+ }
62
+
63
+ /**
64
+ * @param {unknown} payload
65
+ */
66
+ seed(payload) {
67
+ this.primed = true;
68
+ this.accept(payload);
69
+ writeCatalogSeed(payload, this.dataDir);
70
+ }
71
+
72
+ snapshot() {
73
+ return { payload: this.payload, rows: this.rows, fetchedAt: this.fetchedAt };
74
+ }
75
+
76
+ /**
77
+ * Revert to a prior snapshot after a later consumer (settings, default
78
+ * selection) failed so search / STT / chat keep one catalog.
79
+ * @param {{ payload: unknown, rows?: unknown[], fetchedAt?: number } | null} snap
80
+ */
81
+ restore(snap) {
82
+ if (snap?.payload == null) {
83
+ this.payload = null;
84
+ this.rows = [];
85
+ this.fetchedAt = 0;
86
+ return;
87
+ }
88
+ this.payload = snap.payload;
89
+ this.rows = Array.isArray(snap.rows) ? snap.rows : routerCatalogRows(snap.payload);
90
+ this.fetchedAt = Number.isFinite(snap.fetchedAt) ? snap.fetchedAt : 0;
91
+ this.primed = true;
92
+ writeCatalogSeed(snap.payload, this.dataDir);
93
+ }
94
+
95
+ async get() {
96
+ if (this.payload && this.fetchedAt !== 0 && this.now() - this.fetchedAt < this.ttlMs) {
97
+ return this.snapshot();
98
+ }
99
+ if (this.inflight) return this.inflight;
100
+ const pending = this.load();
101
+ const shared = pending.finally(() => {
102
+ if (this.inflight === shared) this.inflight = null;
103
+ });
104
+ this.inflight = shared;
105
+ return shared;
106
+ }
107
+
108
+ async load() {
109
+ if (!this.primed) {
110
+ this.primed = true;
111
+ const seeded = readCatalogSeed(this.dataDir);
112
+ if (seeded) {
113
+ this.accept(seeded);
114
+ return this.snapshot();
115
+ }
116
+ }
117
+ const response = await this.fetchImpl(`${routerGatewayUrl()}/models?include_not_ready=true`, {
118
+ method: "GET",
119
+ headers: routerHeaders(),
120
+ signal: AbortSignal.timeout(15_000),
121
+ });
122
+ if (!response.ok) {
123
+ throw Object.assign(new Error(`Router /models returned ${response.status}`), {
124
+ code: "router_unavailable",
125
+ status: 503,
126
+ });
127
+ }
128
+ const payload = await response.json();
129
+ this.accept(payload);
130
+ writeCatalogSeed(payload, this.dataDir);
131
+ return this.snapshot();
132
+ }
133
+
134
+ /**
135
+ * @param {unknown} payload
136
+ */
137
+ accept(payload) {
138
+ this.payload = payload;
139
+ this.rows = routerCatalogRows(payload);
140
+ this.fetchedAt = this.now();
141
+ }
142
+ }
143
+
144
+ export const catalogCache = new CatalogCache();
@@ -1,20 +1,41 @@
1
1
  import { createInFlightCoalescer } from "../tools/async.js";
2
+ import { catalogCache } from "./catalog-cache.js";
2
3
  import {
3
4
  LLM_SETTINGS_NS,
4
5
  ROUTER_PROVIDER_ID,
5
6
  catalogSettingsOps,
7
+ chatModelsFromRouterCatalog,
6
8
  defaultNeedsRepair,
7
- fetchChatModels,
8
9
  listedCatalog,
9
10
  parseDefaultModelRequest,
10
11
  pickDefaultModel,
11
12
  unregisteredRouterFailure,
12
13
  } from "./models.js";
14
+ import { forgetSttModel } from "./stt.js";
13
15
 
14
16
  function messageOf(err) {
15
17
  return err instanceof Error ? err.message : String(err);
16
18
  }
17
19
 
20
+ let adapterRevision = 0;
21
+ /** @type {Set<(revision: number) => void>} */
22
+ const revisionListeners = new Set();
23
+
24
+ export function catalogRevision() {
25
+ return adapterRevision;
26
+ }
27
+
28
+ /** @param {(revision: number) => void} listener */
29
+ export function onCatalogRevision(listener) {
30
+ revisionListeners.add(listener);
31
+ return () => revisionListeners.delete(listener);
32
+ }
33
+
34
+ function bumpRevision() {
35
+ adapterRevision += 1;
36
+ for (const listener of revisionListeners) listener(adapterRevision);
37
+ }
38
+
18
39
  /**
19
40
  * @param {{
20
41
  * mutateSettings: (ns: string, ops: unknown[]) => Promise<void>,
@@ -23,13 +44,23 @@ function messageOf(err) {
23
44
  * }} ports
24
45
  */
25
46
  async function performRefresh(ports) {
26
- const models = await fetchChatModels();
27
- await ports.mutateSettings(LLM_SETTINGS_NS, catalogSettingsOps(models));
28
- const current = ports.currentSelection();
29
- if (defaultNeedsRepair(current, models)) {
30
- await ports.saveSelection({ provider: ROUTER_PROVIDER_ID, model: pickDefaultModel(models).id });
47
+ const previous = catalogCache.snapshot();
48
+ catalogCache.invalidate();
49
+ try {
50
+ const { payload } = await catalogCache.get();
51
+ const models = chatModelsFromRouterCatalog(payload);
52
+ await ports.mutateSettings(LLM_SETTINGS_NS, catalogSettingsOps(models));
53
+ const current = ports.currentSelection();
54
+ if (defaultNeedsRepair(current, models)) {
55
+ await ports.saveSelection({ provider: ROUTER_PROVIDER_ID, model: pickDefaultModel(models).id });
56
+ }
57
+ forgetSttModel();
58
+ bumpRevision();
59
+ return models;
60
+ } catch (err) {
61
+ catalogCache.restore(previous);
62
+ throw err;
31
63
  }
32
- return models;
33
64
  }
34
65
 
35
66
  const coalesceRefresh = createInFlightCoalescer();
@@ -83,5 +114,10 @@ export async function saveDefault(ports, request) {
83
114
  }
84
115
 
85
116
  export function catalogState(ports, listed) {
86
- return { default: currentDefault(ports), models: listed.models, failures: listed.failures };
117
+ return {
118
+ default: currentDefault(ports),
119
+ models: listed.models,
120
+ failures: listed.failures,
121
+ revision: catalogRevision(),
122
+ };
87
123
  }
@@ -13,4 +13,5 @@ export function fetchRouterModels(env: {
13
13
  routerUrl?: string | null;
14
14
  routerApiKey?: string | null;
15
15
  olaresAppId?: string | null;
16
+ dataDir?: string | null;
16
17
  }): Promise<RouterModelEntry[]>;
package/router/models.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { writeCatalogSeed } from "./catalog-cache.js";
1
2
  import { routerCatalogRows } from "./catalog.js";
2
3
  import { routerAuthHeaders, routerGatewayUrl, routerShimBaseUrl } from "./gateway.js";
3
4
 
@@ -72,7 +73,7 @@ export async function fetchChatModels() {
72
73
  */
73
74
  export async function fetchRouterModels(env) {
74
75
  const routerUrl = String(env.routerUrl ?? "").replace(/\/+$/, "") || routerGatewayUrl();
75
- const res = await fetch(`${routerUrl}/models`, {
76
+ const res = await fetch(`${routerUrl}/models?include_not_ready=true`, {
76
77
  method: "GET",
77
78
  headers: {
78
79
  ...routerAuthHeaders(env.routerApiKey, env.olaresAppId),
@@ -81,7 +82,9 @@ export async function fetchRouterModels(env) {
81
82
  signal: AbortSignal.timeout(15_000),
82
83
  });
83
84
  if (!res.ok) throw new Error(`Router /models returned ${res.status}`);
84
- return modelsFromRouterCatalog(await res.json());
85
+ const payload = await res.json();
86
+ writeCatalogSeed(payload, env.dataDir);
87
+ return modelsFromRouterCatalog(payload);
85
88
  }
86
89
 
87
90
  export function parseDefaultModelRequest(request) {
package/router/search.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { catalogCache } from "./catalog-cache.js";
1
2
  import { routerCatalogRows } from "./catalog.js";
2
3
  import { routerGatewayUrl, routerHeaders } from "./gateway.js";
3
4
 
@@ -57,18 +58,8 @@ export function searchSourcesFromRouter(payload) {
57
58
  const DEFAULT_TIMEOUT_MS = 20_000;
58
59
 
59
60
  export async function fetchRouterSearchModels() {
60
- const response = await fetch(`${routerGatewayUrl()}/models`, {
61
- method: "GET",
62
- headers: routerHeaders(),
63
- signal: AbortSignal.timeout(15_000),
64
- });
65
- if (!response.ok) {
66
- throw Object.assign(new Error(`Router /models returned ${response.status}`), {
67
- code: "router_unavailable",
68
- status: 503,
69
- });
70
- }
71
- return searchModelsFromRouterCatalog(await response.json());
61
+ const { payload } = await catalogCache.get();
62
+ return searchModelsFromRouterCatalog(payload);
72
63
  }
73
64
 
74
65
  /**
package/router/shim.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { request as httpRequest } from "node:http";
2
2
  import { request as httpsRequest } from "node:https";
3
- import { readBody, sendError } from "../tools/http.js";
3
+ import { readBody, sendError, sendJson } from "../tools/http.js";
4
+ import { catalogCache } from "./catalog-cache.js";
4
5
  import { carriesWebpImage, transcodeWebpImages } from "../media/router-images.js";
5
6
  import { routerAuthHeaders, routerGatewayUrl } from "./gateway.js";
6
7
  import { STT_MAX_AUDIO_BYTES } from "./stt.js";
@@ -42,7 +43,9 @@ export function shimBudget(suffix) {
42
43
  };
43
44
  }
44
45
 
46
+ /** @returns {Record<string, string>} */
45
47
  export function shimRequestHeaders(incoming, env = process.env) {
48
+ /** @type {Record<string, string>} */
46
49
  const headers = {};
47
50
  for (const [key, value] of Object.entries(incoming)) {
48
51
  if (value == null || DROPPED_REQ.has(key.toLowerCase())) continue;
@@ -75,7 +78,72 @@ export function healthPayload(env = process.env) {
75
78
  };
76
79
  }
77
80
 
81
+ function isModelsGet(req) {
82
+ const method = (req.method ?? "GET").toUpperCase();
83
+ if (method !== "GET" && method !== "HEAD") return false;
84
+ const u = new URL(req.url ?? "/", "http://x");
85
+ return llmShimSuffix(u.pathname).replace(/\/+$/, "") === "models";
86
+ }
87
+
88
+ function serveCachedModels(req, res) {
89
+ void catalogCache.get().then(
90
+ ({ payload }) => {
91
+ if (res.headersSent) {
92
+ res.destroy();
93
+ return;
94
+ }
95
+ if ((req.method ?? "GET").toUpperCase() === "HEAD") {
96
+ res.writeHead(200, { "content-type": "application/json" });
97
+ res.end();
98
+ return;
99
+ }
100
+ sendJson(res, 200, payload);
101
+ },
102
+ (err) => {
103
+ if (res.headersSent) {
104
+ res.destroy();
105
+ return;
106
+ }
107
+ sendError(res, err, "llm_proxy_failed");
108
+ },
109
+ );
110
+ }
111
+
112
+ function downstreamCancellation(req, res) {
113
+ const controller = new AbortController();
114
+ let finished = false;
115
+
116
+ const cleanup = () => {
117
+ req.off("aborted", cancel);
118
+ res.off("finish", finish);
119
+ res.off("close", close);
120
+ };
121
+ const cancel = () => {
122
+ if (!controller.signal.aborted) {
123
+ controller.abort(new Error("LLM downstream disconnected"));
124
+ }
125
+ cleanup();
126
+ };
127
+ const finish = () => {
128
+ finished = true;
129
+ cleanup();
130
+ };
131
+ const close = () => {
132
+ if (!finished) cancel();
133
+ else cleanup();
134
+ };
135
+
136
+ req.once("aborted", cancel);
137
+ res.once("finish", finish);
138
+ res.once("close", close);
139
+ return controller;
140
+ }
141
+
78
142
  export function proxyToRouter(req, res, env = process.env) {
143
+ if (isModelsGet(req)) {
144
+ serveCachedModels(req, res);
145
+ return;
146
+ }
79
147
  const routerUrl = routerGatewayUrl(env);
80
148
  const rawUrl = req.url ?? "/";
81
149
  const u = new URL(rawUrl, "http://x");
@@ -85,6 +153,7 @@ export function proxyToRouter(req, res, env = process.env) {
85
153
  const headers = shimRequestHeaders(req.headers, env);
86
154
  const transport = target.protocol === "https:" ? httpsRequest : httpRequest;
87
155
  const method = (req.method ?? "GET").toUpperCase();
156
+ const cancellation = downstreamCancellation(req, res);
88
157
 
89
158
  const run = async () => {
90
159
  let body;
@@ -96,16 +165,22 @@ export function proxyToRouter(req, res, env = process.env) {
96
165
  if (!budget.audio && carriesWebpImage(body)) body = await transcodeWebpImages(body);
97
166
  headers["content-length"] = String(body.length);
98
167
  }
168
+ if (cancellation.signal.aborted) return;
99
169
 
100
170
  const upstream = transport(
101
171
  target,
102
- { method, headers, timeout: budget.timeoutMs },
172
+ { method, headers, timeout: budget.timeoutMs, signal: cancellation.signal },
103
173
  (up) => {
174
+ if (cancellation.signal.aborted) {
175
+ up.destroy();
176
+ return;
177
+ }
104
178
  res.writeHead(up.statusCode ?? 502, shimResponseHeaders(up.headers));
105
179
  up.pipe(res);
106
180
  },
107
181
  );
108
182
  upstream.on("error", (err) => {
183
+ if (cancellation.signal.aborted) return;
109
184
  if (res.headersSent) {
110
185
  res.destroy();
111
186
  return;
@@ -125,6 +200,7 @@ export function proxyToRouter(req, res, env = process.env) {
125
200
  };
126
201
 
127
202
  void run().catch((err) => {
203
+ if (cancellation.signal.aborted) return;
128
204
  if (res.headersSent) {
129
205
  res.destroy();
130
206
  return;
package/router/stt.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { createInFlightCoalescer } from "../tools/async.js";
3
- import { routerCatalogRows } from "./catalog.js";
2
+ import { catalogCache } from "./catalog-cache.js";
4
3
  import { routerShimBaseUrl } from "./gateway.js";
5
4
 
6
5
  const STT_HINTS = /whisper|\bstt\b|\basr\b|transcri/i;
@@ -67,30 +66,23 @@ export function audioFilenameForContentType(contentType) {
67
66
  }
68
67
 
69
68
  const REQUEST_TIMEOUT_MS = 180_000;
70
- const CATALOG_TIMEOUT_MS = 15_000;
71
69
  const RETRY_BACKOFF_MS = [1_000, 3_000];
72
70
  const RESOLVED_TTL_MS = 120_000;
73
71
 
74
- const coalesceCatalog = createInFlightCoalescer();
75
- /** @type {{ expires: number, rows: { id: string, mode: string | null }[] } | null} */
76
- let catalog = null;
77
-
78
- async function fetchCatalog() {
79
- const res = await fetch(`${routerShimBaseUrl()}/models`, {
80
- headers: { accept: "application/json" },
81
- signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS),
82
- });
83
- if (!res.ok) throw new VoiceError("voice_model_unavailable", 503, `Router /models returned ${res.status}`);
84
- const rows = routerCatalogRows(await res.json()).map(({ id, mode }) => ({ id, mode }));
85
- catalog = { expires: Date.now() + RESOLVED_TTL_MS, rows };
86
- return rows;
87
- }
88
-
89
72
  /** @returns {Promise<{ id: string, mode: string | null }[]>} */
90
73
  export async function listModels(options) {
91
- if (!options?.refresh && catalog && catalog.expires > Date.now()) return catalog.rows;
92
- if (options?.refresh) return fetchCatalog();
93
- return coalesceCatalog(fetchCatalog);
74
+ try {
75
+ if (options?.refresh) catalogCache.invalidate();
76
+ const { rows } = await catalogCache.get();
77
+ return rows.map(({ id, mode }) => ({ id, mode }));
78
+ } catch (err) {
79
+ const status = err && typeof err === "object" && "status" in err ? Number(err.status) : 503;
80
+ throw new VoiceError(
81
+ "voice_model_unavailable",
82
+ status || 503,
83
+ err instanceof Error ? err.message : String(err),
84
+ );
85
+ }
94
86
  }
95
87
 
96
88
  /** @returns {Promise<string[]>} */
@@ -119,7 +111,6 @@ export async function resolveSttModel(preferred, options) {
119
111
 
120
112
  export function forgetSttModel() {
121
113
  resolved = null;
122
- catalog = null;
123
114
  }
124
115
 
125
116
  /** @param {Record<string, string>} fields @param {{ filename: string, contentType: string, bytes: Buffer }} file */
package/voice/client.js CHANGED
@@ -62,23 +62,39 @@ export function mergeTranscript(draft, transcript) {
62
62
  return `${base} ${text}`;
63
63
  }
64
64
 
65
+ function transcribeErrorCode(payload) {
66
+ const code = payload && typeof payload === "object" ? payload.error?.code : undefined;
67
+ return typeof code === "string" ? code : "voice_failed";
68
+ }
69
+
70
+ /**
71
+ * The only voice call that carries a body, and so the only one that cannot go
72
+ * through `createHostSettings`. Like the upload it must accept the host's
73
+ * `request` port: a cross-origin host page sends no Olares cookie, so a plain
74
+ * `fetch` here answers 401 even though `/config`, `/status` and `/models` — which
75
+ * do ride the port — all succeed.
76
+ */
65
77
  export async function postTranscribe(blob, language, signal, options = {}) {
66
78
  const query = language ? `?language=${encodeURIComponent(language)}` : "";
67
79
  const base = String(options.baseUrl ?? API).replace(/\/$/, "");
68
- const res = await fetch(`${base}/transcribe${query}`, {
69
- method: "POST",
70
- headers: { "content-type": blob.type || "audio/webm" },
71
- body: blob,
72
- signal,
73
- });
80
+ const url = `${base}/transcribe${query}`;
81
+ const headers = { "content-type": blob.type || "audio/webm" };
82
+
83
+ if (typeof options.request === "function") {
84
+ const res = await options.request(url, { method: "POST", headers, body: blob, signal });
85
+ if (!res?.ok) throw new Error(transcribeErrorCode(res?.body));
86
+ return String(res?.body?.text ?? "").trim();
87
+ }
88
+
89
+ const res = await fetch(url, { method: "POST", headers, body: blob, signal });
74
90
  if (!res.ok) {
75
- let code = "voice_failed";
91
+ let payload = null;
76
92
  try {
77
- code = (await res.json())?.error?.code ?? code;
93
+ payload = await res.json();
78
94
  } catch {
79
- /* keep default */
95
+ /* fall back to the generic code */
80
96
  }
81
- throw new Error(code);
97
+ throw new Error(transcribeErrorCode(payload));
82
98
  }
83
99
  return String((await res.json())?.text ?? "").trim();
84
100
  }
package/workspace/path.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { lstatSync } from "node:fs";
2
2
  import { mkdir, realpath, stat } from "node:fs/promises";
3
- import { isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
4
  import { HttpError } from "../tools/http.js";
5
5
 
6
6
  export function isInsideWorkspace(root, target) {
@@ -14,6 +14,21 @@ export async function resolveWorkspaceRoot(workspacePath) {
14
14
  });
15
15
  }
16
16
 
17
+ /**
18
+ * Preview only serves the session workspace. Agents sometimes write
19
+ * `/app/name.ext` (the application overlay) while the open chip still points
20
+ * there; alias that basename into the workspace. Nested overlay paths
21
+ * (`/app/packages/x`) and any other filesystem root (`/tmp/x`) stay forbidden.
22
+ */
23
+ export function workspaceFileAlias(root, requestedPath) {
24
+ if (typeof requestedPath !== "string" || !isAbsolute(requestedPath)) return null;
25
+ if (isInsideWorkspace(root, requestedPath)) return null;
26
+ if (dirname(requestedPath) !== "/app") return null;
27
+ const base = basename(requestedPath);
28
+ if (!base || base === "." || base === "..") return null;
29
+ return base;
30
+ }
31
+
17
32
  export function workspaceCandidate(root, requestedPath) {
18
33
  const candidate = resolve(root, requestedPath);
19
34
  if (!isInsideWorkspace(root, candidate)) {