@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
@@ -0,0 +1,90 @@
1
+ import { createSnapshotStore } from "../tools/async.js";
2
+
3
+ export const API = "/api/lares/voice";
4
+
5
+ const settings = createSnapshotStore();
6
+
7
+ export function rememberedVoiceSettings() {
8
+ return settings.peek();
9
+ }
10
+
11
+ export async function loadVoiceSettings(options = {}) {
12
+ const query = options.force ? "?refresh=1" : "";
13
+ return settings.load(async () => {
14
+ const [config, status, models] = await Promise.all([
15
+ getJson("/config"),
16
+ getJson(`/status${query}`),
17
+ getJson(`/models${query}`),
18
+ ]);
19
+ return {
20
+ config,
21
+ status,
22
+ sttModels: Array.isArray(models.stt) ? models.stt : [],
23
+ };
24
+ }, options);
25
+ }
26
+
27
+ export async function saveVoiceSettings(patch) {
28
+ const res = await fetch(`${API}/config`, {
29
+ method: "POST",
30
+ headers: { "content-type": "application/json" },
31
+ body: JSON.stringify(patch),
32
+ });
33
+ if (!res.ok) throw new Error(String(res.status));
34
+ return loadVoiceSettings({ force: true });
35
+ }
36
+
37
+ export class TranscriptQueue {
38
+ constructor() {
39
+ this.pending = null;
40
+ }
41
+
42
+ apply(text, ready, draft, setDraft) {
43
+ if (!text) return;
44
+ if (ready) setDraft(mergeTranscript(draft, text));
45
+ else this.pending = text;
46
+ }
47
+
48
+ flush(ready, draft, setDraft) {
49
+ if (!ready || this.pending === null) return;
50
+ const text = this.pending;
51
+ this.pending = null;
52
+ setDraft(mergeTranscript(draft, text));
53
+ }
54
+ }
55
+
56
+ export function mergeTranscript(draft, transcript) {
57
+ const base = String(draft ?? "").replace(/\s+$/, "");
58
+ const text = String(transcript ?? "").trim();
59
+ if (!text) return String(draft ?? "");
60
+ if (!base) return text;
61
+ if (/^[,.;!?,。;!?、]/.test(text)) return `${base}${text}`;
62
+ return `${base} ${text}`;
63
+ }
64
+
65
+ export async function postTranscribe(blob, language, signal, options = {}) {
66
+ const query = language ? `?language=${encodeURIComponent(language)}` : "";
67
+ const base = String(options.baseUrl ?? API).replace(/\/$/, "");
68
+ const res = await fetch(`${base}/transcribe${query}`, {
69
+ method: "POST",
70
+ headers: { "content-type": blob.type || "audio/webm" },
71
+ body: blob,
72
+ signal,
73
+ });
74
+ if (!res.ok) {
75
+ let code = "voice_failed";
76
+ try {
77
+ code = (await res.json())?.error?.code ?? code;
78
+ } catch {
79
+ /* keep default */
80
+ }
81
+ throw new Error(code);
82
+ }
83
+ return String((await res.json())?.text ?? "").trim();
84
+ }
85
+
86
+ export async function getJson(path) {
87
+ const res = await fetch(`${API}${path}`);
88
+ if (!res.ok) throw new Error(`${path} → ${res.status}`);
89
+ return res.json();
90
+ }
@@ -0,0 +1,59 @@
1
+ import { readJsonFile, writeJsonFile } from "../tools/json-file.js";
2
+ import { dshPluginConfigPath } from "../workspace/home.js";
3
+ import { STT_LANGUAGE_CHOICES } from "./languages.js";
4
+
5
+ export { STT_LANGUAGE_CHOICES, VOICE_AUTO, voiceMenuValue, voiceValueFromMenu } from "./languages.js";
6
+
7
+ const DEFAULTS = {
8
+ model: "",
9
+ language: "",
10
+ };
11
+
12
+ const WRITABLE = new Set(Object.keys(DEFAULTS));
13
+ const SUPPORTED_LANGUAGES = new Set(["", ...STT_LANGUAGE_CHOICES.map((item) => item.id)]);
14
+ const SAFE_MODEL = /^[^\u0000-\u001f\u007f]{0,512}$/;
15
+
16
+ function invalid(message) {
17
+ throw Object.assign(new Error(message), { code: "voice_config_invalid", status: 400 });
18
+ }
19
+
20
+ /** @param {unknown} raw @returns {Partial<typeof DEFAULTS>} */
21
+ export function validateConfigPatch(raw) {
22
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) invalid("config patch must be an object");
23
+ const patch = {};
24
+ for (const [key, value] of Object.entries(raw)) {
25
+ if (!WRITABLE.has(key)) invalid(`unknown voice setting: ${key}`);
26
+ if (typeof value !== "string") invalid(`${key} must be a string`);
27
+ const normalized = value.trim();
28
+ if (key === "model" && !SAFE_MODEL.test(normalized)) invalid("invalid voice model");
29
+ if (key === "language" && !SUPPORTED_LANGUAGES.has(normalized)) invalid("unsupported recognition language");
30
+ patch[key] = normalized;
31
+ }
32
+ return patch;
33
+ }
34
+
35
+ function configPath() {
36
+ return dshPluginConfigPath("voice-input");
37
+ }
38
+
39
+ /** @returns {typeof DEFAULTS} */
40
+ export function readConfig() {
41
+ const stored = readJsonFile(configPath());
42
+ const merged = { ...DEFAULTS };
43
+ const model = typeof stored?.model === "string" ? stored.model.trim() : "";
44
+ const language = typeof stored?.language === "string" ? stored.language.trim() : "";
45
+ if (SAFE_MODEL.test(model)) merged.model = model;
46
+ if (SUPPORTED_LANGUAGES.has(language)) merged.language = language;
47
+ return merged;
48
+ }
49
+
50
+ /** @param {Record<string, unknown>} patch @returns {typeof DEFAULTS} */
51
+ export function writeConfig(patch) {
52
+ const next = readConfig();
53
+ for (const [key, value] of Object.entries(validateConfigPatch(patch))) {
54
+ next[key] = value;
55
+ }
56
+ const path = configPath();
57
+ writeJsonFile(path, next);
58
+ return next;
59
+ }
@@ -0,0 +1,29 @@
1
+ export const VOICE_AUTO = "auto";
2
+ export const STT_LANGUAGE_CHOICES = [
3
+ { id: "zh", label: "中文" },
4
+ { id: "en", label: "English" },
5
+ { id: "ja", label: "日本語" },
6
+ { id: "ko", label: "한국어" },
7
+ ];
8
+
9
+ export function voiceMenuValue(stored) {
10
+ return stored || VOICE_AUTO;
11
+ }
12
+
13
+ export function voiceValueFromMenu(id) {
14
+ return id === VOICE_AUTO ? "" : id;
15
+ }
16
+
17
+ export const EMPTY_VOICE_CONFIG = { model: "", language: "" };
18
+
19
+ export function voiceLanguageItems(autoLabel) {
20
+ return [{ id: VOICE_AUTO, label: autoLabel }, ...STT_LANGUAGE_CHOICES];
21
+ }
22
+
23
+ export function voiceModelItems(sttIds, autoLabel) {
24
+ return [{ id: VOICE_AUTO, label: autoLabel }, ...sttIds.map((id) => ({ id, label: id }))];
25
+ }
26
+
27
+ export function voiceStatusReady(status) {
28
+ return Boolean(status?.modelAvailable);
29
+ }
@@ -0,0 +1,43 @@
1
+ const MIME_CANDIDATES = [
2
+ "audio/webm;codecs=opus",
3
+ "audio/webm",
4
+ "audio/ogg;codecs=opus",
5
+ "audio/mp4",
6
+ ];
7
+
8
+ /** Below this the blob is container header only. */
9
+ export const MIN_RECORDING_MS = 700;
10
+ export const MIN_RECORDING_BYTES = 1024;
11
+
12
+ export const RECORDING_AUDIO = {
13
+ channelCount: 1,
14
+ echoCancellation: false,
15
+ noiseSuppression: false,
16
+ autoGainControl: true,
17
+ };
18
+
19
+ export function isComposerVoiceReady(phase, setDraft) {
20
+ return phase === "plain" && typeof setDraft === "function";
21
+ }
22
+
23
+ export function isRecordingTooShort(durationMs, byteSize) {
24
+ return durationMs < MIN_RECORDING_MS || byteSize < MIN_RECORDING_BYTES;
25
+ }
26
+
27
+ export function pickRecordingMime(recorder = globalThis.MediaRecorder) {
28
+ if (typeof recorder === "undefined" || recorder === undefined) return "";
29
+ for (const type of MIME_CANDIDATES) {
30
+ try {
31
+ if (recorder.isTypeSupported(type)) return type;
32
+ } catch {
33
+ return "";
34
+ }
35
+ }
36
+ return "";
37
+ }
38
+
39
+ export function formatElapsed(ms) {
40
+ const total = Math.max(0, Math.floor(ms / 1000));
41
+ const s = String(total % 60).padStart(2, "0");
42
+ return `${Math.floor(total / 60)}:${s}`;
43
+ }
@@ -0,0 +1,83 @@
1
+ import { readConfig, validateConfigPatch, writeConfig } from "./config.js";
2
+ import {
3
+ STT_MAX_AUDIO_BYTES,
4
+ VoiceError,
5
+ audioFilenameForContentType,
6
+ listModels,
7
+ pickSttModelId,
8
+ resolveSttModel,
9
+ sttModelIds,
10
+ transcribe,
11
+ } from "../router/stt.js";
12
+
13
+ export { STT_MAX_AUDIO_BYTES };
14
+
15
+ export async function voiceStatusPayload(options) {
16
+ const config = readConfig();
17
+ let modelAvailable = false;
18
+ let resolvedModel = config.model;
19
+ try {
20
+ const models = await listModels(options);
21
+ resolvedModel = pickSttModelId(models, config.model) ?? "";
22
+ modelAvailable = Boolean(resolvedModel);
23
+ } catch {
24
+ modelAvailable = false;
25
+ }
26
+ return {
27
+ ok: modelAvailable,
28
+ model: resolvedModel,
29
+ modelAvailable,
30
+ language: config.language,
31
+ };
32
+ }
33
+
34
+ export async function voiceModelsPayload(options) {
35
+ const models = await listModels(options);
36
+ const ids = models.map((model) => model.id);
37
+ const stt = sttModelIds(models);
38
+ const selected = pickSttModelId(models, readConfig().model);
39
+ return { models: ids, stt, selected };
40
+ }
41
+
42
+ export async function saveVoiceConfig(body) {
43
+ const patch = validateConfigPatch(body);
44
+ if (patch.model) {
45
+ const available = sttModelIds(await listModels());
46
+ if (!available.includes(patch.model)) {
47
+ throw new VoiceError("voice_model_unavailable", 400, "selected voice model is not available");
48
+ }
49
+ }
50
+ return writeConfig(patch);
51
+ }
52
+
53
+ export function languageForTranscribe(requestedLanguage, configLanguage) {
54
+ if (requestedLanguage === null) return configLanguage || undefined;
55
+ return validateConfigPatch({ language: requestedLanguage }).language || undefined;
56
+ }
57
+
58
+ export async function transcribeFromHttp({ languageParam, contentType, bytes }) {
59
+ const config = readConfig();
60
+ const text = await transcribeAudio({
61
+ bytes,
62
+ contentType,
63
+ language: languageForTranscribe(languageParam, config.language),
64
+ preferred: config.model,
65
+ });
66
+ return { text };
67
+ }
68
+
69
+ export async function transcribeAudio({ bytes, contentType, language, preferred }) {
70
+ const model = await resolveSttModel(preferred);
71
+ if (!model) {
72
+ throw new VoiceError("voice_model_unavailable", 503, "no voice model available in the Router catalog");
73
+ }
74
+ if (bytes.length === 0) throw new VoiceError("voice_audio_unreadable", 400, "audio content is empty");
75
+ return transcribe({
76
+ bytes,
77
+ filename: audioFilenameForContentType(contentType),
78
+ contentType,
79
+ model,
80
+ language,
81
+ preferred,
82
+ });
83
+ }
@@ -0,0 +1,13 @@
1
+ /** @param {NodeJS.ProcessEnv} [env] */
2
+ export function workspaceRootFromEnv(env = process.env) {
3
+ const value = env.DSH_CWD ?? env.LARES_WORKSPACE;
4
+ return typeof value === "string" && value.trim() ? value.trim() : null;
5
+ }
6
+
7
+ export function workspaceRootFromSession(exec, env = process.env) {
8
+ const cwd = exec?.agent?.session?.header?.cwd ?? workspaceRootFromEnv(env);
9
+ if (cwd === null || cwd === undefined || cwd === "") {
10
+ throw new Error("no session workspace to fetch into");
11
+ }
12
+ return cwd;
13
+ }
@@ -0,0 +1,9 @@
1
+ import { join } from "node:path";
2
+
3
+ export function dshHome(env = process.env) {
4
+ return env.DSH_HOME?.trim() || "/data/lares/dsh-home";
5
+ }
6
+
7
+ export function dshPluginConfigPath(plugin, env = process.env) {
8
+ return join(dshHome(env), plugin, "config.json");
9
+ }
@@ -0,0 +1,80 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { mkdir, realpath, stat } from "node:fs/promises";
3
+ import { isAbsolute, relative, resolve, sep } from "node:path";
4
+ import { HttpError } from "../tools/http.js";
5
+
6
+ export function isInsideWorkspace(root, target) {
7
+ const path = relative(root, target);
8
+ return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
9
+ }
10
+
11
+ export async function resolveWorkspaceRoot(workspacePath) {
12
+ return realpath(workspacePath).catch(() => {
13
+ throw new HttpError("workspace_unavailable", 409, "session workspace is unavailable");
14
+ });
15
+ }
16
+
17
+ export function workspaceCandidate(root, requestedPath) {
18
+ const candidate = resolve(root, requestedPath);
19
+ if (!isInsideWorkspace(root, candidate)) {
20
+ throw new HttpError("path_forbidden", 403, "path leaves the session workspace");
21
+ }
22
+ return candidate;
23
+ }
24
+
25
+ export async function resolveExistingWorkspacePath(root, candidate) {
26
+ const absolutePath = await realpath(candidate).catch(() => {
27
+ throw new HttpError("file_not_found", 404, "file was not found");
28
+ });
29
+ if (!isInsideWorkspace(root, absolutePath)) {
30
+ throw new HttpError("path_forbidden", 403, "path leaves the session workspace");
31
+ }
32
+ return absolutePath;
33
+ }
34
+
35
+ export async function ensureWorkspaceDirectory(workspacePath, parts) {
36
+ const root = await resolveWorkspaceRoot(workspacePath);
37
+ let current = root;
38
+ for (const part of parts) {
39
+ const candidate = workspaceCandidate(current, part);
40
+ await mkdir(candidate).catch((error) => {
41
+ if (error?.code !== "EEXIST") throw error;
42
+ });
43
+ current = await realpath(candidate).catch(() => {
44
+ throw new HttpError("workspace_unavailable", 409, "workspace directory is unavailable");
45
+ });
46
+ if (!isInsideWorkspace(root, current)) {
47
+ throw new HttpError("path_forbidden", 403, "workspace directory leaves the session workspace");
48
+ }
49
+ const info = await stat(current);
50
+ if (!info.isDirectory()) {
51
+ throw new HttpError("workspace_unavailable", 409, "workspace path is not a directory");
52
+ }
53
+ }
54
+ return { root, directory: current };
55
+ }
56
+
57
+ /**
58
+ * Absolute path a workspace write will occupy. Refuses to clobber a symlink
59
+ * or non-file; an existing regular file needs `overwrite`.
60
+ */
61
+ export async function prepareWorkspaceTarget(root, destination, overwrite) {
62
+ const segments = destination.split("/");
63
+ const { directory } = await ensureWorkspaceDirectory(root, segments.slice(0, -1));
64
+ const absolutePath = workspaceCandidate(directory, segments.at(-1));
65
+ let current = null;
66
+ try {
67
+ current = lstatSync(absolutePath);
68
+ } catch (error) {
69
+ if (error?.code !== "ENOENT") throw error;
70
+ }
71
+ if (current !== null) {
72
+ if (!overwrite) {
73
+ throw new Error(`${destination} already exists; pass overwrite or choose another destination`);
74
+ }
75
+ if (current.isSymbolicLink() || !current.isFile()) {
76
+ throw new Error(`${destination} cannot overwrite a symlink or non-regular file`);
77
+ }
78
+ }
79
+ return absolutePath;
80
+ }
@@ -0,0 +1,13 @@
1
+ import { mkdirSync } from "node:fs";
2
+
3
+ export const DEFAULT_WORKSPACE_TITLE = "Default";
4
+
5
+ /**
6
+ * @param {{ create: (path: string, title?: string) => Promise<{ id: string, path: string, title: string }> }} registry
7
+ * @param {string} workspacePath
8
+ * @param {string} [title]
9
+ */
10
+ export async function seedDefaultWorkspace(registry, workspacePath, title = DEFAULT_WORKSPACE_TITLE) {
11
+ mkdirSync(workspacePath, { recursive: true });
12
+ return registry.create(workspacePath, title);
13
+ }
@@ -0,0 +1,42 @@
1
+ import { HttpError } from "../tools/http.js";
2
+ import { resolveWorkspaceRoot } from "./path.js";
3
+
4
+ /**
5
+ * The directory a session owns is its own header `cwd`. Registry membership is
6
+ * dsh's grouping account instead: it is written only when the client names a
7
+ * workspaceId while creating the session, so an ordinary new chat is ungrouped
8
+ * and has no membership at all while still working in a real directory.
9
+ *
10
+ * The registry stays in the lookup as the boundary of what this deployment
11
+ * serves — a cwd that is not a registered workspace path is refused, so a
12
+ * session created with an arbitrary cwd cannot read outside those directories.
13
+ *
14
+ * @param ctx - Cordis context carrying `workspaceRegistry` and `sessionPersistence`.
15
+ * @param sessionId - Session the request claims to act for.
16
+ */
17
+ export async function resolveSessionWorkspace(ctx, sessionId) {
18
+ if (typeof sessionId !== "string" || !sessionId) {
19
+ throw new HttpError("session_required", 400, "session id is required");
20
+ }
21
+ const cwd = await sessionCwd(ctx, sessionId);
22
+ if (cwd === null) {
23
+ throw new HttpError("workspace_not_found", 404, "session workspace was not found");
24
+ }
25
+ const workspace = await ctx.workspaceRegistry.resolveByPath(await resolveWorkspaceRoot(cwd));
26
+ if (workspace === undefined) {
27
+ throw new HttpError("workspace_not_found", 404, "session workspace was not found");
28
+ }
29
+ if ((await workspace.status()) !== "ok") {
30
+ throw new HttpError("workspace_unavailable", 409, "session workspace is unavailable");
31
+ }
32
+ return workspace;
33
+ }
34
+
35
+ /** Live sessions answer without touching storage; anything else needs the log header. */
36
+ async function sessionCwd(ctx, sessionId) {
37
+ const live = ctx.get("sessions")?.get(sessionId);
38
+ if (typeof live?.header?.cwd === "string") return live.header.cwd;
39
+ const headers = await ctx.sessionPersistence.list();
40
+ const cwd = headers.find((header) => String(header.id) === sessionId)?.cwd;
41
+ return typeof cwd === "string" ? cwd : null;
42
+ }