@olares/lares-core 0.15.7 → 0.15.9
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 +2 -1
- package/brand/identity.js +33 -3
- package/drive/cat.js +62 -0
- package/drive/download.js +3 -3
- package/drive/execute.js +8 -2
- package/drive/files-path.js +33 -0
- package/drive/flowstudio-files.js +87 -0
- package/drive/ls.js +3 -3
- package/drive/paths.js +23 -13
- package/drive/tools.js +4 -2
- package/entrance/surface.js +96 -0
- package/files/app-link.js +64 -0
- package/files/backend-client.js +266 -0
- package/files/deliverables.js +55 -1
- package/files/intake.js +0 -63
- package/files/mention.js +1 -51
- package/files/preview-groups.js +71 -3
- package/files/preview-local.js +69 -0
- package/files/preview-workspace.js +114 -13
- package/files/preview.js +171 -16
- package/files/produced-gate.js +197 -0
- package/files/produced-mentions.js +19 -0
- package/files/published-tools.js +69 -0
- package/files/published-turn-data.js +84 -0
- package/i18n/chat-model.js +5 -5
- package/i18n/mcp.js +83 -0
- package/i18n/mobile.js +96 -2
- package/i18n/preview.js +10 -0
- package/i18n/search.js +13 -7
- package/i18n/skills.js +31 -0
- package/i18n/voice.js +88 -19
- package/larepass/chat.js +9 -0
- package/larepass/host.js +7 -2
- package/larepass/mux.js +210 -2
- package/larepass/process-fold.js +239 -0
- package/larepass/queue.js +44 -0
- package/larepass/rpc.js +50 -3
- package/larepass/runtime.js +215 -83
- package/larepass/session-cache.js +6 -0
- package/larepass/settings.js +15 -7
- package/larepass/transcript.js +49 -11
- package/mcp/approval.js +152 -0
- package/mcp/client.js +38 -0
- package/mcp/config.js +139 -0
- package/media/catalog-row.js +244 -0
- package/media/generation.js +707 -0
- package/media/tool.js +92 -0
- package/olares/entrance.js +67 -15
- package/olares/identity.js +13 -136
- package/olares/session-identity.js +12 -12
- package/olares/trusted-host.js +0 -29
- package/package.json +48 -4
- package/router/catalog-cache.d.ts +1 -1
- package/router/catalog-cache.js +25 -14
- package/router/catalog-sync.js +8 -3
- package/router/catalog.d.ts +6 -1
- package/router/catalog.js +18 -2
- package/router/gateway.d.ts +9 -1
- package/router/gateway.js +40 -4
- package/router/gemini-schema.js +125 -0
- package/router/generation-guard.js +82 -0
- package/router/models.d.ts +6 -0
- package/router/models.js +22 -3
- package/router/search.js +10 -2
- package/router/session-model.js +53 -0
- package/router/shim.js +104 -3
- package/router/sref.js +126 -0
- package/router/stt.js +43 -12
- package/router/tts.js +118 -0
- package/search/client.js +5 -3
- package/search/config.js +53 -39
- package/search/menu.js +45 -10
- package/skills/client.js +42 -0
- package/skills/packs.js +40 -0
- package/skills/state.d.ts +22 -0
- package/skills/state.js +142 -0
- package/tools/crypto-uuid.js +3 -6
- package/tools/head-script.js +15 -0
- package/voice/agent.js +50 -0
- package/voice/capture.js +250 -0
- package/voice/client.js +126 -24
- package/voice/config.js +11 -1
- package/voice/duplex.js +44 -0
- package/voice/endpoint.js +57 -0
- package/voice/host/bluetooth-ready.py +1196 -0
- package/voice/host/install-host.sh +102 -0
- package/voice/input.js +4 -0
- package/voice/install.js +146 -0
- package/voice/languages.js +11 -1
- package/voice/link.js +86 -0
- package/voice/pcm.js +100 -0
- package/voice/playback.js +7 -0
- package/voice/problem.js +46 -0
- package/voice/recorder.js +79 -4
- package/voice/script.js +35 -0
- package/voice/service.js +253 -10
- package/voice/session.js +128 -0
- package/voice/speech.js +93 -0
- package/files/preview-cache.js +0 -100
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { filesPathAfterPrefix, isFilesPath, parseFilesPath } from "../drive/files-path.js";
|
|
1
2
|
import { closeTab, openTab, touchTab } from "./preview-tabs.js";
|
|
2
3
|
|
|
3
4
|
export class NullScrollport {
|
|
@@ -22,18 +23,51 @@ function errorCode(payload) {
|
|
|
22
23
|
return payload?.error?.code || "file_preview_failed";
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
const PREVIEW_KINDS = new Set([
|
|
27
|
+
"image", "video", "audio", "model3d", "pdf", "markdown", "text", "unsupported",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export function isPreviewPayload(payload) {
|
|
31
|
+
return Boolean(
|
|
32
|
+
payload
|
|
33
|
+
&& typeof payload === "object"
|
|
34
|
+
&& typeof payload.path === "string"
|
|
35
|
+
&& payload.path !== ""
|
|
36
|
+
&& typeof payload.name === "string"
|
|
37
|
+
&& PREVIEW_KINDS.has(payload.kind)
|
|
38
|
+
&& Number.isSafeInteger(payload.size)
|
|
39
|
+
&& payload.size >= 0
|
|
40
|
+
&& (payload.modifiedAt === undefined || Number.isFinite(payload.modifiedAt))
|
|
41
|
+
&& (!["text", "markdown"].includes(payload.kind) || typeof payload.text === "string"),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The path the preview should ask the Host for. dsh already joined a relative
|
|
47
|
+
* target onto the session cwd; a Files address must go back to its own
|
|
48
|
+
* namespace before it becomes a tab key or a query.
|
|
49
|
+
*/
|
|
50
|
+
export function previewOpenPath(cwd, path) {
|
|
51
|
+
const requested = String(path ?? "");
|
|
52
|
+
if (isFilesPath(requested)) return parseFilesPath(requested);
|
|
53
|
+
return filesPathAfterPrefix(cwd, requested) ?? requested;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function fetchPreview(sessionId, path, options = {}) {
|
|
26
57
|
const query = new URLSearchParams({ sessionId, path });
|
|
27
|
-
const response = await fetch(`/api/lares/file-preview/preview?${query}
|
|
58
|
+
const response = await fetch(`/api/lares/file-preview/preview?${query}`, {
|
|
59
|
+
signal: options.signal,
|
|
60
|
+
});
|
|
28
61
|
const payload = await response.json().catch(() => null);
|
|
29
62
|
if (!response.ok) throw new Error(errorCode(payload));
|
|
63
|
+
if (!isPreviewPayload(payload)) throw new Error("file_preview_failed");
|
|
30
64
|
return payload;
|
|
31
65
|
}
|
|
32
66
|
|
|
33
|
-
export async function fetchPreviewMap(sessionId, paths) {
|
|
67
|
+
export async function fetchPreviewMap(sessionId, paths, options = {}) {
|
|
34
68
|
const entries = await Promise.all(paths.map(async (path) => {
|
|
35
69
|
try {
|
|
36
|
-
return [path, await fetchPreview(sessionId, path)];
|
|
70
|
+
return [path, await fetchPreview(sessionId, path, options)];
|
|
37
71
|
} catch {
|
|
38
72
|
return [path, null];
|
|
39
73
|
}
|
|
@@ -52,9 +86,54 @@ export function workspaceLinkClickPath(sessionId, event) {
|
|
|
52
86
|
return rawUrlPath(sessionId, anchor.getAttribute("href"));
|
|
53
87
|
}
|
|
54
88
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The agent Reads the application overlay (`/app/packages/…`). That is source,
|
|
91
|
+
* not a user file: the preview cannot serve it, and treating the path as an
|
|
92
|
+
* openable chip only produces a dead tab. `/app/name.ext` still probes because
|
|
93
|
+
* the workspace aliases that basename.
|
|
94
|
+
*/
|
|
95
|
+
export function isUnpreviewableOpenPath(path) {
|
|
96
|
+
const text = String(path ?? "").trim().replace(/\\/g, "/");
|
|
97
|
+
if (!text.startsWith("/app")) return false;
|
|
98
|
+
if (text === "/app" || text === "/app/") return true;
|
|
99
|
+
const rest = text.slice("/app/".length);
|
|
100
|
+
return rest.includes("/");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const FILE_ADDRESS_PREFIX = "dsh-resource://file/session/";
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The session and path behind dsh's session file address
|
|
107
|
+
* (`dsh-resource://file/session/<id>/<path>`), or null for any other resource.
|
|
108
|
+
* Query and fragment suffixes are dsh's navigation parameters, not the path.
|
|
109
|
+
* An empty path names the session's workspace root — a directory the preview
|
|
110
|
+
* never serves — so it reads as no file address at all.
|
|
111
|
+
*/
|
|
112
|
+
export function parseSessionFileAddress(address) {
|
|
113
|
+
const text = String(address ?? "");
|
|
114
|
+
if (!text.startsWith(FILE_ADDRESS_PREFIX)) return null;
|
|
115
|
+
const end = text.search(/[?#]/);
|
|
116
|
+
const body = text.slice(FILE_ADDRESS_PREFIX.length, end === -1 ? undefined : end);
|
|
117
|
+
const [id, ...segments] = body.split("/");
|
|
118
|
+
if (!id || segments.length === 0) return null;
|
|
119
|
+
let decoded;
|
|
120
|
+
try {
|
|
121
|
+
decoded = { sessionId: decodeURIComponent(id), path: segments.map(decodeURIComponent).join("/") };
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return decoded.path === "" ? null : decoded;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param target - the session and path a Host open request decoded to.
|
|
130
|
+
* @param openNative - the Host's own opener for a request the preview declines.
|
|
131
|
+
*/
|
|
132
|
+
export async function interceptOpenPath(workspace, target, openNative) {
|
|
133
|
+
if (target.sessionId !== workspace.boundSession()) return openNative();
|
|
134
|
+
if (isUnpreviewableOpenPath(target.path)) return;
|
|
135
|
+
if (await workspace.openCurrent(target.path)) return;
|
|
136
|
+
await openNative();
|
|
58
137
|
}
|
|
59
138
|
|
|
60
139
|
const RAW_ROUTE = "/api/lares/file-preview/raw";
|
|
@@ -65,9 +144,15 @@ export function previewMetaUrl(sessionId, path) {
|
|
|
65
144
|
return `/api/lares/file-preview/preview?${query}`;
|
|
66
145
|
}
|
|
67
146
|
|
|
68
|
-
|
|
147
|
+
/**
|
|
148
|
+
* @param size - ask the files backend for a rendition of this size instead of
|
|
149
|
+
* the stored bytes. Honored for images on the files backend and ignored
|
|
150
|
+
* everywhere else, so a caller that wants a small copy may always ask.
|
|
151
|
+
*/
|
|
152
|
+
export function rawFileUrl(sessionId, path, modifiedAt, size) {
|
|
69
153
|
const query = new URLSearchParams({ sessionId, path });
|
|
70
154
|
if (modifiedAt !== undefined) query.set("v", String(modifiedAt));
|
|
155
|
+
if (size) query.set("size", size);
|
|
71
156
|
return `${RAW_ROUTE}?${query}`;
|
|
72
157
|
}
|
|
73
158
|
|
|
@@ -139,25 +224,41 @@ export class FilePreviewWorkspace {
|
|
|
139
224
|
return () => state.listeners.delete(listener);
|
|
140
225
|
}
|
|
141
226
|
|
|
142
|
-
bindCurrent(sessionId) {
|
|
143
|
-
const binding = { sessionId };
|
|
227
|
+
bindCurrent(sessionId, cwd) {
|
|
228
|
+
const binding = { sessionId, cwd, openVersion: 0 };
|
|
144
229
|
this.current = binding;
|
|
145
230
|
return () => {
|
|
146
231
|
if (this.current === binding) this.current = null;
|
|
147
232
|
};
|
|
148
233
|
}
|
|
149
234
|
|
|
235
|
+
/** The session the on-screen conversation mounted, or null while none is. */
|
|
236
|
+
boundSession() {
|
|
237
|
+
return this.current?.sessionId ?? null;
|
|
238
|
+
}
|
|
239
|
+
|
|
150
240
|
/**
|
|
151
241
|
* Claim a Host open request, or decline it. The preview serves regular files
|
|
152
242
|
* only, so the target is resolved before any tab exists: a directory stays
|
|
153
243
|
* with the Host's own opener instead of becoming a tab that can never load.
|
|
244
|
+
* Overlay source is declined without a fetch or a tab — claiming it is what
|
|
245
|
+
* turned Read rows like `/app/packages/…` into a clickable preview that then
|
|
246
|
+
* failed. Files errors (auth included) still open a tab so the chat view's
|
|
247
|
+
* successful claim is not a silent click; Host does not native-open.
|
|
154
248
|
*/
|
|
155
249
|
async openCurrent(path) {
|
|
156
250
|
if (!this.current) return false;
|
|
157
|
-
const
|
|
158
|
-
const
|
|
251
|
+
const binding = this.current;
|
|
252
|
+
const { sessionId, cwd } = binding;
|
|
253
|
+
const version = ++binding.openVersion;
|
|
254
|
+
const target = previewOpenPath(cwd, path);
|
|
255
|
+
if (isUnpreviewableOpenPath(target)) return false;
|
|
256
|
+
const content = await this.fetchContent(sessionId, target);
|
|
257
|
+
// A later click or session switch owns the surface. The old request was
|
|
258
|
+
// still claimed, but must not steal focus or open a stale native panel.
|
|
259
|
+
if (this.current !== binding || binding.openVersion !== version) return true;
|
|
159
260
|
if (content.status === "error" && content.message === "path_not_file") return false;
|
|
160
|
-
this.open(sessionId,
|
|
261
|
+
this.open(sessionId, target, content);
|
|
161
262
|
return true;
|
|
162
263
|
}
|
|
163
264
|
|
package/files/preview.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { open, stat } from "node:fs/promises";
|
|
2
2
|
import { basename, extname, relative } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import { filesPathAfterPrefix, isFilesNamespace, parseFilesPath } from "../drive/files-path.js";
|
|
5
6
|
import { HttpError } from "../tools/http.js";
|
|
6
7
|
import {
|
|
7
8
|
resolveExistingWorkspacePath,
|
|
@@ -9,7 +10,7 @@ import {
|
|
|
9
10
|
workspaceCandidate,
|
|
10
11
|
workspaceFileAlias,
|
|
11
12
|
} from "../workspace/path.js";
|
|
12
|
-
import {
|
|
13
|
+
import { resolveLocalPreviewFile } from "./preview-local.js";
|
|
13
14
|
|
|
14
15
|
export const MAX_PREVIEW_TEXT_BYTES = 1024 * 1024;
|
|
15
16
|
export const MAX_RAW_BYTES = 200 * 1024 * 1024;
|
|
@@ -39,8 +40,6 @@ const AUDIO_TYPES = new Map([
|
|
|
39
40
|
]);
|
|
40
41
|
const MODEL3D_TYPES = new Map([
|
|
41
42
|
[".glb", "model/gltf-binary"],
|
|
42
|
-
[".gltf", "model/gltf+json"],
|
|
43
|
-
[".obj", "model/obj"],
|
|
44
43
|
]);
|
|
45
44
|
const TEXT_EXTENSIONS = new Set([
|
|
46
45
|
"",
|
|
@@ -50,6 +49,7 @@ const TEXT_EXTENSIONS = new Set([
|
|
|
50
49
|
".go", ".java", ".kt", ".c", ".h", ".cpp", ".hpp", ".sql", ".r", ".rb",
|
|
51
50
|
".php", ".swift", ".vue", ".svelte", ".mdx", ".gitignore", ".dockerignore",
|
|
52
51
|
".editorconfig", ".srt", ".vtt", ".ass", ".ssa",
|
|
52
|
+
".gltf", ".obj",
|
|
53
53
|
]);
|
|
54
54
|
|
|
55
55
|
export function previewQueryFromUrl(reqUrl) {
|
|
@@ -77,7 +77,10 @@ export async function resolveFilesPreviewFile(requestedPath, deps = {}) {
|
|
|
77
77
|
throw new HttpError("path_invalid", 400, error instanceof Error ? error.message : String(error));
|
|
78
78
|
}
|
|
79
79
|
try {
|
|
80
|
-
|
|
80
|
+
if (typeof deps.stat !== "function") {
|
|
81
|
+
throw new HttpError("files_unavailable", 503, "Olares Files client is unavailable");
|
|
82
|
+
}
|
|
83
|
+
const info = await deps.stat(source, deps);
|
|
81
84
|
return {
|
|
82
85
|
origin: "files",
|
|
83
86
|
path: info.path,
|
|
@@ -91,23 +94,33 @@ export async function resolveFilesPreviewFile(requestedPath, deps = {}) {
|
|
|
91
94
|
}
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
function joinedFilesAddress(workspacePath, root, requestedPath) {
|
|
98
|
+
return filesPathAfterPrefix(workspacePath, requestedPath)
|
|
99
|
+
?? filesPathAfterPrefix(root, requestedPath);
|
|
100
|
+
}
|
|
101
|
+
|
|
94
102
|
export async function fileFromPreviewRequest(reqUrl, resolveWorkspace, deps = {}) {
|
|
95
103
|
const { path, sessionId } = previewQueryFromUrl(reqUrl);
|
|
96
|
-
if (
|
|
104
|
+
if (isFilesNamespace(path)) return resolveFilesPreviewFile(path, deps);
|
|
105
|
+
const local = await (deps.resolveLocal ?? resolveLocalPreviewFile)(path, deps);
|
|
106
|
+
if (local) return { ...local, ...previewTypeForName(local.absolutePath) };
|
|
97
107
|
const workspace = await resolveWorkspace(sessionId);
|
|
98
|
-
|
|
108
|
+
const root = await resolveWorkspaceRoot(workspace.path);
|
|
109
|
+
try {
|
|
110
|
+
return await resolveWorkspaceFile(root, path);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
// A real workspace file of the same name wins. dsh joins Files addresses
|
|
113
|
+
// onto cwd; that prefix may be the lexical workspace path or its realpath.
|
|
114
|
+
const joined = error?.code === "file_not_found" || error?.code === "path_forbidden";
|
|
115
|
+
const address = joined ? joinedFilesAddress(workspace.path, root, path) : null;
|
|
116
|
+
if (address === null) throw error;
|
|
117
|
+
return resolveFilesPreviewFile(address, deps);
|
|
118
|
+
}
|
|
99
119
|
}
|
|
100
120
|
|
|
101
121
|
async function ensurePreviewBytes(file, deps = {}) {
|
|
102
122
|
if (file.absolutePath) return file;
|
|
103
|
-
|
|
104
|
-
throw new HttpError("file_preview_failed", 500, "file has no local path");
|
|
105
|
-
}
|
|
106
|
-
try {
|
|
107
|
-
return await (deps.materialize ?? materializeFilesFile)(file, deps);
|
|
108
|
-
} catch (error) {
|
|
109
|
-
throw asPreviewHttpError(error);
|
|
110
|
-
}
|
|
123
|
+
throw new HttpError("file_preview_failed", 500, "file has no local path");
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
export function previewTypeForName(name) {
|
|
@@ -191,6 +204,37 @@ export async function buildPreview(file, deps) {
|
|
|
191
204
|
};
|
|
192
205
|
const knownText = ["text", "markdown"].includes(file.kind);
|
|
193
206
|
if (!knownText && file.kind !== "unsupported") return metadata;
|
|
207
|
+
if (file.origin === "files") {
|
|
208
|
+
if (typeof deps?.readFilesRaw !== "function") {
|
|
209
|
+
throw new HttpError("files_unavailable", 503, "Olares Files client is unavailable");
|
|
210
|
+
}
|
|
211
|
+
let raw;
|
|
212
|
+
try {
|
|
213
|
+
raw = await deps.readFilesRaw(file.path, MAX_PREVIEW_TEXT_BYTES + 1);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
throw asPreviewHttpError(error);
|
|
216
|
+
}
|
|
217
|
+
const truncated = file.size > MAX_PREVIEW_TEXT_BYTES || raw.truncated;
|
|
218
|
+
const read = raw.bytes.subarray(0, MAX_PREVIEW_TEXT_BYTES);
|
|
219
|
+
const body = truncated ? trimPartialUtf8(read) : read;
|
|
220
|
+
try {
|
|
221
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(body);
|
|
222
|
+
if (!knownText && !looksLikeText(text)) return metadata;
|
|
223
|
+
return {
|
|
224
|
+
...metadata,
|
|
225
|
+
kind: knownText ? file.kind : "text",
|
|
226
|
+
mediaType: knownText ? file.mediaType : "text/plain; charset=utf-8",
|
|
227
|
+
text,
|
|
228
|
+
truncated,
|
|
229
|
+
};
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error instanceof TypeError) {
|
|
232
|
+
if (!knownText) return metadata;
|
|
233
|
+
throw new HttpError("file_not_text", 415, "file is not valid UTF-8 text");
|
|
234
|
+
}
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
194
238
|
const local = await ensurePreviewBytes(file, deps);
|
|
195
239
|
const handle = await openVerified(local);
|
|
196
240
|
try {
|
|
@@ -306,6 +350,113 @@ async function sendFile(req, res, file, disposition) {
|
|
|
306
350
|
stream.pipe(res);
|
|
307
351
|
}
|
|
308
352
|
|
|
353
|
+
function filesDispositionHeaders(file, disposition) {
|
|
354
|
+
return {
|
|
355
|
+
"accept-ranges": "bytes",
|
|
356
|
+
"cache-control": "private, no-cache",
|
|
357
|
+
"content-type": file.mediaType,
|
|
358
|
+
"content-disposition": `${disposition}; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
|
359
|
+
"x-content-type-options": "nosniff",
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function sendFilesFile(req, res, file, disposition, deps = {}) {
|
|
364
|
+
// Validate against the metadata we just resolved before forwarding. This
|
|
365
|
+
// keeps malformed and out-of-bounds ranges from becoming an opaque Files 502.
|
|
366
|
+
parseRange(req.headers.range, file.size);
|
|
367
|
+
// HEAD is answered from the listing we already did. Files has no HEAD verb;
|
|
368
|
+
// a 1-byte GET probe must not become the download preflight's size.
|
|
369
|
+
if (req.method === "HEAD") {
|
|
370
|
+
const range = parseRange(req.headers.range, file.size);
|
|
371
|
+
const start = range?.start ?? 0;
|
|
372
|
+
const end = range?.end ?? Math.max(0, file.size - 1);
|
|
373
|
+
const headers = {
|
|
374
|
+
...filesDispositionHeaders(file, disposition),
|
|
375
|
+
"content-length": String(file.size === 0 ? 0 : end - start + 1),
|
|
376
|
+
};
|
|
377
|
+
if (range) headers["content-range"] = `bytes ${start}-${end}/${file.size}`;
|
|
378
|
+
res.writeHead(range ? 206 : 200, headers);
|
|
379
|
+
res.end();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (typeof deps.openFilesRaw !== "function") {
|
|
383
|
+
throw new HttpError("files_unavailable", 503, "Olares Files client is unavailable");
|
|
384
|
+
}
|
|
385
|
+
let response;
|
|
386
|
+
try {
|
|
387
|
+
response = await deps.openFilesRaw(file.path, {
|
|
388
|
+
method: "GET",
|
|
389
|
+
range: req.headers.range,
|
|
390
|
+
});
|
|
391
|
+
} catch (error) {
|
|
392
|
+
throw asPreviewHttpError(error);
|
|
393
|
+
}
|
|
394
|
+
const headers = filesDispositionHeaders(file, disposition);
|
|
395
|
+
for (const name of ["accept-ranges", "content-length", "content-range"]) {
|
|
396
|
+
const value = response.headers.get(name);
|
|
397
|
+
if (value) headers[name] = value;
|
|
398
|
+
}
|
|
399
|
+
res.writeHead(response.status, headers);
|
|
400
|
+
if (!response.body) {
|
|
401
|
+
res.end();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
await pipeline(Readable.fromWeb(response.body), res);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The rendition this request asked for, or null for the stored bytes.
|
|
409
|
+
*
|
|
410
|
+
* Only an image on the files backend has one, because only there does a
|
|
411
|
+
* resized copy already exist: the backend renders it once and caches it. A
|
|
412
|
+
* conversation showing a 4000px generation at a few hundred pixels wide is
|
|
413
|
+
* the case this exists for. Opening the file and downloading it both stay on
|
|
414
|
+
* the original, so what is displayed and what is kept stay separate choices.
|
|
415
|
+
*/
|
|
416
|
+
export function previewSizeFromUrl(reqUrl, file) {
|
|
417
|
+
if (file?.origin !== "files" || file?.kind !== "image") return null;
|
|
418
|
+
const size = new URL(reqUrl ?? "/", "http://x").searchParams.get("size");
|
|
419
|
+
return ["thumb", "big"].includes(size) ? size : null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* A rendition is not the stored bytes, so it carries neither the original's
|
|
424
|
+
* length nor byte ranges. HEAD is answered without fetching one: the only
|
|
425
|
+
* honest length is the rendered one, and rendering a copy to describe and
|
|
426
|
+
* then discard is worse than omitting a header HEAD does not require.
|
|
427
|
+
*/
|
|
428
|
+
async function sendFilesRendition(req, res, file, size, deps = {}) {
|
|
429
|
+
const headers = {
|
|
430
|
+
"cache-control": "private, no-cache",
|
|
431
|
+
"content-type": file.mediaType,
|
|
432
|
+
"content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
|
433
|
+
"x-content-type-options": "nosniff",
|
|
434
|
+
};
|
|
435
|
+
if (req.method === "HEAD") {
|
|
436
|
+
res.writeHead(200, headers);
|
|
437
|
+
res.end();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (typeof deps.openFilesPreview !== "function") {
|
|
441
|
+
throw new HttpError("files_unavailable", 503, "Olares Files client is unavailable");
|
|
442
|
+
}
|
|
443
|
+
let response;
|
|
444
|
+
try {
|
|
445
|
+
response = await deps.openFilesPreview(file.path, { size });
|
|
446
|
+
} catch (error) {
|
|
447
|
+
throw asPreviewHttpError(error);
|
|
448
|
+
}
|
|
449
|
+
headers["content-type"] = response.headers.get("content-type") || file.mediaType;
|
|
450
|
+
const length = response.headers.get("content-length");
|
|
451
|
+
if (length) headers["content-length"] = length;
|
|
452
|
+
res.writeHead(response.status, headers);
|
|
453
|
+
if (!response.body) {
|
|
454
|
+
res.end();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
await pipeline(Readable.fromWeb(response.body), res);
|
|
458
|
+
}
|
|
459
|
+
|
|
309
460
|
export async function sendRawFile(req, res, file, deps) {
|
|
310
461
|
if (!["image", "video", "audio", "pdf", "model3d"].includes(file.kind)) {
|
|
311
462
|
throw new HttpError("preview_unsupported", 415, "raw preview is not supported for this file");
|
|
@@ -315,6 +466,9 @@ export async function sendRawFile(req, res, file, deps) {
|
|
|
315
466
|
if (["image", "pdf", "model3d"].includes(file.kind) && file.size > MAX_RAW_BYTES) {
|
|
316
467
|
throw new HttpError("file_too_large", 413, `file exceeds ${MAX_RAW_BYTES} bytes`);
|
|
317
468
|
}
|
|
469
|
+
const size = previewSizeFromUrl(req.url, file);
|
|
470
|
+
if (size) return sendFilesRendition(req, res, file, size, deps);
|
|
471
|
+
if (file.origin === "files") return sendFilesFile(req, res, file, "inline", deps);
|
|
318
472
|
return sendFile(req, res, await ensurePreviewBytes(file, deps), "inline");
|
|
319
473
|
}
|
|
320
474
|
|
|
@@ -324,5 +478,6 @@ export async function sendRawFile(req, res, file, deps) {
|
|
|
324
478
|
* about what the user may keep a copy of.
|
|
325
479
|
*/
|
|
326
480
|
export async function sendFileDownload(req, res, file, deps) {
|
|
481
|
+
if (file.origin === "files") return sendFilesFile(req, res, file, "attachment", deps);
|
|
327
482
|
return sendFile(req, res, await ensurePreviewBytes(file, deps), "attachment");
|
|
328
483
|
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { isFilesPath } from "../drive/files-path.js";
|
|
3
|
+
import {
|
|
4
|
+
durablePathFromToolCall,
|
|
5
|
+
parseToolArguments,
|
|
6
|
+
toolResultIsError,
|
|
7
|
+
} from "./published-tools.js";
|
|
8
|
+
import { isMediaDeliverablePath } from "./preview-groups.js";
|
|
9
|
+
import {
|
|
10
|
+
resolveExistingWorkspacePath,
|
|
11
|
+
resolveWorkspaceRoot,
|
|
12
|
+
workspaceCandidate,
|
|
13
|
+
} from "../workspace/path.js";
|
|
14
|
+
|
|
15
|
+
export const PRODUCED_GATE_PLUGIN = "lares-produced-gate";
|
|
16
|
+
export const MAX_PRODUCED_GATE_STEERS = 2;
|
|
17
|
+
|
|
18
|
+
export { durablePathFromToolCall, parseToolArguments } from "./published-tools.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Successful mutation paths for one turn, in tool order, deduped.
|
|
22
|
+
* @param {Iterable<{ type?: string, data?: any }>} events
|
|
23
|
+
*/
|
|
24
|
+
export function durableProducedPathsFromEvents(events, turn) {
|
|
25
|
+
const pending = new Map();
|
|
26
|
+
const paths = [];
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
for (const event of events) {
|
|
29
|
+
if (event?.data?.turn !== turn) continue;
|
|
30
|
+
if (event.type === "tool/call") {
|
|
31
|
+
const callId = String(event.data.callId ?? "");
|
|
32
|
+
const name = String(event.data.name ?? "");
|
|
33
|
+
const path = durablePathFromToolCall(name, parseToolArguments(event.data.arguments));
|
|
34
|
+
if (callId && path) pending.set(callId, path);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (event.type !== "tool/result") continue;
|
|
38
|
+
if (toolResultIsError(event)) continue;
|
|
39
|
+
const callId = String(event.data?.message?.source?.callId ?? "");
|
|
40
|
+
const path = pending.get(callId);
|
|
41
|
+
if (!path || seen.has(path)) continue;
|
|
42
|
+
seen.add(path);
|
|
43
|
+
paths.push(path);
|
|
44
|
+
}
|
|
45
|
+
return paths;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resultTexts(value, texts = []) {
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
for (const item of value) resultTexts(item, texts);
|
|
51
|
+
return texts;
|
|
52
|
+
}
|
|
53
|
+
if (!value || typeof value !== "object") return texts;
|
|
54
|
+
if (typeof value.text === "string") texts.push(value.text);
|
|
55
|
+
if ("content" in value) resultTexts(value.content, texts);
|
|
56
|
+
return texts;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseResultJson(text) {
|
|
60
|
+
const source = String(text ?? "").trim();
|
|
61
|
+
if (!source) return null;
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(source);
|
|
64
|
+
} catch {
|
|
65
|
+
const start = source.indexOf("{");
|
|
66
|
+
const end = source.lastIndexOf("}");
|
|
67
|
+
if (start === -1 || end <= start) return null;
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(source.slice(start, end + 1));
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectFilesPaths(value, paths) {
|
|
77
|
+
if (Array.isArray(value)) {
|
|
78
|
+
for (const item of value) collectFilesPaths(item, paths);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (!value || typeof value !== "object") return;
|
|
82
|
+
const status = typeof value.status === "string" ? value.status.toLowerCase() : "";
|
|
83
|
+
if ([
|
|
84
|
+
"queued", "pending", "submitted", "starting", "processing", "in_progress", "running",
|
|
85
|
+
"failed", "error", "canceled", "cancelled",
|
|
86
|
+
].includes(status)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
for (const [key, item] of Object.entries(value)) {
|
|
90
|
+
if ((key === "files_path" || key === "filesPath") && typeof item === "string") {
|
|
91
|
+
const path = item.trim().replace(/\\/g, "/");
|
|
92
|
+
if (isFilesPath(path) && isMediaDeliverablePath(path)) paths.push(path);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
collectFilesPaths(item, paths);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Generation is performed through bash/curl, so dsh has no native artifact
|
|
101
|
+
* event for its result. Recover media addresses from successful tool output and
|
|
102
|
+
* require the agent to declare each one through workspace_publish.
|
|
103
|
+
*/
|
|
104
|
+
export function unpublishedMediaPathsFromEvents(events, turn) {
|
|
105
|
+
const published = new Set(durableProducedPathsFromEvents(events, turn));
|
|
106
|
+
const paths = [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const event of events) {
|
|
109
|
+
if (event?.type !== "tool/result" || event?.data?.turn !== turn) continue;
|
|
110
|
+
if (toolResultIsError(event)) continue;
|
|
111
|
+
for (const text of resultTexts(event.data?.message?.content)) {
|
|
112
|
+
const parsed = parseResultJson(text);
|
|
113
|
+
if (parsed === null) continue;
|
|
114
|
+
const found = [];
|
|
115
|
+
collectFilesPaths(parsed, found);
|
|
116
|
+
for (const path of found) {
|
|
117
|
+
if (published.has(path) || seen.has(path)) continue;
|
|
118
|
+
seen.add(path);
|
|
119
|
+
paths.push(path);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return paths;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function unpublishedMediaSteerText(paths) {
|
|
127
|
+
const list = paths.map((path) => `- \`${path}\``).join("\n");
|
|
128
|
+
return [
|
|
129
|
+
"A completed media generation returned files that are not published to this turn:",
|
|
130
|
+
list,
|
|
131
|
+
"Call workspace_publish once for each exact files_path above before replying.",
|
|
132
|
+
"Do not regenerate, download, or copy these files; they already exist in Olares Files.",
|
|
133
|
+
"Only claim that the preview is available after workspace_publish succeeds.",
|
|
134
|
+
].join("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function pathIsOpenable(workspaceRoot, path, deps = {}) {
|
|
138
|
+
// Olares Files requires the browser credential carried by an interactive
|
|
139
|
+
// preview request. A background turn-stopping hook must not reuse the last
|
|
140
|
+
// process-global CLI identity or block a turn because that token expired.
|
|
141
|
+
if (isFilesPath(path)) return true;
|
|
142
|
+
try {
|
|
143
|
+
const root = await (deps.resolveWorkspaceRoot ?? resolveWorkspaceRoot)(workspaceRoot);
|
|
144
|
+
const absolute = await (deps.resolveExistingWorkspacePath ?? resolveExistingWorkspacePath)(
|
|
145
|
+
root,
|
|
146
|
+
workspaceCandidate(root, path),
|
|
147
|
+
);
|
|
148
|
+
const info = await (deps.stat ?? stat)(absolute);
|
|
149
|
+
return info.isFile();
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @returns {Promise<string[]>} paths that cannot be opened for preview.
|
|
157
|
+
*/
|
|
158
|
+
export async function findUnopenableProducedPaths(workspaceRoot, paths, deps = {}) {
|
|
159
|
+
const missing = [];
|
|
160
|
+
for (const path of paths) {
|
|
161
|
+
if (!(await pathIsOpenable(workspaceRoot, path, deps))) missing.push(path);
|
|
162
|
+
}
|
|
163
|
+
return missing;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function producedGateSteerText(missing) {
|
|
167
|
+
const list = missing.map((path) => `- \`${path}\``).join("\n");
|
|
168
|
+
return [
|
|
169
|
+
"Produced-file openability check failed before this turn could close.",
|
|
170
|
+
"These paths were offered as deliverables but cannot be opened right now:",
|
|
171
|
+
list,
|
|
172
|
+
"Restore or rewrite each file at that exact path, or workspace_publish a",
|
|
173
|
+
"replacement path and name only openable finals in your reply.",
|
|
174
|
+
"Do not claim a file is ready until it opens. Research fetches under",
|
|
175
|
+
"downloads/ (git/API JSON, chart YAML, READMEs) are scratch — delete them",
|
|
176
|
+
"freely, but keep image / video / audio / 3D media and final write/edit outputs.",
|
|
177
|
+
].join("\n");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Per-agent turn budget so a stuck restore cannot loop forever.
|
|
182
|
+
*/
|
|
183
|
+
export function createProducedGateBudget(max = MAX_PRODUCED_GATE_STEERS) {
|
|
184
|
+
const attempts = new Map();
|
|
185
|
+
return {
|
|
186
|
+
remaining(agentId, turn) {
|
|
187
|
+
const key = `${agentId}:${turn}`;
|
|
188
|
+
return Math.max(0, max - (attempts.get(key) ?? 0));
|
|
189
|
+
},
|
|
190
|
+
consume(agentId, turn) {
|
|
191
|
+
const key = `${agentId}:${turn}`;
|
|
192
|
+
const next = (attempts.get(key) ?? 0) + 1;
|
|
193
|
+
attempts.set(key, next);
|
|
194
|
+
return next <= max;
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { fileName } from "./filename.js";
|
|
2
|
+
|
|
3
|
+
/** Strip mention wrappers (`code`, @path) so a chip can match a produced path. */
|
|
4
|
+
export function mentionText(value) {
|
|
5
|
+
return String(value ?? "").replace(/\s+/g, " ").trim().replace(/^@/, "").replace(/^`+|`+$/g, "");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* True when the visible label is only a produced path (or its leaf). Used to
|
|
10
|
+
* hide the in-message chip that duplicates the turn-tail preview.
|
|
11
|
+
*/
|
|
12
|
+
export function isDuplicateProducedMention(text, paths) {
|
|
13
|
+
const bare = mentionText(text);
|
|
14
|
+
if (!bare || !Array.isArray(paths) || paths.length === 0) return false;
|
|
15
|
+
return paths.some((path) => {
|
|
16
|
+
const produced = mentionText(path);
|
|
17
|
+
return produced !== "" && (bare === produced || bare === fileName(produced));
|
|
18
|
+
});
|
|
19
|
+
}
|