@olares/lares-core 0.15.7 → 0.15.10
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/log/report.js +46 -0
- 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 +49 -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
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { HttpError } from "../tools/http.js";
|
|
2
|
+
import { parseFilesPath } from "../drive/files-path.js";
|
|
3
|
+
import { findFilesChild, isDirectoryItem, itemModifiedAt, itemSize } from "../drive/ls.js";
|
|
4
|
+
import { identityFromHeaders, olaresUsername } from "../olares/identity.js";
|
|
5
|
+
|
|
6
|
+
const MAX_ERROR_BYTES = 256;
|
|
7
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
8
|
+
|
|
9
|
+
function headerValue(headers, name) {
|
|
10
|
+
if (typeof Headers !== "undefined" && headers instanceof Headers) {
|
|
11
|
+
return headers.get(name) ?? "";
|
|
12
|
+
}
|
|
13
|
+
const raw = headers?.[name] ?? headers?.[name.toLowerCase()];
|
|
14
|
+
return Array.isArray(raw) ? raw.join(",") : String(raw ?? "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function hasAuthCookie(cookie) {
|
|
18
|
+
return cookie.split(";").some((part) => {
|
|
19
|
+
const [name, ...rest] = part.trim().split("=");
|
|
20
|
+
return name === "auth_token" && rest.join("=").trim() !== "";
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function explicitToken(headers) {
|
|
25
|
+
const direct = headerValue(headers, "x-authorization").trim();
|
|
26
|
+
if (direct) return direct;
|
|
27
|
+
const authorization = headerValue(headers, "authorization").trim();
|
|
28
|
+
if (!authorization) return "";
|
|
29
|
+
return authorization.toLowerCase().startsWith("bearer ")
|
|
30
|
+
? authorization.slice(7).trim()
|
|
31
|
+
: authorization;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function filesCredentialFromHeaders(headers) {
|
|
35
|
+
const identity = identityFromHeaders(headers ?? {});
|
|
36
|
+
const user = olaresUsername(identity.user);
|
|
37
|
+
const cookie = headerValue(headers, "cookie").trim();
|
|
38
|
+
const token = explicitToken(headers);
|
|
39
|
+
const authenticatedCookie = hasAuthCookie(cookie) ? cookie : "";
|
|
40
|
+
if (!user || (!authenticatedCookie && !token)) {
|
|
41
|
+
throw new HttpError("files_no_credential", 401, "request carried no Olares Files credential");
|
|
42
|
+
}
|
|
43
|
+
return { user, cookie: authenticatedCookie, token };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function filesBaseUrl(template, user) {
|
|
47
|
+
const source = String(template ?? "").trim().replace(/\/+$/, "");
|
|
48
|
+
if (!source) {
|
|
49
|
+
throw new HttpError("files_unavailable", 503, "LARES_FILES_BASE_URL is not configured");
|
|
50
|
+
}
|
|
51
|
+
const value = source.replaceAll("{user}", user);
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = new URL(value);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new HttpError("files_unavailable", 503, "LARES_FILES_BASE_URL is invalid");
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
!["http:", "https:"].includes(parsed.protocol)
|
|
60
|
+
|| parsed.username
|
|
61
|
+
|| parsed.password
|
|
62
|
+
|| parsed.pathname !== "/"
|
|
63
|
+
|| parsed.search
|
|
64
|
+
|| parsed.hash
|
|
65
|
+
) {
|
|
66
|
+
throw new HttpError("files_unavailable", 503, "LARES_FILES_BASE_URL must be an HTTP(S) origin");
|
|
67
|
+
}
|
|
68
|
+
return parsed.origin;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function encodeFilesPath(path) {
|
|
72
|
+
return path.split("/").map((part) => encodeURIComponent(part)).join("/");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function boundedErrorBody(response) {
|
|
76
|
+
try {
|
|
77
|
+
const text = await response.text();
|
|
78
|
+
return text.trim().slice(0, MAX_ERROR_BYTES);
|
|
79
|
+
} catch {
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function checked(response, method, pathname) {
|
|
85
|
+
if (response.status >= 200 && response.status < 300) return response;
|
|
86
|
+
if (
|
|
87
|
+
(response.status >= 300 && response.status < 400)
|
|
88
|
+
|| response.status === 401
|
|
89
|
+
|| response.status === 403
|
|
90
|
+
) {
|
|
91
|
+
await response.body?.cancel().catch(() => {});
|
|
92
|
+
throw new HttpError("files_unauthenticated", 401, "Olares Files refused the browser credential");
|
|
93
|
+
}
|
|
94
|
+
if (response.status === 404) {
|
|
95
|
+
await response.body?.cancel().catch(() => {});
|
|
96
|
+
throw new HttpError("file_not_found", 404, "file was not found");
|
|
97
|
+
}
|
|
98
|
+
if (response.status === 416) {
|
|
99
|
+
await response.body?.cancel().catch(() => {});
|
|
100
|
+
throw new HttpError("range_not_satisfiable", 416, "Files rejected the byte range");
|
|
101
|
+
}
|
|
102
|
+
const detail = await boundedErrorBody(response);
|
|
103
|
+
throw new HttpError(
|
|
104
|
+
"files_unavailable",
|
|
105
|
+
502,
|
|
106
|
+
`${method} ${pathname} failed with ${response.status}${detail ? `: ${detail}` : ""}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function readAtMost(response, maxBytes) {
|
|
111
|
+
const reader = response.body?.getReader();
|
|
112
|
+
if (!reader) return { bytes: Buffer.alloc(0), truncated: false };
|
|
113
|
+
const chunks = [];
|
|
114
|
+
let total = 0;
|
|
115
|
+
let truncated = false;
|
|
116
|
+
try {
|
|
117
|
+
while (true) {
|
|
118
|
+
const { done, value } = await reader.read();
|
|
119
|
+
if (done) break;
|
|
120
|
+
const chunk = Buffer.from(value);
|
|
121
|
+
const remaining = maxBytes - total;
|
|
122
|
+
if (remaining > 0) chunks.push(chunk.subarray(0, remaining));
|
|
123
|
+
total += chunk.length;
|
|
124
|
+
if (total > maxBytes) {
|
|
125
|
+
truncated = true;
|
|
126
|
+
await reader.cancel();
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} finally {
|
|
131
|
+
reader.releaseLock();
|
|
132
|
+
}
|
|
133
|
+
return { bytes: Buffer.concat(chunks), truncated };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export class FilesRequestClient {
|
|
137
|
+
constructor({
|
|
138
|
+
baseUrl,
|
|
139
|
+
credential,
|
|
140
|
+
fetchFn = fetch,
|
|
141
|
+
signal,
|
|
142
|
+
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
143
|
+
}) {
|
|
144
|
+
this.baseUrl = baseUrl;
|
|
145
|
+
this.credential = credential;
|
|
146
|
+
this.fetchFn = fetchFn;
|
|
147
|
+
this.signal = signal;
|
|
148
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
headers(extra = {}) {
|
|
152
|
+
return {
|
|
153
|
+
...(this.credential.cookie ? { cookie: this.credential.cookie } : {}),
|
|
154
|
+
...(this.credential.token ? { "x-authorization": this.credential.token } : {}),
|
|
155
|
+
...extra,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async fetchRaw(method, pathname, options = {}) {
|
|
160
|
+
const callerSignal = options.signal ?? this.signal;
|
|
161
|
+
const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs);
|
|
162
|
+
const signal = callerSignal
|
|
163
|
+
? AbortSignal.any([callerSignal, timeoutSignal])
|
|
164
|
+
: timeoutSignal;
|
|
165
|
+
try {
|
|
166
|
+
return await this.fetchFn(`${this.baseUrl}${pathname}`, {
|
|
167
|
+
method,
|
|
168
|
+
headers: this.headers({ "accept-encoding": "identity", ...options.headers }),
|
|
169
|
+
redirect: "manual",
|
|
170
|
+
signal,
|
|
171
|
+
});
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error?.name === "AbortError") throw error;
|
|
174
|
+
throw new HttpError(
|
|
175
|
+
"files_unavailable",
|
|
176
|
+
502,
|
|
177
|
+
`Olares Files request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async request(method, pathname, options = {}) {
|
|
183
|
+
return checked(await this.fetchRaw(method, pathname, options), method, pathname);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Metadata for one Files object. The backend's file resource URL can embed
|
|
188
|
+
* bytes or 500; the supported probe is the parent listing, same as
|
|
189
|
+
* `olares-cli files download`.
|
|
190
|
+
*/
|
|
191
|
+
async stat(source) {
|
|
192
|
+
const path = parseFilesPath(source);
|
|
193
|
+
const cut = path.lastIndexOf("/");
|
|
194
|
+
const parent = path.slice(0, cut);
|
|
195
|
+
const name = path.slice(cut + 1);
|
|
196
|
+
const response = await this.request("GET", `/api/resources/${encodeFilesPath(parent)}/`);
|
|
197
|
+
let envelope;
|
|
198
|
+
try {
|
|
199
|
+
envelope = await response.json();
|
|
200
|
+
} catch {
|
|
201
|
+
throw new HttpError("files_unavailable", 502, "Olares Files listing was not JSON");
|
|
202
|
+
}
|
|
203
|
+
const item = findFilesChild(envelope, name);
|
|
204
|
+
if (!item) throw new HttpError("file_not_found", 404, "file was not found");
|
|
205
|
+
if (isDirectoryItem(item)) throw new HttpError("path_not_file", 415, "path is not a regular file");
|
|
206
|
+
return {
|
|
207
|
+
path,
|
|
208
|
+
name,
|
|
209
|
+
size: itemSize(item),
|
|
210
|
+
modifiedAt: itemModifiedAt(item),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Files raw is GET `?inline=true`. HEAD is not a Files verb; incoming HEAD
|
|
216
|
+
* still GETs a bounded range so the caller can drop the body.
|
|
217
|
+
*/
|
|
218
|
+
rawPath(source) {
|
|
219
|
+
return `/api/raw/${encodeFilesPath(parseFilesPath(source))}?inline=true`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
openRaw(source, options = {}) {
|
|
223
|
+
const range = options.range
|
|
224
|
+
?? (options.method === "HEAD" ? "bytes=0-0" : undefined);
|
|
225
|
+
return this.request("GET", this.rawPath(source), {
|
|
226
|
+
headers: range ? { range } : {},
|
|
227
|
+
signal: options.signal,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Files renditions are GET `/api/preview/<path>?size=`. The backend resizes
|
|
233
|
+
* once and caches the result: `big` fits the image inside 1000x1000, `thumb`
|
|
234
|
+
* is a 256x256 JPEG. A format it cannot resize comes back as the stored
|
|
235
|
+
* bytes, so this never fails merely for being asked.
|
|
236
|
+
*/
|
|
237
|
+
previewPath(source, size) {
|
|
238
|
+
const path = encodeFilesPath(parseFilesPath(source));
|
|
239
|
+
return `/api/preview/${path}?size=${encodeURIComponent(size)}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
openPreview(source, options = {}) {
|
|
243
|
+
return this.request("GET", this.previewPath(source, options.size ?? "big"), {
|
|
244
|
+
signal: options.signal,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async readRaw(source, maxBytes, options = {}) {
|
|
249
|
+
const response = await this.openRaw(source, {
|
|
250
|
+
range: `bytes=0-${maxBytes}`,
|
|
251
|
+
signal: options.signal,
|
|
252
|
+
});
|
|
253
|
+
return readAtMost(response, maxBytes);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function filesClientFromRequest(req, options = {}) {
|
|
258
|
+
const credential = filesCredentialFromHeaders(req?.headers ?? {});
|
|
259
|
+
const template = options.baseUrl ?? options.env?.LARES_FILES_BASE_URL ?? process.env.LARES_FILES_BASE_URL;
|
|
260
|
+
return new FilesRequestClient({
|
|
261
|
+
baseUrl: filesBaseUrl(template, credential.user),
|
|
262
|
+
credential,
|
|
263
|
+
fetchFn: options.fetchFn,
|
|
264
|
+
signal: options.signal,
|
|
265
|
+
});
|
|
266
|
+
}
|
package/files/deliverables.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { isInlineTurnMediaPath } from "./preview-groups.js";
|
|
2
|
+
import { LARES_PUBLISHED_KEY } from "./published-turn-data.js";
|
|
3
|
+
|
|
1
4
|
/** Same rule as dsh-client-ui-deliverables: only edit/diff call views produce files. */
|
|
2
5
|
export function producedPathsFromView(view) {
|
|
3
6
|
if (!view || !Array.isArray(view.locations)) return [];
|
|
@@ -15,7 +18,10 @@ export function producedPathsFromView(view) {
|
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export function producedForClosing(owner) {
|
|
18
|
-
const
|
|
21
|
+
const deliverables = owner.turn.data.get("deliverables");
|
|
22
|
+
const produced = deliverables?.produced ?? [];
|
|
23
|
+
const presented = deliverables?.presented ?? [];
|
|
24
|
+
const published = owner.turn.data.get(LARES_PUBLISHED_KEY)?.published ?? [];
|
|
19
25
|
const paths = [];
|
|
20
26
|
const seen = new Set();
|
|
21
27
|
for (const item of produced) {
|
|
@@ -23,6 +29,16 @@ export function producedForClosing(owner) {
|
|
|
23
29
|
seen.add(item.path);
|
|
24
30
|
paths.push(item.path);
|
|
25
31
|
}
|
|
32
|
+
for (const item of presented) {
|
|
33
|
+
if (item.seq >= owner.seq || seen.has(item.path)) continue;
|
|
34
|
+
seen.add(item.path);
|
|
35
|
+
paths.push(item.path);
|
|
36
|
+
}
|
|
37
|
+
for (const item of published) {
|
|
38
|
+
if (item.seq > owner.seq || seen.has(item.path)) continue;
|
|
39
|
+
seen.add(item.path);
|
|
40
|
+
paths.push(item.path);
|
|
41
|
+
}
|
|
26
42
|
return paths;
|
|
27
43
|
}
|
|
28
44
|
|
|
@@ -30,3 +46,41 @@ export function selectProducedFiles(owner) {
|
|
|
30
46
|
const paths = producedForClosing(owner);
|
|
31
47
|
return paths.length === 0 ? null : paths;
|
|
32
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Playable produced media for the turn-tail preview. Workspace and Files
|
|
52
|
+
* addresses share that seat so a `drive/…` image is not left as a prose link.
|
|
53
|
+
*/
|
|
54
|
+
export function selectInlineTurnMedia(owner) {
|
|
55
|
+
const paths = producedForClosing(owner).filter(isInlineTurnMediaPath);
|
|
56
|
+
return paths.length === 0 ? null : paths;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function listedPaths(files) {
|
|
60
|
+
if (!Array.isArray(files)) return [];
|
|
61
|
+
const paths = [];
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
const path = typeof file === "string" ? file : file?.path;
|
|
64
|
+
if (typeof path !== "string") continue;
|
|
65
|
+
const normalized = path.trim().replace(/\\/g, "/");
|
|
66
|
+
if (normalized) paths.push(normalized);
|
|
67
|
+
}
|
|
68
|
+
return paths;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Playable media a turn published. The web turn tail reads the projection of
|
|
73
|
+
* these events; a client that only has the session log has to read the events.
|
|
74
|
+
* media_generate names its files when the call returns, so they are not on the
|
|
75
|
+
* tool-call arguments.
|
|
76
|
+
*/
|
|
77
|
+
export function turnMediaPathsFromEvent(event) {
|
|
78
|
+
const data = event?.data;
|
|
79
|
+
if (event?.type === "deliverables/presented" || (
|
|
80
|
+
event?.type === "media/generation-settled"
|
|
81
|
+
&& String(data?.status ?? "").toLowerCase() === "completed"
|
|
82
|
+
)) {
|
|
83
|
+
return listedPaths(data?.files).filter(isInlineTurnMediaPath);
|
|
84
|
+
}
|
|
85
|
+
return [];
|
|
86
|
+
}
|
package/files/intake.js
CHANGED
|
@@ -20,69 +20,6 @@ export function splitComposerFiles(files) {
|
|
|
20
20
|
return { images, documents };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export function documentPasteFiles(clipboardData) {
|
|
24
|
-
const files = Array.from(clipboardData?.files ?? []);
|
|
25
|
-
return splitComposerFiles(files).documents.length > 0 ? files : null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function composerAttachReady({ phase, pending, addImages, sessionId }) {
|
|
29
|
-
return phase === "plain"
|
|
30
|
-
&& !pending
|
|
31
|
-
&& typeof addImages === "function"
|
|
32
|
-
&& typeof sessionId === "string";
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function claimComposerBlock(registry, sessionId, reason) {
|
|
36
|
-
const store = registry.storeFor(sessionId);
|
|
37
|
-
if (store.getSnapshot() !== undefined) return () => {};
|
|
38
|
-
const block = { reason };
|
|
39
|
-
registry.set(sessionId, block);
|
|
40
|
-
return () => {
|
|
41
|
-
if (store.getSnapshot() === block) registry.set(sessionId, undefined);
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export const COMPOSER_CARD = "[data-composer-card]";
|
|
46
|
-
|
|
47
|
-
export function composerPasteInCard(target) {
|
|
48
|
-
return Boolean(target && typeof target.closest === "function" && target.closest(COMPOSER_CARD));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function composerDropHasDocuments(files) {
|
|
52
|
-
return files.length > 0 && splitComposerFiles(files).documents.length > 0;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function draftImageFailureCode(reason) {
|
|
56
|
-
return reason instanceof Error ? reason.message : "file_upload_failed";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function commitComposerImages(ports, files, sessionId) {
|
|
60
|
-
if (files.length === 0) return;
|
|
61
|
-
let attachments = [];
|
|
62
|
-
try {
|
|
63
|
-
attachments = ports.createDraftImages(files);
|
|
64
|
-
if (!ports.addImages(attachments.map((attachment) => attachment.id))) {
|
|
65
|
-
ports.releaseDraftImages(attachments);
|
|
66
|
-
for (const file of files) ports.reportFailure(sessionId, file, "file_input_blocked");
|
|
67
|
-
}
|
|
68
|
-
} catch (reason) {
|
|
69
|
-
if (attachments.length > 0) ports.releaseDraftImages(attachments);
|
|
70
|
-
const code = draftImageFailureCode(reason);
|
|
71
|
-
for (const file of files) ports.reportFailure(sessionId, file, code);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function processComposerFiles(ports, incoming) {
|
|
76
|
-
if (!ports.ready || incoming.length === 0) return;
|
|
77
|
-
const files = [...incoming];
|
|
78
|
-
const { images, documents } = splitComposerFiles(files);
|
|
79
|
-
ports.addImages(images);
|
|
80
|
-
if (documents.length === 0) return;
|
|
81
|
-
const { accepted, oversized } = partitionDocumentsBySize(documents, ports.maxBytes);
|
|
82
|
-
for (const file of oversized) ports.reportFailure(ports.sessionId, file, "file_too_large");
|
|
83
|
-
return ports.uploadFiles(ports.sessionId, accepted, ports.commit);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
23
|
export function partitionDocumentsBySize(documents, maxBytes) {
|
|
87
24
|
const accepted = [];
|
|
88
25
|
const oversized = [];
|
package/files/mention.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export function mentionOf(path) {
|
|
1
|
+
function mentionOf(path) {
|
|
4
2
|
return /[\s"]/u.test(path) ? `@"${path}"` : `@${path}`;
|
|
5
3
|
}
|
|
6
4
|
|
|
@@ -14,51 +12,3 @@ export function appendDraftMentions(draft, paths) {
|
|
|
14
12
|
}
|
|
15
13
|
return next;
|
|
16
14
|
}
|
|
17
|
-
|
|
18
|
-
export function uploadReference(path) {
|
|
19
|
-
const ref = mentionOf(path);
|
|
20
|
-
return {
|
|
21
|
-
source: REFERENCE_SOURCE,
|
|
22
|
-
ref,
|
|
23
|
-
label: path.slice(path.lastIndexOf("/") + 1),
|
|
24
|
-
appearance: "file",
|
|
25
|
-
clipboardText: ref,
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Append one reference per uploaded path at the end of the draft.
|
|
31
|
-
* @returns the paths the input machine refused, in order.
|
|
32
|
-
*/
|
|
33
|
-
export function insertUploadReferences(input, paths) {
|
|
34
|
-
const refused = [];
|
|
35
|
-
for (const path of paths) {
|
|
36
|
-
const current = input.state.getSnapshot();
|
|
37
|
-
if (current.draft !== "" && !/\s$/u.test(current.draft)) input.setDraft(`${current.draft} `);
|
|
38
|
-
const state = input.state.getSnapshot();
|
|
39
|
-
const at = state.draft.length;
|
|
40
|
-
const applied = input.insertReference(uploadReference(path), {
|
|
41
|
-
start: at,
|
|
42
|
-
end: at,
|
|
43
|
-
draftRev: state.draftRev,
|
|
44
|
-
});
|
|
45
|
-
if (!applied) refused.push(path);
|
|
46
|
-
}
|
|
47
|
-
return refused;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function commitUploadPaths(input, paths, notifyUnlinked) {
|
|
51
|
-
if (!input) return;
|
|
52
|
-
for (const path of insertUploadReferences(input, paths)) {
|
|
53
|
-
notifyUnlinked(path);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function createUploadCommit({ scopeSession, inputFor, unlinkedMessage }) {
|
|
58
|
-
return (sessionId) => (paths) => {
|
|
59
|
-
const sessionCtx = scopeSession(sessionId);
|
|
60
|
-
if (sessionCtx === undefined) return;
|
|
61
|
-
const input = inputFor(sessionCtx);
|
|
62
|
-
commitUploadPaths(input, paths, (path) => input.notify("error", unlinkedMessage(path)));
|
|
63
|
-
};
|
|
64
|
-
}
|
package/files/preview-groups.js
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
|
+
import { posixExtname } from "./filename.js";
|
|
2
|
+
import { isFilesPath } from "../drive/files-path.js";
|
|
3
|
+
|
|
1
4
|
const MEDIA_KINDS = new Set(["image", "video", "audio", "model3d"]);
|
|
2
5
|
|
|
6
|
+
/** Extensions that stay as user-facing Produced media after a fetch. */
|
|
7
|
+
const MEDIA_EXTENSIONS = new Set([
|
|
8
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
|
|
9
|
+
".mp4", ".webm", ".mov", ".m4v", ".ogv",
|
|
10
|
+
".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac",
|
|
11
|
+
".glb", ".gltf", ".obj",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** Formats TurnMedia can actually play; .gltf/.obj stay official chips. */
|
|
15
|
+
const INLINE_TURN_EXTENSIONS = new Set([
|
|
16
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
|
|
17
|
+
".mp4", ".webm", ".mov", ".m4v", ".ogv",
|
|
18
|
+
".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac",
|
|
19
|
+
".glb",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function posixPath(path) {
|
|
23
|
+
return String(path ?? "").replace(/\\/g, "/");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Image / video / audio / 3D destinations are durable deliverables. Other
|
|
28
|
+
* fetch targets (git APIs, chart YAML, JSON lists, READMEs) are workspace
|
|
29
|
+
* scratch and must not become Produced chips.
|
|
30
|
+
*/
|
|
31
|
+
export function isMediaDeliverablePath(path) {
|
|
32
|
+
return MEDIA_EXTENSIONS.has(posixExtname(posixPath(path)).toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Media the turn-tail can play. Workspace and Files addresses use the same
|
|
37
|
+
* player; a Files path is not a reason to fall back to an in-message link.
|
|
38
|
+
*/
|
|
39
|
+
export function isInlineTurnMediaPath(path) {
|
|
40
|
+
return INLINE_TURN_EXTENSIONS.has(posixExtname(posixPath(path)).toLowerCase());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Default url_fetch / drive_fetch landing zone for non-final research files. */
|
|
44
|
+
export function isDownloadsScratchPath(path) {
|
|
45
|
+
const normalized = posixPath(path);
|
|
46
|
+
if (normalized === "downloads" || normalized.startsWith("downloads/")) return true;
|
|
47
|
+
return /(?:^|\/)downloads(?:\/|$)/.test(normalized);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Produced UI follows live workspace truth: media that still exists previews
|
|
52
|
+
* inline; missing paths drop out; non-media under downloads/ is scratch even
|
|
53
|
+
* while the file is briefly on disk.
|
|
54
|
+
*/
|
|
3
55
|
export function partitionPreviews(paths, previews) {
|
|
4
56
|
const media = [];
|
|
5
57
|
const files = [];
|
|
@@ -7,15 +59,31 @@ export function partitionPreviews(paths, previews) {
|
|
|
7
59
|
let loading = false;
|
|
8
60
|
for (const original of paths) {
|
|
9
61
|
if (!previews.has(original)) {
|
|
62
|
+
if (isInlineTurnMediaPath(original)) {
|
|
63
|
+
loading = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (isFilesPath(original)) {
|
|
67
|
+
files.push(original);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
10
70
|
loading = true;
|
|
11
71
|
continue;
|
|
12
72
|
}
|
|
13
73
|
const preview = previews.get(original);
|
|
14
|
-
|
|
74
|
+
if (preview === null) {
|
|
75
|
+
if (isFilesPath(original)) files.push(original);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const path = preview.path ?? original;
|
|
15
79
|
if (seen.has(path)) continue;
|
|
16
80
|
seen.add(path);
|
|
17
|
-
if (
|
|
18
|
-
|
|
81
|
+
if (MEDIA_KINDS.has(preview.kind)) {
|
|
82
|
+
media.push(preview);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (isDownloadsScratchPath(path) || isDownloadsScratchPath(original)) continue;
|
|
86
|
+
files.push(path);
|
|
19
87
|
}
|
|
20
88
|
return { media, files, loading };
|
|
21
89
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { basename, isAbsolute, resolve } from "node:path";
|
|
2
|
+
import { realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { runtimeSkillsDir } from "../skills/state.js";
|
|
4
|
+
import { HttpError } from "../tools/http.js";
|
|
5
|
+
import { isInsideWorkspace } from "../workspace/path.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Disk trees the conversation may preview besides the session workspace.
|
|
9
|
+
* Skills are what the agent Reads (`/data/lares/skills/…`); they are Lares
|
|
10
|
+
* app data, not user Drive files, so they stay off `drive/…`.
|
|
11
|
+
*/
|
|
12
|
+
export function localPreviewRoots(env = process.env) {
|
|
13
|
+
const dataDir = env.LARES_DATA_DIR?.trim() || "/data/lares";
|
|
14
|
+
return [runtimeSkillsDir(dataDir)];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function matchingRoot(candidate, roots) {
|
|
18
|
+
for (const root of roots) {
|
|
19
|
+
const resolvedRoot = await realpath(root).catch(() => null);
|
|
20
|
+
if (!resolvedRoot) continue;
|
|
21
|
+
const resolvedCandidate = await realpath(candidate).catch(() => null);
|
|
22
|
+
if (resolvedCandidate && isInsideWorkspace(resolvedRoot, resolvedCandidate)) {
|
|
23
|
+
return resolvedRoot;
|
|
24
|
+
}
|
|
25
|
+
const lexical = resolve(candidate);
|
|
26
|
+
if (
|
|
27
|
+
isInsideWorkspace(resolvedRoot, lexical)
|
|
28
|
+
|| isInsideWorkspace(root, lexical)
|
|
29
|
+
) {
|
|
30
|
+
return resolvedRoot;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Open a file from a Lares-owned local tree. Returns null when the path is
|
|
38
|
+
* not in one of those trees so the caller can try the session workspace.
|
|
39
|
+
*/
|
|
40
|
+
export async function resolveLocalPreviewFile(requestedPath, options = {}) {
|
|
41
|
+
if (typeof requestedPath !== "string" || !requestedPath.trim() || requestedPath.includes("\0")) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (!isAbsolute(requestedPath)) return null;
|
|
45
|
+
const roots = options.roots ?? localPreviewRoots(options.env ?? process.env);
|
|
46
|
+
const candidate = resolve(requestedPath);
|
|
47
|
+
const root = await matchingRoot(candidate, roots);
|
|
48
|
+
if (!root) return null;
|
|
49
|
+
const absolutePath = await realpath(candidate).catch(() => {
|
|
50
|
+
throw new HttpError("file_not_found", 404, "file was not found");
|
|
51
|
+
});
|
|
52
|
+
if (!isInsideWorkspace(root, absolutePath)) {
|
|
53
|
+
throw new HttpError("path_forbidden", 403, "path leaves the allowed preview tree");
|
|
54
|
+
}
|
|
55
|
+
const info = await stat(absolutePath).catch(() => {
|
|
56
|
+
throw new HttpError("file_not_found", 404, "file was not found");
|
|
57
|
+
});
|
|
58
|
+
if (!info.isFile()) throw new HttpError("path_not_file", 415, "path is not a regular file");
|
|
59
|
+
return {
|
|
60
|
+
origin: "local",
|
|
61
|
+
absolutePath,
|
|
62
|
+
path: requestedPath,
|
|
63
|
+
name: basename(absolutePath),
|
|
64
|
+
size: info.size,
|
|
65
|
+
modifiedAt: info.mtimeMs,
|
|
66
|
+
device: info.dev,
|
|
67
|
+
inode: info.ino,
|
|
68
|
+
};
|
|
69
|
+
}
|