@olares/lares-core 0.15.0 → 0.15.2

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/README.md CHANGED
@@ -13,7 +13,7 @@ Import surfaces (`@olares/lares-core/<export>`):
13
13
  - `drive/*`: path policy, tool execution, present cards, and agent tool definitions.
14
14
  - `voice/*`, `search/*`: STT/search settings, Host payloads, and client API contracts.
15
15
  - `olares/*`: Olares entrance identity, trusted-host loopback, and CLI session materialization.
16
- - `larepass/host`: find the Lares entrance from LarePass `myApps`, PC-test proxy URLs, Host probe, and `hostConfigFromEnv`.
16
+ - `larepass/host`: find the Lares entrance from LarePass `myApps`, PC-test proxy URLs, Host probe, `hostConfigFromEnv` / `hostTarget` / `hostKey`, and `laresPortsFromAccount`.
17
17
  - `larepass/rpc`, `larepass/transcript`, `larepass/context-provenance`, `larepass/tool-row`, `larepass/stage-copy`, `larepass/chat`, `larepass/mux`, `larepass/session-cache`, `larepass/runtime`, `larepass/settings`: dsh host-RPC envelope, session timeline fold (user / context / reasoning / tool / retry / files; same classification as dsh conversation nodes), English stage chrome copy and tool-row variants, browser mux as WebSocket (same frames as PC), per-session page cache (switch reads cache; history/mux write only on change), the LarePass chat runtime (UI only subscribes), and Host HTTP for model / voice / search settings.
18
18
  - `brand/identity`, `brand/manifest`: product identity and PWA manifest consumed by the Host and clients.
19
19
  - `icons/*`: product mark SVG and public icon paths (no React).
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/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");
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/larepass/host.js CHANGED
@@ -45,6 +45,33 @@ export function hostConfigFromEnv(env = {}) {
45
45
  };
46
46
  }
47
47
 
48
+ /** Resolve the live Host origin. Explicit baseUrl/proxyPrefix win over env. */
49
+ export function hostTarget(ports = {}) {
50
+ const fromEnv = hostConfigFromEnv(ports.env);
51
+ return {
52
+ baseUrl: ports.baseUrl ?? fromEnv.baseUrl,
53
+ proxyPrefix: ports.proxyPrefix ?? fromEnv.proxyPrefix,
54
+ };
55
+ }
56
+
57
+ export function hostKey(ports = {}) {
58
+ const { baseUrl, proxyPrefix } = hostTarget(ports);
59
+ return hostUrl({ baseUrl, proxyPrefix, path: "/api" });
60
+ }
61
+
62
+ /**
63
+ * LarePass → Host ports. PC preview keeps the webpack env + /laresHost proxy.
64
+ * A logged-in account uses the current myApps entrance, never a baked subdomain.
65
+ */
66
+ export function laresPortsFromAccount({ env, apps } = {}) {
67
+ const fromEnv = hostConfigFromEnv(env);
68
+ if (fromEnv.proxyPrefix) {
69
+ return { env, proxyPrefix: fromEnv.proxyPrefix, baseUrl: undefined };
70
+ }
71
+ const baseUrl = findLaresEntrance(apps);
72
+ return { baseUrl: baseUrl || undefined, env: undefined, proxyPrefix: undefined };
73
+ }
74
+
48
75
  export function hostUrl({ baseUrl = "", proxyPrefix = "", path }) {
49
76
  const suffix = path.startsWith("/") ? path : `/${path}`;
50
77
  if (proxyPrefix) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-core",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
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";
@@ -75,7 +76,42 @@ export function healthPayload(env = process.env) {
75
76
  };
76
77
  }
77
78
 
79
+ function isModelsGet(req) {
80
+ const method = (req.method ?? "GET").toUpperCase();
81
+ if (method !== "GET" && method !== "HEAD") return false;
82
+ const u = new URL(req.url ?? "/", "http://x");
83
+ return llmShimSuffix(u.pathname).replace(/\/+$/, "") === "models";
84
+ }
85
+
86
+ function serveCachedModels(req, res) {
87
+ void catalogCache.get().then(
88
+ ({ payload }) => {
89
+ if (res.headersSent) {
90
+ res.destroy();
91
+ return;
92
+ }
93
+ if ((req.method ?? "GET").toUpperCase() === "HEAD") {
94
+ res.writeHead(200, { "content-type": "application/json" });
95
+ res.end();
96
+ return;
97
+ }
98
+ sendJson(res, 200, payload);
99
+ },
100
+ (err) => {
101
+ if (res.headersSent) {
102
+ res.destroy();
103
+ return;
104
+ }
105
+ sendError(res, err, "llm_proxy_failed");
106
+ },
107
+ );
108
+ }
109
+
78
110
  export function proxyToRouter(req, res, env = process.env) {
111
+ if (isModelsGet(req)) {
112
+ serveCachedModels(req, res);
113
+ return;
114
+ }
79
115
  const routerUrl = routerGatewayUrl(env);
80
116
  const rawUrl = req.url ?? "/";
81
117
  const u = new URL(rawUrl, "http://x");
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/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)) {