@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
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # @olares/lares-core
2
+
3
+ Business rules live here once. `packages/web` and `packages/mobile` only render
4
+ and bind UI; they must not grow a second copy of catalog, path, media, or
5
+ identity policy.
6
+
7
+ Import surfaces (`@olares/lares-core/<export>`):
8
+
9
+ - `tools/*`: HTTP helpers, JSON file IO, in-flight coalescing, insecure-origin `randomUUID` shim.
10
+ - `router/*`: Router transport contracts, catalog normalization, LLM shim, model / STT / search policy, catalog sync, session model switching, and search error codes.
11
+ - `workspace/*`: workspace boundaries, default seed, and dsh session-to-workspace resolution.
12
+ - `files/*`: intake, preview workspace, markdown rewrite, Host upload/preview HTTP.
13
+ - `drive/*`: path policy, tool execution, present cards, and agent tool definitions.
14
+ - `voice/*`, `search/*`: STT/search settings, Host payloads, and client API contracts.
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`.
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
+ - `brand/identity`, `brand/manifest`: product identity and PWA manifest consumed by the Host and clients.
19
+ - `icons/*`: product mark SVG and public icon paths (no React).
20
+ - `i18n/*`: ZH/EN catalogs and `t` / `{name}` interpolation (no React, no dsh locale), including the LarePass mobile shell.
21
+
22
+ Core may depend on Node.js and backend libraries, but never on `packages/web`,
23
+ `packages/mobile`, React, or browser UI.
@@ -0,0 +1,9 @@
1
+ export const PRODUCT_NAME: string;
2
+ export const PLATFORM_NAME: string;
3
+ export const UPSTREAM_TITLE: string;
4
+ export const THEME_COLOR: string;
5
+ export function replaceProductTitle(title: string): string;
6
+ export function identityPrompt(): string;
7
+ export function surfacePrompt(webUrl: string): string;
8
+ export function agentsMarkdown(): string;
9
+ export const LEGACY_AGENTS_SEEDS: readonly string[];
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Product identity. Wordmark, LLM persona, AGENTS.md, and the web-surface
3
+ * prompt all read from here — change this file, not the call sites.
4
+ */
5
+
6
+ export const PRODUCT_NAME = "Lares";
7
+ export const PLATFORM_NAME = "Olares";
8
+ /** dsh document.title needle. Client rewrites it; never ship this as our copy. */
9
+ export const UPSTREAM_TITLE = "DeepSeek Harness";
10
+
11
+ export const THEME_COLOR = "#6366F1";
12
+
13
+ export function replaceProductTitle(title) {
14
+ return String(title ?? "").split(UPSTREAM_TITLE).join(PRODUCT_NAME);
15
+ }
16
+
17
+ /** Model-facing identity. Affirm the product; do not name the upstream kernel. */
18
+ export function identityPrompt() {
19
+ return [
20
+ `You are ${PRODUCT_NAME}, a helpful assistant running on ${PLATFORM_NAME}.`,
21
+ `Prefer olares-cli for ${PLATFORM_NAME} platform tasks when skills apply.`,
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
+ "Use read/write/edit for files; use background jobs for long shell work.",
24
+ "For the current date or time, run `date`; the process TZ is the user's configured Olares time zone.",
25
+ "Images the user attaches are already in context; read_image is only for image files that exist on disk, never for an attachment.",
26
+ `If asked who you are, answer as ${PRODUCT_NAME} on ${PLATFORM_NAME}.`,
27
+ ].join(" ");
28
+ }
29
+
30
+ /**
31
+ * Browser-surface orientation. The Host owns this paragraph; keep internals
32
+ * (HMR / checkout) out of the model prompt.
33
+ * @param {string} webUrl
34
+ */
35
+ export function surfacePrompt(webUrl) {
36
+ return [
37
+ `You are interacting with the user through ${PRODUCT_NAME} at ${webUrl}.`,
38
+ `When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean ${PRODUCT_NAME}.`,
39
+ "The browser provides no implicit DOM, route, or screenshot context.",
40
+ `If asked who you are, answer as ${PRODUCT_NAME} on ${PLATFORM_NAME}.`,
41
+ ].join(" ");
42
+ }
43
+
44
+ export function agentsMarkdown() {
45
+ return `# AGENTS.md
46
+
47
+ You are helping inside an ${PLATFORM_NAME} workspace via ${PRODUCT_NAME}.
48
+
49
+ - Prefer olares-cli skills for platform tasks (market, cluster, files, router, …).
50
+ - Stay inside the workspace for file edits unless the user explicitly asks otherwise.
51
+ - Prefer structured fs tools (read / write / edit) over shell for file work.
52
+ - Long-running shell work can use background jobs; check results with job_output.
53
+ - \`@path\` in a user message is workspace-relative, not absolute; \`/id\` names a skill.
54
+ - If asked who you are, you are ${PRODUCT_NAME} on ${PLATFORM_NAME}.
55
+ `;
56
+ }
57
+
58
+ /** Previous seeded AGENTS.md texts. Rewrite in place so old workspaces pick up the brand. */
59
+ export const LEGACY_AGENTS_SEEDS = [
60
+ `# AGENTS.md
61
+
62
+ You are helping inside an Olares workspace via Dina.
63
+
64
+ - Prefer olares-cli skills for platform tasks (market, cluster, files, router, …).
65
+ - Stay inside the workspace for file edits unless the user explicitly asks otherwise.
66
+ - Prefer structured fs tools (read / write / edit) over shell for file work.
67
+ - Long-running shell work can use background jobs; check results with job_output.
68
+ - \`@path\` in a user message is workspace-relative, not absolute; \`/id\` names a skill.
69
+ - If asked who you are, you are Dina on Olares, not DeepSeek Harness.
70
+ `,
71
+ `# AGENTS.md
72
+
73
+ You are helping inside an Olares workspace via Lares.
74
+
75
+ - Prefer olares-cli skills for platform tasks (market, cluster, files, router, …).
76
+ - Stay inside the workspace for file edits unless the user explicitly asks otherwise.
77
+ - Prefer structured fs tools (read / write / edit) over shell for file work.
78
+ - Long-running shell work can use background jobs; check results with job_output.
79
+ - \`@path\` in a user message is workspace-relative, not absolute; \`/id\` names a skill.
80
+ - If asked who you are, you are Lares on Olares, not DeepSeek Harness.
81
+ `,
82
+ ];
@@ -0,0 +1,15 @@
1
+ import { PRODUCT_NAME, THEME_COLOR } from "./identity.js";
2
+ import { MARK_PATH } from "../icons/mark.js";
3
+
4
+ export const MANIFEST_PATH = "/lares/manifest.webmanifest";
5
+
6
+ export const MANIFEST = {
7
+ id: "/",
8
+ name: PRODUCT_NAME,
9
+ short_name: PRODUCT_NAME,
10
+ start_url: "/",
11
+ scope: "/",
12
+ display: "fullscreen",
13
+ theme_color: THEME_COLOR,
14
+ icons: [{ src: MARK_PATH, sizes: "any", type: "image/svg+xml", purpose: "any" }],
15
+ };
@@ -0,0 +1,40 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /** Enough of a CLI failure to act on, without pulling a progress log into the turn. */
4
+ const STDERR_LIMIT = 2000;
5
+
6
+ /**
7
+ * Stream one files-backend file onto local disk.
8
+ *
9
+ * The edge identity is already materialized into `process.env` (HOME /
10
+ * OLARES_CLI_*) by `lares-olares-identity`, which is what the bash tool relies
11
+ * on too, so the child inherits it rather than resolving a profile of its own.
12
+ * @param spawnFn - seam for tests; the real spawn otherwise.
13
+ */
14
+ export function runOlaresDownload(source, absolutePath, options = {}) {
15
+ const { signal, overwrite = false, resume = false, spawnFn = spawn } = options;
16
+ return new Promise((resolve, reject) => {
17
+ const args = ["files", "download", source, absolutePath];
18
+ if (overwrite) args.push("--overwrite");
19
+ else if (resume) args.push("--resume");
20
+ const child = spawnFn("olares-cli", args, {
21
+ env: process.env,
22
+ stdio: ["ignore", "ignore", "pipe"],
23
+ ...(signal ? { signal } : {}),
24
+ });
25
+ let stderr = "";
26
+ child.stderr?.on("data", (chunk) => {
27
+ stderr = `${stderr}${chunk}`.slice(-STDERR_LIMIT);
28
+ });
29
+ child.on("error", (error) => {
30
+ reject(new Error(`olares-cli files download failed: ${error.message}`));
31
+ });
32
+ child.on("close", (code) => {
33
+ if (code === 0) {
34
+ resolve();
35
+ return;
36
+ }
37
+ reject(new Error(`olares-cli files download exited ${code}: ${stderr.trim() || "no output"}`));
38
+ });
39
+ });
40
+ }
@@ -0,0 +1,143 @@
1
+ import { statSync } from "node:fs";
2
+ import { encodeWorkspaceVideo } from "../media/ffmpeg-run.js";
3
+ import { workspaceRootFromSession } from "../workspace/env.js";
4
+ import {
5
+ prepareWorkspaceTarget,
6
+ resolveExistingWorkspacePath,
7
+ resolveWorkspaceRoot,
8
+ workspaceCandidate,
9
+ } from "../workspace/path.js";
10
+ import { runOlaresDownload } from "./download.js";
11
+ import { statFilesFile } from "./ls.js";
12
+ import {
13
+ describeFetch,
14
+ describeFfmpegEncode,
15
+ describeUrlFetch,
16
+ describeWorkspacePublish,
17
+ resolveFetch,
18
+ resolveFfmpegEncode,
19
+ resolveUrlFetch,
20
+ resolveWorkspacePublish,
21
+ } from "./paths.js";
22
+ import { downloadUrl, saveDataUrl } from "./url-download.js";
23
+
24
+ export async function executeDriveFetch(args, exec, download) {
25
+ const run = download ?? runOlaresDownload;
26
+ const { source, destination } = resolveFetch(args);
27
+ const root = workspaceRootFromSession(exec);
28
+ const absolutePath = await prepareWorkspaceTarget(root, destination, args.overwrite === true);
29
+ await run(source, absolutePath, {
30
+ signal: exec.signal,
31
+ overwrite: args.overwrite === true,
32
+ });
33
+ return { path: destination, bytes: statSync(absolutePath).size };
34
+ }
35
+
36
+ export async function executeUrlFetch(args, exec, download) {
37
+ const run = download ?? downloadUrl;
38
+ const resolved = resolveUrlFetch(args);
39
+ const root = workspaceRootFromSession(exec);
40
+ const absolutePath = await prepareWorkspaceTarget(root, resolved.destination, args.overwrite === true);
41
+ const result = resolved.kind === "data"
42
+ ? await saveDataUrl(String(args.url), absolutePath, { overwrite: args.overwrite === true })
43
+ : await run(resolved.source, absolutePath, {
44
+ signal: exec.signal,
45
+ overwrite: args.overwrite === true,
46
+ });
47
+ return { path: resolved.destination, bytes: result.bytes, mediaType: result.mediaType };
48
+ }
49
+
50
+ export async function executeWorkspacePublish(args, exec, statFile) {
51
+ const resolved = resolveWorkspacePublish(args);
52
+ if (resolved.origin === "files") {
53
+ const info = await (statFile ?? statFilesFile)(resolved.path, { signal: exec.signal });
54
+ return { path: resolved.path, bytes: info.size };
55
+ }
56
+ const root = await resolveWorkspaceRoot(workspaceRootFromSession(exec));
57
+ const absolutePath = await resolveExistingWorkspacePath(
58
+ root,
59
+ workspaceCandidate(root, resolved.path),
60
+ );
61
+ const info = statSync(absolutePath);
62
+ if (!info.isFile()) throw new Error(`${resolved.path} is not a regular file`);
63
+ return { path: resolved.path, bytes: info.size };
64
+ }
65
+
66
+ export async function executeFfmpegEncode(args, exec, encode) {
67
+ const run = encode ?? encodeWorkspaceVideo;
68
+ const resolved = resolveFfmpegEncode(args);
69
+ const root = workspaceRootFromSession(exec);
70
+ const absolutePath = await prepareWorkspaceTarget(root, resolved.destination, resolved.overwrite);
71
+ let inputAbsolute = null;
72
+ let subtitlesAbsolute = null;
73
+ if (resolved.input) {
74
+ const workspace = await resolveWorkspaceRoot(root);
75
+ inputAbsolute = await resolveExistingWorkspacePath(
76
+ workspace,
77
+ workspaceCandidate(workspace, resolved.input),
78
+ );
79
+ if (resolved.subtitles) {
80
+ subtitlesAbsolute = await resolveExistingWorkspacePath(
81
+ workspace,
82
+ workspaceCandidate(workspace, resolved.subtitles),
83
+ );
84
+ }
85
+ }
86
+ const result = await run({
87
+ ...resolved,
88
+ absolutePath,
89
+ inputAbsolute,
90
+ subtitlesAbsolute,
91
+ }, { signal: exec.signal });
92
+ return {
93
+ path: resolved.destination,
94
+ bytes: result.bytes,
95
+ encoder: result.encoder,
96
+ speed: result.speed,
97
+ };
98
+ }
99
+
100
+ export function producedEditCard(title, path) {
101
+ return {
102
+ card: "generic",
103
+ kind: "edit",
104
+ title,
105
+ ...(path ? { locations: [{ path }] } : {}),
106
+ };
107
+ }
108
+
109
+ export function presentDriveFetch(args) {
110
+ const fetched = describeFetch(args);
111
+ return producedEditCard(
112
+ `Fetch ${fetched?.source ?? String(args.path ?? "")}`,
113
+ fetched?.destination,
114
+ );
115
+ }
116
+
117
+ export function presentUrlFetch(args) {
118
+ const fetched = describeUrlFetch(args);
119
+ return producedEditCard(
120
+ fetched?.kind === "data"
121
+ ? `Save ${fetched.destination}`
122
+ : `Download ${fetched?.source ?? String(args.url ?? "").slice(0, 160)}`,
123
+ fetched?.destination,
124
+ );
125
+ }
126
+
127
+ export function presentWorkspacePublish(args) {
128
+ const published = describeWorkspacePublish(args);
129
+ return producedEditCard(
130
+ `Publish ${published?.path ?? String(args.path ?? "").slice(0, 160)}`,
131
+ published?.path,
132
+ );
133
+ }
134
+
135
+ export function presentFfmpegEncode(args) {
136
+ const encoded = describeFfmpegEncode(args);
137
+ return producedEditCard(
138
+ encoded?.pattern
139
+ ? `Encode ${encoded.pattern} → ${encoded.destination}`
140
+ : `Encode ${encoded?.destination ?? String(args.destination ?? "").slice(0, 160)}`,
141
+ encoded?.destination,
142
+ );
143
+ }
@@ -0,0 +1,55 @@
1
+ /** Namespaces `olares-cli files download` serves. */
2
+ const DOWNLOADABLE = new Set([
3
+ "drive", "cache", "sync", "external", "awss3", "google", "dropbox", "tencent",
4
+ ]);
5
+
6
+ /**
7
+ * Files UI and knowledge sometimes omit `drive/` or prefix a slash on a real
8
+ * files address. Only those shapes unwrap; a POSIX path stays rejected.
9
+ */
10
+ function canonicalizeFilesAddress(value) {
11
+ let source = String(value ?? "").trim();
12
+ if (source.startsWith("/")) {
13
+ const rest = source.slice(1);
14
+ const first = rest.split("/", 1)[0];
15
+ if (first === "Home" || DOWNLOADABLE.has(first)) source = rest;
16
+ }
17
+ if (source === "Home" || source.startsWith("Home/")) source = `drive/${source}`;
18
+ return source;
19
+ }
20
+
21
+ /**
22
+ * @returns a validated `<fileType>/<extend>/<subPath>` files-backend address.
23
+ * @throws when the value is empty, a URL, a directory, or outside downloadable namespaces.
24
+ */
25
+ export function parseFilesPath(value) {
26
+ const source = canonicalizeFilesAddress(value);
27
+ if (source === "" || source.includes("\0")) {
28
+ throw new Error("path is required, e.g. drive/Home/Documents/clip.webm");
29
+ }
30
+ if (source.startsWith("/") || /^[a-z]+:/i.test(source)) {
31
+ throw new Error(`"${source}" is not an Olares files path; expected <fileType>/<extend>/<subPath>`);
32
+ }
33
+ if (source.endsWith("/")) {
34
+ throw new Error(`"${source}" names a directory; expected a single file`);
35
+ }
36
+ const segments = source.split("/");
37
+ if (!DOWNLOADABLE.has(segments[0])) {
38
+ throw new Error(
39
+ `"${segments[0]}" is not a downloadable namespace; expected one of ${[...DOWNLOADABLE].join(", ")}`,
40
+ );
41
+ }
42
+ if (segments.length < 3 || segments.some((part) => part === "" || part === "." || part === "..")) {
43
+ throw new Error(`"${source}" is malformed; expected <fileType>/<extend>/<subPath>`);
44
+ }
45
+ return source;
46
+ }
47
+
48
+ export function isFilesPath(value) {
49
+ try {
50
+ parseFilesPath(value);
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
package/drive/ls.js ADDED
@@ -0,0 +1,126 @@
1
+ import { spawn } from "node:child_process";
2
+ import { parseFilesPath } from "./files-path.js";
3
+
4
+ const STDERR_LIMIT = 2000;
5
+ const STDOUT_LIMIT = 8 * 1024 * 1024;
6
+
7
+ function lookupError(code, message) {
8
+ const error = new Error(message);
9
+ error.code = code;
10
+ return error;
11
+ }
12
+
13
+ /**
14
+ * Children of one `olares-cli files ls --json` envelope.
15
+ * Drive-like namespaces use `items`; cloud namespaces use `data`.
16
+ */
17
+ export function filesLsChildren(envelope) {
18
+ if (envelope == null || typeof envelope !== "object") return [];
19
+ const body = envelope.data !== undefined && !Array.isArray(envelope.items)
20
+ ? envelope.data
21
+ : envelope;
22
+ if (Array.isArray(body)) return body;
23
+ if (body && typeof body === "object") {
24
+ if (Array.isArray(body.items)) return body.items;
25
+ if (Array.isArray(body.data)) return body.data;
26
+ }
27
+ return [];
28
+ }
29
+
30
+ function itemLeaf(item) {
31
+ const raw = String(item?.name ?? item?.fileName ?? "").replace(/\/$/, "");
32
+ const parts = raw.split("/");
33
+ return parts.at(-1) ?? "";
34
+ }
35
+
36
+ function isDirectoryItem(item) {
37
+ if (item?.isDir === true || item?.isDirectory === true) return true;
38
+ const type = String(item?.type ?? "").toLowerCase();
39
+ if (type === "dir" || type === "directory") return true;
40
+ return typeof item?.name === "string" && item.name.endsWith("/");
41
+ }
42
+
43
+ function itemSize(item) {
44
+ const n = Number(item?.size ?? item?.fileSize);
45
+ return Number.isFinite(n) && n >= 0 ? n : 0;
46
+ }
47
+
48
+ function itemModifiedAt(item) {
49
+ const t = Date.parse(item?.modified ?? item?.mtime ?? item?.modTime ?? "");
50
+ return Number.isFinite(t) ? t : 0;
51
+ }
52
+
53
+ export function findFilesChild(envelope, name) {
54
+ return filesLsChildren(envelope).find((item) => itemLeaf(item) === name) ?? null;
55
+ }
56
+
57
+ /**
58
+ * List one files-backend directory. Identity is already in `process.env`.
59
+ * @param spawnFn - seam for tests; the real spawn otherwise.
60
+ */
61
+ export function runOlaresLs(parent, options = {}) {
62
+ const { signal, spawnFn = spawn } = options;
63
+ return new Promise((resolve, reject) => {
64
+ const child = spawnFn("olares-cli", ["files", "ls", parent, "--json"], {
65
+ env: process.env,
66
+ stdio: ["ignore", "pipe", "pipe"],
67
+ ...(signal ? { signal } : {}),
68
+ });
69
+ let stdout = "";
70
+ let stderr = "";
71
+ let truncated = false;
72
+ child.stdout?.on("data", (chunk) => {
73
+ if (truncated) return;
74
+ stdout += chunk;
75
+ if (stdout.length > STDOUT_LIMIT) {
76
+ truncated = true;
77
+ stdout = "";
78
+ }
79
+ });
80
+ child.stderr?.on("data", (chunk) => {
81
+ stderr = `${stderr}${chunk}`.slice(-STDERR_LIMIT);
82
+ });
83
+ child.on("error", (error) => {
84
+ reject(lookupError("files_unavailable", `olares-cli files ls failed: ${error.message}`));
85
+ });
86
+ child.on("close", (code) => {
87
+ if (truncated) {
88
+ reject(lookupError("files_unavailable", "olares-cli files ls output exceeded the read limit"));
89
+ return;
90
+ }
91
+ if (code !== 0) {
92
+ reject(lookupError(
93
+ "files_unavailable",
94
+ `olares-cli files ls exited ${code}: ${stderr.trim() || "no output"}`,
95
+ ));
96
+ return;
97
+ }
98
+ try {
99
+ resolve(JSON.parse(stdout));
100
+ } catch {
101
+ reject(lookupError("files_unavailable", "olares-cli files ls returned invalid JSON"));
102
+ }
103
+ });
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Stat one files-backend file by listing its parent and matching the leaf.
109
+ * Direct GET of the file URL can 500; parent `ls` is the supported probe.
110
+ */
111
+ export async function statFilesFile(source, options = {}) {
112
+ const path = parseFilesPath(source);
113
+ const cut = path.lastIndexOf("/");
114
+ const parent = path.slice(0, cut);
115
+ const name = path.slice(cut + 1);
116
+ const envelope = await (options.ls ?? runOlaresLs)(parent, options);
117
+ const item = findFilesChild(envelope, name);
118
+ if (!item) throw lookupError("file_not_found", `${path} was not found`);
119
+ if (isDirectoryItem(item)) throw lookupError("path_not_file", `${path} is not a regular file`);
120
+ return {
121
+ path,
122
+ name,
123
+ size: itemSize(item),
124
+ modifiedAt: itemModifiedAt(item),
125
+ };
126
+ }